press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3)

Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3);
das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin
sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und
entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die
Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen.

Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false).
Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte,
Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung.

Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock
gelesen - vier Stellen auf Blockform umgestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-16 14:30:26 +00:00
parent c08c9e5de0
commit 0692d892e0
17 changed files with 487 additions and 47 deletions

View file

@ -0,0 +1,168 @@
<?php
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
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 — der Autor sieht den Stand im Konto.
Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft'));
});
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');
});