create(); $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); $artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]); UserRecommendationCache::query()->create([ 'user_id' => $user->id, 'algo_version' => (string) config('discovery.algo_version'), 'cache_version' => (string) config('discovery.cache_version'), 'recommendations_json' => [ 'items' => [ ['artwork_id' => $artworkA->id, 'score' => 0.9, 'source' => 'profile'], ['artwork_id' => $artworkB->id, 'score' => 0.8, 'source' => 'profile'], ['artwork_id' => $artworkC->id, 'score' => 0.7, 'source' => 'profile'], ], ], 'generated_at' => now(), 'expires_at' => now()->addMinutes(30), ]); $first = $this->actingAs($user)->getJson('/api/v1/feed?limit=2'); $first->assertOk(); $first->assertJsonPath('meta.cache_status', 'hit'); expect(count((array) $first->json('data')))->toBe(2); $nextCursor = $first->json('meta.next_cursor'); expect($nextCursor)->not->toBeNull(); $second = $this->actingAs($user)->getJson('/api/v1/feed?limit=2&cursor=' . urlencode((string) $nextCursor)); $second->assertOk(); expect(count((array) $second->json('data')))->toBe(1); expect($second->json('meta.next_cursor'))->toBeNull(); }); it('dispatches async regeneration on cache miss and returns cold start items', function () { Queue::fake(); $user = User::factory()->create(); $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); DB::table('artwork_stats')->insert([ ['artwork_id' => $artworkA->id, 'views' => 100, 'downloads' => 30, 'favorites' => 10, 'rating_avg' => 0, 'rating_count' => 0], ['artwork_id' => $artworkB->id, 'views' => 80, 'downloads' => 10, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0], ]); $response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10'); $response->assertOk(); expect(count((array) $response->json('data')))->toBeGreaterThan(0); expect((string) $response->json('meta.cache_status'))->toContain('miss'); Queue::assertPushed(RegenerateUserRecommendationCacheJob::class, function (RegenerateUserRecommendationCacheJob $job) use ($user): bool { return $job->userId === $user->id; }); }); it('applies diversity guard to avoid near-duplicates in cold start fallback', function () { Queue::fake(); $user = User::factory()->create(); $artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]); $artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]); $artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]); DB::table('artwork_stats')->insert([ ['artwork_id' => $artworkA->id, 'views' => 200, 'downloads' => 20, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0], ['artwork_id' => $artworkB->id, 'views' => 190, 'downloads' => 18, 'favorites' => 4, 'rating_avg' => 0, 'rating_count' => 0], ['artwork_id' => $artworkC->id, 'views' => 180, 'downloads' => 12, 'favorites' => 3, 'rating_avg' => 0, 'rating_count' => 0], ]); DB::table('artwork_similarities')->insert([ 'artwork_id' => $artworkA->id, 'similar_artwork_id' => $artworkB->id, 'model' => 'clip', 'model_version' => 'v1', 'algo_version' => (string) config('discovery.algo_version'), 'rank' => 1, 'score' => 0.991, 'generated_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); $response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10'); $response->assertOk(); $ids = collect((array) $response->json('data'))->pluck('id')->all(); expect(in_array($artworkA->id, $ids, true) && in_array($artworkB->id, $ids, true))->toBeFalse(); expect(in_array($artworkC->id, $ids, true))->toBeTrue(); });