162 lines
5.8 KiB
PHP
162 lines
5.8 KiB
PHP
<?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.'
|
|
);
|
|
}
|
|
}
|