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:
parent
5f7fbd0c65
commit
13716b9c06
17 changed files with 487 additions and 47 deletions
168
tests/Feature/ManualReviewGateTest.php
Normal file
168
tests/Feature/ManualReviewGateTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue