Files
SkinbaseNova/tests/Feature/Discovery/FeedEndpointV2Test.php
2026-03-28 19:15:39 +01:00

263 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\Tag;
use App\Models\User;
use App\Models\UserRecommendationCache;
use App\Services\Recommendations\SessionRecoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
uses(RefreshDatabase::class);
it('can serve the feed through the v2 selector path', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
$user = User::factory()->create();
$creator = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $creator->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'trending_score_1h' => 5,
'trending_score_24h' => 10,
'trending_score_7d' => 20,
]);
$tag = Tag::query()->create(['name' => 'Abstract', 'slug' => 'abstract']);
DB::table('artwork_tag')->insert([
'artwork_id' => $artwork->id,
'tag_id' => $tag->id,
'source' => 'user',
'created_at' => now(),
]);
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 150,
'downloads' => 20,
'favorites' => 15,
'comments_count' => 5,
'shares_count' => 2,
'views_24h' => 150,
'views_7d' => 150,
'downloads_24h' => 20,
'downloads_7d' => 20,
'shares_24h' => 2,
'comments_24h' => 5,
'favourites_24h' => 15,
'views_1h' => 40,
'downloads_1h' => 5,
'favourites_1h' => 4,
'comments_1h' => 2,
'shares_1h' => 1,
'ranking_score' => 50,
'engagement_velocity' => 12,
'heat_score' => 30,
'rating_avg' => 0,
'rating_count' => 0,
]);
UserRecommendationCache::query()->create([
'user_id' => $user->id,
'algo_version' => 'clip-cosine-v2-adaptive',
'cache_version' => 'cache-v2',
'recommendations_json' => [
'items' => [
['artwork_id' => $artwork->id, 'score' => 1.2, 'source' => 'trending', 'layer_sources' => ['trending']],
],
],
'generated_at' => now(),
'expires_at' => now()->addMinutes(10),
]);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.algo_version', 'clip-cosine-v2-adaptive');
$response->assertJsonPath('meta.local_embedding_count', 0);
$response->assertJsonPath('meta.vector_indexed_count', 0);
$response->assertJsonPath('data.0.primary_tag.slug', 'abstract');
$response->assertJsonPath('data.0.has_local_embedding', false);
$response->assertJsonPath('data.0.vector_indexed_at', null);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', false);
$response->assertJsonPath('data.0.ranking_signals.vector_indexed_at', null);
expect((array) $response->json('data'))->toHaveCount(1);
});
it('boosts vector-similar candidates in the v3 hybrid feed', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
config()->set('discovery.v3.enabled', true);
config()->set('discovery.v3.vector_similarity_weight', 2.0);
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
$user = User::factory()->create();
$creator = User::factory()->create();
$seedArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
]);
$vectorMatch = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'ddeeff445566',
'thumb_ext' => 'webp',
'title' => 'Vector winner',
'slug' => 'vector-winner',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 5,
'trending_score_24h' => 5,
'trending_score_7d' => 5,
'last_vector_indexed_at' => now()->subMinutes(5),
]);
$trendingLeader = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '778899001122',
'thumb_ext' => 'webp',
'title' => 'Trending leader',
'slug' => 'trending-leader',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 30,
'trending_score_24h' => 30,
'trending_score_7d' => 30,
]);
$sectionArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '334455667788',
'thumb_ext' => 'webp',
'title' => 'Section artwork',
'slug' => 'section-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 6,
'trending_score_24h' => 6,
'trending_score_7d' => 6,
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $vectorMatch->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => 'ddeeff445566',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
foreach ([$seedArtwork, $vectorMatch, $trendingLeader, $sectionArtwork] as $artwork) {
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 100,
'downloads' => 10,
'favorites' => 8,
'comments_count' => 3,
'shares_count' => 1,
'views_24h' => 100,
'views_7d' => 100,
'downloads_24h' => 10,
'downloads_7d' => 10,
'shares_24h' => 1,
'comments_24h' => 3,
'favourites_24h' => 8,
'views_1h' => 20,
'downloads_1h' => 2,
'favourites_1h' => 2,
'comments_1h' => 1,
'shares_1h' => 1,
'ranking_score' => 25,
'engagement_velocity' => 8,
'heat_score' => 15,
'rating_avg' => 0,
'rating_count' => 0,
]);
}
Http::fake(function ($request) use ($seedArtwork, $vectorMatch, $sectionArtwork) {
$payload = json_decode($request->body(), true);
$url = (string) ($payload['url'] ?? '');
if (str_contains($url, 'aabbcc112233')) {
return Http::response([
'results' => [
['id' => $seedArtwork->id, 'score' => 1.0],
['id' => $vectorMatch->id, 'score' => 0.98],
],
], 200);
}
if (str_contains($url, 'ddeeff445566')) {
return Http::response([
'results' => [
['id' => $vectorMatch->id, 'score' => 1.0],
['id' => $sectionArtwork->id, 'score' => 0.91],
],
], 200);
}
return Http::response(['results' => []], 200);
});
app(SessionRecoService::class)->applyEvent(
userId: $user->id,
eventType: 'view',
artworkId: $seedArtwork->id,
categoryId: null,
occurredAt: now()->toIso8601String(),
meta: []
);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.vector_influenced_count', 1);
$response->assertJsonPath('meta.local_embedding_count', 1);
$response->assertJsonPath('meta.vector_indexed_count', 1);
$response->assertJsonPath('data.0.id', $vectorMatch->id);
$response->assertJsonPath('data.0.source', 'vector');
$response->assertJsonPath('data.0.reason', 'Visually similar to art you engaged with');
$response->assertJsonPath('data.0.vector_influenced', true);
$response->assertJsonPath('data.0.has_local_embedding', true);
expect($response->json('data.0.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('data.0.ranking_signals.vector_similarity_score', 0.98);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', true);
expect($response->json('data.0.ranking_signals.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('sections.0.key', 'similar_style');
$response->assertJsonPath('sections.1.key', 'you_may_also_like');
$response->assertJsonPath('sections.2.key', 'visually_related');
$response->assertJsonPath('sections.0.items.0.id', $sectionArtwork->id);
$response->assertJsonPath('sections.2.items.0.id', $sectionArtwork->id);
});