set('scoring.classification.provider', 'openai'); config()->set('services.openai.api_key', 'test-key'); Http::fake([ '*' => Http::response([ 'choices' => [ ['message' => ['content' => json_encode([ 'classification' => $classification, 'reasons' => $reasons, ])]], ], ], 200), ]); } test('classify job stores the openai classification and writes an audit', function () { fakeOpenAiClassification('green'); $pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); $fresh = $pressRelease->fresh(); expect($fresh->classification)->toBe(PressReleaseClassification::Green); expect($fresh->classified_at)->not->toBeNull(); $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); expect($audit->type)->toBe(KiAudit::TYPE_CLASSIFICATION); expect($audit->provider)->toBe('openai'); expect($audit->result)->toBe('green'); }); test('classify job records a yellow classification with reasons', function () { fakeOpenAiClassification('yellow', ['grenzwertige Werbesprache']); $pressRelease = PressRelease::factory()->create(); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Yellow); expect($audit->reason)->toContain('Werbesprache'); }); test('classify job falls back to the deterministic driver when openai fails', function () { config()->set('scoring.classification.provider', 'openai'); config()->set('services.openai.api_key', 'test-key'); config()->set('blacklist.words', ['penis']); Http::fake(['*' => Http::response('error', 500)]); // Sauberer Text -> deterministischer Fallback liefert green. $pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Green); expect($audit->provider)->toBe('deterministic'); }); test('deterministic driver classifies a banned word as red', function () { config()->set('scoring.classification.provider', 'deterministic'); config()->set('blacklist.words', ['penis']); $pressRelease = PressRelease::factory()->create(['title' => 'Titel penis', 'text' => 'Inhalt']); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); $audit = KiAudit::where('press_release_id', $pressRelease->id)->firstOrFail(); expect($pressRelease->fresh()->classification)->toBe(PressReleaseClassification::Red); expect($audit->provider)->toBe('deterministic'); }); test('classify job routes a red classification to rejected and notifies the author', function () { Mail::fake(); config()->set('scoring.classification.provider', 'deterministic'); config()->set('blacklist.words', ['penis']); $author = User::factory()->create(); $pressRelease = PressRelease::factory()->create([ 'user_id' => $author->id, 'status' => PressReleaseStatus::Review->value, 'title' => 'Titel penis', 'text' => 'Inhalt', ]); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected); Mail::assertQueued(PressReleaseRejected::class); }); test('classify job auto-publishes a green classification without a schedule', function () { Mail::fake(); config()->set('scoring.classification.provider', 'deterministic'); config()->set('blacklist.words', []); $pressRelease = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Review->value, 'scheduled_at' => null, 'title' => 'Sauber', 'text' => 'Inhalt', ]); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Published); Mail::assertQueued(PressReleasePublished::class); }); test('classify job leaves a green scheduled press release in review for the scheduler', function () { config()->set('scoring.classification.provider', 'deterministic'); config()->set('blacklist.words', []); $pressRelease = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Review->value, 'scheduled_at' => now()->addWeek(), 'title' => 'Sauber', 'text' => 'Inhalt', ]); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); $fresh = $pressRelease->fresh(); expect($fresh->classification)->toBe(PressReleaseClassification::Green); expect($fresh->status)->toBe(PressReleaseStatus::Review); }); test('classify job keeps a yellow classification in the manual review queue', function () { fakeOpenAiClassification('yellow', ['grenzwertig']); $pressRelease = PressRelease::factory()->create([ 'status' => PressReleaseStatus::Review->value, 'title' => 'Grenzfall', 'text' => 'Inhalt', ]); (new ClassifyPressRelease($pressRelease->id))->handle( app(ClassificationManager::class), app(PressReleaseService::class), ); expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Review); }); test('submitForReview dispatches the classification 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', ClassifyPressRelease::class, function (ClassifyPressRelease $job) use ($pressRelease) { return $job->pressReleaseId === $pressRelease->id; }); });