Files
SkinbaseNova/tests/Feature/Discovery/FeedEndpointTest.php
2026-02-14 15:14:12 +01:00

114 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\User;
use App\Models\UserRecommendationCache;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('returns feed from cache with cursor pagination', function () {
$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)]);
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();
});