presseportale/tests/Feature/PressReleaseSchedulingTest.php
Kevin Adametz f0eeef0144 press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

557 lines
20 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\ActivityEvent;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\ManualReviewRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
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 blockiert, 0 fehlgeschlagen')
->assertSuccessful();
});
/*
* K-01 (Umsetzungsaudit 29.09.2026): Eine Meldung, die auf eine menschliche
* Entscheidung wartet (E3), geht nie automatisch live — weder über den
* Scheduler noch über eine KI-Freigabe. Der Admin kann weiter veröffentlichen.
*/
test('the scheduler leaves a due release that awaits a human decision in review (Audit K-01)', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$held = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => '2026-06-01 11:55:00',
'published_at' => null,
'manual_review_required_at' => '2026-05-30 10:00:00',
]);
$this->artisan(PublishScheduledPressReleases::class)->assertSuccessful();
expect($held->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('an automatic publish refuses a release held for a human decision, an admin publish does not (Audit K-01)', function (string $source) {
/** @var TestCase $this */
$held = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'published_at' => null,
'manual_review_required_at' => now(),
]);
expect(fn () => app(PressReleaseService::class)->publish($held, source: $source))
->toThrow(ManualReviewRequiredException::class);
expect($held->fresh()->status)->toBe(PressReleaseStatus::Review);
app(PressReleaseService::class)->publish($held->fresh(), source: 'admin');
expect($held->fresh()->status)->toBe(PressReleaseStatus::Published);
})->with(['ki', 'scheduler']);
test('a green routing of a held release does not publish it (Audit K-01)', function () {
/** @var TestCase $this */
config()->set('blacklist.words', []);
$held = PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'scheduled_at' => null,
'published_at' => null,
'manual_review_required_at' => now(),
]);
app(PressReleaseService::class)->routeByClassification($held, PressReleaseClassification::Green);
expect($held->fresh()->status)->toBe(PressReleaseStatus::Review);
});
/*
* K-02: Kontingent und Bildsperre sind fachliche Ausgänge des Schedulers —
* kein FAILURE (sonst alle 5 Minuten Exit 1 und Alarm-Mail), sondern ein
* einmaliger Eintrag im Aktivitäts-Verlauf je Sperre.
*/
test('the scheduler treats an exhausted quota or unconfirmed image as a blocked release, logged once (Audit K-02)', function (Closure $exception, string $reason) {
/** @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')
->twice()
->andThrow($exception());
$this->artisan(PublishScheduledPressReleases::class)
->expectsOutputToContain('[BLOCKIERT')
->expectsOutputToContain('1 blockiert, 0 fehlgeschlagen')
->assertSuccessful();
Carbon::setTestNow('2026-06-01 12:05:00');
$this->artisan(PublishScheduledPressReleases::class)->assertSuccessful();
$events = ActivityEvent::query()
->where('subject_type', $due->getMorphClass())
->where('subject_id', $due->id)
->where('description', 'like', '%Termin erreicht, aber nicht veröffentlicht%')
->get();
expect($events)->toHaveCount(1)
->and($events->first()->meta['publish_blocked'])->toBe($reason)
->and($due->fresh()->status)->toBe(PressReleaseStatus::Review);
})->with([
'quota' => [fn () => new QuotaExceededException, 'quota'],
'image rights' => [fn () => new ImageRightsConfirmationRequiredException, 'image_rights'],
]);
/**
* Umsetzungsaudit 02.10.2026, O06: Blockierte Kandidaten bleiben fällig und
* standen bisher dauerhaft vorn in der Auswahl — eine volle Seite davon hielt
* jede jüngere, veröffentlichungsfähige Meldung auf.
*/
test('blocked releases do not starve a later publishable release in the scheduler', function () {
/** @var TestCase $this */
Carbon::setTestNow('2026-06-01 12:00:00');
$make = fn (string $scheduledAt) => PressRelease::factory()->create([
'status' => PressReleaseStatus::Review->value,
'classification' => PressReleaseClassification::Green->value,
'scheduled_at' => $scheduledAt,
'published_at' => null,
]);
$blocked = [$make('2026-06-01 09:00:00')->id, $make('2026-06-01 10:00:00')->id];
$free = $make('2026-06-01 11:00:00');
$published = [];
$this->mock(PressReleaseService::class)
->shouldReceive('publish')
->andReturnUsing(function (PressRelease $pressRelease) use ($blocked, &$published): void {
if (in_array($pressRelease->id, $blocked, true)) {
throw new QuotaExceededException;
}
$published[] = $pressRelease->id;
});
$this->artisan(PublishScheduledPressReleases::class, ['--limit' => 2])
->expectsOutputToContain('1 veröffentlicht, 0 wegen Blacklist abgelehnt, 0 neu zur Prüfung, 2 blockiert')
->assertSuccessful();
expect($published)->toBe([$free->id]);
});