seed(RolesAndPermissionsSeeder::class); config()->set('scoring.classification.max_automatic_attempts', 2); config()->set('billing.enforce_booking', false); Queue::fake(); Mail::fake(); }); function gateDraft(): array { $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); $pr = PressRelease::factory()->create(['user_id' => $customer->id, 'status' => PressReleaseStatus::Draft->value]); return [$customer, $pr]; } function redRun(PressRelease $pr, string $reason): void { app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Red, $reason); } test('each release counts its automatic checks and the last red blocks instead of rejecting', function () { /** @var TestCase $this */ [$customer, $pr] = gateDraft(); $service = app(PressReleaseService::class); $this->actingAs($customer); // Erster Durchlauf: Rot → abgelehnt, Zähler 1. $service->submitForReview($pr); Queue::assertPushed(ClassifyPressRelease::class, 1); expect($pr->fresh()->auto_check_attempts)->toBe(1); redRun($pr, 'K1: Werbliche Sprache'); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected) ->and($pr->fresh()->requiresManualReview())->toBeFalse(); // Zweiter (= letzter erlaubter) Durchlauf: Rot → nicht ablehnen, blocken. $service->submitForReview($pr->fresh()); Queue::assertPushed(ClassifyPressRelease::class, 2); expect($pr->fresh()->auto_check_attempts)->toBe(2); redRun($pr, 'K1: Immer noch werblich'); $fresh = $pr->fresh(); expect($fresh->status)->toBe(PressReleaseStatus::Review) ->and($fresh->isAwaitingManualReview())->toBeTrue() ->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason) ->toContain('Automatische Prüfung erschöpft (2 von 2)'); // Keine Ablehnungsmail für den Block — stattdessen „ein Mensch prüft jetzt". Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft')); Mail::assertQueued(PressReleaseHeldForReview::class, function (PressReleaseHeldForReview $mail) use ($customer, $pr) { return $mail->hasTo($customer->email) && $mail->automationExhausted === true && $mail->pressRelease->is($pr) && str_contains($mail->showUrl, '/admin/me/press-releases/'.$pr->id.'?src=mail_review&pr='.$pr->id); }); }); test('the held-for-review mail says nothing is required and respects the rejected opt-out', function () { /** @var TestCase $this */ [$customer, $pr] = gateDraft(); $pr->update(['title' => 'Zurückgehaltene Meldung']); $html = (new PressReleaseHeldForReview($customer, $pr, automationExhausted: true))->render(); expect($html) ->toContain('Zurückgehaltene Meldung') ->toContain('auch im 2. Durchlauf nicht') ->toContain('Sie müssen nichts tun') ->toContain('Stand der Prüfung ansehen'); $this->assertStringNotContainsString('Ablehnungsgrund', $html); $yellow = (new PressReleaseHeldForReview($customer, $pr, automationExhausted: false))->render(); expect($yellow)->toContain('zur Sicherheit zurückgehalten'); $this->assertStringNotContainsString('Durchlauf', $yellow); // Wer die Ablehnungsmails abbestellt hat, bekommt auch den Zwischenstand nicht. $customer->optOutOfEmailNotification(EmailNotificationType::PressReleaseRejected); $pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value]); redRun($pr, 'K1: Rot'); expect($pr->fresh()->isAwaitingManualReview())->toBeTrue(); Mail::assertNotQueued(PressReleaseHeldForReview::class); }); test('after the human decision the automation stays off for this release', function () { /** @var TestCase $this */ [$customer, $pr] = gateDraft(); $service = app(PressReleaseService::class); $this->actingAs($customer); $pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value]); redRun($pr, 'K2: Rot'); expect($pr->fresh()->isAwaitingManualReview())->toBeTrue(); // Admin: mit Kommentar zurück. $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $this->actingAs($admin); $service->reject($pr->fresh(), 'Bitte die Behauptung im zweiten Absatz belegen.'); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected) ->and($pr->fresh()->requiresManualReview())->toBeTrue(); // Autor gibt erneut frei: kein KI-Job, direkt wieder in der Queue. $this->actingAs($customer); Queue::fake(); $service->submitForReview($pr->fresh()); Queue::assertNotPushed(ClassifyPressRelease::class); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review) ->and($pr->fresh()->isAwaitingManualReview())->toBeTrue() ->and($pr->fresh()->auto_check_attempts)->toBe(2) ->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)->toContain('wartet auf manuelle Entscheidung'); }); test('yellow publishes by default and is held only when the switch is on', function () { /** @var TestCase $this */ [, $pr] = gateDraft(); $pr->update(['status' => PressReleaseStatus::Review->value]); config()->set('scoring.classification.yellow_to_manual_queue', false); app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Yellow, 'Gelb'); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); [, $other] = gateDraft(); $other->update(['status' => PressReleaseStatus::Review->value]); config()->set('scoring.classification.yellow_to_manual_queue', true); app(PressReleaseService::class)->routeByClassification($other->fresh(), PressReleaseClassification::Yellow, 'Gelb'); expect($other->fresh()->status)->toBe(PressReleaseStatus::Review) ->and($other->fresh()->isAwaitingManualReview())->toBeTrue(); }); test('the admin sees blocked releases in their own view and the detail page explains the decision', function () { /** @var TestCase $this */ [, $pr] = gateDraft(); $pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value, 'title' => 'Geblockte Meldung']); redRun($pr, 'K4: Falsche Tatsachenbehauptung'); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $this->actingAs($admin); Livewire::test('admin.press-releases.index') ->call('setView', 'manual') ->assertSeeText('Geblockte Meldung') ->assertSeeText('Prüfung nötig'); Livewire::test('admin.press-releases.show', ['id' => $pr->id]) ->assertSeeText('Automatik erschöpft') ->assertSeeText('K4: Falsche Tatsachenbehauptung') ->assertSeeText('Freischalten') ->assertSeeText('Mit Kommentar zurück'); }); test('the customer page explains the release button, counts attempts and shows the human review', function () { /** @var TestCase $this */ [$customer, $pr] = gateDraft(); $this->actingAs($customer); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSeeText('Zur Prüfung freigeben') ->assertSeeText('Was nach der Freigabe passiert') ->assertSeeText('Nach 2 Prüfungen mit Rot'); $pr->update(['status' => PressReleaseStatus::Rejected->value, 'auto_check_attempts' => 1]); PressReleaseStatusLog::query()->create(['press_release_id' => $pr->id, 'from_status' => 'review', 'to_status' => 'rejected', 'reason' => 'K1: Werblich', 'source' => 'ki']); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSeeText('Automatische Prüfung 1 von 2 genutzt'); $pr->update(['status' => PressReleaseStatus::Review->value, 'auto_check_attempts' => 2, 'manual_review_required_at' => now()]); Livewire::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSeeText('von einem Menschen geprüft'); });