feat: add reusable gallery carousel and ranking feed infrastructure
This commit is contained in:
161
tests/Feature/Ranking/RankGlobalTrendingTest.php
Normal file
161
tests/Feature/Ranking/RankGlobalTrendingTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Ranking;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RankList;
|
||||
use App\Models\User;
|
||||
use App\Services\RankingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for the ranking system API endpoints and service logic.
|
||||
*
|
||||
* Covered:
|
||||
* 1. GET /api/rank/global returns artworks in the pre-ranked order.
|
||||
* 2. RankingService::applyDiversity() enforces max-per-author cap.
|
||||
* 3. Fallback to latest when no rank_list row exists for a scope.
|
||||
*/
|
||||
class RankGlobalTrendingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ── Test 1: ranked order ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A stored rank_list drives the response order.
|
||||
* The controller must return artworks in the same sequence as artwork_ids.
|
||||
*/
|
||||
public function test_global_trending_returns_artworks_in_ranked_order(): void
|
||||
{
|
||||
$user = User::factory()->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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user