'null']); Cache::flush(); }); // ─── Helper ──────────────────────────────────────────────────────────────────── function createPublicArtwork(array $attrs = []): Artwork { return Artwork::withoutEvents(function () use ($attrs) { return Artwork::factory()->create(array_merge([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subHour(), ], $attrs)); }); } // ─── API returns fallback if precomputed list is missing ─────────────────────── it('returns fallback results when no precomputed similar list exists', function () { $artwork = createPublicArtwork(); // Create some other artworks so the trending fallback can find them $other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]); $other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); // Should still return artworks via trending fallback, not an empty set expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class) ->and($result)->not->toBeEmpty(); }); it('returns empty collection for non-existent artwork', function () { $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork(999999, 12); expect($result)->toBeEmpty(); }); it('returns similar_tags list when hybrid is missing', function () { $artwork = createPublicArtwork(); $similar1 = createPublicArtwork(); $similar2 = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_tags', 'model_version' => 'sim_v1', 'recs' => [$similar1->id, $similar2->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); expect($result)->toHaveCount(2) ->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]); }); // ─── Ordering is preserved ───────────────────────────────────────────────────── it('preserves precomputed ordering exactly', function () { $artwork = createPublicArtwork(); $a = createPublicArtwork(); $b = createPublicArtwork(); $c = createPublicArtwork(); $d = createPublicArtwork(); // Deliberate non-sequential order $orderedIds = [$c->id, $a->id, $d->id, $b->id]; RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_hybrid', 'model_version' => 'sim_v1', 'recs' => $orderedIds, 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); expect($result->pluck('id')->all())->toEqual($orderedIds); }); it('falls through from hybrid to tags preserving order', function () { $artwork = createPublicArtwork(); $a = createPublicArtwork(); $b = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_tags', 'model_version' => 'sim_v1', 'recs' => [$b->id, $a->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]); }); // ─── Diversity cap (max per author) is enforced ──────────────────────────────── it('enforces author diversity cap at runtime', function () { $artwork = createPublicArtwork(); // One author with 4 artworks $author = User::factory()->create(); $sameAuthor1 = createPublicArtwork(['user_id' => $author->id]); $sameAuthor2 = createPublicArtwork(['user_id' => $author->id]); $sameAuthor3 = createPublicArtwork(['user_id' => $author->id]); $sameAuthor4 = createPublicArtwork(['user_id' => $author->id]); // Another author with 1 artwork $otherAuthor = User::factory()->create(); $diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]); // Put all 5 in the precomputed list — same author dominates RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_hybrid', 'model_version' => 'sim_v1', 'recs' => [ $sameAuthor1->id, $sameAuthor2->id, $sameAuthor3->id, $sameAuthor4->id, $diffAuthor->id, ], 'computed_at' => now(), ]); config(['recommendations.similarity.max_per_author' => 2]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); // Max 2 from same author, 1 from different author = 3 total $resultByAuthor = $result->groupBy('user_id'); foreach ($resultByAuthor as $authorId => $artworks) { expect($artworks->count())->toBeLessThanOrEqual(2); } expect($result)->toHaveCount(3); }); // ─── Pair building doesn't explode per user ──────────────────────────────────── it('caps pairs per user to avoid combinatorial explosion', function () { $user = User::factory()->create(); // Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue) $artworks = []; for ($i = 0; $i < 5; $i++) { $art = createPublicArtwork(); $artworks[] = $art; DB::table('artwork_favourites')->insert([ 'user_id' => $user->id, 'artwork_id' => $art->id, 'created_at' => now()->subMinutes($i), 'updated_at' => now()->subMinutes($i), ]); } $job = new RecBuildItemPairsFromFavouritesJob(); $pairs = $job->pairsForUser($user->id, 5); // C(5,2) = 10 pairs max expect($pairs)->toHaveCount(10); // Verify each pair is ordered (a < b) foreach ($pairs as [$a, $b]) { expect($a)->toBeLessThan($b); } }); it('respects the favourites cap for pair generation', function () { $user = User::factory()->create(); // Create 10 favourites (bypass observers) for ($i = 0; $i < 10; $i++) { $art = createPublicArtwork(); DB::table('artwork_favourites')->insert([ 'user_id' => $user->id, 'artwork_id' => $art->id, 'created_at' => now()->subMinutes($i), 'updated_at' => now()->subMinutes($i), ]); } // Cap at 3 → C(3,2) = 3 pairs $job = new RecBuildItemPairsFromFavouritesJob(); $pairs = $job->pairsForUser($user->id, 3); expect($pairs)->toHaveCount(3); }); it('returns empty pairs for user with only one favourite', function () { $user = User::factory()->create(); $art = createPublicArtwork(); DB::table('artwork_favourites')->insert([ 'user_id' => $user->id, 'artwork_id' => $art->id, 'created_at' => now(), 'updated_at' => now(), ]); $job = new RecBuildItemPairsFromFavouritesJob(); $pairs = $job->pairsForUser($user->id, 50); expect($pairs)->toBeEmpty(); }); // ─── API endpoint integration ────────────────────────────────────────────────── it('returns JSON response from API endpoint with precomputed data', function () { $artwork = createPublicArtwork(); $similar = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_hybrid', 'model_version' => 'sim_v1', 'recs' => [$similar->id], 'computed_at' => now(), ]); $response = $this->getJson("/api/art/{$artwork->id}/similar"); $response->assertOk() ->assertJsonStructure(['data']) ->assertJsonCount(1, 'data'); }); it('returns 404 for non-existent artwork in API', function () { $response = $this->getJson('/api/art/999999/similar'); $response->assertNotFound(); }); // ─── RecArtworkRec model ─────────────────────────────────────────────────────── it('stores and retrieves rec list with correct types', function () { $artwork = createPublicArtwork(); $ids = [10, 20, 30]; $rec = RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_hybrid', 'model_version' => 'sim_v1', 'recs' => $ids, 'computed_at' => now(), ]); $fresh = RecArtworkRec::find($rec->id); expect($fresh->recs)->toBeArray() ->and($fresh->recs)->toEqual($ids) ->and($fresh->artwork_id)->toBe($artwork->id); }); // ─── Fallback priority ───────────────────────────────────────────────────────── it('chooses similar_behavior when tags and hybrid are missing', function () { $artwork = createPublicArtwork(); $beh1 = createPublicArtwork(); $beh2 = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_behavior', 'model_version' => 'sim_v1', 'recs' => [$beh1->id, $beh2->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]); }); it('filters out unpublished artworks from precomputed list', function () { $artwork = createPublicArtwork(); $published = createPublicArtwork(); $unpublished = Artwork::withoutEvents(function () { return Artwork::factory()->unpublished()->create(); }); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_hybrid', 'model_version' => 'sim_v1', 'recs' => [$unpublished->id, $published->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12); expect($result->pluck('id')->all())->toEqual([$published->id]); }); // ─── Type query param support (spec §8) ──────────────────────────────────────── it('returns specific rec type when ?type=tags is passed', function () { $artwork = createPublicArtwork(); $tagSimilar = createPublicArtwork(); $behSimilar = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_tags', 'model_version' => 'sim_v1', 'recs' => [$tagSimilar->id], 'computed_at' => now(), ]); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_behavior', 'model_version' => 'sim_v1', 'recs' => [$behSimilar->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12, 'tags'); expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]); }); it('returns behavior list when ?type=behavior is passed', function () { $artwork = createPublicArtwork(); $behSimilar = createPublicArtwork(); $tagSimilar = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_tags', 'model_version' => 'sim_v1', 'recs' => [$tagSimilar->id], 'computed_at' => now(), ]); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_behavior', 'model_version' => 'sim_v1', 'recs' => [$behSimilar->id], 'computed_at' => now(), ]); $service = app(HybridSimilarArtworksService::class); $result = $service->forArtwork($artwork->id, 12, 'behavior'); expect($result->pluck('id')->all())->toEqual([$behSimilar->id]); }); it('passes type query param from API endpoint', function () { $artwork = createPublicArtwork(); $tagSimilar = createPublicArtwork(); RecArtworkRec::create([ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_tags', 'model_version' => 'sim_v1', 'recs' => [$tagSimilar->id], 'computed_at' => now(), ]); $response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags"); $response->assertOk() ->assertJsonCount(1, 'data'); }); // ─── Cosine normalized pair weights ──────────────────────────────────────────── it('produces cosine-normalized weights in pair builder', function () { // User A: likes artwork 1, 2 $userA = User::factory()->create(); $art1 = createPublicArtwork(); $art2 = createPublicArtwork(); DB::table('artwork_favourites')->insert([ ['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()], ['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()], ]); // User B: also likes artwork 1, 2 $userB = User::factory()->create(); DB::table('artwork_favourites')->insert([ ['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()], ['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()], ]); $job = new RecBuildItemPairsFromFavouritesJob(); $job->handle(); $pair = RecItemPair::query() ->where('a_artwork_id', min($art1->id, $art2->id)) ->where('b_artwork_id', max($art1->id, $art2->id)) ->first(); expect($pair)->not->toBeNull(); // co_like = 2 (both users liked both), likes_A = 2, likes_B = 2 // S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0 expect($pair->weight)->toBe(1.0); });