From 082e6bb76ca4baf9422c66a3f0f3c016e8852345 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 12:22:22 +0000 Subject: [PATCH] =?UTF-8?q?ki/quota:=20Hash-Bindung=20atomar=20bis=20in=20?= =?UTF-8?q?den=20Publish-Lock,=20roter=20Re-Check=20lehnt=20ab,=20Kontinge?= =?UTF-8?q?nt-Altbestand=20(Block=207,=20U02=E2=80=93U04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jobs speichern Ergebnis nur per bedingtem UPDATE auf den analysierten Hash; publish() prüft bei ki/scheduler unter der Zeilensperre die jüngste gültige Klassifikation und plant sonst eine neue Prüfung ein (StaleClassificationException) - Re-Check ohne Routing: Rot bei einer PM in Review lehnt wie die Erstprüfung ab - consumePublishSlot() zählt eine NULL-Periode wie den laufenden Monat; Backfill-Migration setzt die Periode für Altverbrauch - ApiPortalResolutionTest: gemeinsame Kategorie wegen der U10-Prüfung Co-Authored-By: Claude Fable 5.1 --- .../PublishScheduledPressReleases.php | 9 + app/Jobs/ClassifyPressRelease.php | 77 ++++++- app/Jobs/ScorePressRelease.php | 32 ++- .../PressRelease/PressReleaseService.php | 95 +++++++- .../StaleClassificationException.php | 28 +++ ...00_backfill_press_release_quota_period.php | 41 ++++ tests/Feature/Api/ApiPortalResolutionTest.php | 3 + .../PressReleaseClassificationJobTest.php | 214 ++++++++++++++++++ .../Feature/PressReleaseContentScoreTest.php | 68 ++++++ tests/Feature/PressReleaseQuotaTest.php | 58 +++++ tests/Feature/PressReleaseSchedulingTest.php | 98 ++++++++ 11 files changed, 698 insertions(+), 25 deletions(-) create mode 100644 app/Services/PressRelease/StaleClassificationException.php create mode 100644 database/migrations/2026_09_18_190000_backfill_press_release_quota_period.php diff --git a/app/Console/Commands/PublishScheduledPressReleases.php b/app/Console/Commands/PublishScheduledPressReleases.php index cdb30822..df335665 100644 --- a/app/Console/Commands/PublishScheduledPressReleases.php +++ b/app/Console/Commands/PublishScheduledPressReleases.php @@ -10,6 +10,7 @@ use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\StaleClassificationException; use Illuminate\Console\Command; use Illuminate\Support\Str; use Throwable; @@ -30,6 +31,11 @@ use Throwable; * zum aktuellen Text (Admin- oder Korrektur-Bearbeitung nach der Prüfung), * wird nicht veröffentlicht, sondern eine Prüfung mit Routing eingeplant – * Grün geht dann zum fälligen Termin live, Rot lehnt ab und benachrichtigt. + * Die Vorprüfung hier ist nur der schnelle Filter; verbindlich prüft + * PressReleaseService::publish() unter der Zeilensperre noch einmal + * (Audit U02) und meldet eine Abweichung als StaleClassificationException – + * die zählt hier ebenfalls als Re-Check, nicht als Fehler; publish() hat die + * Prüfung dann schon eingeplant. * Technische Fehler ergeben einen Fehler-Exit-Code, damit der Failure-Hook * des Schedulers greift (Audit F20); fachliche Ablehnungen nicht. */ @@ -115,6 +121,9 @@ class PublishScheduledPressReleases extends Command } catch (BlacklistViolationException $e) { $rejected++; $this->warn($line.' [REJECT: '.$e->word.']'); + } catch (StaleClassificationException) { + $rechecked++; + $this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]'); } catch (Throwable $e) { $failed++; $this->error($line.' [FAIL: '.$e->getMessage().']'); diff --git a/app/Jobs/ClassifyPressRelease.php b/app/Jobs/ClassifyPressRelease.php index 4af5f42a..f4b9b604 100644 --- a/app/Jobs/ClassifyPressRelease.php +++ b/app/Jobs/ClassifyPressRelease.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Enums\PressReleaseClassification; +use App\Enums\PressReleaseStatus; use App\Models\KiAudit; use App\Models\PressRelease; use App\Scopes\PortalScope; @@ -10,6 +12,7 @@ use App\Services\PressRelease\Classification\ClassificationManager; use App\Services\PressRelease\Classification\ClassificationResult; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; +use App\Services\PressRelease\StaleClassificationException; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; @@ -32,6 +35,24 @@ use Illuminate\Support\Facades\Log; * geändert, wird das Ergebnis verworfen (Audit mit `discarded_at`) und ein * neuer Lauf für den aktuellen Stand eingeplant – ein altes Urteil darf * weder eine neue Version bewerten noch freigeben. + * + * Atomar (Audit U02): Das Ergebnis wird mit einem bedingten UPDATE + * (`WHERE content_hash = `) geschrieben — Vergleich und + * Schreiben sind damit ein Statement, ein Editor kann nicht mehr zwischen + * Hash-SELECT und Speichern dazwischenfunken. Der KI-Aufruf läuft bewusst + * außerhalb jeder Sperre. Query-Builder-Update ist hier unbedenklich: kein + * Observer reagiert auf Klassifikationsfelder (die Suchtabelle spiegelt nur + * Titel/Untertitel/Text/Keywords). + * + * Roter Re-Check (Audit U03): Liefert ein Lauf ohne Routing (`route: false`, + * z. B. nach einer Bearbeitung) Rot und die PM wartet noch in `review` — + * typisch eine terminierte Grün-PM, die vor dem Termin geändert wurde —, + * greift der Rot-Zweig der Erstprüfung (Ablehnung inkl. Autorenmail bzw. + * manuelle Prüfung bei erschöpfter Automatik). Sonst bliebe sie als rote + * terminierte Meldung still liegen, weil der Scheduler nur Grün/Gelb zieht. + * Gelb/Grün ohne Routing und bereits veröffentlichte PMs bleiben unverändert + * (Admin-Signal); eine PM, die ausdrücklich auf einen Menschen wartet + * (`manual_review_required_at`), wird nicht automatisch abgelehnt. */ class ClassifyPressRelease implements ShouldQueue { @@ -75,11 +96,27 @@ class ClassifyPressRelease implements ShouldQueue $analyzedHash = $this->contentHash ?? $pressRelease->content_hash; $result = $this->classify($manager, $pressRelease); - // Inhalt seit der Einplanung geändert? Dann gilt das Urteil für einen - // Text, den es nicht mehr gibt: verwerfen und den aktuellen prüfen. - $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash'); + // Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die geprüfte + // Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt + // geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg. + $classifiedAt = now(); + $stored = PressRelease::withoutGlobalScope(PortalScope::class) + ->whereKey($pressRelease->id) + ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) + ->update([ + 'classification' => $result->classification->value, + 'classified_at' => $classifiedAt, + 'updated_at' => $classifiedAt, + ]); + + if ($stored === 0) { + $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); + + if ($currentHash === null) { + // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). + return; + } - if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) { KiAudit::query()->create([ 'press_release_id' => $pressRelease->id, 'type' => KiAudit::TYPE_CLASSIFICATION, @@ -98,10 +135,12 @@ class ClassifyPressRelease implements ShouldQueue return; } + // Modell auf den geschriebenen Stand bringen, ohne ein zweites + // (unbedingtes) UPDATE auszulösen. $pressRelease->forceFill([ 'classification' => $result->classification->value, - 'classified_at' => now(), - ])->save(); + 'classified_at' => $classifiedAt, + ])->syncOriginal(); KiAudit::query()->create([ 'press_release_id' => $pressRelease->id, @@ -115,7 +154,7 @@ class ClassifyPressRelease implements ShouldQueue 'created_at' => now(), ]); - if (! $this->route) { + if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result)) { return; } @@ -123,6 +162,12 @@ class ClassifyPressRelease implements ShouldQueue $service->routeByClassification($pressRelease, $result->classification, $result->reasonText()); } catch (BlacklistViolationException) { // publish() hat die PM bereits abgelehnt und den Autor benachrichtigt. + } catch (StaleClassificationException $exception) { + // Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat + // bereits eine Prüfung für den aktuellen Stand eingeplant (U02). + Log::info('Auto-Veröffentlichung übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [ + 'press_release_id' => $pressRelease->id, + ]); } catch (QuotaExceededException $exception) { // Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM // bleibt in Review, damit Autor/Admin bewusst entscheiden — kein @@ -133,6 +178,24 @@ class ClassifyPressRelease implements ShouldQueue } } + /** + * Roter Re-Check ohne Routing einer noch wartenden PM (Audit U03): nur + * dann greift der Rot-Zweig der Erstprüfung. Status frisch lesen — der + * KI-Aufruf kann gedauert haben, und ein Admin hat womöglich inzwischen + * entschieden. + */ + private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result): bool + { + if ($result->classification !== PressReleaseClassification::Red) { + return false; + } + + $pressRelease->refresh(); + + return $pressRelease->status === PressReleaseStatus::Review + && ! $pressRelease->requiresManualReview(); + } + /** * Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei * Fehler greift der deterministische Fallback, damit das Ergebnis diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index b3004675..79d3a07f 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -25,6 +25,9 @@ use Illuminate\Support\Facades\Log; * * Inhaltsbindung wie bei ClassifyPressRelease (Audit F07): Ergebnis für * einen inzwischen geänderten Text wird verworfen und neu eingeplant. + * Atomar per bedingtem UPDATE auf die geprüfte Prüfsumme (Audit U02); der + * KI-Aufruf läuft außerhalb jeder Sperre. Query-Builder-Update ist hier + * unbedenklich: kein Observer reagiert auf Score-Felder. */ class ScorePressRelease implements ShouldQueue { @@ -57,9 +60,28 @@ class ScorePressRelease implements ShouldQueue $result = $this->score($manager, $pressRelease); $tier = PressReleaseContentTier::fromScore($result->score); - $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash'); + // Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die bewertete + // Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt + // geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg. + $scoredAt = now(); + $stored = PressRelease::withoutGlobalScope(PortalScope::class) + ->whereKey($pressRelease->id) + ->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash)) + ->update([ + 'content_score' => $result->score, + 'content_tier' => $tier->value, + 'scored_at' => $scoredAt, + 'updated_at' => $scoredAt, + ]); + + if ($stored === 0) { + $currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash'); + + if ($currentHash === null) { + // Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK). + return; + } - if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) { KiAudit::query()->create([ 'press_release_id' => $pressRelease->id, 'type' => KiAudit::TYPE_CONTENT_SCORE, @@ -78,12 +100,6 @@ class ScorePressRelease implements ShouldQueue return; } - $pressRelease->forceFill([ - 'content_score' => $result->score, - 'content_tier' => $tier->value, - 'scored_at' => now(), - ])->save(); - KiAudit::query()->create([ 'press_release_id' => $pressRelease->id, 'type' => KiAudit::TYPE_CONTENT_SCORE, diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index c4f7a3d8..6dd41a50 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -14,6 +14,7 @@ use App\Mail\PressReleasePublished; use App\Mail\PressReleaseRejected; use App\Models\AdminPreset; use App\Models\Company; +use App\Models\KiAudit; use App\Models\PressRelease; use App\Models\PressReleaseAiDeclaration; use App\Models\PressReleaseImage; @@ -136,7 +137,13 @@ class PressReleaseService * Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden * aktualisiert, der Status bleibt unverändert. So führt das bloße * Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung - * oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin. + * – die Entscheidung bleibt beim regulären Workflow/Admin. + * + * Eine Ausnahme (Audit U03): Wird der Re-Check Rot und die PM wartet + * noch in `review` (etwa eine terminierte Grün-PM, die vor dem Termin + * bearbeitet wurde), lehnt der Job sie wie bei der Erstprüfung ab – + * inkl. Autorenmail –, statt sie still als rote terminierte Meldung + * liegen zu lassen. Siehe ClassifyPressRelease. */ public function reclassifyIfClassified(PressRelease $pressRelease): void { @@ -272,9 +279,28 @@ class PressReleaseService $this->publish($pressRelease, 'ki', $publishedAtOverride); } + /** + * Quellen, die ohne menschliche Entscheidung veröffentlichen. Nur sie + * prüfen unter der Sperre, ob die freigebende Klassifikation noch zum + * Inhalt gehört (Audit U02); `admin` ist die bewusste Übersteuerung. + */ + private const AUTOMATIC_PUBLISH_SOURCES = ['ki', 'scheduler']; + + /** + * @throws BlacklistViolationException Unzulässiges Wort — die PM wurde + * abgelehnt, der Autor benachrichtigt. + * @throws StaleClassificationException Nur bei automatischen Quellen + * (`ki`, `scheduler`): Die jüngste + * gültige Klassifikation gehört zu + * einer anderen Inhaltsversion als + * der gesperrten. Nichts geändert; + * eine Prüfung mit Routing für den + * aktuellen Stand ist eingeplant. + */ public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void { - $blacklistViolation = DB::transaction(function () use ($pressRelease, $source, $publishedAtOverride): ?array { + /** @var array{blacklist?: array{reason: string, word: string}, stale?: array{analyzed: string|null, current: string}}|null $outcome */ + $outcome = DB::transaction(function () use ($pressRelease, $source, $publishedAtOverride): ?array { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) @@ -284,13 +310,24 @@ class PressReleaseService $this->assertStatus($locked, [PressReleaseStatus::Review]); $previous = $locked->status; + // Inhaltsbindung unter der Sperre (Audit U02): Der Vergleich im + // Job bzw. Scheduler liegt VOR der Sperre – hier zählt, was jetzt + // gesperrt ist. Innerhalb der Transaktion nichts ändern. + if (in_array($source, self::AUTOMATIC_PUBLISH_SOURCES, true)) { + $analyzedHash = $this->latestClassificationHash($locked); + + if ($this->classificationIsStale($locked, $analyzedHash)) { + return ['stale' => ['analyzed' => $analyzedHash, 'current' => (string) $locked->content_hash]]; + } + } + if ($word = $this->blacklist->findInPressRelease($locked)) { $reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word); $locked->update(['status' => PressReleaseStatus::Rejected->value]); $this->logStatusChange($locked, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist'); - return ['reason' => $reason, 'word' => $word]; + return ['blacklist' => ['reason' => $reason, 'word' => $word]]; } $this->consumePublishSlot($locked); @@ -307,12 +344,18 @@ class PressReleaseService $pressRelease->refresh(); - if ($blacklistViolation !== null) { - $this->notifyAuthor($pressRelease, 'rejected', $blacklistViolation['reason']); + if (isset($outcome['stale'])) { + ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification'); + + throw new StaleClassificationException($pressRelease->id, $outcome['stale']['analyzed'], $outcome['stale']['current']); + } + + if (isset($outcome['blacklist'])) { + $this->notifyAuthor($pressRelease, 'rejected', $outcome['blacklist']['reason']); throw new BlacklistViolationException( - $blacklistViolation['reason'], - $blacklistViolation['word'], + $outcome['blacklist']['reason'], + $outcome['blacklist']['word'], ); } @@ -328,6 +371,32 @@ class PressReleaseService $this->notifyAuthor($pressRelease, 'published'); } + /** + * Prüfsumme der jüngsten gültigen (nicht verworfenen) Klassifikation. + * Null, wenn es keine gibt oder sie aus der Zeit vor der Bindung stammt. + */ + private function latestClassificationHash(PressRelease $pressRelease): ?string + { + return KiAudit::query() + ->where('press_release_id', $pressRelease->id) + ->where('type', KiAudit::TYPE_CLASSIFICATION) + ->whereNull('discarded_at') + ->orderByDesc('id') + ->value('content_hash'); + } + + /** + * Gleiche Übergangsregel wie im Scheduler: Altbestand ohne Prüfsumme — + * auf der PM oder im Audit (auch: noch gar kein Audit) — gilt weiter. + * Nur eine vorhandene, abweichende Summe blockiert. + */ + private function classificationIsStale(PressRelease $pressRelease, ?string $analyzedHash): bool + { + return $analyzedHash !== null + && $pressRelease->content_hash !== null + && $analyzedHash !== $pressRelease->content_hash; + } + /** * Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers * (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte @@ -363,11 +432,17 @@ class PressReleaseService $plan = $user->currentPlan(); if ($plan && $user->pressReleaseQuotaUsedThisMonth() < $plan->press_release_quota) { - // Periodenwechsel atomar unter der Zeilensperre: neuer Monat → - // Zähler beginnt bei 1, sonst erhöhen (Audit F18). + // Periodenwechsel atomar unter der Zeilensperre: ein explizit + // ABWEICHENDER Monat → Zähler beginnt bei 1, sonst erhöhen + // (Audit F18). NULL (Altbestand vor der Periodenbindung) zählt + // wie der laufende Monat — dieselbe Lesart wie + // User::pressReleaseQuotaUsedThisMonth() (Audit U04). $period = User::currentQuotaPeriod(); + $continuesPeriod = $user->press_release_quota_period === null + || $user->press_release_quota_period === $period; + $user->forceFill([ - 'press_release_quota_used_this_month' => $user->press_release_quota_period === $period + 'press_release_quota_used_this_month' => $continuesPeriod ? (int) $user->press_release_quota_used_this_month + 1 : 1, 'press_release_quota_period' => $period, diff --git a/app/Services/PressRelease/StaleClassificationException.php b/app/Services/PressRelease/StaleClassificationException.php new file mode 100644 index 00000000..0a8604c9 --- /dev/null +++ b/app/Services/PressRelease/StaleClassificationException.php @@ -0,0 +1,28 @@ + 0` wird + * angefasst) und bleibt als Sicherheitsnetz für den Fall, dass der Befehl + * vor diesem Backfill läuft. + */ +return new class extends Migration +{ + public function up(): void + { + DB::table('users') + ->where('press_release_quota_used_this_month', '>', 0) + ->whereNull('press_release_quota_period') + ->update(['press_release_quota_period' => now()->format('Y-m')]); + } + + public function down(): void + { + // Bewusst leer: reine Datenzuordnung; die Spalte entfernt die + // zugehörige DDL-Migration (2026_09_18_170100). + } +}; diff --git a/tests/Feature/Api/ApiPortalResolutionTest.php b/tests/Feature/Api/ApiPortalResolutionTest.php index 612577e5..291b7bc7 100644 --- a/tests/Feature/Api/ApiPortalResolutionTest.php +++ b/tests/Feature/Api/ApiPortalResolutionTest.php @@ -197,8 +197,11 @@ test('v1 update keeps the portal unless an explicit portal parameter is sent', f $company = Company::factory()->businessportal24()->create(); $user->companies()->attach($company->id, ['role' => 'owner']); + // Gemeinsame Kategorie, damit der Portalwechsel nicht an der + // Kategorieprüfung (Audit U10) scheitert. $pressRelease = PressRelease::factory() ->forPortal(Portal::Businessportal24) + ->for(Category::factory()->withTranslations()->create(['portal' => Portal::Both])) ->create(['user_id' => $user->id, 'company_id' => $company->id]); Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index 32e4046d..30e497db 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -11,8 +11,10 @@ use App\Models\RejectionCategory; use App\Models\User; use App\Services\PressRelease\Classification\ClassificationManager; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\StaleClassificationException; use Database\Seeders\RejectionCategorySeeder; use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; @@ -332,3 +334,215 @@ test('a classification for unchanged content is stored with its content hash', f ->and($audit->discarded_at)->toBeNull() ->and($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green); }); + +test('a classification is not stored when the content is overtaken between the analysis and the write (Audit U02)', function () { + Queue::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins', 'status' => PressReleaseStatus::Review->value]); + $hashAtDispatch = $pressRelease->content_hash; + $overtakenHash = str_repeat('b', 64); + + // Ein Editor speichert Version zwei exakt vor dem Schreiben des + // Ergebnisses – nach jedem vorherigen Hash-SELECT des Jobs. + $raced = false; + DB::beforeExecuting(function (string $query) use (&$raced, $pressRelease, $overtakenHash): void { + if ($raced || ! str_starts_with($query, 'update') || ! str_contains($query, 'press_releases') || ! str_contains($query, 'classification')) { + return; + } + + $raced = true; + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Inhalt zwei', 'content_hash' => $overtakenHash]); + }); + + (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $hashAtDispatch))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($raced)->toBeTrue(); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBeNull() + ->and($fresh->classified_at)->toBeNull() + ->and($fresh->status)->toBe(PressReleaseStatus::Review); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->discarded_at)->not->toBeNull() + ->and($audit->content_hash)->toBe($hashAtDispatch); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->route === true + && $job->contentHash === $overtakenHash); +}); + +test('a classification job carrying an outdated hash discards its result and re-queues for the current hash (Audit U02)', function () { + Queue::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins', 'status' => PressReleaseStatus::Review->value]); + $hashAtDispatch = $pressRelease->content_hash; + $currentHash = str_repeat('c', 64); + + // Massen-Update am Query-Builder vorbei (kein Model-Event) – der Job in + // der Queue trägt noch die alte Prüfsumme. + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Inhalt zwei', 'content_hash' => $currentHash]); + + (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $hashAtDispatch))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBeNull() + ->and($fresh->status)->toBe(PressReleaseStatus::Review) + ->and(KiAudit::where('press_release_id', $pressRelease->id)->whereNotNull('discarded_at')->count())->toBe(1); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->route === true + && $job->contentHash === $currentHash); +}); + +test('a legacy classification job without a hash still stores its result (Audit U02)', function () { + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create(['title' => 'Altbestand', 'text' => 'Inhalt']); + DB::table('press_releases')->where('id', $pressRelease->id)->update(['content_hash' => null]); + + (new ClassifyPressRelease($pressRelease->id, route: false))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green) + ->and(KiAudit::where('press_release_id', $pressRelease->id)->whereNull('discarded_at')->count())->toBe(1); +}); + +test('the classify job treats a stale classification at publish time as handled, not as a failure (Audit U02)', 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 StaleClassificationException($pressRelease->id, $pressRelease->content_hash, str_repeat('d', 64))); + + (new ClassifyPressRelease($pressRelease->id, route: true, contentHash: $pressRelease->content_hash))->handle( + app(ClassificationManager::class), + $service, + ); + + expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green); +}); + +test('a red re-check of a scheduled green release still waiting in review rejects it and notifies the author (Audit U03)', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['gesperrt']); + + $author = User::factory()->create(); + // Terminierte Grün-PM, die vor dem Termin bearbeitet wird und dabei ein + // unzulässiges Wort bekommt. + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $author->id, + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'classified_at' => now()->subHour(), + 'scheduled_at' => now()->addWeek(), + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + $pressRelease->update(['text' => 'Dieser Inhalt ist jetzt gesperrt.']); + // Sync-Queue: der Re-Check ohne Routing läuft sofort durch. + app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Red) + ->and($fresh->status)->toBe(PressReleaseStatus::Rejected); + + Mail::assertQueued(PressReleaseRejected::class, fn (PressReleaseRejected $mail) => $mail->hasTo($author->email)); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->where('type', KiAudit::TYPE_CLASSIFICATION)->latest('id')->firstOrFail(); + expect($audit->result)->toBe('red') + ->and($audit->discarded_at)->toBeNull() + ->and($audit->content_hash)->toBe($fresh->content_hash); +}); + +test('a red re-check of a published release leaves it published (Audit U03)', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['gesperrt']); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Published->value, + 'published_at' => now()->subDay(), + 'classification' => PressReleaseClassification::Green->value, + 'classified_at' => now()->subDay(), + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + $pressRelease->update(['text' => 'Dieser Inhalt ist jetzt gesperrt.']); + app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Red) + ->and($fresh->status)->toBe(PressReleaseStatus::Published); + + Mail::assertNothingQueued(); +}); + +test('a red re-check of a release already held for a human decision does not auto-reject it (Audit U03)', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', ['gesperrt']); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'classified_at' => now()->subHour(), + 'manual_review_required_at' => now()->subMinute(), + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + $pressRelease->update(['text' => 'Dieser Inhalt ist jetzt gesperrt.']); + app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Red) + ->and($fresh->status)->toBe(PressReleaseStatus::Review); + + Mail::assertNothingQueued(); +}); + +test('a green re-check without routing leaves the status untouched (Audit U03)', function () { + Mail::fake(); + config()->set('scoring.classification.provider', 'deterministic'); + config()->set('blacklist.words', []); + + $pressRelease = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Yellow->value, + 'classified_at' => now()->subHour(), + 'scheduled_at' => null, + 'title' => 'Sauber', + 'text' => 'Inhalt', + ]); + + $pressRelease->update(['text' => 'Inhalt, leicht überarbeitet.']); + app(PressReleaseService::class)->reprocessAfterContentChange($pressRelease); + + $fresh = $pressRelease->fresh(); + expect($fresh->classification)->toBe(PressReleaseClassification::Green) + ->and($fresh->status)->toBe(PressReleaseStatus::Review); + + Mail::assertNothingQueued(); +}); diff --git a/tests/Feature/PressReleaseContentScoreTest.php b/tests/Feature/PressReleaseContentScoreTest.php index 754e8b33..4f1c8337 100644 --- a/tests/Feature/PressReleaseContentScoreTest.php +++ b/tests/Feature/PressReleaseContentScoreTest.php @@ -8,6 +8,7 @@ use App\Models\PressRelease; use App\Models\User; use App\Services\PressRelease\ContentScore\ContentScoreManager; use App\Services\PressRelease\PressReleaseService; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; @@ -184,3 +185,70 @@ test('the score job has a timeout that fits the http budget and retry_after (Aud expect($job->timeout)->toBeGreaterThanOrEqual(120) ->and((int) config('queue.connections.database.retry_after'))->toBeGreaterThan($job->timeout); }); + +test('a score is not stored when the content is overtaken between the analysis and the write (Audit U02)', function () { + Queue::fake(); + fakeOpenAiScore(72); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']); + $hashAtDispatch = $pressRelease->content_hash; + $overtakenHash = str_repeat('b', 64); + + // Ein Editor speichert Version zwei exakt vor dem Schreiben des + // Ergebnisses – nach jedem vorherigen Hash-SELECT des Jobs. + $raced = false; + DB::beforeExecuting(function (string $query) use (&$raced, $pressRelease, $overtakenHash): void { + if ($raced || ! str_starts_with($query, 'update') || ! str_contains($query, 'press_releases') || ! str_contains($query, 'content_score')) { + return; + } + + $raced = true; + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Inhalt zwei', 'content_hash' => $overtakenHash]); + }); + + (new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class)); + + expect($raced)->toBeTrue(); + + $fresh = $pressRelease->fresh(); + expect($fresh->content_score)->toBeNull() + ->and($fresh->content_tier)->toBeNull() + ->and($fresh->scored_at)->toBeNull(); + + $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); + expect($audit->discarded_at)->not->toBeNull() + ->and($audit->content_hash)->toBe($hashAtDispatch); + + Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->contentHash === $overtakenHash); +}); + +test('a score job carrying an outdated hash discards its result and re-queues for the current hash (Audit U02)', function () { + Queue::fake(); + fakeOpenAiScore(72); + + $pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']); + $hashAtDispatch = $pressRelease->content_hash; + $currentHash = str_repeat('c', 64); + + DB::table('press_releases')->where('id', $pressRelease->id)->update(['text' => 'Inhalt zwei', 'content_hash' => $currentHash]); + + (new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class)); + + expect($pressRelease->fresh()->content_score)->toBeNull() + ->and(KiAudit::where('press_release_id', $pressRelease->id)->whereNotNull('discarded_at')->count())->toBe(1); + + Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id + && $job->contentHash === $currentHash); +}); + +test('a legacy score job without a hash still stores its result (Audit U02)', function () { + fakeOpenAiScore(72); + + $pressRelease = PressRelease::factory()->create(); + DB::table('press_releases')->where('id', $pressRelease->id)->update(['content_hash' => null]); + + (new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class)); + + expect($pressRelease->fresh()->content_score)->toBe(72); +}); diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php index 85a37c72..7c940e6f 100644 --- a/tests/Feature/PressReleaseQuotaTest.php +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -272,3 +272,61 @@ test('the monthly reset is idempotent and leaves this month\'s consumption alone ->and($lastMonth->fresh()->press_release_quota_period)->toBe('2026-09') ->and($unknown->fresh()->press_release_quota_used_this_month)->toBe(0); }); + +test('legacy consumption without a period counts as this month and continues the counter on publish (Audit U04)', function () { + Carbon::setTestNow('2026-09-18 10:00:00'); + + // Altbestand aus der Zeit vor der Periodenbindung: 2 von 3 verbraucht, + // Monat unbekannt (NULL). + $user = quotaTestSubscriber(planQuota: 3, used: 2); + expect($user->press_release_quota_period)->toBeNull() + ->and($user->pressReleaseQuotaRemaining())->toBe(1); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + $fresh = $user->fresh(); + expect($fresh->press_release_quota_used_this_month)->toBe(3) + ->and($fresh->press_release_quota_period)->toBe('2026-09') + ->and($fresh->pressReleaseQuotaRemaining())->toBe(0); + + $second = quotaTestPressRelease($user, 'review'); + expect(fn () => app(PressReleaseService::class)->publish($second)) + ->toThrow(QuotaExceededException::class); +}); + +test('the backfill migration pins legacy consumption to the migration month so the next month frees it (Audit U04)', function () { + Carbon::setTestNow('2026-09-18 10:00:00'); + + $user = quotaTestSubscriber(planQuota: 3, used: 3); + $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0, 'press_release_quota_period' => null]); + $pinned = User::factory()->create(['press_release_quota_used_this_month' => 1, 'press_release_quota_period' => '2026-08']); + + expect($user->pressReleaseQuotaRemaining())->toBe(0); + + // Heutige Lesart ohne Backfill: NULL gilt als laufender Monat – der + // Monatswechsel gibt ohne Reset-Cron nichts frei. + Carbon::setTestNow('2026-10-02 10:00:00'); + expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(0); + + Carbon::setTestNow('2026-09-18 10:00:00'); + $migration = require base_path('database/migrations/2026_09_18_190000_backfill_press_release_quota_period.php'); + $migration->up(); + + expect($user->fresh()->press_release_quota_period)->toBe('2026-09') + ->and($user->fresh()->press_release_quota_used_this_month)->toBe(3) + ->and($untouched->fresh()->press_release_quota_period)->toBeNull() + ->and($pinned->fresh()->press_release_quota_period)->toBe('2026-08'); + + // Nach dem Backfill erkennt der Monatswechsel den Altverbrauch als Vormonat. + Carbon::setTestNow('2026-10-02 10:00:00'); + expect($user->fresh()->pressReleaseQuotaRemaining())->toBe(3); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + $fresh = $user->fresh(); + expect($fresh->press_release_quota_used_this_month)->toBe(1) + ->and($fresh->press_release_quota_period)->toBe('2026-10') + ->and($fresh->pressReleaseQuotaRemaining())->toBe(2); +}); diff --git a/tests/Feature/PressReleaseSchedulingTest.php b/tests/Feature/PressReleaseSchedulingTest.php index c02a6f72..43c9f0da 100644 --- a/tests/Feature/PressReleaseSchedulingTest.php +++ b/tests/Feature/PressReleaseSchedulingTest.php @@ -8,6 +8,7 @@ use App\Models\KiAudit; use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; use App\Services\PressRelease\PressReleaseService; +use App\Services\PressRelease\StaleClassificationException; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Artisan; @@ -316,3 +317,100 @@ test('the scheduler re-checks a due release whose classification predates the la && $job->route === true && $job->contentHash === $stale->fresh()->content_hash); }); + +test('an automatic publish refuses a classification whose hash no longer matches the locked content (Audit U02)', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pr = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'title' => 'Version zwei', + 'text' => 'Inhalt zwei', + 'published_at' => null, + ]); + // Grünes Urteil gehört zu Version eins – die PM trägt inzwischen Version zwei. + KiAudit::factory()->create([ + 'press_release_id' => $pr->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => 'green', + 'content_hash' => str_repeat('a', 64), + ]); + + expect(fn () => app(PressReleaseService::class)->publish($pr, source: 'scheduler')) + ->toThrow(StaleClassificationException::class); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review) + ->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->count())->toBe(0); + + Queue::assertPushedOn('classification', ClassifyPressRelease::class, fn (ClassifyPressRelease $job) => $job->pressReleaseId === $pr->id + && $job->route === true + && $job->contentHash === $pr->content_hash); +}); + +test('a manual admin publish overrides a stale classification (Audit U02)', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pr = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'title' => 'Version zwei', + 'text' => 'Inhalt zwei', + 'published_at' => null, + ]); + KiAudit::factory()->create([ + 'press_release_id' => $pr->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => 'green', + 'content_hash' => str_repeat('a', 64), + ]); + + app(PressReleaseService::class)->publish($pr, source: 'admin'); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); + Queue::assertNotPushed(ClassifyPressRelease::class); +}); + +test('an automatic publish lets a legacy release without an audit hash through (Audit U02)', function () { + /** @var TestCase $this */ + Queue::fake(); + + $pr = PressRelease::factory()->create([ + 'status' => PressReleaseStatus::Review->value, + 'classification' => PressReleaseClassification::Green->value, + 'published_at' => null, + ]); + KiAudit::factory()->create([ + 'press_release_id' => $pr->id, + 'type' => KiAudit::TYPE_CLASSIFICATION, + 'result' => 'green', + 'content_hash' => null, + ]); + + app(PressReleaseService::class)->publish($pr, source: 'scheduler'); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); +}); + +test('the scheduler counts a stale classification at publish time as a re-check, not as a failure (Audit U02)', function () { + /** @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') + ->once() + ->andThrow(new StaleClassificationException($due->id, str_repeat('a', 64), (string) $due->content_hash)); + + $this->artisan(PublishScheduledPressReleases::class) + ->expectsOutputToContain('[RECHECK') + ->expectsOutputToContain('1 neu zur Prüfung, 0 fehlgeschlagen') + ->assertSuccessful(); +});