API: KI-Deklaration als v1-Pflichtfeld, Legacy-Default fuer /service (Entscheidung 10.07.)

Befund: v1 hatte keine KI-Deklarationsfelder — Submit lief seit der
KI-Kennzeichnungspflicht (09.07.) immer auf 422 und haette alle
Legacy-API-Kunden blockiert (Suite uebersah es: Factory deklariert).

- v1-Submit verlangt ai_text_declaration; der Call gilt dokumentiert
  als verbindliche Bestaetigung (Art. 50 KI-VO) und schliesst die
  Archiv-Kenntnisnahme (AGB par. 13) ein
- Bild-Upload verlangt ai_declaration (original/ai_generated/ai_edited)
- Legacy-/service-Aliasse defaulten auf none/original — das
  API-Kunden-Mailing (R6.4) weist auf die Erklaerungswirkung hin
- OpenAPI v1.yml + lang-Hinweis aktualisiert; Tests erweitert
  (Submit/Image/LegacyCompatibility)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-10 12:41:24 +00:00
parent 3843e534da
commit 92af015532
11 changed files with 213 additions and 10 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\AiTextDeclaration;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
@ -19,6 +20,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class PressReleaseController extends Controller
{
@ -56,6 +58,15 @@ class PressReleaseController extends Controller
// `review` erfolgt ausschließlich über die explizite submit-Route,
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
'status' => PressReleaseStatus::Draft->value,
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
// weist darauf hin, dass die weitere Nutzung diese Erklärung
// darstellt. v1-Clients deklarieren explizit beim Submit.
...($this->isLegacyServiceRequest($request) ? [
'ai_text_declaration' => AiTextDeclaration::None->value,
'ai_declaration_confirmed_at' => now(),
] : []),
]);
return PressReleaseResource::make(
@ -133,6 +144,16 @@ class PressReleaseController extends Controller
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
// KI-Kennzeichnungspflicht (Art. 50 KI-VO) auch via API: der
// Submit-Call trägt den deklarierten KI-Grad des Textes und gilt
// dokumentiert als verbindliche Bestätigung der Deklaration sowie
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
// Entscheidung 10.07.2026.
$validated = $request->validate([
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
]);
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
abort_unless($pressRelease !== null, 403);
@ -142,6 +163,11 @@ class PressReleaseController extends Controller
], 409);
}
$pressRelease->update([
'ai_text_declaration' => $validated['ai_text_declaration'],
'ai_declaration_confirmed_at' => now(),
]);
try {
$service->submitForReview($pressRelease);
} catch (BookingRequiredException $exception) {
@ -211,6 +237,16 @@ class PressReleaseController extends Controller
->first();
}
/**
* Kam der Call über die Legacy-Aliasse (/service/…, routes/legacy-api.php)?
* Dort gelten Kompatibilitäts-Defaults für Felder, die die Altportale
* nicht kannten (KI-Kennzeichnung).
*/
private function isLegacyServiceRequest(Request $request): bool
{
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
}
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
{
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1;
use App\Enums\AiImageDeclaration;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseImageRequest;
@ -63,6 +64,10 @@ class PressReleaseImageController extends Controller
'disk' => 'public',
'path' => $stored['path'],
'variants' => $stored['variants'],
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest);
// Legacy-/service-Calls ohne Feld gelten als Originalfoto
// (Kompatibilitäts-Default, Entscheidung 10.07.2026).
'ai_declaration' => $validated['ai_declaration'] ?? AiImageDeclaration::Original->value,
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,

View file

@ -2,8 +2,10 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\AiImageDeclaration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePressReleaseImageRequest extends FormRequest
{
@ -18,12 +20,23 @@ class StorePressReleaseImageRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* KI-Kennzeichnung je Bild (Art. 50 KI-VO) ist im v1-Vertrag Pflicht;
* Legacy-/service-Clients kennen das Feld nicht — dort greift der
* Kompatibilitäts-Default „original" (Entscheidung 10.07.2026).
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$isLegacyServiceCall = str_starts_with((string) $this->route()?->getName(), 'legacy-service.');
return [
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
'ai_declaration' => [
Rule::requiredIf(! $isLegacyServiceCall),
'nullable',
Rule::enum(AiImageDeclaration::class),
],
'title' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:500'],
'copyright' => ['nullable', 'string', 'max:255'],

View file

@ -28,6 +28,8 @@ R2.2+R2.3 erledigt: Hub-Landing komplett ueberarbeitet (datengetriebenes Tarif-G
**09.07. abends — R5.3 Monitoring + Buchungsdaten + kompletter WIP in 7 Commits committet (Suite: 1149 gruen):** (a) **R5.3 Monitoring** umgesetzt (Details in R5.3): `OperationalAlert` (immer `Log::critical` + Best-Effort-Mail an `MONITORING_ALERT_EMAIL`), alle 6 Scheduler-Tasks mit `->onFailure()`, Queue-`failing`-Listener, `LOG_STACK=daily` als Prod-Soll. (b) **Buchungs-/Verlaengerungsdatum** des Abos auf der Buchungsseite (`SubscriptionPeriodResolver` holt `start_date` + `current_period_end` gecacht aus Stripe, da Cashier die Periode nicht lokal spiegelt). (c) Den kompletten bis dahin uncommitteten Arbeitsbaum in **7 fokussierte Commits** zerlegt (Branch jetzt **~187 vor `main`**): OpenAPI-Route auf `public/api/v1.yml` (500-Fix) · Depublish-Credit 35 · Buchungs-/Verlaengerungsdatum · R5.3 Monitoring · Release-Doku · Backend-WIP (Kontakt-Queue, Page-Cache, Validierungs-Feedback, Company-Portal-Restriction, i18n-Auslagerung) · Web-Frontend-Umbau (separat, User-Bereich; Kontakt-Modal durch `contact-launcher` ersetzt). **Bewusst uncommittet belassen:** `sidebar.blade.php` (User-Regel, aus allen Commits ausgeschlossen), der geloeschte `docs/`-Mount + `config/pressekonto.code-workspace` (Mount nicht mitgekommen — die einzige code-relevante Datei `docs/api/v1.yml` liegt jetzt als `public/api/v1.yml` vor), zwei Dev-Screenshots in `dev/frontend/booking/`.
**10.07. — PM-Tracking (User-Einschub, `docs/weiteres/Pm Tracken.md`):** Ereignis-Log `press_release_events` (type view/share, channel, ohne Personenbezug) + Model/Enums/Factory. Aufrufe: Legacy-`hits`-Zaehler bleibt die angezeigte Wahrheit (bewusst einfacher Page-Request-Zaehler, User-Vorgabe), zusaetzlich View-Event je Detailaufruf; oeffentliche Detailseite zeigt Aufrufe wieder in der Meta-Zeile (wie im Altsystem, beide Hero-Varianten, nicht in der Vorschau). Shares: `POST …/press-release/{slug}/share` (throttle 30/min, nur archiv-sichtbare PMs) loggt LinkedIn/X/E-Mail/Link-kopiert-Klicks der Teilen-Buttons (fetch keepalive + CSRF-Meta); Kundenpanel-PM-Seite zeigt neue Kachel „Geteilt" mit Gesamtzahl + Kanal-Aufschluesselung. Tests: `Web/PressReleaseTrackingTest` (7). Hinweis: Branch war zu Sessionbeginn bereits vollstaendig gepusht; die „drei user_filter_presets-Migrations" (R6.5 optional) existieren nicht mehr — es gibt nur noch eine konsolidierte, Punkt gegenstandslos.
**10.07. nachmittags — R2.5 + QA-Nachpruefung + R4.3 + KI-Queue-Fix + API-KI-Deklaration (Suite: 1170 gruen):** R2.5 erledigt (legal-request-Texte aus freigegebenem Meldeverfahren, s. R2.5) · QA-Checkliste B–G vertieft (s. R6.1) · R4.3 erledigt (Karten-Helper am Model, s. R4.3). **Befund KI-Pruefung:** Auto-Routing (Rot → Ablehnung, Gelb/Gruen → Auto-Publish) war laengst gebaut, lief lokal aber nie — `composer run dev` startete den Worker ohne `--queue=default,classification`; gefixt, Backlog abgearbeitet (Pipeline live verifiziert; Prod-Runbook war korrekt). **Befund + Fix API (Entscheidung User 10.07.):** v1-API hatte keine KI-Deklarationsfelder → Submit lief seit 09.07. immer auf 422 (haette alle 406 Legacy-API-Kunden blockiert; Suite uebersah es, weil die Factory deklariert). Jetzt: `ai_text_declaration` Pflicht im v1-Submit (Call gilt dokumentiert als verbindliche Bestaetigung + Archiv-Kenntnisnahme AGB § 13), `ai_declaration` Pflicht je Bild-Upload; Legacy-/service-Pfade defaulten auf none/original (Mailing R6.4 weist darauf hin, dass die weitere Nutzung diese Erklaerung darstellt); OpenAPI + /api-Seite aktualisiert. Tests: Submit-/Image-/Legacy-API-Tests erweitert. **Beobachtung fuer QA/Launch:** Test-Kauderwelsch („asdas dsada…") wurde Gruen klassifiziert und auto-publiziert — vor Go-Live Prompt/Schwelle pruefen bzw. `CLASSIFICATION_GREEN_DELAY` als Sicherheitsfenster setzen [USER-ENTSCHEIDUNG].
**Wiedereinstieg / naechste Schritte:**
1. ~~Klaerungsliste Rechtstexte~~ **erledigt 06.07.**; ~~R3.1-Restbacklog~~ **erledigt 06.07.** (siehe oben).
2. **[EXTERN]:** Postfach `info@pressekonto.com` anlegen (Haupt-Rechts-Mail, 06.07.); CONTACT_EMAIL_*-Support-Postfaecher je Brand; REPORT_EMAIL_* optional · R1.2 Stripe-Live · R1.4 Preisfreigabe · R5.4 Favicons · anwaltliche Freigabe AGB v0.6/Datenschutz v0.3. (~~Depublikations-Preis~~ entschieden: 35 EUR netto.)

View file

@ -60,7 +60,7 @@ Staging-Rehearsal (R6.3) mit echtem Datenbestand wiederholen.
- [ ] **`POST /v1/press-releases` mit Sanctum-Token** — Automatisiert: `Api/V1/PressReleaseApiTest`, `PressReleaseSubmitApiTest`, `PressReleaseImageApiTest`, `CustomerDataApiTest`. Manuell: Token im Kundenkonto erzeugen (setzt aktive Buchung voraus), curl-Workflow aus der /api-Doku durchspielen.
- [ ] **Legacy-API-Übernahme** (geändert 06.07., User-Entscheidung): Importierte Alt-Schlüssel bleiben gültig — als Bearer, per `X-ApiKey`/`api_key` und auf den `/service/…`-Pfaden; nur unbekannte Keys bekommen 410 mit Migrationshinweis. Automatisiert: `Api/LegacyApiCompatibilityTest`, `ApiAccessSecurityAndLoggingTest`. Manuell nach Staging-Import: `legacy:import-api-keys --dry-run` prüfen (lokal: 406 Keys), dann echter Lauf; einen echten Alt-Key gegen `/service/pressreleases` testen.
- [ ] **Rate-Limit 60/min** — Automatisiert: abgedeckt. Manuell: entfällt.
- [ ] **KI-Deklaration auch via API erzwungen** (neu 09.07.) — Submit ohne KI-Deklaration liefert einen Validierungsfehler (zentral in `PressReleaseService`, `AiDeclarationRequiredException` im API-Controller gemappt). Automatisiert: `PressReleaseAiDeclarationTest`. Manuell: entfällt; beim API-Kunden-Mailing (R6.4) auf das neue Pflichtfeld hinweisen.
- [ ] **KI-Deklaration via API** (Entscheidung 10.07.) — v1-Submit verlangt `ai_text_declaration` (Call = verbindliche Bestätigung + Archiv-Kenntnisnahme AGB § 13), Bild-Upload verlangt `ai_declaration`; Legacy-/service-Pfade defaulten auf none/original. Automatisiert: `PressReleaseSubmitApiTest`, `PressReleaseImageApiTest`, `LegacyApiCompatibilityTest`. Manuell: entfällt; **R6.4-Mailing muss Pflichtfelder + Legacy-Default erwähnen.**
## F. Frontend Spokes + Hub

View file

@ -300,6 +300,7 @@ return [
'quickstart_step_1' => '# 1. Entwurf anlegen',
'quickstart_step_2' => '# 2. Bild hochladen (multipart)',
'quickstart_step_3' => '# 3. Zur Inhaltsprüfung einreichen',
'ai_declaration_note' => 'Pflicht seit Juli 2026: Der Submit-Aufruf erklärt verbindlich den KI-Grad des Textes (ai_text_declaration: none, assisted, mostly_ai, fully_ai — Transparenzpflicht nach Art. 50 KI-VO) und schließt die Kenntnisnahme der dauerhaften Archivierung ein (AGB § 13). Jedes Bild trägt seine eigene Kennzeichnung (ai_declaration: original, ai_generated, ai_edited).',
'openapi_note' => 'Maschinenlesbare Referenz:',
'openapi_link' => 'OpenAPI-Spezifikation (YAML)',

View file

@ -299,6 +299,7 @@ return [
'quickstart_step_1' => '# 1. Create a draft',
'quickstart_step_2' => '# 2. Upload an image (multipart)',
'quickstart_step_3' => '# 3. Submit for content review',
'ai_declaration_note' => 'Required since July 2026: the submit call bindingly declares the AI grade of the text (ai_text_declaration: none, assisted, mostly_ai, fully_ai — transparency obligation under Art. 50 EU AI Act) and includes acknowledgement of permanent archiving (Terms § 13). Each image carries its own label (ai_declaration: original, ai_generated, ai_edited).',
'openapi_note' => 'Machine-readable reference:',
'openapi_link' => 'OpenAPI specification (YAML)',

View file

@ -125,16 +125,39 @@ paths:
description: |
Der einzige API-Weg Richtung Veröffentlichung. Durchläuft denselben
Funnel wie das Web-Formular (Richtlinien-Filter, Kontingent, Status-Log).
Der Aufruf erklärt verbindlich den deklarierten KI-Grad des Textes
(`ai_text_declaration`, Transparenzpflicht nach Art. 50 KI-VO) und
schließt die Kenntnisnahme der dauerhaften Archivierung ein
(AGB § 13: Depublikation nur als kostenpflichtiger Service,
gesetzliche Ansprüche unberührt).
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ai_text_declaration]
properties:
ai_text_declaration:
type: string
enum: [none, assisted, mostly_ai, fully_ai]
description: >
Deklarierter KI-Grad des Textes: ohne generative KI (none),
KI als Formulierungshilfe mit redaktioneller Überarbeitung
(assisted), überwiegend KI-erstellt und geprüft (mostly_ai),
vollständig KI-generiert (fully_ai). Ab mostly_ai trägt die
Veröffentlichung ein öffentliches Transparenz-Label.
responses:
"200": { description: "Eingereicht (Status review)" }
"402": { description: "Aktive Buchung erforderlich (Tarif oder Einzel-PM)" }
"409": { description: "Nur draft/rejected kann eingereicht werden" }
"422": { description: "Kontingent erschöpft oder Richtlinienverstoß" }
"422": { description: "KI-Deklaration fehlt/ungültig, Kontingent erschöpft oder Richtlinienverstoß" }
/press-releases/{id}/images:
parameters:
- name: id
@ -164,12 +187,18 @@ paths:
multipart/form-data:
schema:
type: object
required: [image]
required: [image, ai_declaration]
properties:
image:
type: string
format: binary
description: JPG, PNG oder WebP, max. 8 MB
ai_declaration:
type: string
enum: [original, ai_generated, ai_edited]
description: >
KI-Kennzeichnung des Bildes (Art. 50 KI-VO): Originalfoto,
KI-generiert oder KI-bearbeitet.
title: { type: string, maxLength: 120 }
description: { type: string, maxLength: 500 }
copyright: { type: string, maxLength: 255 }

View file

@ -2,10 +2,15 @@
use App\Enums\Portal;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PaymentOption;
use App\Models\PressRelease;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\PersonalAccessToken;
use Tests\TestCase;
@ -126,3 +131,38 @@ test('retired legacy service endpoints answer with a helpful 410 instead of 404'
$this->getJson('/service/irgendwas')->assertStatus(410);
});
test('legacy service calls default the ai declaration instead of breaking old clients', function () {
/** @var TestCase $this */
Storage::fake('public');
$user = eligibleLegacyApiUser();
app(LegacyApiKeyImporter::class)->import('businessportal24', [
['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'],
]);
$company = Company::factory()->create(['portal' => Portal::Businessportal24]);
$user->companies()->attach($company->id, ['role' => 'owner']);
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
// Store ohne KI-Felder (Alt-Client): Default "ohne generative KI erstellt"
// (Entscheidung 10.07.2026 — das API-Kunden-Mailing weist darauf hin).
$response = $this->postJson('/service/pressreleases', [
'company_id' => $company->id,
'category_id' => $category->id,
'language' => 'de',
'title' => 'Alt-Client-Meldung',
'text' => 'Inhalt aus einer bestehenden Integration.',
], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated();
$pressRelease = PressRelease::withoutGlobalScopes()->find($response->json('data.id'));
expect($pressRelease->ai_text_declaration?->value)->toBe('none')
->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull();
// Bild-Upload ohne KI-Feld: Default Originalfoto.
$this->postJson("/service/pressreleases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('alt-client.jpg'),
], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated();
expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original');
});

View file

@ -23,6 +23,7 @@ test('api user can upload list and delete own press release images', function ()
$uploadResponse = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('press-release.jpg', 1200, 800)->size(5120),
'ai_declaration' => 'original',
'title' => 'Pressefoto',
'is_preview' => true,
]);
@ -101,6 +102,7 @@ test('api user cannot manage another users press release images', function () {
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('foreign.jpg'),
'ai_declaration' => 'original',
])->assertForbidden();
});
@ -117,6 +119,32 @@ test('published press release images cannot be changed via api', function () {
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('published.jpg'),
'ai_declaration' => 'original',
])->assertConflict()
->assertJsonPath('message', 'Only draft or rejected press releases may be edited.');
});
test('api image upload requires the per-image ai declaration', function () {
/** @var TestCase $this */
Storage::fake('public');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-release-images:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('undeklariert.jpg'),
])->assertUnprocessable()
->assertInvalid(['ai_declaration']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [
'image' => UploadedFile::fake()->image('ki-bild.jpg'),
'ai_declaration' => 'ai_generated',
])->assertCreated();
expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('ai_generated');
});

View file

@ -46,7 +46,7 @@ test('api submit route raises a draft to review without consuming quota and writ
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertOk()
->assertJsonPath('data.status', PressReleaseStatus::Review->value);
@ -72,7 +72,7 @@ test('api submit responds 402 when the booking gate is enforced', function () {
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(402);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Draft);
@ -99,7 +99,7 @@ test('api submit responds 422 when the monthly quota is exhausted', function ()
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(422);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Draft);
@ -119,7 +119,7 @@ test('api submit auto-rejects a press release containing a banned word', functio
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(422);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected);
@ -135,7 +135,7 @@ test('api submit requires the write ability', function () {
Sanctum::actingAs($user, ['press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertForbidden();
});
@ -148,7 +148,7 @@ test('api submit rejects a press release already in review', function () {
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(409);
});
@ -163,6 +163,54 @@ test('api user cannot submit another users press release', function () {
Sanctum::actingAs($otherUser, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none'])
->assertForbidden();
});
test('api submit requires the ai text declaration (Art. 50 KI-VO)', function () {
/** @var TestCase $this */
Queue::fake();
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->aiUndeclared()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
]);
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit")
->assertUnprocessable()
->assertInvalid(['ai_text_declaration']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'erfunden'])
->assertUnprocessable()
->assertInvalid(['ai_text_declaration']);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Draft);
});
test('api submit records the declared ai grade as binding confirmation', function () {
/** @var TestCase $this */
Queue::fake();
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->aiUndeclared()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
'title' => 'Saubere Pressemitteilung',
'text' => 'Vollkommen unauffälliger Inhalt.',
]);
Sanctum::actingAs($user, ['press-releases:write']);
$this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'assisted'])
->assertOk()
->assertJsonPath('data.status', PressReleaseStatus::Review->value);
$fresh = $pressRelease->fresh();
expect($fresh->ai_text_declaration?->value)->toBe('assisted')
->and($fresh->ai_declaration_confirmed_at)->not->toBeNull()
// Audit-Trail: der Submit protokolliert die Deklaration versioniert.
->and($fresh->aiDeclarations()->count())->toBe(1);
});