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>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent d6e49c11c3
commit c82f279fef
20 changed files with 927 additions and 89 deletions

View file

@ -50,6 +50,14 @@ class ExpireGrandfathering extends Command
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
// E18 als Zusicherung statt nur als Kommentar (OPT-9): vor jeder
// Änderung, damit ein Lauf auf falscher Queue gar nichts anfasst.
if (($violation = $this->queueViolation()) !== null) {
$this->error($violation);
return self::FAILURE;
}
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
@ -153,7 +161,8 @@ class ExpireGrandfathering extends Command
*
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
* ist damit Teil der Transaktion. Bei einem Wechsel auf Redis o. ä. bräuchte
* ist damit Teil der Transaktion. handle() sichert das zu (OPT-9,
* queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
* `notified_at` nach Zustellung).
*
@ -214,6 +223,36 @@ class ExpireGrandfathering extends Command
return [$notified, $failed];
}
/**
* Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in
* derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für
* die Datenbank-Queue auf der Standard-Verbindung — oder für `sync`
* (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft
* und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den
* Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als
* stille Doppel- oder Nicht-Zustellung.
*/
private function queueViolation(): ?string
{
$default = (string) config('queue.default');
if ($default === 'sync') {
return null;
}
if ($default !== 'database') {
return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default);
}
$queueConnection = config('queue.connections.database.connection');
if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) {
return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default'));
}
return null;
}
/**
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
* Sperre und UPDATE identisch (U11).

View file

@ -2,16 +2,26 @@
namespace App\Console\Commands;
use App\Enums\ActivityType;
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\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\PressRelease\BlacklistViolationException;
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 Carbon\CarbonInterface;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
@ -38,6 +48,19 @@ use Throwable;
* Prüfung dann schon eingeplant.
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
*
* Menschliche Entscheidung (Umsetzungsaudit 29.09.2026, K-01): Meldungen mit
* `manual_review_required_at` zieht der Scheduler nie; publish() prüft die
* Marke für automatische Quellen zusätzlich unter der Sperre.
*
* Fachlich blockiert (K-02): Ist das Kontingent aufgebraucht oder wartet ein
* Titelbild noch auf die Bestätigung des Kontoinhabers, bleibt die Meldung in
* `review` — das ist kein Betriebsfehler (sonst alle 5 Minuten Exit 1 und
* Alarm-Mail). Der Lauf endet erfolgreich; die Sperre steht einmal je
* Sperrgrund im Aktivitäts-Verlauf der Meldung (und im Log), bis sich ihr
* Status ändert. Eine eigene Autorenmail gibt es bewusst nicht: für das
* Kontingent existiert keine passende Vorlage, und die Bildbestätigung hat
* der Kunde beim Admin-Upload bereits per Mail angefordert bekommen.
*/
class PublishScheduledPressReleases extends Command
{
@ -53,17 +76,116 @@ class PublishScheduledPressReleases extends Command
*/
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
public function handle(PressReleaseService $service): int
public function handle(PressReleaseService $service, ActivityLog $activityLog): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$now = now();
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
// Fallback in der manuellen Queue.
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
$published = 0;
$rejected = 0;
$failed = 0;
$rechecked = 0;
$blocked = 0;
// Fairer Fortschritt (Umsetzungsaudit 02.10.2026, O06): Fachlich
// blockierte Meldungen bleiben fällig und stünden sonst bei jedem
// Lauf vorn — eine volle Seite davon hielte jede jüngere Meldung auf.
// Deshalb seitenweise per Cursor über (scheduled_at, id); das Limit
// gilt für bearbeitete Meldungen, Blockaden zählen nur auf ein
// Scan-Budget, das den Lauf begrenzt.
$scanBudget = $limit * 10;
$scanned = 0;
$handled = 0;
$cursor = null;
while ($handled < $limit && $scanned < $scanBudget) {
$page = $this->duePage($now, $cursor, min($limit, $scanBudget - $scanned));
if ($page->isEmpty()) {
break;
}
foreach ($page as $pressRelease) {
$scanned++;
$cursor = [$pressRelease->scheduled_at, $pressRelease->id];
$line = sprintf(
' #%d scheduled_at=%s title="%s"',
$pressRelease->id,
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
Str::limit($pressRelease->title, 60),
);
if ($dryRun) {
$this->line($line.' [DRY]');
$handled++;
if ($handled >= $limit) {
break;
}
continue;
}
$outcome = $this->processCandidate($service, $activityLog, $pressRelease, $line);
match ($outcome) {
'published' => $published++,
'rejected' => $rejected++,
'rechecked' => $rechecked++,
'blocked' => $blocked++,
default => $failed++,
};
if ($outcome !== 'blocked') {
$handled++;
}
if ($handled >= $limit) {
break;
}
}
}
if ($scanned === 0) {
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
return self::SUCCESS;
}
if ($dryRun) {
$this->info(sprintf('%d fällige Pressemitteilung(en) gefunden. (Dry-Run)', $scanned));
}
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d blockiert, %d fehlgeschlagen.',
$published,
$rejected,
$rechecked,
$blocked,
$failed,
));
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* Nächste Seite fälliger Kandidaten nach dem Cursor (scheduled_at, id).
* Gelb und Grün gehen zum Termin automatisch live (Decision-Update §5.0);
* nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als Fallback in
* der manuellen Queue.
*
* @param array{0: mixed, 1: int}|null $cursor
* @return Collection<int, PressRelease>
*/
private function duePage(CarbonInterface $now, ?array $cursor, int $size): Collection
{
return PressRelease::withoutGlobalScope(PortalScope::class)
->where('status', PressReleaseStatus::Review->value)
->whereIn('classification', [
PressReleaseClassification::Green->value,
@ -71,78 +193,99 @@ class PublishScheduledPressReleases extends Command
])
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now)
->whereNull('manual_review_required_at')
->when($cursor !== null, fn ($query) => $query->where(fn ($query) => $query
->where('scheduled_at', '>', $cursor[0])
->orWhere(fn ($query) => $query->where('scheduled_at', $cursor[0])->where('id', '>', $cursor[1]))))
->orderBy('scheduled_at')
->limit($limit)
->orderBy('id')
->limit(max(1, $size))
->get();
}
if ($candidates->isEmpty()) {
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
/**
* Eine fällige Meldung veröffentlichen oder fachlich einordnen.
*
* @return 'published'|'rejected'|'rechecked'|'blocked'|'failed'
*/
private function processCandidate(PressReleaseService $service, ActivityLog $activityLog, PressRelease $pressRelease, string $line): string
{
if ($this->classificationIsStale($pressRelease)) {
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
return self::SUCCESS;
return 'rechecked';
}
$this->info(sprintf(
'%d fällige Pressemitteilung(en) gefunden.%s',
$candidates->count(),
$dryRun ? ' (Dry-Run)' : '',
));
try {
$service->publish($pressRelease, source: 'scheduler');
$this->line($line.' [OK]');
$published = 0;
$rejected = 0;
$failed = 0;
$rechecked = 0;
return 'published';
} catch (BlacklistViolationException $e) {
$this->warn($line.' [REJECT: '.$e->word.']');
foreach ($candidates as $pressRelease) {
$line = sprintf(
' #%d scheduled_at=%s title="%s"',
$pressRelease->id,
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
Str::limit($pressRelease->title, 60),
);
return 'rejected';
} catch (StaleClassificationException) {
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
if ($dryRun) {
$this->line($line.' [DRY]');
return 'rechecked';
} catch (ManualReviewRequiredException) {
$this->warn($line.' [BLOCKIERT: wartet auf manuelle Prüfung]');
continue;
}
return 'blocked';
} catch (QuotaExceededException) {
$this->warn($line.' [BLOCKIERT: Kontingent aufgebraucht]');
$this->recordBlockedOnce($activityLog, $pressRelease, 'quota', 'Kontingent aufgebraucht');
if ($this->classificationIsStale($pressRelease)) {
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
$rechecked++;
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
return 'blocked';
} catch (ImageRightsConfirmationRequiredException) {
$this->warn($line.' [BLOCKIERT: Titelbild nicht bestätigt]');
$this->recordBlockedOnce($activityLog, $pressRelease, 'image_rights', 'Titelbild vom Kontoinhaber noch nicht bestätigt');
continue;
}
return 'blocked';
} catch (Throwable $e) {
$this->error($line.' [FAIL: '.$e->getMessage().']');
report($e);
try {
$service->publish($pressRelease, source: 'scheduler');
$published++;
$this->line($line.' [OK]');
} catch (BlacklistViolationException $e) {
$rejected++;
$this->warn($line.' [REJECT: '.$e->word.']');
} catch (StaleClassificationException) {
$rechecked++;
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
} catch (Throwable $e) {
$failed++;
$this->error($line.' [FAIL: '.$e->getMessage().']');
report($e);
}
return 'failed';
}
}
/**
* Fachliche Sperre einmal je Sperrgrund festhalten (K-02): Ein Eintrag im
* Aktivitäts-Verlauf der Meldung (für Admins sichtbar) und eine
* Log-Warnung — nicht bei jedem 5-Minuten-Lauf erneut. Ein Statuswechsel
* der Meldung beginnt eine neue Episode.
*/
private function recordBlockedOnce(ActivityLog $activityLog, PressRelease $pressRelease, string $reason, string $label): void
{
$description = sprintf('PM #%d: Termin erreicht, aber nicht veröffentlicht — %s.', $pressRelease->id, $label);
$since = PressReleaseStatusLog::query()
->where('press_release_id', $pressRelease->id)
->max('created_at');
$alreadyRecorded = ActivityEvent::query()
->where('subject_type', $pressRelease->getMorphClass())
->where('subject_id', $pressRelease->id)
->where('description', $description)
->when($since !== null, fn ($query) => $query->where('occurred_at', '>=', $since))
->exists();
if ($alreadyRecorded) {
return;
}
if (! $dryRun) {
$this->newLine();
$this->info(sprintf(
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d fehlgeschlagen.',
$published,
$rejected,
$rechecked,
$failed,
));
}
Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]);
return $failed > 0 ? self::FAILURE : self::SUCCESS;
$activityLog->record(
ActivityType::PressReleaseStatus,
$description,
subject: $pressRelease,
meta: ['publish_blocked' => $reason],
actorLabel: 'scheduler',
);
}
/**

View file

@ -31,6 +31,12 @@ class ResumeNewsletterCampaign extends Command
public function handle(): int
{
if (! CampaignSender::sendingEnabled()) {
$this->error('Der Newsletter-Versand ist gesperrt (NEWSLETTER_SENDING_ENABLED=false) — nichts eingeplant.');
return self::FAILURE;
}
$campaign = NewsletterCampaign::query()->find((int) $this->argument('campaign'));
if ($campaign === null) {

View file

@ -10,6 +10,8 @@ use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
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;
@ -17,6 +19,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use LogicException;
/**
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
@ -67,6 +70,17 @@ class ClassifyPressRelease implements ShouldQueue
*/
public int $timeout = 120;
/**
* Wartezeit vor dem 2. und 3. Versuch (OPT-8): Ein 429 bzw. eine kurze
* Störung des KI-Anbieters soll nicht sofort zweimal nachgeschossen werden.
*
* @return list<int>
*/
public function backoff(): array
{
return [30, 120];
}
/**
* @param int $pressReleaseId Zu prüfende PM
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
@ -176,6 +190,11 @@ class ClassifyPressRelease implements ShouldQueue
return;
}
// Status frisch lesen (Audit K-05): Der KI-Aufruf kann gedauert haben;
// hat ein Admin inzwischen entschieden, greift routeByClassification()
// gar nicht erst, statt an einer Statusprüfung zu scheitern.
$pressRelease->refresh();
try {
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
} catch (BlacklistViolationException) {
@ -194,6 +213,26 @@ class ClassifyPressRelease implements ShouldQueue
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
} catch (ImageRightsConfirmationRequiredException) {
// Titelbild wartet auf die Bestätigung des Kontoinhabers (E10,
// Audit K-02): fachlicher Ausgang, kein Retry. Die PM bleibt in
// Review; die Bestätigung hat der Kunde per Mail bekommen.
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Titelbild noch nicht bestätigt, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
} catch (ManualReviewRequiredException) {
// Wartet auf einen Menschen (E3, Audit K-01): keine Automatik.
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM wartet auf manuelle Prüfung.', [
'press_release_id' => $pressRelease->id,
]);
} catch (LogicException $exception) {
// Status zwischen refresh() und Zeilensperre überholt (Audit K-05):
// die Entscheidung ist gefallen, ein Retry würde nur einen zweiten
// KI-Aufruf und ein doppeltes Audit erzeugen.
Log::channel('ki')->info('Routing überholt: Status wurde während der Prüfung geändert.', [
'press_release_id' => $pressRelease->id,
'error' => $exception->getMessage(),
]);
}
}

View file

@ -43,6 +43,17 @@ class ScorePressRelease implements ShouldQueue
*/
public int $timeout = 120;
/**
* Wartezeit vor dem 2. und 3. Versuch (OPT-8): Ein 429 bzw. eine kurze
* Störung des KI-Anbieters soll nicht sofort zweimal nachgeschossen werden.
*
* @return list<int>
*/
public function backoff(): array
{
return [30, 120];
}
public function __construct(
public readonly int $pressReleaseId,
public readonly ?string $providerOverride = null,

View file

@ -9,6 +9,7 @@ use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Models\NewsletterCampaignRecipient;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Newsletter\CampaignSender;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
@ -52,6 +53,13 @@ class SendNewsletterCampaignMail implements ShouldQueue
public function handle(): void
{
// Versand gesperrt (E9-Schalter): nichts beanspruchen, der Empfänger
// bleibt `pending` und kann nach dem Freischalten per
// `newsletter:resume` erneut eingeplant werden.
if (! CampaignSender::sendingEnabled()) {
return;
}
if (! $this->claim()) {
return;
}

View file

@ -30,11 +30,26 @@ class CampaignSender
{
public function __construct(private readonly CampaignRecipients $recipients) {}
/**
* Versand per Konfiguration freigegeben (E9-Schalter).
*/
public static function sendingEnabled(): bool
{
return (bool) config('newsletter.sending_enabled', false);
}
/**
* @return int Anzahl der eingeplanten Empfänger.
*
* @throws NewsletterSendingDisabledException Versand gesperrt; der Entwurf
* bleibt unverändert.
*/
public function start(NewsletterCampaign $campaign): int
{
if (! self::sendingEnabled()) {
throw new NewsletterSendingDisabledException;
}
if (! $this->claimDraft($campaign)) {
return 0;
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\Newsletter;
use RuntimeException;
/**
* Kampagnen-Versand ist per Konfiguration gesperrt (`newsletter.sending_enabled`,
* E9-Schalter, Umsetzungsaudit 29.09.2026) — bis U05–U07 umgesetzt sind.
*/
class NewsletterSendingDisabledException extends RuntimeException
{
public function __construct()
{
parent::__construct(__('Der Newsletter-Versand ist derzeit gesperrt (NEWSLETTER_SENDING_ENABLED). Entwürfe und Test-Mails bleiben möglich.'));
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Automatische Veröffentlichung (`ki`, `scheduler`) einer Meldung, die auf
* eine menschliche Entscheidung wartet (E3, `manual_review_required_at`) —
* Umsetzungsaudit 29.09.2026, K-01. Fachlicher Ausgang, kein Fehler: Die
* Meldung bleibt unverändert in `review`, nur ein Admin gibt sie frei.
*/
class ManualReviewRequiredException extends RuntimeException
{
public function __construct(public readonly int $pressReleaseId)
{
parent::__construct(sprintf('PM #%d wartet auf eine manuelle Prüfung und wird nicht automatisch veröffentlicht.', $pressReleaseId));
}
}

View file

@ -1,6 +1,14 @@
<?php
return [
/*
* Kampagnen-Versand (E9, Umsetzungsaudit 29.09.2026): Standard aus, bis
* U05–U07 umgesetzt sind. Solange aus, verweigern CampaignSender::start(),
* die Admin-UI, `newsletter:resume` und bereits eingereihte Versand-Jobs
* den Versand. Abmeldung, Sperrliste und Test-Mails sind nicht betroffen.
*/
'sending_enabled' => (bool) env('NEWSLETTER_SENDING_ENABLED', false),
'sync' => [
'enabled' => env('NEWSLETTER_SYNC_ENABLED', false),
'provider' => env('NEWSLETTER_SYNC_PROVIDER', 'placeholder'),

View file

@ -5,6 +5,7 @@ use App\Mail\NewsletterCampaignMail;
use App\Models\NewsletterCampaign;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use App\Services\Newsletter\NewsletterSendingDisabledException;
use Flux\Flux;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
@ -104,7 +105,14 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
{
$campaign = $this->persist();
$total = app(CampaignSender::class)->start($campaign);
try {
$total = app(CampaignSender::class)->start($campaign);
} catch (NewsletterSendingDisabledException $e) {
Flux::toast(heading: __('Versand gesperrt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-campaign-send')->close();
return;
}
if ($total === 0) {
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
@ -241,12 +249,23 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
{{ __('Test-Mail an mich') }}
</flux:button>
<flux:spacer />
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
@if (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:modal.trigger name="confirm-campaign-send">
<flux:button variant="primary" icon="paper-airplane">
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
@else
<flux:button variant="primary" icon="paper-airplane" disabled>
{{ __('Jetzt senden') }}
</flux:button>
</flux:modal.trigger>
@endif
</div>
@unless (\App\Services\Newsletter\CampaignSender::sendingEnabled())
<flux:callout variant="warning" icon="exclamation-triangle" class="mt-3"
:heading="__('Der Newsletter-Versand ist derzeit gesperrt')"
:text="__('Kampagnen lassen sich als Entwurf vorbereiten und als Test-Mail prüfen. Der Versand wird erst mit NEWSLETTER_SENDING_ENABLED freigeschaltet.')" />
@endunless
</div>
</article>

View file

@ -15,6 +15,7 @@ use App\Models\PressReleaseSearchIndex;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
@ -184,11 +185,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}
/**
* Manuelle KI-Prüfung aus der Liste — bewusst ohne Routing: bewertet
* und protokolliert, ändert aber nie den Status (Admin entscheidet).
* Manuelle KI-Prüfung aus der Liste — ohne Routing: bewertet und
* protokolliert, gibt aber nie frei. Ausnahme (Audit U03): Liefert der
* Lauf Rot und wartet die Meldung noch in `review`, lehnt der Job sie ab
* (inkl. Autorenmail). Deshalb dieselbe Berechtigung wie Freigeben und
* Ablehnen (Audit K-06).
*/
public function runAiCheck(?int $id = null): void
{
$this->authorize('press-releases:publish');
$id ??= $this->aiCheckId;
if ($id === null) {
@ -241,6 +247,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
} catch (QuotaExceededException $e) {
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
return;
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
return;
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');

View file

@ -18,6 +18,7 @@ use App\Console\Commands\PurgePressReleaseEvents;
use App\Console\Commands\PurgeRedactionSnapshots;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Foundation\Inspiring;
@ -39,6 +40,12 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
]);
};
// Sperren von withoutOverlapping() (Audit K-09): Ohne Angabe hält Laravel
// sie 24 h. Stürzt ein Lauf ab (OOM, Deploy, Container-Neustart), würde der
// Task bis zu einen Tag still übersprungen. Jede Sperre endet deshalb kurz
// nach der erwarteten Höchstlaufzeit: häufige Tasks nach ihrem Intervall,
// nächtliche nach 30–180 Minuten je nach Datenmenge.
// ========================================
// Tägliche Bereinigungen
// ========================================
@ -48,7 +55,7 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
// beendet sich der Befehl sofort.
Schedule::command(FetchMailBounces::class)
->everyTenMinutes()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('mail:fetch-bounces'));
@ -57,21 +64,21 @@ Schedule::command(FetchMailBounces::class)
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->dailyAt('04:20')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('mail:check-domains'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('magic-links:purge'));
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
Schedule::command(PurgeRedactionSnapshots::class)
->dailyAt('03:10')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('snapshots:purge'));
@ -79,13 +86,19 @@ Schedule::command(PurgeRedactionSnapshots::class)
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
Schedule::command(ArchivePurge::class)
->dailyAt('03:20')
->withoutOverlapping()
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:purge'));
// Bewusst eigener Termin statt ->then() an archive:purge (OPT-6, K-09):
// Als Anhängsel liefe der Export nach einem gescheiterten Purge gar nicht
// (onSuccess) bzw. ohne eigenen Failure-Alarm (then). So bleibt der Export
// auch dann aktuell und meldet sich selbst. Die Sperren enden nach 2 h bzw.
// 1 h; ein Purge, der länger als 10 Minuten läuft, überschneidet sich mit dem
// Export — der Export liest nur und nimmt den nächsten Stand am Folgetag mit.
Schedule::command(ArchiveExportTombstones::class)
->dailyAt('03:30')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:export-tombstones'));
@ -93,13 +106,13 @@ Schedule::command(ArchiveExportTombstones::class)
// nach 24 h Karenz entfernen (03:40) — Umsetzungsaudit 25.09.2026, N11.
Schedule::command(ArchiveRetryPending::class)
->dailyAt('03:35')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:retry-pending'));
Schedule::command(ArchiveSweepOrphans::class)
->dailyAt('03:40')
->withoutOverlapping()
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:sweep-orphans'));
@ -108,10 +121,21 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
->weekly()
->sundays()
->at('04:00')
->withoutOverlapping()
->withoutOverlapping(180)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-drafts'));
// Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09.2026, B-11): Reste
// eines hart abgebrochenen Uploads. 48 h Karenz schützt laufende Uploads;
// während eines Restore-Abgleichs (Archiv-Sweep pausiert) löscht er nichts.
Schedule::command(SweepOrphanPressReleaseOriginals::class, ['--execute', '--hours=48'])
->weekly()
->sundays()
->at('04:45')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-release-images:sweep-orphan-originals'));
// ========================================
// Geplante PM-Veröffentlichung
// ========================================
@ -120,7 +144,7 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
// Läuft alle 5 Min — passt zum FormRule "scheduled_at min. 5 Min in Zukunft".
Schedule::command(PublishScheduledPressReleases::class)
->everyFiveMinutes()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('press-releases:publish-scheduled'));
@ -133,17 +157,18 @@ Schedule::command(PublishScheduledPressReleases::class)
// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews).
Schedule::command(AggregatePressReleaseViews::class)
->everyMinute()
->withoutOverlapping()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('press-releases:aggregate-views'));
// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:20).
// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:50 —
// nicht mehr zeitgleich mit archive:purge um 03:20, Audit K-09).
// Die Tabelle nimmt jeden Seitenaufruf auf und wuchs als einzige Log-Tabelle
// des Systems unbegrenzt. Gelöscht wird nur unterhalb der Wasserstandsmarke —
// noch nicht in `hits` verbuchte Aufrufe bleiben unangetastet.
Schedule::command(PurgePressReleaseEvents::class)
->dailyAt('03:20')
->withoutOverlapping()
->dailyAt('03:50')
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-events'));
@ -151,7 +176,7 @@ Schedule::command(PurgePressReleaseEvents::class)
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
Schedule::command(CheckIndexStatus::class)
->dailyAt('05:10')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-releases:check-index-status'));
@ -159,7 +184,7 @@ Schedule::command(CheckIndexStatus::class)
// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
Schedule::command(PurgeConversionEvents::class)
->dailyAt('03:25')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('conversion:purge-events'));
@ -172,7 +197,7 @@ Schedule::command(PurgeConversionEvents::class)
// abgerechnet wird.
Schedule::command(ExpireGrandfathering::class)
->dailyAt('04:15')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('billing:expire-grandfathering'));
@ -181,7 +206,7 @@ Schedule::command(ExpireGrandfathering::class)
// Stripe (STR-Kreis) und werden hier nicht angefasst.
Schedule::command(GenerateManualInvoices::class)
->dailyAt('04:30')
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('billing:generate-manual-invoices'));
@ -192,7 +217,7 @@ Schedule::command(GenerateManualInvoices::class)
// Monatlicher Reset des verbrauchten PM-Kontingents (am 1. um 00:05).
Schedule::command(ResetMonthlyPressReleaseQuota::class)
->monthlyOn(1, '00:05')
->withoutOverlapping()
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('press-releases:reset-monthly-quota'));
@ -204,6 +229,6 @@ Schedule::command(ResetMonthlyPressReleaseQuota::class)
// nur aus dem Cache — ein API-Ausfall leert die Anzeige nicht sofort.
Schedule::command(RefreshMarketData::class)
->everyFifteenMinutes()
->withoutOverlapping()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('market:refresh'));

View file

@ -11,6 +11,9 @@ use App\Models\KiAudit;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Livewire\Volt\Volt as LivewireVolt;
@ -352,3 +355,83 @@ test('show page renders confirmation modals for status actions', function () {
LivewireVolt::test('admin.press-releases.show', ['id' => $publishedPressRelease->id])
->assertSee('Pressemitteilung archivieren?');
});
/*
* K-06 (Umsetzungsaudit 29.09.2026): Die Detailseite verlangt für jede
* statusändernde Aktion dieselbe Berechtigung wie Liste und Bearbeiten.
*/
test('show page actions are forbidden without the publish permission', function (string $action, array $set) {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Role::findByName('editor')->revokePermissionTo('press-releases:publish');
app(PermissionRegistrar::class)->forgetCachedPermissions();
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]);
$component = LivewireVolt::test('admin.press-releases.show', ['id' => $pressRelease->id]);
foreach ($set as $property => $value) {
$component->set($property, $value);
}
$component->call($action)->assertForbidden();
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
})->with([
'publish' => ['publish', []],
'reject' => ['reject', ['rejectReason' => 'Bitte überarbeiten.']],
'archive' => ['archive', []],
'correctImage' => ['correctImage', ['correctionReason' => 'Falscher Nachweis.']],
'approvePrecheck' => ['approvePrecheck', ['precheckNote' => 'Geprüft und ok.']],
'rejectPrecheck' => ['rejectPrecheck', ['precheckNote' => 'Geprüft, nicht ok.']],
'toggleWithheld' => ['toggleWithheld', ['correctionReason' => 'Beschwerde eingegangen.']],
]);
test('the manual ai check from the list needs the publish permission because a red result can reject', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Queue::fake();
Role::findByName('editor')->revokePermissionTo('press-releases:publish');
app(PermissionRegistrar::class)->forgetCachedPermissions();
$editor = User::factory()->create(['is_active' => true]);
$editor->assignRole('editor');
$this->actingAs($editor);
$pressRelease = PressRelease::factory()->inReview()->create();
LivewireVolt::test('admin.press-releases.index')
->call('runAiCheck', $pressRelease->id)
->assertForbidden();
Queue::assertNotPushed(ClassifyPressRelease::class);
});
test('publishing from the show page reports a blocked publish instead of a 500', function (Closure $exception) {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin);
$pressRelease = PressRelease::factory()->inReview()->create();
$this->mock(PressReleaseService::class)->shouldReceive('publish')->twice()->andThrow($exception());
LivewireVolt::test('admin.press-releases.show', ['id' => $pressRelease->id])
->call('publish')
->assertOk();
LivewireVolt::test('admin.press-releases.index')
->call('publish', $pressRelease->id)
->assertOk();
})->with([
'quota' => [fn () => new QuotaExceededException],
'status' => [fn () => new LogicException('Status passt nicht.')],
'image rights' => [fn () => new ImageRightsConfirmationRequiredException],
]);

View file

@ -16,6 +16,12 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
// Versand ist standardmäßig gesperrt (E9-Schalter, Umsetzungsaudit
// 29.09.2026); diese Tests prüfen den Versand selbst.
beforeEach(function (): void {
config()->set('newsletter.sending_enabled', true);
});
/**
* Newsletter-Versand unter Last und im Fehlerfall (Audit F05/F14/F15):
* Chunking über die 500er-Grenze, atomarer Kampagnenstart, eindeutige
@ -380,3 +386,25 @@ test('the recipient protocol shows a stuck recipient as sending', function () {
expect($recipient->status->label())->toBe('Wird versendet');
});
test('newsletter:resume refuses to continue while sending is disabled (E9)', function () {
/** @var TestCase $this */
config()->set('newsletter.sending_enabled', false);
$campaign = NewsletterCampaign::factory()->create(['status' => NewsletterCampaignStatus::Sending->value]);
$this->artisan('newsletter:resume', ['campaign' => $campaign->id])
->expectsOutputToContain('gesperrt')
->assertFailed();
});
test('a queued send job does not mail while sending is disabled and leaves the recipient pending (E9)', function () {
Mail::fake();
config()->set('newsletter.sending_enabled', false);
[, $recipient] = sendingCampaignWithRecipient(User::factory()->create());
(new SendNewsletterCampaignMail($recipient->id))->handle();
Mail::assertNothingSent();
expect($recipient->refresh()->status)->toBe(NewsletterRecipientStatus::Pending);
});

View file

@ -15,6 +15,7 @@ use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Newsletter\CampaignRecipients;
use App\Services\Newsletter\CampaignSender;
use App\Services\Newsletter\NewsletterSendingDisabledException;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
@ -22,6 +23,12 @@ use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
// Versand ist standardmäßig gesperrt (E9-Schalter, Umsetzungsaudit
// 29.09.2026); diese Tests prüfen den Versand selbst.
beforeEach(function (): void {
config()->set('newsletter.sending_enabled', true);
});
/**
* Internes Newsletter-Modul (Admin ▸ Newsletter): Empfänger-Filter,
* Versand mit Protokoll und die sofort greifende Abmeldung.
@ -386,3 +393,45 @@ test('the campaign pages require the users manage permission', function () {
$this->get(route('admin.newsletter.campaigns.index'))->assertOk();
});
/*
* E9-Schalter (Umsetzungsaudit 29.09.2026): Bis U05–U07 umgesetzt sind,
* startet keine Kampagne — weder im Service noch in der Admin-UI noch per
* Wiederanlauf. Abmeldung und Sperrliste laufen unabhängig davon.
*/
test('with sending disabled a campaign cannot be started anywhere', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config()->set('newsletter.sending_enabled', false);
Queue::fake();
$admin = newsletterAdmin();
$this->actingAs($admin);
User::factory()->create(['portal' => Portal::Presseecho, 'last_login_at' => now()->subMonth()]);
$campaign = NewsletterCampaign::factory()->create();
expect(fn () => app(CampaignSender::class)->start($campaign))->toThrow(NewsletterSendingDisabledException::class);
LivewireVolt::test('admin.newsletter.campaigns.edit', ['id' => $campaign->id])
->assertSee('Der Newsletter-Versand ist derzeit gesperrt')
->call('send')
->assertNoRedirect();
expect($campaign->fresh()->isDraft())->toBeTrue()
->and($campaign->recipients()->count())->toBe(0);
Queue::assertNothingPushed();
});
test('with sending disabled an unsubscribe still works', function () {
/** @var TestCase $this */
config()->set('newsletter.sending_enabled', false);
$user = User::factory()->create();
$this->get(URL::signedRoute('email-preferences.unsubscribe', [
'user' => $user->id,
'type' => EmailNotificationType::Newsletter->value,
]))->assertOk()->assertSee('abgemeldet');
expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse();
});

View file

@ -288,3 +288,35 @@ test('the notification marker and the queued job stand or fall together (N14)',
Carbon::setTestNow();
});
/*
* OPT-9 / E18 (Umsetzungsaudit 29.09.2026): Die Idempotenz der Benachrichtigung
* hängt daran, dass der Job-Insert in derselben DB-Transaktion läuft. Mit einer
* anderen Queue bricht der Befehl laut ab, statt still doppelt zu mailen.
*/
test('the command refuses to run on a queue outside the database transaction (OPT-9)', function (string $default, ?string $queueConnection) {
/** @var TestCase $this */
Mail::fake();
config()->set('queue.default', $default);
config()->set('queue.connections.database.connection', $queueConnection);
$expired = grandfatheredAgreement(now()->subDays(2)->toDateString());
$this->artisan(ExpireGrandfathering::class)
->expectsOutputToContain('E18')
->assertFailed();
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
Mail::assertNothingQueued();
})->with([
'redis' => ['redis', null],
'own queue connection' => ['database', 'queue_mysql'],
]);
test('the command accepts the database queue on the default connection (OPT-9)', function () {
/** @var TestCase $this */
config()->set('queue.default', 'database');
config()->set('queue.connections.database.connection', config('database.default'));
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
});

View file

@ -3,6 +3,7 @@
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\KiAudit;
@ -10,6 +11,10 @@ use App\Models\PressRelease;
use App\Models\RejectionCategory;
use App\Models\User;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\ManualReviewRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\StaleClassificationException;
use Database\Seeders\RejectionCategorySeeder;
@ -685,3 +690,94 @@ test('a red re-check whose content changed right after the result was stored ski
Mail::assertNothingQueued();
Queue::assertNotPushed(ClassifyPressRelease::class);
});
/*
* K-02 / K-05 (Umsetzungsaudit 29.09.2026): Fachliche Ausgänge beim Routing
* sind kein Job-Fehler — sonst drei Wiederholungen (drei OpenAI-Aufrufe,
* doppelte Audits) und ein Eintrag in failed_jobs.
*/
test('the classify job treats an unconfirmed title image at publish time as handled, not as a failure (Audit K-02)', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', []);
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value, 'title' => 'Sauber', 'text' => 'Inhalt']);
$service = $this->mock(PressReleaseService::class);
$service->shouldReceive('routeByClassification')->once()->andThrow(new ImageRightsConfirmationRequiredException);
(new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle(
app(ClassificationManager::class),
$service,
);
expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green);
});
test('the classify job treats a release held for a human decision as handled (Audit K-01)', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', []);
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value, 'title' => 'Sauber', 'text' => 'Inhalt']);
$service = $this->mock(PressReleaseService::class);
$service->shouldReceive('routeByClassification')->once()->andThrow(new ManualReviewRequiredException($pressRelease->id));
(new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle(
app(ClassificationManager::class),
$service,
);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review);
});
test('a status decided by an admin during the analysis overtakes the routing without a retry (Audit K-05)', function () {
/** @var TestCase $this */
Mail::fake();
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value, 'title' => 'Sauber', 'text' => 'Inhalt']);
// Während des KI-Aufrufs lehnt ein Admin die Meldung ab; die KI liefert Rot.
$manager = app(ClassificationManager::class);
$manager->extend('overtaken', fn () => new class implements ClassificationDriver
{
public function classify(PressRelease $pressRelease): ClassificationResult
{
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->update(['status' => PressReleaseStatus::Rejected->value]);
return new ClassificationResult(PressReleaseClassification::Red, ['K1: werblich'], 'overtaken', null, []);
}
});
(new ClassifyPressRelease($pressRelease->id, route: true, providerOverride: 'overtaken', contentHash: $pressRelease->content_hash))->handle(
$manager,
app(PressReleaseService::class),
);
expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected);
Mail::assertNotQueued(PressReleaseRejected::class);
});
test('a logic exception from an overtaken status is logged as overtaken, not retried (Audit K-05)', function () {
/** @var TestCase $this */
config()->set('scoring.classification.provider', 'deterministic');
config()->set('blacklist.words', []);
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value, 'title' => 'Sauber', 'text' => 'Inhalt']);
$service = $this->mock(PressReleaseService::class);
$service->shouldReceive('routeByClassification')->once()->andThrow(new LogicException('Status passt nicht mehr.'));
(new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle(
app(ClassificationManager::class),
$service,
);
expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green);
});
test('the ai jobs back off before a retry (OPT-8)', function () {
expect((new ClassifyPressRelease(1))->backoff())->toBe([30, 120])
->and((new ScorePressRelease(1))->backoff())->toBe([30, 120]);
});

View file

@ -4,10 +4,14 @@ 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;
@ -411,6 +415,143 @@ test('the scheduler counts a stale classification at publish time as a re-check,
$this->artisan(PublishScheduledPressReleases::class)
->expectsOutputToContain('[RECHECK')
->expectsOutputToContain('1 neu zur Prüfung, 0 fehlgeschlagen')
->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]);
});

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Console\Scheduling\Event;
use Illuminate\Console\Scheduling\Schedule;
/**
* Scheduler-Konfiguration (Umsetzungsaudit 29.09.2026, K-09): Die Sperre von
* withoutOverlapping() darf einen abgestürzten Lauf nicht einen ganzen Tag
* lang überleben, und keine zwei nächtlichen Aufgaben teilen sich die Minute.
*/
function scheduledEvents(): array
{
return collect(app(Schedule::class)->events())
->filter(fn (Event $event): bool => ! str_contains((string) $event->command, 'inspire'))
->values()
->all();
}
function scheduledName(Event $event): string
{
return preg_replace('/^.*artisan[\'"]?\s+/', '', (string) $event->command) ?: (string) $event->description;
}
test('every scheduled task releases its overlap lock well before a day', function () {
$events = scheduledEvents();
expect($events)->not->toBeEmpty();
foreach ($events as $event) {
expect($event->withoutOverlapping)->toBeTrue(scheduledName($event).' ohne withoutOverlapping')
->and($event->expiresAt)->toBeLessThan(1440, scheduledName($event).' hält die Sperre 24 h');
}
});
test('the frequent tasks unlock within their interval window', function () {
$expires = collect(scheduledEvents())->mapWithKeys(fn (Event $event): array => [scheduledName($event) => $event->expiresAt]);
expect($expires->first(fn ($minutes, string $name): bool => str_starts_with($name, 'press-releases:publish-scheduled')))->toBe(10)
->and($expires->first(fn ($minutes, string $name): bool => str_starts_with($name, 'press-releases:aggregate-views')))->toBe(15);
});
test('no two daily tasks start in the same minute', function () {
$daily = collect(scheduledEvents())
->filter(fn (Event $event): bool => preg_match('/^\d+ \d+ \* \* \*$/', $event->expression) === 1)
->groupBy(fn (Event $event): string => $event->expression)
->filter(fn ($group): bool => $group->count() > 1)
->map(fn ($group) => $group->map(fn (Event $event): string => scheduledName($event))->all());
expect($daily->all())->toBe([]);
});