presseportale/tests/Feature/PressReleaseSchedulingTest.php
Kevin Adametz ea68362310 ki/quota: Hash-Bindung atomar bis in den Publish-Lock, roter Re-Check lehnt ab, Kontingent-Altbestand (Block 7, U02–U04)
- Jobs speichern Ergebnis nur per bedingtem UPDATE auf den analysierten Hash;
  publish() prüft bei ki/scheduler unter der Zeilensperre die jüngste gültige
  Klassifikation und plant sonst eine neue Prüfung ein (StaleClassificationException)
- Re-Check ohne Routing: Rot bei einer PM in Review lehnt wie die Erstprüfung ab
- consumePublishSlot() zählt eine NULL-Periode wie den laufenden Monat;
  Backfill-Migration setzt die Periode für Altverbrauch
- ApiPortalResolutionTest: gemeinsame Kategorie wegen der U10-Prüfung

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:22:22 +00:00

416 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Console\Commands\PublishScheduledPressReleases;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\StaleClassificationException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
});
test('publish ohne scheduled_at und ohne embargo_at setzt published_at auf jetzt', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
'embargo_at' => null,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-01 10:00:00');
});
test('publish mit scheduled_at in der Zukunft setzt published_at auf den Termin', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => null,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-05 14:30:00');
});
test('publish mit embargo_at in der Zukunft verschiebt published_at auf das Embargo', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
'embargo_at' => '2026-06-10 08:00:00',
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('publish mit scheduled_at und späterem embargo_at nimmt das Embargo', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => '2026-06-10 08:00:00',
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2026-06-10 08:00:00');
});
test('publish übernimmt bereits gesetztes published_at und überschreibt nicht', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 10:00:00');
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-05 14:30:00',
'embargo_at' => null,
'published_at' => '2025-12-01 00:00:00',
]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->published_at?->toDateTimeString())->toBe('2025-12-01 00:00:00');
});
test('publish-Source landet als source im Status-Log', function () {
/** @var TestCase $this */
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'published_at' => null,
]);
app(PressReleaseService::class)->publish($pr, source: 'scheduler');
$log = PressReleaseStatusLog::query()->latest('id')->firstOrFail();
expect($log->source)->toBe('scheduler');
expect($log->to_status)->toBe(PressReleaseStatus::Published);
});
test('Command publisht fällige Review-PMs mit scheduled_at <= now', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$due = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
$fresh = $due->fresh();
expect($fresh->status)->toBe(PressReleaseStatus::Published);
expect($fresh->published_at?->toDateTimeString())->toBe('2026-06-01 11:55:00');
});
test('Command publisht fällige gelbe PMs wie grüne (Direkt-Live)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$yellow = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Yellow->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($yellow->fresh()->status)->toBe(PressReleaseStatus::Published);
});
test('Command ignoriert fällige unklassifizierte PMs (Fallback: manuelle Queue)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$unclassified = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => null,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($unclassified->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command ignoriert PMs mit scheduled_at in der Zukunft', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$future = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => '2026-06-01 12:30:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($future->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command ignoriert PMs ohne scheduled_at', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$manual = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($manual->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command ignoriert gelöschte fällige Pressemitteilungen', function () {
Carbon::setTestNow('2026-06-01 12:00:00');
$deleted = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
$deleted->delete();
Artisan::call(PublishScheduledPressReleases::class);
expect(PressRelease::withTrashed()->findOrFail($deleted->id)->status)
->toBe(PressReleaseStatus::Review);
});
test('Command läuft mit dry-run ohne Statusänderung', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$due = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:50:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class, ['--dry-run' => true]);
expect($due->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('Command publisht maximal --limit pro Lauf', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
PressRelease::factory()->count(3)->state([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:50:00',
'published_at' => null,
])->create();
Artisan::call(PublishScheduledPressReleases::class, ['--limit' => 2]);
$publishedCount = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Published->value)
->count();
expect($publishedCount)->toBe(2);
});
test('the scheduler reports failure when a due release cannot be published (Audit F20)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
$this->mock(PressReleaseService::class)
->shouldReceive('publish')
->once()
->andThrow(new RuntimeException('Datenbank nicht erreichbar'));
$this->artisan(PublishScheduledPressReleases::class)->assertFailed();
});
test('the scheduler re-checks a due release whose classification predates the last edit instead of publishing it (Audit F07)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
Queue::fake();
$stale = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $stale->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => str_repeat('0', 64),
]);
$current = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $current->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => $current->content_hash,
]);
// Ohne Audit (Altbestand vor der Hash-Bindung) wird wie bisher veröffentlicht.
$legacy = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
Artisan::call(PublishScheduledPressReleases::class);
expect($stale->fresh()->status)->toBe(PressReleaseStatus::Review)
->and($current->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($legacy->fresh()->status)->toBe(PressReleaseStatus::Published);
Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $stale->id
&& $job->route === true
&& $job->contentHash === $stale->fresh()->content_hash);
});
test('an automatic publish refuses a classification whose hash no longer matches the locked content (Audit U02)', function () {
/** @var TestCase $this */
Queue::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'title' => 'Version zwei',
'text' => 'Inhalt zwei',
'published_at' => null,
]);
// Grünes Urteil gehört zu Version eins – die PM trägt inzwischen Version zwei.
KiAudit::factory()->create([
'press_release_id' => $pr->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => str_repeat('a', 64),
]);
expect(fn () => app(PressReleaseService::class)->publish($pr, source: 'scheduler'))
->toThrow(StaleClassificationException::class);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review)
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->count())->toBe(0);
Queue::assertPushedOn('classification', ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pr->id
&& $job->route === true
&& $job->contentHash === $pr->content_hash);
});
test('a manual admin publish overrides a stale classification (Audit U02)', function () {
/** @var TestCase $this */
Queue::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'title' => 'Version zwei',
'text' => 'Inhalt zwei',
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $pr->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => str_repeat('a', 64),
]);
app(PressReleaseService::class)->publish($pr, source: 'admin');
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
Queue::assertNotPushed(ClassifyPressRelease::class);
});
test('an automatic publish lets a legacy release without an audit hash through (Audit U02)', function () {
/** @var TestCase $this */
Queue::fake();
$pr = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'published_at' => null,
]);
KiAudit::factory()->create([
'press_release_id' => $pr->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'result' => 'green',
'content_hash' => null,
]);
app(PressReleaseService::class)->publish($pr, source: 'scheduler');
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
});
test('the scheduler counts a stale classification at publish time as a re-check, not as a failure (Audit U02)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$due = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
]);
$this->mock(PressReleaseService::class)
->shouldReceive('publish')
->once()
->andThrow(new StaleClassificationException($due->id, str_repeat('a', 64), (string) $due->content_hash));
$this->artisan(PublishScheduledPressReleases::class)
->expectsOutputToContain('[RECHECK')
->expectsOutputToContain('1 neu zur Prüfung, 0 fehlgeschlagen')
->assertSuccessful();
});