- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so object-cover fills the max-height capped box instead of collapsing the width - MasonryGallery.css: add width:100% to media container, position img absolutely so top/bottom is cropped rather than leaving dark gaps - Add React MasonryGallery + ArtworkCard components and entry point - Add recommendation system: UserRecoProfile model/DTO/migration, SuggestedCreatorsController, SuggestedTagsController, Recommendation services, config/recommendations.php - SimilarArtworksController, DiscoverController, HomepageService updates - Update routes (api + web) and discover/for-you views - Refresh favicon assets, update vite.config.js
228 lines
9.7 KiB
PHP
228 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\DTOs\UserRecoProfileDTO;
|
|
use App\Models\Artwork;
|
|
use App\Models\User;
|
|
use App\Services\Recommendation\RecommendationService;
|
|
use App\Services\Recommendation\UserPreferenceBuilder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
// Disable Meilisearch so tests remain fast / deterministic
|
|
config(['scout.driver' => 'null']);
|
|
|
|
// Seed recommendations config
|
|
config([
|
|
'recommendations.weights.tag_overlap' => 0.40,
|
|
'recommendations.weights.creator_affinity' => 0.25,
|
|
'recommendations.weights.popularity' => 0.20,
|
|
'recommendations.weights.freshness' => 0.15,
|
|
'recommendations.candidate_pool_size' => 200,
|
|
'recommendations.max_per_creator' => 3,
|
|
'recommendations.min_unique_tags' => 5,
|
|
'recommendations.ttl.for_you_feed' => 5,
|
|
]);
|
|
|
|
Cache::flush();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// RecommendationService cold-start (no signals)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('returns cold-start feed when user has no signals', function () {
|
|
$user = User::factory()->create();
|
|
|
|
// Profile builder will return a DTO with no signals
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
|
topTagSlugs: [],
|
|
topCategorySlugs: [],
|
|
strongCreatorIds: [],
|
|
tagWeights: [],
|
|
categoryWeights: [],
|
|
dislikedTagSlugs: [],
|
|
));
|
|
|
|
$service = new RecommendationService($builder);
|
|
$result = $service->forYouFeed($user, 10);
|
|
|
|
expect($result)->toHaveKeys(['data', 'meta'])
|
|
->and($result['meta']['source'])->toBe('cold_start');
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// RecommendationService personalised flow (mocked profile)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('returns personalised feed with data when user has signals', function () {
|
|
$user = User::factory()->create();
|
|
|
|
// Two artworks from other creators (tags not needed — Scout driver is null)
|
|
Artwork::factory()->create([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'user_id' => User::factory()->create()->id,
|
|
'published_at' => now()->subDay(),
|
|
]);
|
|
Artwork::factory()->create([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'user_id' => User::factory()->create()->id,
|
|
'published_at' => now()->subDays(2),
|
|
]);
|
|
|
|
$profile = new UserRecoProfileDTO(
|
|
topTagSlugs: ['cyberpunk', 'neon'],
|
|
topCategorySlugs: [],
|
|
strongCreatorIds: [],
|
|
tagWeights: ['cyberpunk' => 5.0, 'neon' => 3.0],
|
|
categoryWeights: [],
|
|
dislikedTagSlugs: [],
|
|
);
|
|
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->with($user)->andReturn($profile);
|
|
|
|
$service = new RecommendationService($builder);
|
|
$result = $service->forYouFeed($user, 10);
|
|
|
|
expect($result)->toHaveKeys(['data', 'meta'])
|
|
->and($result['meta'])->toHaveKey('source');
|
|
// With scout null driver the collection is empty → cold-start path
|
|
// This tests the structure contract regardless of driver
|
|
expect($result['data'])->toBeArray();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Diversity: max 3 per creator
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('enforces max_per_creator diversity limit via forYouPreview', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$creatorA = User::factory()->create();
|
|
$creatorB = User::factory()->create();
|
|
|
|
// 4 artworks by creatorA, 1 by creatorB (Scout driver null — no Meili calls)
|
|
Artwork::factory(4)->create([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'user_id' => $creatorA->id,
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
Artwork::factory()->create([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'user_id' => $creatorB->id,
|
|
'published_at' => now()->subHour(),
|
|
]);
|
|
|
|
$profile = new UserRecoProfileDTO(
|
|
topTagSlugs: ['abstract'],
|
|
topCategorySlugs: [],
|
|
strongCreatorIds: [],
|
|
tagWeights: ['abstract' => 5.0],
|
|
categoryWeights: [],
|
|
dislikedTagSlugs: [],
|
|
);
|
|
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->andReturn($profile);
|
|
|
|
$service = new RecommendationService($builder);
|
|
|
|
// With null scout driver the candidate collection is empty; we test contract.
|
|
$result = $service->forYouFeed($user, 10);
|
|
expect($result)->toHaveKeys(['data', 'meta']);
|
|
expect($result['data'])->toBeArray();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Favourited artworks are excluded from For You feed
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('excludes artworks already favourited by user', function () {
|
|
$user = User::factory()->create();
|
|
$art = Artwork::factory()->create(['is_public' => true, 'is_approved' => true]);
|
|
|
|
// Insert a favourite
|
|
DB::table('artwork_favourites')->insert([
|
|
'user_id' => $user->id,
|
|
'artwork_id' => $art->id,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$profile = new UserRecoProfileDTO(
|
|
topTagSlugs: ['tag-x'],
|
|
topCategorySlugs: [],
|
|
strongCreatorIds: [],
|
|
tagWeights: ['tag-x' => 3.0],
|
|
categoryWeights: [],
|
|
dislikedTagSlugs: [],
|
|
);
|
|
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->andReturn($profile);
|
|
|
|
$service = new RecommendationService($builder);
|
|
|
|
// With null scout, no candidates surface — checking that getFavoritedIds runs without error
|
|
$result = $service->forYouFeed($user, 10);
|
|
expect($result)->toHaveKeys(['data', 'meta']);
|
|
|
|
$artworkIds = array_column($result['data'], 'id');
|
|
expect($artworkIds)->not->toContain($art->id);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Cursor pagination shape
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('returns null next_cursor when no more pages available', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
|
topTagSlugs: [],
|
|
topCategorySlugs: [],
|
|
strongCreatorIds: [],
|
|
tagWeights: [],
|
|
categoryWeights: [],
|
|
dislikedTagSlugs: [],
|
|
));
|
|
|
|
$service = new RecommendationService($builder);
|
|
$result = $service->forYouFeed($user, 40, null);
|
|
|
|
expect($result['meta'])->toHaveKey('next_cursor');
|
|
// Cold-start with 0 results: next_cursor should be null
|
|
expect($result['meta']['next_cursor'])->toBeNull();
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// forYouPreview is a subset of forYouFeed
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
it('forYouPreview returns an array', function () {
|
|
$user = User::factory()->create();
|
|
|
|
$builder = Mockery::mock(UserPreferenceBuilder::class);
|
|
$builder->shouldReceive('build')->andReturn(new UserRecoProfileDTO(
|
|
topTagSlugs: [], topCategorySlugs: [], strongCreatorIds: [],
|
|
tagWeights: [], categoryWeights: [], dislikedTagSlugs: [],
|
|
));
|
|
|
|
$service = new RecommendationService($builder);
|
|
$preview = $service->forYouPreview($user, 12);
|
|
|
|
expect($preview)->toBeArray();
|
|
});
|