'null']); // Seed recommendations config config([ 'recommendations.weights.tag_overlap' => 0.40, 'recommendations.weights.creator_affinity' => 0.25, 'recommendations.weights.popularity' => 0.20, 'recommendations.weights.freshness' => 0.15, 'recommendations.candidate_pool_size' => 200, 'recommendations.max_per_creator' => 3, 'recommendations.min_unique_tags' => 5, 'recommendations.ttl.for_you_feed' => 5, ]); Cache::flush(); }); // ───────────────────────────────────────────────────────────────────────────── // RecommendationService cold-start (no signals) // ───────────────────────────────────────────────────────────────────────────── it('returns cold-start feed when user has no signals', function () { $user = User::factory()->create(); // Profile builder will return a DTO with no signals $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [], tagWeights: [], categoryWeights: [], dislikedTagSlugs: [], )); $service = new RecommendationService($builder); $result = $service->forYouFeed($user, 10); expect($result)->toHaveKeys(['data', 'meta']) ->and($result['meta']['source'])->toBe('cold_start'); }); // ───────────────────────────────────────────────────────────────────────────── // RecommendationService personalised flow (mocked profile) // ───────────────────────────────────────────────────────────────────────────── it('returns personalised feed with data when user has signals', function () { $user = User::factory()->create(); // Two artworks from other creators (tags not needed — Scout driver is null) Artwork::factory()->create([ 'is_public' => true, 'is_approved' => true, 'user_id' => User::factory()->create()->id, 'published_at' => now()->subDay(), ]); Artwork::factory()->create([ 'is_public' => true, 'is_approved' => true, 'user_id' => User::factory()->create()->id, 'published_at' => now()->subDays(2), ]); $profile = new UserRecoProfileDTO( topTagSlugs: ['cyberpunk', 'neon'], topCategorySlugs: [], strongCreatorIds: [], tagWeights: ['cyberpunk' => 5.0, 'neon' => 3.0], categoryWeights: [], dislikedTagSlugs: [], ); $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->with($user)->andReturn($profile); $service = new RecommendationService($builder); $result = $service->forYouFeed($user, 10); expect($result)->toHaveKeys(['data', 'meta']) ->and($result['meta'])->toHaveKey('source'); // With scout null driver the collection is empty → cold-start path // This tests the structure contract regardless of driver expect($result['data'])->toBeArray(); }); // ───────────────────────────────────────────────────────────────────────────── // Diversity: max 3 per creator // ───────────────────────────────────────────────────────────────────────────── it('enforces max_per_creator diversity limit via forYouPreview', function () { $user = User::factory()->create(); $creatorA = User::factory()->create(); $creatorB = User::factory()->create(); // 4 artworks by creatorA, 1 by creatorB (Scout driver null — no Meili calls) Artwork::factory(4)->create([ 'is_public' => true, 'is_approved' => true, 'user_id' => $creatorA->id, 'published_at' => now()->subHour(), ]); Artwork::factory()->create([ 'is_public' => true, 'is_approved' => true, 'user_id' => $creatorB->id, 'published_at' => now()->subHour(), ]); $profile = new UserRecoProfileDTO( topTagSlugs: ['abstract'], topCategorySlugs: [], strongCreatorIds: [], tagWeights: ['abstract' => 5.0], categoryWeights: [], dislikedTagSlugs: [], ); $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->andReturn($profile); $service = new RecommendationService($builder); // With null scout driver the candidate collection is empty; we test contract. $result = $service->forYouFeed($user, 10); expect($result)->toHaveKeys(['data', 'meta']); expect($result['data'])->toBeArray(); }); // ───────────────────────────────────────────────────────────────────────────── // Favourited artworks are excluded from For You feed // ───────────────────────────────────────────────────────────────────────────── it('excludes artworks already favourited by user', function () { $user = User::factory()->create(); $art = Artwork::factory()->create(['is_public' => true, 'is_approved' => true]); // Insert a favourite DB::table('artwork_favourites')->insert([ 'user_id' => $user->id, 'artwork_id' => $art->id, 'created_at' => now(), 'updated_at' => now(), ]); $profile = new UserRecoProfileDTO( topTagSlugs: ['tag-x'], topCategorySlugs: [], strongCreatorIds: [], tagWeights: ['tag-x' => 3.0], categoryWeights: [], dislikedTagSlugs: [], ); $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->andReturn($profile); $service = new RecommendationService($builder); // With null scout, no candidates surface — checking that getFavoritedIds runs without error $result = $service->forYouFeed($user, 10); expect($result)->toHaveKeys(['data', 'meta']); $artworkIds = array_column($result['data'], 'id'); expect($artworkIds)->not->toContain($art->id); }); // ───────────────────────────────────────────────────────────────────────────── // Cursor pagination shape // ───────────────────────────────────────────────────────────────────────────── it('returns null next_cursor when no more pages available', function () { $user = User::factory()->create(); $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [], tagWeights: [], categoryWeights: [], dislikedTagSlugs: [], )); $service = new RecommendationService($builder); $result = $service->forYouFeed($user, 40, null); expect($result['meta'])->toHaveKey('next_cursor'); // Cold-start with 0 results: next_cursor should be null expect($result['meta']['next_cursor'])->toBeNull(); }); // ───────────────────────────────────────────────────────────────────────────── // forYouPreview is a subset of forYouFeed // ───────────────────────────────────────────────────────────────────────────── it('forYouPreview returns an array', function () { $user = User::factory()->create(); $builder = Mockery::mock(UserPreferenceBuilder::class); $builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO( topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [], tagWeights: [], categoryWeights: [], dislikedTagSlugs: [], )); $service = new RecommendationService($builder); $preview = $service->forYouPreview($user, 12); expect($preview)->toBeArray(); });