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
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue