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 5f7fbd0c65
commit 13716b9c06
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');
});

View file

@ -50,7 +50,7 @@ test('with the gate on a draft without a booking shows the notice and links to b
->assertSee('Noch keine Buchung')
->assertSee('Buchung wählen')
->assertSee(route('me.bookings.index'), false)
->assertDontSee('Zur Prüfung einreichen');
->assertDontSee('Zur Prüfung freigeben');
});
test('with the gate on a paid single purchase unlocks the submission action', function () {
@ -63,7 +63,7 @@ test('with the gate on a paid single purchase unlocks the submission action', fu
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Noch keine Buchung')
->assertSee('Zur Prüfung einreichen');
->assertSee('Zur Prüfung freigeben');
});
test('submitting without a booking is rejected at the service level', function () {
@ -105,5 +105,5 @@ test('with the gate off the notice never shows', function () {
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Noch keine Buchung')
->assertSee('Zur Prüfung einreichen');
->assertSee('Zur Prüfung freigeben');
});

View file

@ -15,7 +15,7 @@ test('the pm create page shows a notice instead of the form without a company',
->assertSet('hasCompanies', false)
->assertSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Firma anlegen')
->assertDontSee('Zur Prüfung senden');
->assertDontSee('Zur Prüfung freigeben');
});
test('the pm create page shows the editor when a company exists', function () {
@ -30,5 +30,5 @@ test('the pm create page shows the editor when a company exists', function () {
->assertSet('hasCompanies', true)
->assertSet('companyId', $company->id)
->assertDontSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Zur Prüfung senden');
->assertSee('Zur Prüfung freigeben');
});

View file

@ -34,9 +34,9 @@ test('customer show renders the publish confirmation modal with legal note and q
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Pressemitteilung zur Prüfung einreichen')
->assertSee('Pressemitteilung zur Prüfung freigeben')
->assertSee('Rechtliche Hinweise')
->assertSee('Mit dem Einreichen versichern Sie:')
->assertSee('Mit der Freigabe versichern Sie:')
->assertSee('PM-Kontingent diesen Monat')
->assertSee('2 / 3')
->assertSee('Ihre Bestätigungen');
@ -49,7 +49,7 @@ test('the quota block is hidden for users with an unlimited quota', function ()
// Launch-Schalter aus → Kontingent unbegrenzt → kein Quota-Block.
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Pressemitteilung zur Prüfung einreichen')
->assertSee('Pressemitteilung zur Prüfung freigeben')
->assertDontSee('PM-Kontingent diesen Monat');
});
@ -113,5 +113,5 @@ test('the modal shows a booking notice instead of the submit flow when the gate
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Buchung erforderlich')
->assertDontSee('Mit dem Einreichen versichern Sie:');
->assertDontSee('Mit der Freigabe versichern Sie:');
});

View file

@ -230,7 +230,7 @@ test('customer show page surfaces latest reject reason prominently', function ()
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Werbliche Sprache wurde markiert.')
->assertSee('Erneut einreichen');
->assertSee('Erneut zur Prüfung freigeben');
});
test('admin index publish goes through service and creates audit log', function () {