- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)
Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.8 KiB
PHP
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\Livewire;
|
|
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);
|
|
|
|
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');
|
|
});
|