- 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
177 lines
6.6 KiB
PHP
177 lines
6.6 KiB
PHP
<?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();
|
||
});
|