From f0eeef01446717f3bdee2a65f0757e5ddcf92627 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 2 Oct 2026 13:09:00 +0000 Subject: [PATCH] =?UTF-8?q?press-releases/jobs:=20Scheduler=20respektiert?= =?UTF-8?q?=20manuelle=20Pr=C3=BCfung=20und=20kommt=20an=20Blockaden=20vor?= =?UTF-8?q?bei,=20Newsletter-Versand=20per=20Schalter=20gesperrt,=20kurze?= =?UTF-8?q?=20Scheduler-Sperren=20(Umsetzungsaudit=2029.09.:=20K-01,=20K-0?= =?UTF-8?q?2,=20K-05,=20K-06,=20K-09,=20E9,=20OPT-8,=20OPT-9;=2002.10.:=20?= =?UTF-8?q?O06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Console/Commands/ExpireGrandfathering.php | 41 ++- .../PublishScheduledPressReleases.php | 265 ++++++++++++++---- .../Commands/ResumeNewsletterCampaign.php | 6 + app/Jobs/ClassifyPressRelease.php | 39 +++ app/Jobs/ScorePressRelease.php | 11 + app/Jobs/SendNewsletterCampaignMail.php | 8 + app/Services/Newsletter/CampaignSender.php | 15 + .../NewsletterSendingDisabledException.php | 17 ++ .../ManualReviewRequiredException.php | 19 ++ config/newsletter.php | 8 + .../admin/newsletter/campaigns/edit.blade.php | 27 +- .../admin/press-releases/index.blade.php | 14 +- routes/console.php | 65 +++-- .../Admin/AdminPressReleaseActionsTest.php | 83 ++++++ .../NewsletterCampaignResilienceTest.php | 28 ++ .../Feature/Admin/NewsletterCampaignTest.php | 49 ++++ .../Billing/ExpireGrandfatheringTest.php | 32 +++ .../PressReleaseClassificationJobTest.php | 96 +++++++ tests/Feature/PressReleaseSchedulingTest.php | 143 +++++++++- tests/Feature/ScheduleConfigurationTest.php | 50 ++++ 20 files changed, 927 insertions(+), 89 deletions(-) create mode 100644 app/Services/Newsletter/NewsletterSendingDisabledException.php create mode 100644 app/Services/PressRelease/ManualReviewRequiredException.php create mode 100644 tests/Feature/ScheduleConfigurationTest.php diff --git a/app/Console/Commands/ExpireGrandfathering.php b/app/Console/Commands/ExpireGrandfathering.php index 3c6af30f..06c517a5 100644 --- a/app/Console/Commands/ExpireGrandfathering.php +++ b/app/Console/Commands/ExpireGrandfathering.php @@ -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). diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index df335665..0fe4fad8 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -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 + */ + 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', + ); } /** diff --git a/app/Console/Commands/ResumeNewsletterCampaign.php b/app/Console/Commands/ResumeNewsletterCampaign.php index ec5f3b61..c2171c13 100644 --- a/app/Console/Commands/ResumeNewsletterCampaign.php +++ b/app/Console/Commands/ResumeNewsletterCampaign.php @@ -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) { diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php index 062ced60..098af4a2 100644 --- a/app/Jobs/ClassifyPressRelease.php +++ b/app/Jobs/ClassifyPressRelease.php @@ -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 + */ + 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(), + ]); } } diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index 8030ed7a..a37c1cf9 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -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 + */ + public function backoff(): array + { + return [30, 120]; + } + public function __construct( public readonly int $pressReleaseId, public readonly ?string $providerOverride = null, diff --git a/app/Jobs/SendNewsletterCampaignMail.php b/app/Jobs/SendNewsletterCampaignMail.php index e76fece9..346d81c1 100644 --- a/app/Jobs/SendNewsletterCampaignMail.php +++ b/app/Jobs/SendNewsletterCampaignMail.php @@ -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; } diff --git a/app/Services/Newsletter/CampaignSender.php b/app/Services/Newsletter/CampaignSender.php index 75873475..a80787de 100644 --- a/app/Services/Newsletter/CampaignSender.php +++ b/app/Services/Newsletter/CampaignSender.php @@ -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; } diff --git a/app/Services/Newsletter/NewsletterSendingDisabledException.php b/app/Services/Newsletter/NewsletterSendingDisabledException.php new file mode 100644 index 00000000..a02d34c3 --- /dev/null +++ b/app/Services/Newsletter/NewsletterSendingDisabledException.php @@ -0,0 +1,17 @@ + (bool) env('NEWSLETTER_SENDING_ENABLED', false), + 'sync' => [ 'enabled' => env('NEWSLETTER_SYNC_ENABLED', false), 'provider' => env('NEWSLETTER_SYNC_PROVIDER', 'placeholder'), diff --git a/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php index 5d7f5271..da5f7f15 100644 --- a/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php +++ b/resources/views/livewire/admin/newsletter/campaigns/edit.blade.php @@ -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') }} - - + @if (\App\Services\Newsletter\CampaignSender::sendingEnabled()) + + + {{ __('Jetzt senden') }} + + + @else + {{ __('Jetzt senden') }} - + @endif + @unless (\App\Services\Newsletter\CampaignSender::sendingEnabled()) + + @endunless diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index 00cf25ed..ed9f9f4a 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -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'); diff --git a/routes/console.php b/routes/console.php index 8f09307a..a257e34f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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')); diff --git a/tests/Feature/Admin/AdminPressReleaseActionsTest.php b/tests/Feature/Admin/AdminPressReleaseActionsTest.php index 8cd8dd11..b80b59e0 100644 --- a/tests/Feature/Admin/AdminPressReleaseActionsTest.php +++ b/tests/Feature/Admin/AdminPressReleaseActionsTest.php @@ -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], +]); diff --git a/tests/Feature/Admin/NewsletterCampaignResilienceTest.php b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php index 2a05f5f4..b70a0b55 100644 --- a/tests/Feature/Admin/NewsletterCampaignResilienceTest.php +++ b/tests/Feature/Admin/NewsletterCampaignResilienceTest.php @@ -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); +}); diff --git a/tests/Feature/Admin/NewsletterCampaignTest.php b/tests/Feature/Admin/NewsletterCampaignTest.php index b3616d24..ba3a7280 100644 --- a/tests/Feature/Admin/NewsletterCampaignTest.php +++ b/tests/Feature/Admin/NewsletterCampaignTest.php @@ -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(); +}); diff --git a/tests/Feature/Billing/ExpireGrandfatheringTest.php b/tests/Feature/Billing/ExpireGrandfatheringTest.php index b7b2e1eb..f11740a6 100644 --- a/tests/Feature/Billing/ExpireGrandfatheringTest.php +++ b/tests/Feature/Billing/ExpireGrandfatheringTest.php @@ -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(); +}); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index 9c69bf2d..72821f69 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -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]); +}); diff --git a/tests/Feature/PressReleaseSchedulingTest.php b/tests/Feature/PressReleaseSchedulingTest.php index 43c9f0da..c4e5545b 100644 --- a/tests/Feature/PressReleaseSchedulingTest.php +++ b/tests/Feature/PressReleaseSchedulingTest.php @@ -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]); +}); diff --git a/tests/Feature/ScheduleConfigurationTest.php b/tests/Feature/ScheduleConfigurationTest.php new file mode 100644 index 00000000..cbeb224a --- /dev/null +++ b/tests/Feature/ScheduleConfigurationTest.php @@ -0,0 +1,50 @@ +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([]); +});