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