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:
parent
d6e49c11c3
commit
c82f279fef
20 changed files with 927 additions and 89 deletions
|
|
@ -50,6 +50,14 @@ class ExpireGrandfathering extends Command
|
||||||
|
|
||||||
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
|
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();
|
$candidates = $this->expiredQuery()->with('user')->get();
|
||||||
$pending = $this->pendingNotificationQuery()->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`
|
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
|
||||||
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
|
* (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,
|
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
|
||||||
* `notified_at` nach Zustellung).
|
* `notified_at` nach Zustellung).
|
||||||
*
|
*
|
||||||
|
|
@ -214,6 +223,36 @@ class ExpireGrandfathering extends Command
|
||||||
return [$notified, $failed];
|
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,
|
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
|
||||||
* Sperre und UPDATE identisch (U11).
|
* Sperre und UPDATE identisch (U11).
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,26 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Jobs\ClassifyPressRelease;
|
use App\Jobs\ClassifyPressRelease;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
use App\Models\KiAudit;
|
use App\Models\KiAudit;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseStatusLog;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||||
|
use App\Services\PressRelease\ManualReviewRequiredException;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use App\Services\PressRelease\StaleClassificationException;
|
use App\Services\PressRelease\StaleClassificationException;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
|
@ -38,6 +48,19 @@ use Throwable;
|
||||||
* Prüfung dann schon eingeplant.
|
* Prüfung dann schon eingeplant.
|
||||||
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
|
* Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook
|
||||||
* des Schedulers greift (Audit F20); fachliche Ablehnungen nicht.
|
* 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
|
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).';
|
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');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$limit = max(1, (int) $this->option('limit'));
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
// Gelb und Grün gehen zum Termin automatisch live (Decision-Update
|
$published = 0;
|
||||||
// §5.0); nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als
|
$rejected = 0;
|
||||||
// Fallback in der manuellen Queue.
|
$failed = 0;
|
||||||
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
|
$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)
|
->where('status', PressReleaseStatus::Review->value)
|
||||||
->whereIn('classification', [
|
->whereIn('classification', [
|
||||||
PressReleaseClassification::Green->value,
|
PressReleaseClassification::Green->value,
|
||||||
|
|
@ -71,78 +193,99 @@ class PublishScheduledPressReleases extends Command
|
||||||
])
|
])
|
||||||
->whereNotNull('scheduled_at')
|
->whereNotNull('scheduled_at')
|
||||||
->where('scheduled_at', '<=', $now)
|
->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')
|
->orderBy('scheduled_at')
|
||||||
->limit($limit)
|
->orderBy('id')
|
||||||
|
->limit(max(1, $size))
|
||||||
->get();
|
->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(
|
try {
|
||||||
'%d fällige Pressemitteilung(en) gefunden.%s',
|
$service->publish($pressRelease, source: 'scheduler');
|
||||||
$candidates->count(),
|
$this->line($line.' [OK]');
|
||||||
$dryRun ? ' (Dry-Run)' : '',
|
|
||||||
));
|
|
||||||
|
|
||||||
$published = 0;
|
return 'published';
|
||||||
$rejected = 0;
|
} catch (BlacklistViolationException $e) {
|
||||||
$failed = 0;
|
$this->warn($line.' [REJECT: '.$e->word.']');
|
||||||
$rechecked = 0;
|
|
||||||
|
|
||||||
foreach ($candidates as $pressRelease) {
|
return 'rejected';
|
||||||
$line = sprintf(
|
} catch (StaleClassificationException) {
|
||||||
' #%d scheduled_at=%s title="%s"',
|
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
|
||||||
$pressRelease->id,
|
|
||||||
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
|
|
||||||
Str::limit($pressRelease->title, 60),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($dryRun) {
|
return 'rechecked';
|
||||||
$this->line($line.' [DRY]');
|
} 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)) {
|
return 'blocked';
|
||||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification');
|
} catch (ImageRightsConfirmationRequiredException) {
|
||||||
$rechecked++;
|
$this->warn($line.' [BLOCKIERT: Titelbild nicht bestätigt]');
|
||||||
$this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]');
|
$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 {
|
return 'failed';
|
||||||
$service->publish($pressRelease, source: 'scheduler');
|
}
|
||||||
$published++;
|
}
|
||||||
$this->line($line.' [OK]');
|
|
||||||
} catch (BlacklistViolationException $e) {
|
/**
|
||||||
$rejected++;
|
* Fachliche Sperre einmal je Sperrgrund festhalten (K-02): Ein Eintrag im
|
||||||
$this->warn($line.' [REJECT: '.$e->word.']');
|
* Aktivitäts-Verlauf der Meldung (für Admins sichtbar) und eine
|
||||||
} catch (StaleClassificationException) {
|
* Log-Warnung — nicht bei jedem 5-Minuten-Lauf erneut. Ein Statuswechsel
|
||||||
$rechecked++;
|
* der Meldung beginnt eine neue Episode.
|
||||||
$this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]');
|
*/
|
||||||
} catch (Throwable $e) {
|
private function recordBlockedOnce(ActivityLog $activityLog, PressRelease $pressRelease, string $reason, string $label): void
|
||||||
$failed++;
|
{
|
||||||
$this->error($line.' [FAIL: '.$e->getMessage().']');
|
$description = sprintf('PM #%d: Termin erreicht, aber nicht veröffentlicht — %s.', $pressRelease->id, $label);
|
||||||
report($e);
|
|
||||||
}
|
$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) {
|
Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]);
|
||||||
$this->newLine();
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d fehlgeschlagen.',
|
|
||||||
$published,
|
|
||||||
$rejected,
|
|
||||||
$rechecked,
|
|
||||||
$failed,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
$activityLog->record(
|
||||||
|
ActivityType::PressReleaseStatus,
|
||||||
|
$description,
|
||||||
|
subject: $pressRelease,
|
||||||
|
meta: ['publish_blocked' => $reason],
|
||||||
|
actorLabel: 'scheduler',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,12 @@ class ResumeNewsletterCampaign extends Command
|
||||||
|
|
||||||
public function handle(): int
|
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'));
|
$campaign = NewsletterCampaign::query()->find((int) $this->argument('campaign'));
|
||||||
|
|
||||||
if ($campaign === null) {
|
if ($campaign === null) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use App\Scopes\PortalScope;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||||
use App\Services\PressRelease\Classification\ClassificationResult;
|
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\PressReleaseService;
|
||||||
use App\Services\PressRelease\QuotaExceededException;
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use App\Services\PressRelease\StaleClassificationException;
|
use App\Services\PressRelease\StaleClassificationException;
|
||||||
|
|
@ -17,6 +19,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
|
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
|
||||||
|
|
@ -67,6 +70,17 @@ class ClassifyPressRelease implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public int $timeout = 120;
|
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 int $pressReleaseId Zu prüfende PM
|
||||||
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
|
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
|
||||||
|
|
@ -176,6 +190,11 @@ class ClassifyPressRelease implements ShouldQueue
|
||||||
return;
|
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 {
|
try {
|
||||||
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
|
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
|
||||||
} catch (BlacklistViolationException) {
|
} 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.', [
|
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
|
||||||
'press_release_id' => $pressRelease->id,
|
'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(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,17 @@ class ScorePressRelease implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public int $timeout = 120;
|
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 function __construct(
|
||||||
public readonly int $pressReleaseId,
|
public readonly int $pressReleaseId,
|
||||||
public readonly ?string $providerOverride = null,
|
public readonly ?string $providerOverride = null,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use App\Mail\NewsletterCampaignMail;
|
||||||
use App\Models\NewsletterCampaign;
|
use App\Models\NewsletterCampaign;
|
||||||
use App\Models\NewsletterCampaignRecipient;
|
use App\Models\NewsletterCampaignRecipient;
|
||||||
use App\Services\Mail\EmailSuppressionService;
|
use App\Services\Mail\EmailSuppressionService;
|
||||||
|
use App\Services\Newsletter\CampaignSender;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -52,6 +53,13 @@ class SendNewsletterCampaignMail implements ShouldQueue
|
||||||
|
|
||||||
public function handle(): void
|
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()) {
|
if (! $this->claim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,26 @@ class CampaignSender
|
||||||
{
|
{
|
||||||
public function __construct(private readonly CampaignRecipients $recipients) {}
|
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.
|
* @return int Anzahl der eingeplanten Empfänger.
|
||||||
|
*
|
||||||
|
* @throws NewsletterSendingDisabledException Versand gesperrt; der Entwurf
|
||||||
|
* bleibt unverändert.
|
||||||
*/
|
*/
|
||||||
public function start(NewsletterCampaign $campaign): int
|
public function start(NewsletterCampaign $campaign): int
|
||||||
{
|
{
|
||||||
|
if (! self::sendingEnabled()) {
|
||||||
|
throw new NewsletterSendingDisabledException;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->claimDraft($campaign)) {
|
if (! $this->claimDraft($campaign)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Services/PressRelease/ManualReviewRequiredException.php
Normal file
19
app/Services/PressRelease/ManualReviewRequiredException.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
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' => [
|
'sync' => [
|
||||||
'enabled' => env('NEWSLETTER_SYNC_ENABLED', false),
|
'enabled' => env('NEWSLETTER_SYNC_ENABLED', false),
|
||||||
'provider' => env('NEWSLETTER_SYNC_PROVIDER', 'placeholder'),
|
'provider' => env('NEWSLETTER_SYNC_PROVIDER', 'placeholder'),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use App\Mail\NewsletterCampaignMail;
|
||||||
use App\Models\NewsletterCampaign;
|
use App\Models\NewsletterCampaign;
|
||||||
use App\Services\Newsletter\CampaignRecipients;
|
use App\Services\Newsletter\CampaignRecipients;
|
||||||
use App\Services\Newsletter\CampaignSender;
|
use App\Services\Newsletter\CampaignSender;
|
||||||
|
use App\Services\Newsletter\NewsletterSendingDisabledException;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
|
@ -104,7 +105,14 @@ new #[Layout('components.layouts.app'), Title('Newsletter-Kampagne')] class exte
|
||||||
{
|
{
|
||||||
$campaign = $this->persist();
|
$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) {
|
if ($total === 0) {
|
||||||
Flux::toast(text: __('Keine Empfänger für die gewählten Filter — nichts versendet.'), variant: 'warning');
|
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') }}
|
{{ __('Test-Mail an mich') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:spacer />
|
<flux:spacer />
|
||||||
<flux:modal.trigger name="confirm-campaign-send">
|
@if (\App\Services\Newsletter\CampaignSender::sendingEnabled())
|
||||||
<flux:button variant="primary" icon="paper-airplane">
|
<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') }}
|
{{ __('Jetzt senden') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
@endif
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use App\Models\PressReleaseSearchIndex;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Admin\AdminPerformanceCache;
|
use App\Services\Admin\AdminPerformanceCache;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use App\Services\PressRelease\QuotaExceededException;
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use Flux\Flux;
|
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
|
* Manuelle KI-Prüfung aus der Liste — ohne Routing: bewertet und
|
||||||
* und protokolliert, ändert aber nie den Status (Admin entscheidet).
|
* 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
|
public function runAiCheck(?int $id = null): void
|
||||||
{
|
{
|
||||||
|
$this->authorize('press-releases:publish');
|
||||||
|
|
||||||
$id ??= $this->aiCheckId;
|
$id ??= $this->aiCheckId;
|
||||||
|
|
||||||
if ($id === null) {
|
if ($id === null) {
|
||||||
|
|
@ -241,6 +247,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
} catch (QuotaExceededException $e) {
|
} catch (QuotaExceededException $e) {
|
||||||
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
|
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;
|
return;
|
||||||
} catch (\LogicException $e) {
|
} catch (\LogicException $e) {
|
||||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use App\Console\Commands\PurgePressReleaseEvents;
|
||||||
use App\Console\Commands\PurgeRedactionSnapshots;
|
use App\Console\Commands\PurgeRedactionSnapshots;
|
||||||
use App\Console\Commands\RefreshMarketData;
|
use App\Console\Commands\RefreshMarketData;
|
||||||
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
|
||||||
|
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
|
||||||
use App\Support\OperationalAlert;
|
use App\Support\OperationalAlert;
|
||||||
use Illuminate\Console\Scheduling\Event;
|
use Illuminate\Console\Scheduling\Event;
|
||||||
use Illuminate\Foundation\Inspiring;
|
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
|
// Tägliche Bereinigungen
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -48,7 +55,7 @@ $alertOnFailure = fn (string $task) => function (Event $event) use ($task): void
|
||||||
// beendet sich der Befehl sofort.
|
// beendet sich der Befehl sofort.
|
||||||
Schedule::command(FetchMailBounces::class)
|
Schedule::command(FetchMailBounces::class)
|
||||||
->everyTenMinutes()
|
->everyTenMinutes()
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(10)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('mail:fetch-bounces'));
|
->onFailure($alertOnFailure('mail:fetch-bounces'));
|
||||||
|
|
||||||
|
|
@ -57,21 +64,21 @@ Schedule::command(FetchMailBounces::class)
|
||||||
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
|
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
|
||||||
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
|
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
|
||||||
->dailyAt('04:20')
|
->dailyAt('04:20')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('mail:check-domains'));
|
->onFailure($alertOnFailure('mail:check-domains'));
|
||||||
|
|
||||||
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
|
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
|
||||||
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
|
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
|
||||||
->dailyAt('03:00')
|
->dailyAt('03:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(30)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('magic-links:purge'));
|
->onFailure($alertOnFailure('magic-links:purge'));
|
||||||
|
|
||||||
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
|
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
|
||||||
Schedule::command(PurgeRedactionSnapshots::class)
|
Schedule::command(PurgeRedactionSnapshots::class)
|
||||||
->dailyAt('03:10')
|
->dailyAt('03:10')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(30)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('snapshots:purge'));
|
->onFailure($alertOnFailure('snapshots:purge'));
|
||||||
|
|
||||||
|
|
@ -79,13 +86,19 @@ Schedule::command(PurgeRedactionSnapshots::class)
|
||||||
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
|
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
|
||||||
Schedule::command(ArchivePurge::class)
|
Schedule::command(ArchivePurge::class)
|
||||||
->dailyAt('03:20')
|
->dailyAt('03:20')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(120)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('archive:purge'));
|
->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)
|
Schedule::command(ArchiveExportTombstones::class)
|
||||||
->dailyAt('03:30')
|
->dailyAt('03:30')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('archive:export-tombstones'));
|
->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.
|
// nach 24 h Karenz entfernen (03:40) — Umsetzungsaudit 25.09.2026, N11.
|
||||||
Schedule::command(ArchiveRetryPending::class)
|
Schedule::command(ArchiveRetryPending::class)
|
||||||
->dailyAt('03:35')
|
->dailyAt('03:35')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('archive:retry-pending'));
|
->onFailure($alertOnFailure('archive:retry-pending'));
|
||||||
|
|
||||||
Schedule::command(ArchiveSweepOrphans::class)
|
Schedule::command(ArchiveSweepOrphans::class)
|
||||||
->dailyAt('03:40')
|
->dailyAt('03:40')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(120)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('archive:sweep-orphans'));
|
->onFailure($alertOnFailure('archive:sweep-orphans'));
|
||||||
|
|
||||||
|
|
@ -108,10 +121,21 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
|
||||||
->weekly()
|
->weekly()
|
||||||
->sundays()
|
->sundays()
|
||||||
->at('04:00')
|
->at('04:00')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(180)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:purge-drafts'));
|
->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
|
// 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".
|
// Läuft alle 5 Min — passt zum FormRule "scheduled_at min. 5 Min in Zukunft".
|
||||||
Schedule::command(PublishScheduledPressReleases::class)
|
Schedule::command(PublishScheduledPressReleases::class)
|
||||||
->everyFiveMinutes()
|
->everyFiveMinutes()
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(10)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:publish-scheduled'));
|
->onFailure($alertOnFailure('press-releases:publish-scheduled'));
|
||||||
|
|
||||||
|
|
@ -133,17 +157,18 @@ Schedule::command(PublishScheduledPressReleases::class)
|
||||||
// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews).
|
// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews).
|
||||||
Schedule::command(AggregatePressReleaseViews::class)
|
Schedule::command(AggregatePressReleaseViews::class)
|
||||||
->everyMinute()
|
->everyMinute()
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(15)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:aggregate-views'));
|
->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
|
// Die Tabelle nimmt jeden Seitenaufruf auf und wuchs als einzige Log-Tabelle
|
||||||
// des Systems unbegrenzt. Gelöscht wird nur unterhalb der Wasserstandsmarke —
|
// des Systems unbegrenzt. Gelöscht wird nur unterhalb der Wasserstandsmarke —
|
||||||
// noch nicht in `hits` verbuchte Aufrufe bleiben unangetastet.
|
// noch nicht in `hits` verbuchte Aufrufe bleiben unangetastet.
|
||||||
Schedule::command(PurgePressReleaseEvents::class)
|
Schedule::command(PurgePressReleaseEvents::class)
|
||||||
->dailyAt('03:20')
|
->dailyAt('03:50')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(120)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:purge-events'));
|
->onFailure($alertOnFailure('press-releases:purge-events'));
|
||||||
|
|
||||||
|
|
@ -151,7 +176,7 @@ Schedule::command(PurgePressReleaseEvents::class)
|
||||||
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
|
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
|
||||||
Schedule::command(CheckIndexStatus::class)
|
Schedule::command(CheckIndexStatus::class)
|
||||||
->dailyAt('05:10')
|
->dailyAt('05:10')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:check-index-status'));
|
->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.
|
// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
|
||||||
Schedule::command(PurgeConversionEvents::class)
|
Schedule::command(PurgeConversionEvents::class)
|
||||||
->dailyAt('03:25')
|
->dailyAt('03:25')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('conversion:purge-events'));
|
->onFailure($alertOnFailure('conversion:purge-events'));
|
||||||
|
|
||||||
|
|
@ -172,7 +197,7 @@ Schedule::command(PurgeConversionEvents::class)
|
||||||
// abgerechnet wird.
|
// abgerechnet wird.
|
||||||
Schedule::command(ExpireGrandfathering::class)
|
Schedule::command(ExpireGrandfathering::class)
|
||||||
->dailyAt('04:15')
|
->dailyAt('04:15')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(30)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('billing:expire-grandfathering'));
|
->onFailure($alertOnFailure('billing:expire-grandfathering'));
|
||||||
|
|
||||||
|
|
@ -181,7 +206,7 @@ Schedule::command(ExpireGrandfathering::class)
|
||||||
// Stripe (STR-Kreis) und werden hier nicht angefasst.
|
// Stripe (STR-Kreis) und werden hier nicht angefasst.
|
||||||
Schedule::command(GenerateManualInvoices::class)
|
Schedule::command(GenerateManualInvoices::class)
|
||||||
->dailyAt('04:30')
|
->dailyAt('04:30')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(60)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('billing:generate-manual-invoices'));
|
->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).
|
// Monatlicher Reset des verbrauchten PM-Kontingents (am 1. um 00:05).
|
||||||
Schedule::command(ResetMonthlyPressReleaseQuota::class)
|
Schedule::command(ResetMonthlyPressReleaseQuota::class)
|
||||||
->monthlyOn(1, '00:05')
|
->monthlyOn(1, '00:05')
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(30)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('press-releases:reset-monthly-quota'));
|
->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.
|
// nur aus dem Cache — ein API-Ausfall leert die Anzeige nicht sofort.
|
||||||
Schedule::command(RefreshMarketData::class)
|
Schedule::command(RefreshMarketData::class)
|
||||||
->everyFifteenMinutes()
|
->everyFifteenMinutes()
|
||||||
->withoutOverlapping()
|
->withoutOverlapping(15)
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->onFailure($alertOnFailure('market:refresh'));
|
->onFailure($alertOnFailure('market:refresh'));
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ use App\Models\KiAudit;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||||
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Volt\Volt as LivewireVolt;
|
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])
|
LivewireVolt::test('admin.press-releases.show', ['id' => $publishedPressRelease->id])
|
||||||
->assertSee('Pressemitteilung archivieren?');
|
->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],
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Tests\TestCase;
|
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):
|
* Newsletter-Versand unter Last und im Fehlerfall (Audit F05/F14/F15):
|
||||||
* Chunking über die 500er-Grenze, atomarer Kampagnenstart, eindeutige
|
* 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');
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use App\Models\User;
|
||||||
use App\Models\UserPaymentOption;
|
use App\Models\UserPaymentOption;
|
||||||
use App\Services\Newsletter\CampaignRecipients;
|
use App\Services\Newsletter\CampaignRecipients;
|
||||||
use App\Services\Newsletter\CampaignSender;
|
use App\Services\Newsletter\CampaignSender;
|
||||||
|
use App\Services\Newsletter\NewsletterSendingDisabledException;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
@ -22,6 +23,12 @@ use Illuminate\Support\Facades\URL;
|
||||||
use Livewire\Volt\Volt as LivewireVolt;
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
use Tests\TestCase;
|
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,
|
* Internes Newsletter-Modul (Admin ▸ Newsletter): Empfänger-Filter,
|
||||||
* Versand mit Protokoll und die sofort greifende Abmeldung.
|
* 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();
|
$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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -288,3 +288,35 @@ test('the notification marker and the queued job stand or fall together (N14)',
|
||||||
|
|
||||||
Carbon::setTestNow();
|
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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Jobs\ClassifyPressRelease;
|
use App\Jobs\ClassifyPressRelease;
|
||||||
|
use App\Jobs\ScorePressRelease;
|
||||||
use App\Mail\PressReleasePublished;
|
use App\Mail\PressReleasePublished;
|
||||||
use App\Mail\PressReleaseRejected;
|
use App\Mail\PressReleaseRejected;
|
||||||
use App\Models\KiAudit;
|
use App\Models\KiAudit;
|
||||||
|
|
@ -10,6 +11,10 @@ use App\Models\PressRelease;
|
||||||
use App\Models\RejectionCategory;
|
use App\Models\RejectionCategory;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\PressRelease\Classification\ClassificationManager;
|
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\PressReleaseService;
|
||||||
use App\Services\PressRelease\StaleClassificationException;
|
use App\Services\PressRelease\StaleClassificationException;
|
||||||
use Database\Seeders\RejectionCategorySeeder;
|
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();
|
Mail::assertNothingQueued();
|
||||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
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]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ use App\Console\Commands\PublishScheduledPressReleases;
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Jobs\ClassifyPressRelease;
|
use App\Jobs\ClassifyPressRelease;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
use App\Models\KiAudit;
|
use App\Models\KiAudit;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\PressReleaseStatusLog;
|
use App\Models\PressReleaseStatusLog;
|
||||||
|
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||||
|
use App\Services\PressRelease\ManualReviewRequiredException;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
use App\Services\PressRelease\StaleClassificationException;
|
use App\Services\PressRelease\StaleClassificationException;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
use Illuminate\Support\Carbon;
|
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)
|
$this->artisan(PublishScheduledPressReleases::class)
|
||||||
->expectsOutputToContain('[RECHECK')
|
->expectsOutputToContain('[RECHECK')
|
||||||
->expectsOutputToContain('1 neu zur Prüfung, 0 fehlgeschlagen')
|
->expectsOutputToContain('1 neu zur Prüfung, 0 blockiert, 0 fehlgeschlagen')
|
||||||
->assertSuccessful();
|
->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]);
|
||||||
|
});
|
||||||
|
|
|
||||||
50
tests/Feature/ScheduleConfigurationTest.php
Normal file
50
tests/Feature/ScheduleConfigurationTest.php
Normal 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([]);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue