Files
SkinbaseNova/tests/Feature/Profile/AiBiographyTest.php

1790 lines
77 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\CreatorAiBiography;
use App\Models\User;
use App\Services\AiBiography\AiBiographyInputBuilder;
use App\Services\AiBiography\AiBiographyGenerator;
use App\Services\AiBiography\AiBiographyPromptBuilder;
use App\Services\AiBiography\AiBiographyService;
use App\Services\AiBiography\AiBiographyValidator;
use App\Services\AiBiography\VisionLlmClient;
use App\Services\AiBiography\VisionLlmException;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
// ─────────────────────────────────────────────────────────────────────────────
// AiBiographyInputBuilder
// ─────────────────────────────────────────────────────────────────────────────
describe('AiBiographyInputBuilder', function () {
it('includes only public uploads in uploads_count', function () {
$user = User::factory()->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 since 2007. They have uploaded one public artwork categorized under GTK+.';
$retryAttempt = 'szerencsefia has been part of Skinbase 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');
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.');
});
});