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/md/sy/nc/syncaa112233.webp') ->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/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('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('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), ]); 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') ->assertJson(fn ($json) => $json ->has('data.title_suggestions', 5) ->where('data.tag_suggestions.0.tag', 'rose') ->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']); });