create(['created_at' => '2020-01-01']); // Public artwork Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2021-06-01', 'deleted_at' => null, ]); // Private artwork — must not be counted Artwork::factory()->for($user)->create([ 'is_public' => false, 'is_approved' => false, 'published_at' => '2021-08-01', 'deleted_at' => null, ]); // Soft-deleted artwork — must not be counted Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2021-09-01', 'deleted_at' => now(), ]); $builder = new AiBiographyInputBuilder(); $input = $builder->build($user); expect($input['uploads_count'])->toBe(1); }); it('derives member_since_year from user created_at', function () { $user = User::factory()->create(['created_at' => '2004-03-15']); $builder = new AiBiographyInputBuilder(); $input = $builder->build($user); expect($input['member_since_year'])->toBe(2004); }); it('source hash changes when upload count changes', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $builder = new AiBiographyInputBuilder(); $hash1 = $builder->sourceHash($builder->build($user)); Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2021-06-01', 'deleted_at' => null, ]); $hash2 = $builder->sourceHash($builder->build($user)); expect($hash1)->not()->toBe($hash2); }); it('source hash is stable for the same data', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $builder = new AiBiographyInputBuilder(); $hash1 = $builder->sourceHash($builder->build($user)); $hash2 = $builder->sourceHash($builder->build($user)); expect($hash1)->toBe($hash2); }); it('source hash is stable when only downloads_count changes', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $builder = new AiBiographyInputBuilder(); $input1 = $builder->build($user); $input2 = array_merge($input1, ['downloads_count' => ($input1['downloads_count'] ?? 0) + 5000]); expect($builder->sourceHash($input1))->toBe($builder->sourceHash($input2)); }); }); // ───────────────────────────────────────────────────────────────────────────── // AiBiographyValidator // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyValidator', function () { $validator = fn () => new AiBiographyValidator(); it('accepts a valid single paragraph', function () use ($validator) { $text = 'Gregor has been part of Skinbase since 2004, building a long-running portfolio centered on wallpapers and digital art. Over the years his work has earned featured selections, with Blue Eclipse standing out as a strong piece. His creator journey shows both long-term consistency and a notable comeback after a significant hiatus.'; expect($validator()->isValid($text))->toBeTrue(); }); it('rejects empty text', function () use ($validator) { expect($validator()->validate(''))->not()->toBeEmpty(); }); it('rejects text that is too short', function () use ($validator) { expect($validator()->validate('Too short.'))->not()->toBeEmpty(); }); it('rejects text that is too long', function () use ($validator) { $long = str_repeat('This is a very long sentence about an artist creator person who lives and works. ', 30); expect($validator()->validate($long))->not()->toBeEmpty(); }); it('rejects markdown headings', function () use ($validator) { $text = "## About the Creator\n\nCreator has been active since 2010 with many quality artworks."; expect($validator()->validate($text))->not()->toBeEmpty(); }); it('rejects bullet points', function () use ($validator) { $text = "- Member since 2010\n- Featured works: 3\n- Top category: wallpapers"; expect($validator()->validate($text))->not()->toBeEmpty(); }); it('rejects multiple paragraphs', function () use ($validator) { $text = "First paragraph about the creator and their history on the platform.\n\nSecond paragraph providing additional made-up details about style and themes."; expect($validator()->validate($text))->not()->toBeEmpty(); }); it('rejects forbidden hype phrases', function () use ($validator) { $text = 'This legendary world-class creator has been active since 2010 and has defined the platform with their iconic visionary approach to digital art.'; expect($validator()->validate($text))->not()->toBeEmpty(); }); it('rejects AI apology patterns', function () use ($validator) { $text = 'I cannot write a biography for this user as I do not have enough information about their artistic achievements.'; expect($validator()->validate($text))->not()->toBeEmpty(); }); }); // ───────────────────────────────────────────────────────────────────────────── // AiBiographyService — storage and visibility // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyService', function () { function makeService(?string $generatedText = null, bool $generationSuccess = true): AiBiographyService { $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $mockGenerator->shouldReceive('generate')->andReturn([ 'success' => $generationSuccess, 'text' => $generatedText, 'errors' => $generationSuccess ? [] : ['Mocked failure'], 'model' => 'test-model', 'prompt_version' => 'v1.1', 'was_retried' => false, ]); return new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); } it('stores biography and marks it active on successful generation', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null]); } $service = makeService('A valid biography text with enough words to pass the test validation check here.'); $result = $service->generate($user); expect($result['success'])->toBeTrue(); expect(CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->exists())->toBeTrue(); }); it('does not overwrite a user-edited biography on automatic generate', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null]); } CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Custom written bio by the creator.', 'source_hash' => 'abc123', 'model' => null, 'status' => CreatorAiBiography::STATUS_EDITED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => true, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService('A newly generated biography text for the creator.'); $result = $service->generate($user); // The draft is stored, but the original edited bio remains active. expect($result['success'])->toBeTrue(); expect($result['action'])->toBe('draft_stored'); $active = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($active->is_user_edited)->toBeTrue(); expect($active->text)->toBe('Custom written bio by the creator.'); }); it('does not expose hidden biography in publicPayload', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A hidden biography that should not be shown.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => true, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService(); $payload = $service->publicPayload($user); expect($payload)->toBeNull(); }); it('returns null publicPayload when no biography exists', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $service = makeService(); expect($service->publicPayload($user))->toBeNull(); }); it('returns structured payload when biography is visible', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A valid biography for public display about the creator here.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService(); $payload = $service->publicPayload($user); expect($payload)->toBeArray(); expect($payload['is_visible'])->toBeTrue(); expect($payload['text'])->not()->toBeEmpty(); }); it('hide makes biography invisible', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Visible biography about this creator with enough words here.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService(); $service->hide($user); expect($service->publicPayload($user))->toBeNull(); }); it('show restores biography visibility', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A hidden biography about the creator for testing purposes.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => true, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService(); $service->show($user); $payload = $service->publicPayload($user); expect($payload)->not()->toBeNull(); expect($payload['is_visible'])->toBeTrue(); }); it('updateText marks biography as user-edited', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Generated biography text here about the creator.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService(); $service->updateText($user, 'My custom biography text that I wrote myself and is accurate.'); $record = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($record->is_user_edited)->toBeTrue(); expect($record->status)->toBe(CreatorAiBiography::STATUS_EDITED); expect($record->text)->toBe('My custom biography text that I wrote myself and is accurate.'); }); it('failed generation does not create a biography record', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $service = makeService(null, false); $result = $service->generate($user); expect($result['success'])->toBeFalse(); expect(CreatorAiBiography::where('user_id', $user->id)->exists())->toBeFalse(); }); it('regenerate with force replaces user-edited biography', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null]); } CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Custom written bio by the creator.', 'source_hash' => 'abc123', 'model' => null, 'status' => CreatorAiBiography::STATUS_EDITED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => true, 'generated_at' => now(), 'approved_at' => now(), ]); $service = makeService('A newly generated fresh biography text for the creator who has many works here.'); $result = $service->regenerate($user, true); expect($result['success'])->toBeTrue(); expect($result['action'])->toBe('generated'); $active = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($active->is_user_edited)->toBeFalse(); }); }); // ───────────────────────────────────────────────────────────────────────────── // VisionLlmClient — HTTP error handling // ───────────────────────────────────────────────────────────────────────────── describe('VisionLlmClient', function () { it('throws VisionLlmException on 401', function () { config(['vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key']); Http::fake([ 'vision.test/*' => Http::response(['error' => 'Unauthorized'], 401), ]); $client = new VisionLlmClient(); expect(fn () => $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]))->toThrow(VisionLlmException::class); }); it('throws VisionLlmException on 503', function () { config(['vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key']); Http::fake([ 'vision.test/*' => Http::response(['error' => 'Service unavailable'], 503), ]); $client = new VisionLlmClient(); expect(fn () => $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]))->toThrow(VisionLlmException::class); }); it('returns generated text on success', function () { config([ 'ai_biography.provider' => 'vision_gateway', 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key', 'ai_biography.llm_endpoint' => '/ai/chat', ]); Http::fake([ 'vision.test/ai/chat' => Http::response([ 'choices' => [[ 'message' => ['content' => 'A creator biography from the gateway.'], ]], ], 200), ]); $client = new VisionLlmClient(); $text = $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]); expect($text)->toBe('A creator biography from the gateway.'); }); it('throws VisionLlmException when gateway is not configured', function () { config(['vision.gateway.base_url' => '', 'vision.gateway.api_key' => '']); $client = new VisionLlmClient(); expect(fn () => $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]))->toThrow(VisionLlmException::class); }); it('maps biography payload to Gemini generateContent and returns text', function () { config([ 'ai_biography.provider' => 'gemini', 'ai_biography.gemini.base_url' => 'https://generativelanguage.googleapis.com', 'ai_biography.gemini.api_key' => 'gemini-test-key', 'ai_biography.gemini.model' => 'gemini-flash-latest', ]); Http::fake(function ($request) { expect($request->url())->toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent'); expect($request->hasHeader('X-goog-api-key', 'gemini-test-key'))->toBeTrue(); expect($request['systemInstruction']['parts'][0]['text'])->toBe('System rules.'); expect($request['contents'][0]['role'])->toBe('user'); expect($request['contents'][0]['parts'][0]['text'])->toBe('Write a grounded creator biography.'); expect($request['generationConfig']['maxOutputTokens'])->toBe(450); expect($request['generationConfig']['temperature'])->toBe(0.2); return Http::response([ 'candidates' => [[ 'content' => [ 'parts' => [ ['text' => 'A creator biography from Gemini.'], ], ], ]], ], 200); }); $client = new VisionLlmClient(); $text = $client->chat([ 'messages' => [ ['role' => 'system', 'content' => 'System rules.'], ['role' => 'user', 'content' => 'Write a grounded creator biography.'], ], 'max_tokens' => 450, 'temperature' => 0.2, 'stream' => false, ]); expect($text)->toBe('A creator biography from Gemini.'); }); it('throws VisionLlmException when Gemini is not configured', function () { config([ 'ai_biography.provider' => 'gemini', 'ai_biography.gemini.base_url' => 'https://generativelanguage.googleapis.com', 'ai_biography.gemini.api_key' => '', 'ai_biography.gemini.model' => 'gemini-flash-latest', ]); $client = new VisionLlmClient(); expect(fn () => $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]))->toThrow(VisionLlmException::class); }); it('maps biography payload to Home LM Studio OpenAI-compatible API and returns text', function () { config([ 'ai_biography.provider' => 'home', 'ai_biography.home.base_url' => 'http://home.klevze.si:8200', 'ai_biography.home.endpoint' => '/v1/chat/completions', 'ai_biography.home.model' => 'google/gemma-3-4b', 'ai_biography.home.api_key' => '', 'ai_biography.home.verify_ssl' => true, ]); Http::fake(function ($request) { expect($request->url())->toBe('http://home.klevze.si:8200/v1/chat/completions'); expect($request['model'])->toBe('google/gemma-3-4b'); expect($request['messages'][0]['role'])->toBe('system'); expect($request['messages'][1]['content'])->toBe('Write a grounded creator biography.'); expect($request['max_tokens'])->toBe(450); expect($request['temperature'])->toBe(0.2); expect($request['stream'])->toBeFalse(); return Http::response([ 'choices' => [[ 'message' => ['content' => 'A creator biography from Home LM Studio.'], ]], ], 200); }); $client = new VisionLlmClient(); $text = $client->chat([ 'messages' => [ ['role' => 'system', 'content' => 'System rules.'], ['role' => 'user', 'content' => 'Write a grounded creator biography.'], ], 'max_tokens' => 450, 'temperature' => 0.2, 'stream' => false, ]); expect($text)->toBe('A creator biography from Home LM Studio.'); }); it('throws VisionLlmException when Home LM Studio is not configured', function () { config([ 'ai_biography.provider' => 'home', 'ai_biography.home.base_url' => '', 'ai_biography.home.model' => '', ]); $client = new VisionLlmClient(); expect(fn () => $client->chat([ 'messages' => [['role' => 'user', 'content' => 'test']], 'max_tokens' => 100, 'temperature' => 0.5, 'stream' => false, ]))->toThrow(VisionLlmException::class); }); }); // ───────────────────────────────────────────────────────────────────────────── // Public API endpoint // ───────────────────────────────────────────────────────────────────────────── describe('Public AI biography endpoint', function () { it('returns null data when no visible biography exists', function () { $user = User::factory()->create([ 'username' => 'nobiouser', 'is_active' => true, ]); $response = $this->getJson('/api/profile/nobiouser/ai-biography'); $response->assertOk(); $response->assertJsonPath('data', null); }); it('returns biography data when biography is visible', function () { $user = User::factory()->create([ 'username' => 'biovisibleuser', 'is_active' => true, ]); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A publicly visible biography about this creator with enough text here.', 'source_hash' => 'def456', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $response = $this->getJson('/api/profile/biovisibleuser/ai-biography'); $response->assertOk(); $response->assertJsonPath('data.is_visible', true); $response->assertJsonStructure(['data' => ['text', 'is_visible', 'is_user_edited', 'generated_at']]); }); it('returns null data when biography is hidden', function () { $user = User::factory()->create([ 'username' => 'biohiddenuser', 'is_active' => true, ]); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'This biography is hidden from public view by the creator.', 'source_hash' => 'ghi789', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => true, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $response = $this->getJson('/api/profile/biohiddenuser/ai-biography'); $response->assertOk(); $response->assertJsonPath('data', null); }); }); // ───────────────────────────────────────────────────────────────────────────── // Creator management endpoints // ───────────────────────────────────────────────────────────────────────────── describe('Creator AI biography management endpoints', function () { it('generate endpoint requires authentication', function () { $this->postJson('/api/creator/profile/ai-biography/generate') ->assertStatus(401); }); it('generate endpoint queues a job for authenticated user', function () { Queue::fake(); $user = User::factory()->create(['is_active' => true]); $this->actingAs($user) ->postJson('/api/creator/profile/ai-biography/generate') ->assertStatus(202); Queue::assertPushed(\App\Jobs\GenerateAiBiographyJob::class, fn ($job) => $job->userId === (int) $user->id); }); it('update endpoint saves user-edited biography', function () { $user = User::factory()->create(['is_active' => true]); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Original generated text here about the creator.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $this->actingAs($user) ->patchJson('/api/creator/profile/ai-biography', [ 'text' => 'This is my personal custom biography that I have written myself.', ]) ->assertOk(); $record = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($record->is_user_edited)->toBeTrue(); }); it('hide endpoint hides biography', function () { $user = User::factory()->create(['is_active' => true]); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Biography to be hidden by the creator for privacy reasons.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $this->actingAs($user) ->postJson('/api/creator/profile/ai-biography/hide') ->assertOk(); $record = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($record->is_hidden)->toBeTrue(); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — AiBiographyInputBuilder quality tiers and threshold // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyInputBuilder v1.1 — quality tiers', function () { it('classifies a sparse profile as sparse', function () { $user = User::factory()->create(['created_at' => now()->subYear()]); $builder = new AiBiographyInputBuilder(); $input = $builder->build($user); expect($builder->qualityTier($input))->toBe('sparse'); }); it('classifies a creator with many uploads and featured works as rich', function () { $user = User::factory()->create(['created_at' => now()->subYears(5)]); $builder = new AiBiographyInputBuilder(); // Inject rich input manually to avoid needing 20+ DB rows. $richInput = [ 'uploads_count' => 50, 'featured_count' => 3, 'years_on_skinbase' => 5, 'milestones' => ['has_comeback' => true, 'best_upload_streak_months' => 6], 'eras' => [['title' => 'Early'], ['title' => 'Recent']], 'evolution_count' => 5, ]; expect($builder->qualityTier($richInput))->toBe('rich'); }); it('classifies a medium-signal creator as medium', function () { $builder = new AiBiographyInputBuilder(); $input = [ 'uploads_count' => 10, 'featured_count' => 0, 'years_on_skinbase' => 2, 'milestones' => ['has_comeback' => false, 'best_upload_streak_months' => 0], 'eras' => [], 'evolution_count' => 0, ]; expect($builder->qualityTier($input))->toBe('medium'); }); it('returns false for minimum threshold on new account with no uploads', function () { $user = User::factory()->create(['created_at' => now()->subMonths(1)]); $builder = new AiBiographyInputBuilder(); $input = $builder->build($user); expect($builder->meetsMinimumThreshold($input))->toBeFalse(); }); it('returns true for minimum threshold when creator has enough uploads', function () { $user = User::factory()->create(['created_at' => now()->subYear()]); for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } $builder = new AiBiographyInputBuilder(); $input = $builder->build($user); expect($builder->meetsMinimumThreshold($input))->toBeTrue(); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — AiBiographyValidator hardening // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyValidator v1.1 — extended rules', function () { $validator = fn () => new AiBiographyValidator(); it('rejects "renowned for" as forbidden hype', function () use ($validator) { $text = 'This creator is renowned for producing some of the finest digital art on the platform since joining.'; $errors = $validator()->validate($text); expect($errors)->not()->toBeEmpty(); }); it('rejects "celebrated by" as forbidden hype', function () use ($validator) { $text = 'Celebrated by the community, this creator has produced consistent digital work across many categories.'; $errors = $validator()->validate($text); expect($errors)->not()->toBeEmpty(); }); it('rejects output that repeats "creator journey" twice', function () use ($validator) { $text = 'The creator journey of this artist spans many years. The creator journey shows both evolution and comeback milestones that define their long platform history.'; $errors = $validator()->validate($text); expect($errors)->not()->toBeEmpty(); }); it('rejects sparse-profile bio that references too many rich signals', function () use ($validator) { $text = 'This creator has featured artworks, a notable comeback, a strong upload streak, and several remastered evolution works with high downloads and a most productive year.'; $errors = $validator()->validate($text, 'sparse'); expect($errors)->not()->toBeEmpty(); }); it('accepts a valid sparse-profile bio with modest claims', function () use ($validator) { $text = 'A Skinbase creator building an early digital portfolio of public artworks and uploads. Their work spans a growing set of categories on the platform, with new pieces added on a regular basis.'; expect($validator()->isValid($text, 'sparse'))->toBeTrue(); }); it('accepts a rich bio with rich tier tier context', function () use ($validator) { $text = 'Active since 2005, this creator has built a portfolio featuring over thirty public uploads across wallpapers and digital art, with several featured selections and a notable return to publishing after a long break.'; expect($validator()->isValid($text, 'rich'))->toBeTrue(); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — AiBiographyPromptBuilder versioning // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyPromptBuilder v1.1 — versioning', function () { it('returns v1.1 as prompt_version', function () { $builder = new AiBiographyPromptBuilder(); $payload = $builder->build(['username' => 'test', 'uploads_count' => 10, 'member_since_year' => 2010, 'years_on_skinbase' => 15]); expect($payload['prompt_version'])->toBe('v1.1'); }); it('returns lower max_tokens and temperature in strict mode', function () { $builder = new AiBiographyPromptBuilder(); $normal = $builder->build(['username' => 'test']); $strict = $builder->build(['username' => 'test'], strict: true); expect($strict['max_tokens'])->toBeLessThan($normal['max_tokens']); expect($strict['temperature'])->toBeLessThan($normal['temperature']); }); it('returns even lower max_tokens in sparse mode', function () { $builder = new AiBiographyPromptBuilder(); $strict = $builder->build(['username' => 'test'], strict: true); $sparse = $builder->build(['username' => 'test'], sparse: true); expect($sparse['max_tokens'])->toBeLessThanOrEqual($strict['max_tokens']); }); it('uses a stricter sparse prompt on retry and enforces the minimum word floor', function () { $builder = new AiBiographyPromptBuilder(); $sparse = $builder->build([ 'username' => 'test', 'member_since_year' => 2007, 'years_on_skinbase' => 19, 'uploads_count' => 1, 'top_categories' => ['GTK+'], ], sparse: true); $sparseStrict = $builder->build([ 'username' => 'test', 'member_since_year' => 2007, 'years_on_skinbase' => 19, 'uploads_count' => 1, 'top_categories' => ['GTK+'], ], strict: true, sparse: true); expect($sparseStrict['max_tokens'])->toBeLessThan($sparse['max_tokens']); expect($sparseStrict['temperature'])->toBeLessThan($sparse['temperature']); expect($sparse['messages'][0]['content'])->toContain('Minimum 30 words.'); expect($sparseStrict['messages'][0]['content'])->toContain('Prefer two short factual sentences.'); expect($sparseStrict['messages'][1]['content'])->toContain('Write at least 30 words.'); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — AiBiographyGenerator retry logic // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyGenerator v1.1 — retry', function () { it('retries exactly once when first attempt fails validation', function () { config([ 'ai_biography.provider' => 'vision_gateway', 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key', 'ai_biography.llm_endpoint' => '/ai/chat', ]); $callCount = 0; $validBio = 'Active on Skinbase for many years, this creator has built a portfolio spanning wallpapers and digital art, with several featured selections and a consistent upload history reflecting dedication to the platform and craft.'; Http::fake(function ($request) use (&$callCount, $validBio) { $callCount++; if ($callCount === 1) { // First call: return something too short that will fail validation. return Http::response(['choices' => [['message' => ['content' => 'Too short.']]]], 200); } // Second call (retry): return a valid biography. return Http::response(['choices' => [['message' => ['content' => $validBio]]]], 200); }); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate(['username' => 'test', 'user_id' => 99, 'uploads_count' => 20, 'member_since_year' => 2004, 'years_on_skinbase' => 21], 'rich'); expect($result['success'])->toBeTrue(); expect($result['was_retried'])->toBeTrue(); expect($callCount)->toBe(2); }); it('returns failure after both attempts fail', function () { config([ 'ai_biography.provider' => 'vision_gateway', 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key', 'ai_biography.llm_endpoint' => '/ai/chat', ]); // Both calls return text that is too short to pass validation. Http::fake([ 'vision.test/ai/chat' => Http::response(['choices' => [['message' => ['content' => 'Short.']]]], 200), ]); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate(['username' => 'test', 'user_id' => 99], 'rich'); expect($result['success'])->toBeFalse(); expect($result['was_retried'])->toBeTrue(); }); it('returns prompt_version in result', function () { config([ 'ai_biography.provider' => 'vision_gateway', 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key', 'ai_biography.llm_endpoint' => '/ai/chat', ]); Http::fake([ 'vision.test/ai/chat' => Http::response(['choices' => [['message' => ['content' => 'Active on Skinbase since 2010, this creator has developed a consistent portfolio across digital art categories with several featured artworks and a strong upload history.']]]], 200), ]); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate(['username' => 'test', 'user_id' => 99, 'uploads_count' => 15, 'member_since_year' => 2010, 'years_on_skinbase' => 15]); expect($result['prompt_version'])->toBe('v1.1'); }); it('retries sparse biographies with a stricter sparse prompt after a too-short first attempt', function () { config([ 'ai_biography.provider' => 'vision_gateway', 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'test-key', 'ai_biography.llm_endpoint' => '/ai/chat', ]); $requests = []; $firstAttempt = 'szerencsefia has been a member of Skinbase Nova since 2007. They have uploaded one public artwork categorized under GTK+.'; $retryAttempt = 'szerencsefia has been part of Skinbase Nova since 2007. They have one public artwork on the platform, and that published work is categorized under GTK+, giving a modest but concrete snapshot of their public activity.'; Http::fake(function ($request) use (&$requests, $firstAttempt, $retryAttempt) { $requests[] = $request->data(); if (count($requests) === 1) { return Http::response(['choices' => [['message' => ['content' => $firstAttempt]]]], 200); } return Http::response(['choices' => [['message' => ['content' => $retryAttempt]]]], 200); }); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate([ 'username' => 'szerencsefia', 'user_id' => 99, 'member_since_year' => 2007, 'years_on_skinbase' => 19, 'uploads_count' => 1, 'top_categories' => ['GTK+'], ], 'sparse'); expect($result['success'])->toBeTrue(); expect($result['was_retried'])->toBeTrue(); expect($requests)->toHaveCount(2); expect($requests[0]['messages'][0]['content'])->not->toBe($requests[1]['messages'][0]['content']); expect($requests[1]['messages'][0]['content'])->toContain('Prefer two short factual sentences.'); }); it('returns configured Gemini model in result metadata', function () { config([ 'ai_biography.provider' => 'gemini', 'ai_biography.gemini.base_url' => 'https://generativelanguage.googleapis.com', 'ai_biography.gemini.api_key' => 'gemini-test-key', 'ai_biography.gemini.model' => 'gemini-flash-latest', ]); Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ 'content' => [ 'parts' => [ ['text' => 'Active on Skinbase for many years, this creator has built a portfolio spanning wallpapers and digital art, with several featured selections and a consistent upload history reflecting dedication to the platform and craft.'], ], ], ]], ], 200), ]); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate(['username' => 'test', 'user_id' => 99, 'uploads_count' => 15, 'member_since_year' => 2010, 'years_on_skinbase' => 15]); expect($result['success'])->toBeTrue(); expect($result['model'])->toBe('gemini-flash-latest'); }); it('returns configured Home model in result metadata', function () { config([ 'ai_biography.provider' => 'home', 'ai_biography.home.base_url' => 'http://home.klevze.si:8200', 'ai_biography.home.endpoint' => '/v1/chat/completions', 'ai_biography.home.model' => 'google/gemma-3-4b', ]); Http::fake([ 'http://home.klevze.si:8200/*' => Http::response([ 'choices' => [[ 'message' => [ 'content' => 'Active on Skinbase for many years, this creator has built a portfolio spanning wallpapers and digital art, with several featured selections and a consistent upload history reflecting dedication to the platform and craft.', ], ]], ], 200), ]); $generator = new AiBiographyGenerator( new AiBiographyPromptBuilder(), new VisionLlmClient(), new AiBiographyValidator(), ); $result = $generator->generate(['username' => 'test', 'user_id' => 99, 'uploads_count' => 15, 'member_since_year' => 2010, 'years_on_skinbase' => 15]); expect($result['success'])->toBeTrue(); expect($result['model'])->toBe('google/gemma-3-4b'); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — AiBiographyService sparse suppression and metadata // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyService v1.1 — sparse suppression and metadata', function () { it('suppresses generation for sparse profile below threshold', function () { // User with no artworks at all — well below the minimum threshold. $user = User::factory()->create(['created_at' => now()->subMonths(2)]); $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $mockGenerator->shouldNotReceive('generate'); // must NOT be called $service = new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); $result = $service->generate($user); expect($result['success'])->toBeFalse(); expect($result['action'])->toBe('suppressed_low_signal'); expect(CreatorAiBiography::where('user_id', $user->id)->exists())->toBeFalse(); }); it('stores prompt_version and input_quality_tier on successful generation', function () { $user = User::factory()->create(['created_at' => now()->subYears(3)]); // Give the user enough artworks to meet the threshold. for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $mockGenerator->shouldReceive('generate')->andReturn([ 'success' => true, 'text' => 'Valid biography text for this creator with enough words to be accepted.', 'errors' => [], 'model' => 'test-model', 'prompt_version' => 'v1.1', 'was_retried' => false, ]); $service = new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); $result = $service->generate($user, 'admin_batch'); expect($result['success'])->toBeTrue(); $record = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($record)->not()->toBeNull(); expect($record->prompt_version)->toBe('v1.1'); expect($record->generation_reason)->toBe('admin_batch'); expect($record->input_quality_tier)->toBeString(); }); it('flags needs_review on existing user-edited bio when stale draft is stored', function () { $user = User::factory()->create(['created_at' => now()->subYears(3)]); // Give the user enough artworks to meet the threshold. for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'My hand-written biography that I prefer to keep.', 'source_hash' => 'oldhash', 'model' => null, 'status' => CreatorAiBiography::STATUS_EDITED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => true, 'generated_at' => now()->subDays(30), 'approved_at' => now()->subDays(30), ]); $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $mockGenerator->shouldReceive('generate')->andReturn([ 'success' => true, 'text' => 'Refreshed AI biography text for this active creator with notable works.', 'errors' => [], 'model' => 'test-model', 'prompt_version' => 'v1.1', 'was_retried' => false, ]); $service = new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); $result = $service->generate($user); expect($result['action'])->toBe('draft_stored'); // Original active user-edited record should be flagged needs_review. $active = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($active->is_user_edited)->toBeTrue(); expect($active->needs_review)->toBeTrue(); // A non-active draft should now exist. $draft = CreatorAiBiography::where('user_id', $user->id)->where('is_active', false)->where('is_user_edited', false)->first(); expect($draft)->not()->toBeNull(); expect($draft->prompt_version)->toBe('v1.1'); }); it('creatorStatusPayload returns needs_review and prompt_version fields', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A stored biography about this creator.', 'source_hash' => 'abc', 'model' => 'test', 'prompt_version' => 'v1.1', 'input_quality_tier' => 'medium', 'generation_reason' => 'initial_generate', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $service = new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); $payload = $service->creatorStatusPayload($user); expect($payload['has_biography'])->toBeTrue(); expect($payload['prompt_version'])->toBe('v1.1'); expect($payload['input_quality_tier'])->toBe('medium'); expect($payload['generation_reason'])->toBe('initial_generate'); expect($payload['needs_review'])->toBeFalse(); }); it('hidden biography remains hidden after stale refresh with draft_stored action', function () { $user = User::factory()->create(['created_at' => now()->subYears(3)]); for ($i = 0; $i < 5; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'My edited hidden biography text for privacy reasons.', 'source_hash' => 'oldhash', 'model' => null, 'status' => CreatorAiBiography::STATUS_EDITED, 'is_active' => true, 'is_hidden' => true, // ← hidden 'is_user_edited' => true, 'generated_at' => now()->subDays(30), 'approved_at' => now()->subDays(30), ]); $mockGenerator = Mockery::mock(AiBiographyGenerator::class); $mockGenerator->shouldReceive('generate')->andReturn([ 'success' => true, 'text' => 'New draft text for a very active creator with portfolio history here.', 'errors' => [], 'model' => 'test-model', 'prompt_version' => 'v1.1', 'was_retried' => false, ]); $service = new AiBiographyService(new AiBiographyInputBuilder(), $mockGenerator); $service->generate($user); // The active record must still be hidden — draft storage must not un-hide it. $active = CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first(); expect($active->is_hidden)->toBeTrue(); // Public payload must still be null. expect($service->publicPayload($user))->toBeNull(); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — ValidateAiBiographyCommand // ───────────────────────────────────────────────────────────────────────────── use App\Console\Commands\ValidateAiBiographyCommand; describe('ValidateAiBiographyCommand v1.1', function () { it('flags needs_review on a stored bio that fails current validator rules', function () { $user = User::factory()->create(); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A legendary and world-class creator whose iconic work has made them a widely recognized platform icon.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'input_quality_tier' => 'rich', 'generated_at' => now(), 'approved_at' => now(), ]); $this->artisan(ValidateAiBiographyCommand::class, ['user_id' => $user->id]) ->assertSuccessful(); expect( CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first()->needs_review )->toBeTrue(); }); it('leaves needs_review false for a bio that passes validation', function () { $user = User::factory()->create(); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Active on Skinbase for over a decade, this creator has built a portfolio of public artworks spanning wallpapers and digital illustrations, with several pieces selected as featured works across multiple categories.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'input_quality_tier' => 'rich', 'generated_at' => now(), 'approved_at' => now(), ]); $this->artisan(ValidateAiBiographyCommand::class, ['user_id' => $user->id]) ->assertSuccessful(); expect( CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first()->needs_review )->toBeFalse(); }); it('dry-run does not update needs_review', function () { $user = User::factory()->create(); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A legendary and world-class iconic creator.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $this->artisan(ValidateAiBiographyCommand::class, ['user_id' => $user->id, '--dry-run' => true]) ->assertSuccessful(); expect( CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first()->needs_review )->toBeFalse(); }); it('skips user-edited biographies', function () { $user = User::factory()->create(); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'A legendary world-class iconic creator.', 'source_hash' => 'abc', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_EDITED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => true, 'needs_review' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $this->artisan(ValidateAiBiographyCommand::class, ['user_id' => $user->id]) ->assertSuccessful(); // User-edited bio must never be touched by the validate command. expect( CreatorAiBiography::where('user_id', $user->id)->where('is_active', true)->first()->needs_review )->toBeFalse(); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — source hash ordering stability // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyInputBuilder v1.1 — hash stability', function () { it('source hash is stable when downloads_count changes', function () { $user = User::factory()->create(['created_at' => '2015-01-01']); $builder = new AiBiographyInputBuilder(); $input1 = $builder->build($user); $input2 = array_merge($input1, ['downloads_count' => ($input1['downloads_count'] ?? 0) + 99999]); expect($builder->sourceHash($input1))->toBe($builder->sourceHash($input2)); }); }); // ───────────────────────────────────────────────────────────────────────────── // v1.1 — GenerateAiBiographyCommand default missing-only batch // ───────────────────────────────────────────────────────────────────────────── describe('GenerateAiBiographyCommand v1.1 — missing batch', function () { it('outputs the built prompt for a single user when --prompt is used', function () { config(['ai_biography.provider' => 'vision_gateway']); $user = User::factory()->create(['username' => 'prompt_creator', 'created_at' => '2019-01-01']); for ($i = 0; $i < 3; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } $exitCode = Artisan::call('ai-biography:generate', [ 'user_id' => $user->id, '--prompt' => true, '--dry-run' => true, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Prompt preview'); expect($output)->toContain('Provider : vision_gateway'); expect($output)->toContain('System prompt:'); expect($output)->toContain('You are a concise writing assistant for Skinbase Nova'); expect($output)->toContain('User prompt:'); expect($output)->toContain('Write a creator biography in 70 to 130 words'); }); it('prints the generated biography text when --result is used', function () { $user = User::factory()->create(['username' => 'result_creator', 'created_at' => '2019-01-01']); for ($i = 0; $i < 3; $i++) { Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMonths($i + 1), 'deleted_at' => null, ]); } config([ 'ai_biography.provider' => 'home', 'ai_biography.home.base_url' => 'http://home.test', 'ai_biography.home.endpoint' => '/v1/chat/completions', 'ai_biography.home.model' => 'qwen/qwen3.5-9b', 'ai_biography.home.api_key' => '', ]); $validBio = 'Active on Skinbase for many years, this creator has built a portfolio spanning wallpapers and digital art, with several featured selections and a consistent upload history reflecting dedication to the platform and craft.'; Http::fake([ 'http://home.test/v1/chat/completions' => Http::response([ 'choices' => [ [ 'message' => [ 'role' => 'assistant', 'content' => $validBio, ], ], ], ], 200), ]); $exitCode = Artisan::call('ai-biography:generate', [ 'user_id' => $user->id, '--result' => true, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Generated biography text:'); expect($output)->toContain($validBio); }); it('skips single-user generation when --skip-existing is used and an active biography already exists', function () { $user = User::factory()->create(['username' => 'skip_existing_creator']); CreatorAiBiography::create([ 'user_id' => $user->id, 'text' => 'Existing biography text that should remain untouched during a skip-existing run.', 'source_hash' => 'skip-existing-hash', 'model' => 'test-model', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $exitCode = Artisan::call('ai-biography:generate', [ 'user_id' => $user->id, '--skip-existing' => true, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain("Processing user #{$user->id} ({$user->username})"); expect($output)->toContain('skipped_existing_active'); }); it('shows when no prompt will be sent for low-signal profiles', function () { $user = User::factory()->create(['username' => 'low_signal_prompt_creator']); $exitCode = Artisan::call('ai-biography:generate', [ 'user_id' => $user->id, '--prompt' => true, '--dry-run' => true, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Prompt preview'); expect($output)->toContain('Meets threshold: no'); expect($output)->toContain('No prompt will be sent because this profile is below the minimum generation threshold.'); }); it('accepts --provider=gemini in dry-run batch mode', function () { $user = User::factory()->create(['username' => 'gemini_batch_creator']); Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-08-10 12:00:00', 'deleted_at' => null, ]); $exitCode = Artisan::call('ai-biography:generate', [ '--provider' => 'gemini', '--dry-run' => true, '--limit' => 1, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Using AI biography provider override: gemini'); expect($output)->toContain("[{$user->id}] {$user->username}"); }); it('accepts --provider=home in dry-run batch mode', function () { $user = User::factory()->create(['username' => 'home_batch_creator']); Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-08-11 12:00:00', 'deleted_at' => null, ]); $exitCode = Artisan::call('ai-biography:generate', [ '--provider' => 'home', '--dry-run' => true, '--limit' => 1, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Using AI biography provider override: home'); expect($output)->toContain("[{$user->id}] {$user->username}"); }); it('passes provider override into queued jobs', function () { Queue::fake(); $user = User::factory()->create(['username' => 'queued_gemini_creator']); $exitCode = Artisan::call('ai-biography:generate', [ 'user_id' => $user->id, '--provider' => 'gemini', '--queue' => true, ]); expect($exitCode)->toBe(0); Queue::assertPushed( \App\Jobs\GenerateAiBiographyJob::class, fn ($job) => $job->userId === (int) $user->id && $job->provider === 'gemini' ); }); it('rejects unsupported provider values', function () { $exitCode = Artisan::call('ai-biography:generate', [ '--provider' => 'openai', '--dry-run' => true, ]); $output = Artisan::output(); expect($exitCode)->toBe(1); expect($output)->toContain('Invalid provider [openai]. Supported values: vision_gateway, vision, gemini, home.'); }); it('defaults to missing biographies ordered by latest public upload and skips existing biographies', function () { $older = User::factory()->create(['username' => 'older_creator']); $latest = User::factory()->create(['username' => 'latest_creator']); $existing = User::factory()->create(['username' => 'existing_creator']); Artwork::factory()->for($older)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-03-10 12:00:00', 'deleted_at' => null, ]); Artwork::factory()->for($latest)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-06-15 12:00:00', 'deleted_at' => null, ]); Artwork::factory()->for($existing)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-07-20 12:00:00', 'deleted_at' => null, ]); CreatorAiBiography::create([ 'user_id' => $existing->id, 'text' => 'Existing biography text that should cause this creator to be skipped in missing-only batch mode.', 'source_hash' => 'existing-hash', 'model' => 'test', 'status' => CreatorAiBiography::STATUS_GENERATED, 'is_active' => true, 'is_hidden' => false, 'is_user_edited' => false, 'needs_review' => false, 'generated_at' => now(), 'approved_at' => now(), ]); $exitCode = Artisan::call('ai-biography:generate', [ '--dry-run' => true, '--limit' => 10, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Generating missing AI biographies ordered by latest public upload'); expect($output)->toContain("[{$latest->id}] {$latest->username}"); expect($output)->toContain("[{$older->id}] {$older->username}"); expect($output)->not->toContain($existing->username); expect(strpos($output, "[{$latest->id}] {$latest->username}")) ->toBeLessThan(strpos($output, "[{$older->id}] {$older->username}")); }); it('treats --all as an alias for the default missing-only batch mode', function () { $user = User::factory()->create(['username' => 'alias_creator']); Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-05-01 12:00:00', 'deleted_at' => null, ]); $exitCode = Artisan::call('ai-biography:generate', [ '--all' => true, '--dry-run' => true, '--limit' => 1, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('`--all` is now an alias for the default missing-only batch mode.'); expect($output)->toContain("[{$user->id}] {$user->username}"); }); it('normalizes --provider=vision to vision_gateway', function () { $user = User::factory()->create(['username' => 'vision_alias_creator']); Artwork::factory()->for($user)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => '2024-09-01 12:00:00', 'deleted_at' => null, ]); $exitCode = Artisan::call('ai-biography:generate', [ '--provider' => 'vision', '--dry-run' => true, '--limit' => 1, ]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('Using AI biography provider override: vision_gateway'); expect($output)->toContain("[{$user->id}] {$user->username}"); }); }); // ───────────────────────────────────────────────────────────────────────────── // AiBiographyProvidersCommand // ───────────────────────────────────────────────────────────────────────────── describe('AiBiographyProvidersCommand', function () { it('reports provider health and lists models', function () { config([ 'vision.gateway.base_url' => 'http://vision.test', 'vision.gateway.api_key' => 'vision-key', 'ai_biography.llm_model' => 'vision-gateway', 'ai_biography.gemini.base_url' => 'https://generativelanguage.googleapis.com', 'ai_biography.gemini.api_key' => 'gemini-key', 'ai_biography.gemini.model' => 'gemini-flash-latest', 'ai_biography.home.base_url' => 'http://home.klevze.si:8200', 'ai_biography.home.model' => 'qwen/qwen3.5-9b', ]); Http::fake([ 'http://vision.test/v1/models' => Http::response([ 'data' => [ ['id' => 'vision-gateway'], ['id' => 'vision-fallback'], ], ], 200), 'https://generativelanguage.googleapis.com/v1beta/models' => Http::response([ 'models' => [ ['name' => 'models/gemini-2.0-flash'], ['name' => 'models/gemini-1.5-flash'], ], ], 200), 'http://home.klevze.si:8200/v1/models' => Http::response([ 'data' => [ ['id' => 'qwen/qwen3.5-9b'], ['id' => 'google/gemma-3-4b'], ], ], 200), ]); $exitCode = Artisan::call('ai-biography:providers', ['--limit' => 2]); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('vision_gateway'); expect($output)->toContain('gemini'); expect($output)->toContain('home'); expect($output)->toContain('online'); expect($output)->toContain('vision-gateway'); expect($output)->toContain('models/gemini-2.0-flash'); expect($output)->toContain('qwen/qwen3.5-9b'); }); it('filters to a single provider alias', function () { config([ 'ai_biography.home.base_url' => 'http://home.klevze.si:8200', 'ai_biography.home.model' => 'qwen/qwen3.5-9b', ]); Http::fake([ 'http://home.klevze.si:8200/v1/models' => Http::response([ 'data' => [ ['id' => 'qwen/qwen3.5-9b'], ], ], 200), ]); $exitCode = Artisan::call('ai-biography:providers', ['--provider' => 'lmstudio']); $output = Artisan::output(); expect($exitCode)->toBe(0); expect($output)->toContain('home'); expect($output)->toContain('qwen/qwen3.5-9b'); expect($output)->not->toContain('gemini'); expect($output)->not->toContain('vision_gateway'); }); it('rejects unsupported provider values', function () { $exitCode = Artisan::call('ai-biography:providers', ['--provider' => 'claude']); $output = Artisan::output(); expect($exitCode)->toBe(1); expect($output)->toContain('Invalid provider [claude]. Supported values: together, vision_gateway, vision, gemini, home.'); }); });