fix(gallery): fill tall portrait cards to full block width with object-cover crop
- 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
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\HomepageService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
it('allForUser includes a for_you key', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$service = app(HomepageService::class);
|
||||
$result = $service->allForUser($user);
|
||||
|
||||
expect($result)->toHaveKey('for_you')
|
||||
->and($result['for_you'])->toBeArray();
|
||||
});
|
||||
|
||||
it('allForUser for_you is an array even with no cached recommendations', function () {
|
||||
$user = User::factory()->create();
|
||||
$service = app(HomepageService::class);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$result = $service->allForUser($user);
|
||||
|
||||
expect($result['for_you'])->toBeArray();
|
||||
});
|
||||
|
||||
it('allForUser for_you returns items when cache exists', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
UserRecommendationCache::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'),
|
||||
'cache_version' => (string) config('discovery.cache_version', 'cache-v1'),
|
||||
'recommendations_json' => [
|
||||
'items' => [
|
||||
['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(60),
|
||||
]);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$service = app(HomepageService::class);
|
||||
$result = $service->allForUser($user);
|
||||
|
||||
expect($result['for_you'])->toBeArray();
|
||||
// At least one item should have the base shape (id, title, slug, url)
|
||||
if (count($result['for_you']) > 0) {
|
||||
expect($result['for_you'][0])->toHaveKeys(['id', 'title', 'slug', 'url']);
|
||||
}
|
||||
});
|
||||
176
tests/Feature/Recommendations/RecommendationEndpointsTest.php
Normal file
176
tests/Feature/Recommendations/RecommendationEndpointsTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFollower;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Disable Meilisearch so tests remain fast
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// /discover/for-you
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('redirects guests away from /discover/for-you', function () {
|
||||
$this->get('/discover/for-you')
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
it('renders For You page for authenticated user', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/discover/for-you')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('For You page shows empty state with no prior activity', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/discover/for-you')
|
||||
->assertOk()
|
||||
->assertSee('For You');
|
||||
});
|
||||
|
||||
it('For You page uses cached recommendations when available', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
UserRecommendationCache::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'algo_version' => (string) config('discovery.algo_version', 'clip-cosine-v1'),
|
||||
'cache_version' => (string) config('discovery.cache_version', 'cache-v1'),
|
||||
'recommendations_json' => [
|
||||
'items' => [
|
||||
['artwork_id' => $artwork->id, 'score' => 0.9, 'source' => 'profile'],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/discover/for-you')
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// /api/user/suggestions/creators
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('requires auth for suggested creators endpoint', function () {
|
||||
$this->getJson('/api/user/suggestions/creators')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('returns data array from suggested creators endpoint', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/api/user/suggestions/creators')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data']);
|
||||
});
|
||||
|
||||
it('suggested creators does not include the requesting user', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/user/suggestions/creators')
|
||||
->assertOk();
|
||||
|
||||
$ids = collect($response->json('data'))->pluck('id')->all();
|
||||
expect($ids)->not->toContain($user->id);
|
||||
});
|
||||
|
||||
it('suggested creators excludes already-followed creators', function () {
|
||||
$user = User::factory()->create();
|
||||
$followed = User::factory()->create();
|
||||
|
||||
UserFollower::create([
|
||||
'user_id' => $followed->id,
|
||||
'follower_id' => $user->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/user/suggestions/creators')
|
||||
->assertOk();
|
||||
|
||||
$ids = collect($response->json('data'))->pluck('id')->all();
|
||||
expect($ids)->not->toContain($followed->id);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// /api/user/suggestions/tags
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('requires auth for suggested tags endpoint', function () {
|
||||
$this->getJson('/api/user/suggestions/tags')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('returns data array from suggested tags endpoint', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('/api/user/suggestions/tags')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data']);
|
||||
});
|
||||
|
||||
it('suggested tags returns correct shape', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/user/suggestions/tags')
|
||||
->assertOk();
|
||||
|
||||
$data = $response->json('data');
|
||||
expect($data)->toBeArray();
|
||||
|
||||
// If non-empty each item must have these keys
|
||||
foreach ($data as $item) {
|
||||
expect($item)->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'source']);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Similar artworks cache TTL
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('similar artworks endpoint returns 200 for a valid public artwork', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data']);
|
||||
});
|
||||
|
||||
it('similar artworks response is cached (second call hits cache layer)', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// Two consecutive calls – the second must also succeed (confirming cache does not corrupt)
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")->assertOk();
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")->assertOk();
|
||||
});
|
||||
227
tests/Feature/Recommendations/RecommendationServiceTest.php
Normal file
227
tests/Feature/Recommendations/RecommendationServiceTest.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?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();
|
||||
});
|
||||
81
tests/Feature/Recommendations/UserPreferenceBuilderTest.php
Normal file
81
tests/Feature/Recommendations/UserPreferenceBuilderTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\DTOs\UserRecoProfileDTO;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecoProfile;
|
||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UserRecoProfileDTO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('DTO serialises and round-trips correctly', function () {
|
||||
$dto = new UserRecoProfileDTO(
|
||||
topTagSlugs: ['space', 'nature'],
|
||||
topCategorySlugs: ['wallpapers'],
|
||||
strongCreatorIds: [1, 2, 3],
|
||||
tagWeights: ['space' => 0.6, 'nature' => 0.4],
|
||||
categoryWeights: ['wallpapers' => 1.0],
|
||||
);
|
||||
|
||||
$arr = $dto->toArray();
|
||||
$restored = UserRecoProfileDTO::fromArray($arr);
|
||||
|
||||
expect($restored->topTagSlugs)->toBe(['space', 'nature'])
|
||||
->and($restored->topCategorySlugs)->toBe(['wallpapers'])
|
||||
->and($restored->strongCreatorIds)->toBe([1, 2, 3])
|
||||
->and($restored->tagWeight('space'))->toBe(0.6)
|
||||
->and($restored->followsCreator(2))->toBeTrue()
|
||||
->and($restored->followsCreator(99))->toBeFalse();
|
||||
});
|
||||
|
||||
it('DTO hasSignals returns false for empty profile', function () {
|
||||
$empty = new UserRecoProfileDTO();
|
||||
expect($empty->hasSignals())->toBeFalse();
|
||||
});
|
||||
|
||||
it('DTO hasSignals returns true when tags are present', function () {
|
||||
$dto = new UserRecoProfileDTO(topTagSlugs: ['space']);
|
||||
expect($dto->hasSignals())->toBeTrue();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UserPreferenceBuilder
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('UserPreferenceBuilder returns empty DTO for user with no activity', function () {
|
||||
$user = User::factory()->create();
|
||||
$builder = app(UserPreferenceBuilder::class);
|
||||
|
||||
$dto = $builder->build($user);
|
||||
|
||||
expect($dto)->toBeInstanceOf(UserRecoProfileDTO::class)
|
||||
->and($dto->topTagSlugs)->toBe([])
|
||||
->and($dto->strongCreatorIds)->toBe([]);
|
||||
});
|
||||
|
||||
it('UserPreferenceBuilder persists profile row on first build', function () {
|
||||
$user = User::factory()->create();
|
||||
$builder = app(UserPreferenceBuilder::class);
|
||||
|
||||
$builder->buildFresh($user);
|
||||
|
||||
expect(UserRecoProfile::find($user->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('UserPreferenceBuilder produces stable output on repeated calls', function () {
|
||||
$user = User::factory()->create();
|
||||
$builder = app(UserPreferenceBuilder::class);
|
||||
|
||||
$first = $builder->buildFresh($user)->toArray();
|
||||
$second = $builder->buildFresh($user)->toArray();
|
||||
|
||||
expect($first)->toBe($second);
|
||||
});
|
||||
Reference in New Issue
Block a user