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]); });