Files
SkinbaseNova/tests/Feature/Studio/StudioArtworkAiAssistApiTest.php
2026-04-18 17:02:56 +02:00

669 lines
23 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Models\Artwork;
use App\Models\ArtworkAiAssist;
use App\Models\ArtworkAiAssistEvent;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\Studio\StudioAiAssistService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
use function Pest\Laravel\postJson;
use function Pest\Laravel\putJson;
it('returns a not-analyzed payload for artworks without ai assist data', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create(['user_id' => $user->id]);
actingAs($user);
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
->assertOk()
->assertJsonPath('data.status', 'not_analyzed')
->assertJsonPath('data.title_suggestions', [])
->assertJsonPath('data.current.sources.title', 'manual');
});
it('queues artwork ai analysis from the studio endpoint', function (): void {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'aabbcc112233',
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze')
->assertStatus(202)
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
$this->assertDatabaseHas('artwork_ai_assists', [
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_QUEUED,
]);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
return true;
});
});
it('can analyze artwork ai suggestions directly without queueing', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
config()->set('vision.image_variant', 'md');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'syncaa112233',
'file_name' => 'rose-closeup.jpg',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
'intent' => 'title',
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.debug.request.hash', 'syncaa112233')
->assertJsonPath('data.debug.request.intent', 'title')
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.service', 'gateway_all');
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
$this->assertDatabaseHas('artwork_ai_assist_events', [
'artwork_id' => $artwork->id,
'event_type' => 'analysis_requested',
]);
$completedEvent = ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'analysis_completed')
->first();
expect($completedEvent)->not->toBeNull();
expect($completedEvent?->meta['intent'] ?? null)->toBe('title');
});
it('accepts a together provider override for direct studio ai analysis', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.together.base_url', 'https://api.together.xyz');
config()->set('vision.together.endpoint', '/v1/chat/completions');
config()->set('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo');
config()->set('vision.together.api_key', 'together-test-key');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'togaa112233',
'file_name' => 'rose-closeup.jpg',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://api.together.xyz/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
'provider' => 'together',
'intent' => 'tags',
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('data.debug.request.provider', 'together')
->assertJsonPath('data.debug.tag_generation.provider', 'together')
->assertJsonPath('data.debug.tag_generation.endpoint', 'https://api.together.xyz/v1/chat/completions');
Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool {
return $request->url() === 'https://api.together.xyz/v1/chat/completions'
&& $request->hasHeader('Authorization', 'Bearer together-test-key');
});
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
});
it('passes a provider override into queued studio ai analysis', function (): void {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'queueprov1122',
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'provider' => 'together',
'intent' => 'tags',
])
->assertStatus(202)
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
$property = new \ReflectionProperty($job, 'provider');
$property->setAccessible(true);
return $property->getValue($job) === 'together';
});
$requestedEvent = ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'analysis_requested')
->latest('id')
->first();
expect($requestedEvent)->not->toBeNull();
expect($requestedEvent?->meta['provider'] ?? null)->toBe('together');
});
it('persists upload-style visibility options from studio save', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
'artwork_status' => 'published',
'published_at' => now()->subHour(),
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'visibility' => Artwork::VISIBILITY_UNLISTED,
'mode' => 'now',
])
->assertOk()
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_UNLISTED)
->assertJsonPath('artwork.publish_mode', 'now');
$artwork->refresh();
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_UNLISTED)
->and($artwork->is_public)->toBeTrue()
->and($artwork->artwork_status)->toBe('published');
});
it('can schedule publishing from studio save', function (): void {
Carbon::setTestNow('2026-03-28 12:00:00');
try {
$user = User::factory()->create();
$artwork = Artwork::factory()->private()->create([
'user_id' => $user->id,
'artwork_status' => 'draft',
'published_at' => null,
'publish_at' => null,
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'visibility' => Artwork::VISIBILITY_PUBLIC,
'mode' => 'schedule',
'publish_at' => '2026-03-28T12:10:00Z',
'timezone' => 'Europe/Ljubljana',
])
->assertOk()
->assertJsonPath('artwork.publish_mode', 'schedule')
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_PUBLIC)
->assertJsonPath('artwork.artwork_status', 'scheduled');
$artwork->refresh();
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_PUBLIC)
->and($artwork->is_public)->toBeFalse()
->and($artwork->artwork_status)->toBe('scheduled')
->and($artwork->artwork_timezone)->toBe('Europe/Ljubljana')
->and($artwork->publish_at?->toIso8601String())->toBe('2026-03-28T12:10:00+00:00')
->and($artwork->published_at)->toBeNull();
} finally {
Carbon::setTestNow();
}
});
it('can analyze artwork directly when exact and vector similar matches are both present', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vector.local');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'mixaa112233',
'file_name' => 'rose-closeup.jpg',
]);
$exactMatch = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'mixaa112233',
'title' => 'Exact match artwork',
]);
$vectorMatch = Artwork::factory()->create([
'title' => 'Vector match artwork',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://vector.local/vectors/search' => Http::response([
'matches' => [
[
'id' => (string) $vectorMatch->id,
'score' => 0.88,
'metadata' => [],
],
],
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonCount(2, 'data.similar_candidates')
->assertJsonPath('data.similar_candidates.0.artwork_id', $exactMatch->id)
->assertJsonPath('data.similar_candidates.1.artwork_id', $vectorMatch->id);
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
});
it('builds and exposes normalized studio ai suggestions', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.lm_studio.base_url', 'https://lmstudio.local');
config()->set('vision.lm_studio.model', 'google/gemma-3-4b');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'ddeeff112233',
'file_name' => 'rose-closeup.jpg',
'title' => 'Untitled Rose',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
['tag' => 'macro', 'confidence' => 0.72],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://lmstudio.local/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'content' => json_encode([
'rose macro',
'flower close-up',
'soft petals',
'natural light',
'botanical photography',
'pink tones',
'shallow depth',
'floral detail',
'macro photography',
'garden bloom',
], JSON_THROW_ON_ERROR),
],
],
],
], 200),
]);
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
actingAs($user);
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
->assertOk()
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.mode', 'artwork')
->assertJsonPath('data.content_type.value', 'photography')
->assertJsonPath('data.category.value', 'flowers')
->assertJsonPath('data.debug.tag_generation.raw_content', '["rose macro","flower close-up","soft petals","natural light","botanical photography","pink tones","shallow depth","floral detail","macro photography","garden bloom"]')
->assertJson(fn ($json) => $json
->has('data.title_suggestions', 5)
->where('data.tag_suggestions', fn ($tags): bool => collect($tags)->contains(fn (array $row): bool => ($row['tag'] ?? null) === 'rose-macro'))
->has('data.description_suggestions', 3));
});
it('applies ai suggestions to artwork fields and tracks ai sources', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$flowers = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Original title',
'description' => 'Original description.',
]);
ArtworkAiAssist::query()->create([
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_READY,
'similar_candidates_json' => [
[
'artwork_id' => 998,
'title' => 'Possible duplicate',
'match_type' => 'exact_hash',
'score' => 1,
'review_state' => null,
],
],
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
'title' => 'Rose Bud in Soft Focus',
'description' => 'A close-up photograph of a rose bud with a soft floral backdrop.',
'tags' => ['rose', 'macro'],
'tag_mode' => 'replace',
'category_id' => $flowers->id,
'similar_actions' => [
['artwork_id' => 998, 'state' => 'reviewed'],
],
])
->assertOk()
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.current.sources.title', 'ai_applied')
->assertJsonPath('data.current.sources.tags', 'ai_applied');
$this->assertDatabaseHas('artworks', [
'id' => $artwork->id,
'title' => 'Rose Bud in Soft Focus',
'title_source' => 'ai_applied',
'description_source' => 'ai_applied',
'tags_source' => 'ai_applied',
'category_source' => 'ai_applied',
]);
$this->assertDatabaseHas('artwork_tag', [
'artwork_id' => $artwork->id,
'source' => 'ai',
]);
expect(ArtworkAiAssist::query()->where('artwork_id', $artwork->id)->first()?->similar_candidates_json[0]['review_state'])
->toBe('reviewed');
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'suggestions_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'title_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'description_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'tags_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'category_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'duplicate_candidate_reviewed')
->exists())
->toBeTrue();
});
it('applies ai content type suggestions by resolving a default category', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$rootCategory = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Photography',
'slug' => 'photography-root',
'is_active' => true,
'sort_order' => 1,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
]);
ArtworkAiAssist::query()->create([
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_READY,
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
'content_type_id' => $photography->id,
])
->assertOk()
->assertJsonPath('data.current.content_type_id', $photography->id)
->assertJsonPath('data.current.category_id', $rootCategory->id)
->assertJsonPath('data.current.sources.category', 'ai_applied');
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
->toBe([$rootCategory->id]);
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'content_type_suggestion_applied')
->exists())
->toBeTrue();
});
it('updates studio artworks with a content type when no category is provided', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$rootCategory = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Photography',
'slug' => 'photography-default',
'is_active' => true,
'sort_order' => 1,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Existing title',
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'content_type_id' => $photography->id,
'category_source' => 'ai_applied',
])
->assertOk()
->assertJsonPath('artwork.content_type_id', $photography->id)
->assertJsonPath('artwork.category_id', $rootCategory->id)
->assertJsonPath('artwork.category_source', 'ai_applied');
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
->toBe([$rootCategory->id]);
});
it('removes previously attached ai tags when studio save sends a smaller visible tag set', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'tags_source' => 'mixed',
]);
$tagService = app(\App\Services\TagService::class);
$tagService->attachAiTags($artwork, [
['tag' => 'rose'],
['tag' => 'macro'],
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'tags' => ['rose'],
'tags_source' => 'mixed',
])
->assertOk()
->assertJsonPath('artwork.tags.0.slug', 'rose');
expect($artwork->fresh()->tags()->pluck('tags.slug')->sort()->values()->all())
->toBe(['rose']);
});