presseportale/tests/Feature/PressReleaseContentScoreTest.php
Kevin Adametz 082e6bb76c ki/quota: Hash-Bindung atomar bis in den Publish-Lock, roter Re-Check lehnt ab, Kontingent-Altbestand (Block 7, U02–U04)
- 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 <noreply@anthropic.com>
2026-09-18 12:22:22 +00:00

254 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\PressReleaseContentTier;
use App\Enums\PressReleaseStatus;
use App\Jobs\ScorePressRelease;
use App\Models\KiAudit;
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;
function fakeOpenAiScore(int $score): void
{
config()->set('scoring.content_score.provider', 'openai');
config()->set('services.openai.api_key', 'test-key');
Http::fake([
'*' => Http::response([
'choices' => [
['message' => ['content' => json_encode([
'score' => $score,
'breakdown' => ['pressestil' => 15],
])]],
],
], 200),
]);
}
test('tier mapping follows the configured thresholds', function () {
config()->set('scoring.content_score.tiers', ['gepruft' => 60, 'hochwertig' => 80]);
expect(PressReleaseContentTier::fromScore(45))->toBe(PressReleaseContentTier::Standard);
expect(PressReleaseContentTier::fromScore(60))->toBe(PressReleaseContentTier::Geprueft);
expect(PressReleaseContentTier::fromScore(79))->toBe(PressReleaseContentTier::Geprueft);
expect(PressReleaseContentTier::fromScore(80))->toBe(PressReleaseContentTier::Hochwertig);
});
test('only gepruft and hochwertig are publicly badged', function () {
expect(PressReleaseContentTier::Standard->isPubliclyBadged())->toBeFalse();
expect(PressReleaseContentTier::Geprueft->isPubliclyBadged())->toBeTrue();
expect(PressReleaseContentTier::Hochwertig->isPubliclyBadged())->toBeTrue();
});
test('score job stores the openai score, derives the tier and writes an audit', function () {
fakeOpenAiScore(72);
$pressRelease = PressRelease::factory()->create();
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
$fresh = $pressRelease->fresh();
expect($fresh->content_score)->toBe(72);
expect($fresh->content_tier)->toBe(PressReleaseContentTier::Geprueft);
expect($fresh->scored_at)->not->toBeNull();
$audit = KiAudit::where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CONTENT_SCORE)
->firstOrFail();
expect($audit->provider)->toBe('openai');
expect($audit->result)->toBe('72');
});
test('openai score awards the full image points when an upload image exists', function () {
fakeOpenAiScore(62);
$pressRelease = PressRelease::factory()->create();
$pressRelease->images()->create([
'disk' => 'public',
'path' => 'press-releases/'.$pressRelease->id.'/images/title.jpg',
'is_preview' => true,
'sort_order' => 1,
]);
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
expect($pressRelease->fresh()->content_score)->toBe(72);
$audit = KiAudit::where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CONTENT_SCORE)
->firstOrFail();
expect($audit->raw_response['breakdown']['bild'])->toBe(10);
});
test('openai score without image stays uncapped at the ai maximum of 90', function () {
fakeOpenAiScore(100);
$pressRelease = PressRelease::factory()->create();
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
expect($pressRelease->fresh()->content_score)->toBe(90);
$audit = KiAudit::where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CONTENT_SCORE)
->firstOrFail();
expect($audit->raw_response['breakdown']['bild'])->toBe(0);
});
test('score job falls back to the deterministic driver when openai fails', function () {
config()->set('scoring.content_score.provider', 'openai');
config()->set('services.openai.api_key', 'test-key');
Http::fake(['*' => Http::response('error', 500)]);
$pressRelease = PressRelease::factory()->create([
'title' => 'Eine ausreichend lange und klare Pressemitteilungs-Headline',
'text' => str_repeat('Inhaltlicher Satz mit Substanz. ', 80),
]);
(new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class));
$audit = KiAudit::where('press_release_id', $pressRelease->id)
->where('type', KiAudit::TYPE_CONTENT_SCORE)
->firstOrFail();
expect($audit->provider)->toBe('deterministic');
expect($pressRelease->fresh()->content_score)->toBeGreaterThan(0);
Http::assertSentCount(3);
});
test('submitForReview dispatches the scoring job onto the classification queue', function () {
Queue::fake();
config()->set('blacklist.words', []);
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
'title' => 'Sauber',
'text' => 'Inhalt',
]);
app(PressReleaseService::class)->submitForReview($pressRelease);
Queue::assertPushedOn('classification', ScorePressRelease::class, function (ScorePressRelease $job) use ($pressRelease) {
return $job->pressReleaseId === $pressRelease->id;
});
});
test('rescoreIfScored dispatches only for an already scored press release', function () {
Queue::fake();
$scored = PressRelease::factory()->create(['content_score' => 55]);
$neverScored = PressRelease::factory()->create(['content_score' => null]);
app(PressReleaseService::class)->rescoreIfScored($scored);
app(PressReleaseService::class)->rescoreIfScored($neverScored);
Queue::assertPushed(ScorePressRelease::class, 1);
});
test('a score for content that changed during the analysis is discarded and re-queued (Audit F07)', function () {
Queue::fake();
config()->set('scoring.content_score.provider', 'openai');
config()->set('services.openai.api_key', 'test-key');
$pressRelease = PressRelease::factory()->create(['title' => 'Version eins', 'text' => 'Inhalt eins']);
$hashAtDispatch = $pressRelease->content_hash;
Http::fake(function () use ($pressRelease) {
PressRelease::withoutGlobalScopes()->whereKey($pressRelease->id)->first()->update(['text' => 'Inhalt zwei']);
return Http::response(['choices' => [['message' => ['content' => json_encode(['score' => 90, 'breakdown' => ['pressestil' => 20]])]]]], 200);
});
(new ScorePressRelease($pressRelease->id, contentHash: $hashAtDispatch))->handle(app(ContentScoreManager::class));
$fresh = $pressRelease->fresh();
expect($fresh->content_score)->toBeNull();
$audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail();
expect($audit->discarded_at)->not->toBeNull();
Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job) => $job->pressReleaseId === $pressRelease->id
&& $job->contentHash === $fresh->content_hash);
});
test('the score job has a timeout that fits the http budget and retry_after (Audit F17)', function () {
$job = new ScorePressRelease(1);
// Drei Versuche à 30 s plus Verbindungsaufbau passen nicht in die 60 s des
// Workers; der Job trägt sein eigenes Budget, und retry_after liegt darüber.
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);
});