create(); // Create three artworks in chronological order (oldest first) $oldest = Artwork::factory()->for($user)->create([ 'published_at' => now()->subDays(3), ]); $middle = Artwork::factory()->for($user)->create([ 'published_at' => now()->subDays(2), ]); $newest = Artwork::factory()->for($user)->create([ 'published_at' => now()->subDays(1), ]); // The rank list ranks them in a non-chronological order to prove it is respected: // newest > oldest > middle $rankedOrder = [$newest->id, $oldest->id, $middle->id]; RankList::create([ 'scope_type' => 'global', 'scope_id' => 0, 'list_type' => 'trending', 'model_version' => 'rank_v1', 'artwork_ids' => $rankedOrder, 'computed_at' => now(), ]); $response = $this->getJson('/api/rank/global?type=trending'); $response->assertOk(); $returnedIds = collect($response->json('data')) ->pluck('slug') ->all(); // Retrieve slugs in the expected order for comparison $expectedSlugs = Artwork::whereIn('id', $rankedOrder) ->get() ->keyBy('id') ->pipe(fn ($keyed) => array_map(fn ($id) => $keyed[$id]->slug, $rankedOrder)); $this->assertSame($expectedSlugs, $returnedIds, 'Artworks must be returned in the exact pre-ranked order.' ); // Meta block is present $response->assertJsonPath('meta.list_type', 'trending'); $response->assertJsonPath('meta.fallback', false); $response->assertJsonPath('meta.model_version', 'rank_v1'); } // ── Test 2: diversity constraint ─────────────────────────────────────── /** * applyDiversity() must cap the number of artworks per author * at config('ranking.diversity.max_per_author') = 3. * * Given 5 artworks from the same author, only 3 should pass through. */ public function test_diversity_constraint_caps_items_per_author(): void { /** @var RankingService $service */ $service = app(RankingService::class); $maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3); $listSize = 50; // Build fake candidates: 5 from author 1, 3 from author 2 $candidates = []; for ($i = 1; $i <= 5; $i++) { $candidates[] = (object) ['artwork_id' => $i, 'user_id' => 1]; } for ($i = 6; $i <= 8; $i++) { $candidates[] = (object) ['artwork_id' => $i, 'user_id' => 2]; } $result = $service->applyDiversity($candidates, $maxPerAuthor, $listSize); $authorTotals = array_count_values( array_map(fn ($item) => (int) $item->user_id, $result) ); foreach ($authorTotals as $authorId => $count) { $this->assertLessThanOrEqual( $maxPerAuthor, $count, "Author {$authorId} appears {$count} times, but max is {$maxPerAuthor}." ); } // Exactly 3 from author 1 + 3 from author 2 = 6 total $this->assertCount(6, $result); } // ── Test 3: fallback to latest ───────────────────────────────────────── /** * When no rank_list row exists for the requested scope, the controller * falls back to latest-published artworks and signals this in the meta. */ public function test_global_trending_falls_back_to_latest_when_no_rank_list_exists(): void { $user = User::factory()->create(); // Create artworks in a known order so we can verify fallback ordering $first = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(3)]); $second = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(2)]); $third = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(1)]); // Deliberately leave rank_lists table empty $response = $this->getJson('/api/rank/global?type=trending'); $response->assertOk(); // Meta must indicate fallback $response->assertJsonPath('meta.fallback', true); $response->assertJsonPath('meta.model_version', 'fallback'); // Artworks must appear in published_at DESC order (third, second, first) $returnedIds = collect($response->json('data'))->pluck('slug')->all(); $this->assertSame( [$third->slug, $second->slug, $first->slug], $returnedIds, 'Fallback must return artworks in latest-first order.' ); } }