press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent d6e49c11c3
commit c82f279fef
20 changed files with 927 additions and 89 deletions

View file

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