presseportale/tests/Feature/ManualReviewGateTest.php
Kevin Adametz 4dffb1368f press-releases: Mail "wird persoenlich geprueft" beim Block, Blade-Guard fuer @php-Bloecke (Roadmap 1d, Nachtrag)
Der Autor bekommt beim Block nach dem letzten Rot (oder Gelb mit
eingeschalteter Queue) eine kurze Mail statt Stillstand: was passiert ist,
dass nichts zu tun ist, und dass das Ergebnis per Mail kommt. Laeuft ueber
notifyAuthor() wie Veroeffentlichung und Ablehnung (API-Regel, Sperrliste,
adressbezogene Abmeldung, Bounce-Referenz); Opt-out am Typ "abgelehnt".
Neue ConversionSource mail_review fuer den Link ins Konto.

BladePhpBlockGuardTest wendet das Compiler-Muster auf alle Views an und
faengt inline @php( vor einem spaeteren @endphp ab — fand den eigenen
Erklaer-Kommentar im Modal, der nur zufaellig funktionierte. Regel in
CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:05:47 +00:00

203 lines
8.8 KiB
PHP

<?php
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleaseRejected;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Begrenzter Prüfkreislauf (Roadmap 1d, E3 entschieden 16.09.2026): die
* automatische Prüfung läuft je Meldung N-mal, danach entscheidet ein Mensch
* und die Automatik bleibt für diese Meldung aus.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->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);
LivewireVolt::test('admin.press-releases.index')
->call('setView', 'manual')
->assertSeeText('Geblockte Meldung')
->assertSeeText('Prüfung nötig');
LivewireVolt::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);
LivewireVolt::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']);
LivewireVolt::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()]);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSeeText('von einem Menschen geprüft');
});