Save workspace changes
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
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']);
|
||||
}
|
||||
});
|
||||
|
||||
it('allForUser for_you uses group publisher payload for group-published artworks', function () {
|
||||
$user = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$group = Group::factory()->create([
|
||||
'owner_user_id' => $owner->id,
|
||||
'name' => 'Nova Group',
|
||||
'slug' => 'nova-group',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'group_id' => $group->id,
|
||||
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
|
||||
'title' => 'Group Recommended Artwork',
|
||||
'hash' => 'grouprecommendedartwork',
|
||||
'thumb_ext' => 'webp',
|
||||
'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.91, 'source' => 'profile'],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(60),
|
||||
]);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$result = app(HomepageService::class)->allForUser($user);
|
||||
|
||||
expect($result['for_you'])->not->toBeEmpty()
|
||||
->and($result['for_you'][0]['author'])->toBe('Nova Group')
|
||||
->and($result['for_you'][0]['published_as_type'])->toBe(Artwork::PUBLISHED_AS_GROUP)
|
||||
->and($result['for_you'][0]['publisher']['type'])->toBe('group')
|
||||
->and($result['for_you'][0]['publisher']['name'])->toBe('Nova Group');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RecBuildItemPairsFromFavouritesJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkFavourite;
|
||||
use App\Models\Category;
|
||||
use App\Models\RecArtworkRec;
|
||||
use App\Models\RecItemPair;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['scout.driver' => 'null']);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createPublicArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(function () use ($attrs) {
|
||||
return Artwork::factory()->create(array_merge([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
], $attrs));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── API returns fallback if precomputed list is missing ───────────────────────
|
||||
|
||||
it('returns fallback results when no precomputed similar list exists', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
|
||||
// Create some other artworks so the trending fallback can find them
|
||||
$other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]);
|
||||
$other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
// Should still return artworks via trending fallback, not an empty set
|
||||
expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class)
|
||||
->and($result)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty collection for non-existent artwork', function () {
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork(999999, 12);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns similar_tags list when hybrid is missing', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$similar1 = createPublicArtwork();
|
||||
$similar2 = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$similar1->id, $similar2->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]);
|
||||
});
|
||||
|
||||
// ─── Ordering is preserved ─────────────────────────────────────────────────────
|
||||
|
||||
it('preserves precomputed ordering exactly', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$a = createPublicArtwork();
|
||||
$b = createPublicArtwork();
|
||||
$c = createPublicArtwork();
|
||||
$d = createPublicArtwork();
|
||||
|
||||
// Deliberate non-sequential order
|
||||
$orderedIds = [$c->id, $a->id, $d->id, $b->id];
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => $orderedIds,
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual($orderedIds);
|
||||
});
|
||||
|
||||
it('falls through from hybrid to tags preserving order', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$a = createPublicArtwork();
|
||||
$b = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$b->id, $a->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]);
|
||||
});
|
||||
|
||||
// ─── Diversity cap (max per author) is enforced ────────────────────────────────
|
||||
|
||||
it('enforces author diversity cap at runtime', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
|
||||
// One author with 4 artworks
|
||||
$author = User::factory()->create();
|
||||
$sameAuthor1 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor2 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor3 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor4 = createPublicArtwork(['user_id' => $author->id]);
|
||||
|
||||
// Another author with 1 artwork
|
||||
$otherAuthor = User::factory()->create();
|
||||
$diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]);
|
||||
|
||||
// Put all 5 in the precomputed list — same author dominates
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [
|
||||
$sameAuthor1->id,
|
||||
$sameAuthor2->id,
|
||||
$sameAuthor3->id,
|
||||
$sameAuthor4->id,
|
||||
$diffAuthor->id,
|
||||
],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
config(['recommendations.similarity.max_per_author' => 2]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
// Max 2 from same author, 1 from different author = 3 total
|
||||
$resultByAuthor = $result->groupBy('user_id');
|
||||
foreach ($resultByAuthor as $authorId => $artworks) {
|
||||
expect($artworks->count())->toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect($result)->toHaveCount(3);
|
||||
});
|
||||
|
||||
// ─── Pair building doesn't explode per user ────────────────────────────────────
|
||||
|
||||
it('caps pairs per user to avoid combinatorial explosion', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue)
|
||||
$artworks = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$art = createPublicArtwork();
|
||||
$artworks[] = $art;
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now()->subMinutes($i),
|
||||
'updated_at' => now()->subMinutes($i),
|
||||
]);
|
||||
}
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 5);
|
||||
|
||||
// C(5,2) = 10 pairs max
|
||||
expect($pairs)->toHaveCount(10);
|
||||
|
||||
// Verify each pair is ordered (a < b)
|
||||
foreach ($pairs as [$a, $b]) {
|
||||
expect($a)->toBeLessThan($b);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the favourites cap for pair generation', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create 10 favourites (bypass observers)
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$art = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now()->subMinutes($i),
|
||||
'updated_at' => now()->subMinutes($i),
|
||||
]);
|
||||
}
|
||||
|
||||
// Cap at 3 → C(3,2) = 3 pairs
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 3);
|
||||
|
||||
expect($pairs)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('returns empty pairs for user with only one favourite', function () {
|
||||
$user = User::factory()->create();
|
||||
$art = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 50);
|
||||
|
||||
expect($pairs)->toBeEmpty();
|
||||
});
|
||||
|
||||
// ─── API endpoint integration ──────────────────────────────────────────────────
|
||||
|
||||
it('returns JSON response from API endpoint with precomputed data', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$similar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$similar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$artwork->id}/similar");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['data'])
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent artwork in API', function () {
|
||||
$response = $this->getJson('/api/art/999999/similar');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── RecArtworkRec model ───────────────────────────────────────────────────────
|
||||
|
||||
it('stores and retrieves rec list with correct types', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$ids = [10, 20, 30];
|
||||
|
||||
$rec = RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => $ids,
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = RecArtworkRec::find($rec->id);
|
||||
expect($fresh->recs)->toBeArray()
|
||||
->and($fresh->recs)->toEqual($ids)
|
||||
->and($fresh->artwork_id)->toBe($artwork->id);
|
||||
});
|
||||
|
||||
// ─── Fallback priority ─────────────────────────────────────────────────────────
|
||||
|
||||
it('chooses similar_behavior when tags and hybrid are missing', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$beh1 = createPublicArtwork();
|
||||
$beh2 = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$beh1->id, $beh2->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]);
|
||||
});
|
||||
|
||||
it('filters out unpublished artworks from precomputed list', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$published = createPublicArtwork();
|
||||
$unpublished = Artwork::withoutEvents(function () {
|
||||
return Artwork::factory()->unpublished()->create();
|
||||
});
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$unpublished->id, $published->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$published->id]);
|
||||
});
|
||||
|
||||
// ─── Type query param support (spec §8) ────────────────────────────────────────
|
||||
|
||||
it('returns specific rec type when ?type=tags is passed', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
$behSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$behSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12, 'tags');
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]);
|
||||
});
|
||||
|
||||
it('returns behavior list when ?type=behavior is passed', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$behSimilar = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$behSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12, 'behavior');
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$behSimilar->id]);
|
||||
});
|
||||
|
||||
it('passes type query param from API endpoint', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
// ─── Cosine normalized pair weights ────────────────────────────────────────────
|
||||
|
||||
it('produces cosine-normalized weights in pair builder', function () {
|
||||
// User A: likes artwork 1, 2
|
||||
$userA = User::factory()->create();
|
||||
$art1 = createPublicArtwork();
|
||||
$art2 = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
// User B: also likes artwork 1, 2
|
||||
$userB = User::factory()->create();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$job->handle();
|
||||
|
||||
$pair = RecItemPair::query()
|
||||
->where('a_artwork_id', min($art1->id, $art2->id))
|
||||
->where('b_artwork_id', max($art1->id, $art2->id))
|
||||
->first();
|
||||
|
||||
expect($pair)->not->toBeNull();
|
||||
// co_like = 2 (both users liked both), likes_A = 2, likes_B = 2
|
||||
// S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
|
||||
expect($pair->weight)->toBe(1.0);
|
||||
});
|
||||
@@ -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