Files
SkinbaseNova/tests/Feature/Recommendations/RecommendationEndpointsTest.php
Gregor Klevze 67ef79766c 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
2026-02-27 13:34:08 +01:00

177 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
});