From 1d18052fde221eb175f40421e3b9200fd8b4ef17 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 20 Sep 2026 14:49:32 +0200 Subject: [PATCH] Extract related artwork lookup into a dedicated service. Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results. --- .../Web/SimilarArtworksPageController.php | 293 ++++++++++-------- app/Services/ArtworkRelatedService.php | 163 ++++++++++ tests/Feature/ArtworkRelatedServiceTest.php | 115 +++++++ 3 files changed, 449 insertions(+), 122 deletions(-) create mode 100644 app/Services/ArtworkRelatedService.php create mode 100644 tests/Feature/ArtworkRelatedServiceTest.php diff --git a/app/Http/Controllers/Web/SimilarArtworksPageController.php b/app/Http/Controllers/Web/SimilarArtworksPageController.php index 9584871c..5f268923 100644 --- a/app/Http/Controllers/Web/SimilarArtworksPageController.php +++ b/app/Http/Controllers/Web/SimilarArtworksPageController.php @@ -35,8 +35,8 @@ final class SimilarArtworksPageController extends Controller private const QDRANT_LIMIT = 120; public function __construct( - private readonly VectorService $vectors, - private readonly ArtworkMaturityService $maturity, + private readonly VectorService $vectors, + private readonly ArtworkMaturityService $maturity, private readonly HybridSimilarArtworksService $hybridService, ) {} @@ -57,53 +57,53 @@ final class SimilarArtworksPageController extends Controller $baseUrl = url("/art/{$id}/similar"); // ── Normalise source artwork for the view ────────────────────────────── - $primaryCat = $source->categories->sortBy('sort_order')->first(); - $sourceMd = ThumbnailPresenter::present($source, 'md'); - $sourceLg = ThumbnailPresenter::present($source, 'lg'); + $primaryCat = $source->categories->sortBy('sort_order')->first(); + $sourceMd = ThumbnailPresenter::present($source, 'md'); + $sourceLg = ThumbnailPresenter::present($source, 'lg'); $sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]); + $sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]); $sourceCard = (object) [ - 'id' => $source->id, - 'title' => $sourceTitle, - 'url' => $sourceUrl, - 'thumb_md' => $sourceMd['url'] ?? null, - 'thumb_lg' => $sourceLg['url'] ?? null, - 'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null, - 'author_name' => $source->user?->name ?? 'Artist', - 'author_username' => $source->user?->username ?? '', - 'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null, - 'author_avatar' => AvatarUrl::forUser( - (int) ($source->user_id ?? 0), - $source->user?->profile?->avatar_hash ?? null, - 80 - ), - 'category_name' => $primaryCat?->name ?? '', - 'category_slug' => $primaryCat?->slug ?? '', + 'id' => $source->id, + 'title' => $sourceTitle, + 'url' => $sourceUrl, + 'thumb_md' => $sourceMd['url'] ?? null, + 'thumb_lg' => $sourceLg['url'] ?? null, + 'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null, + 'author_name' => $source->user?->name ?? 'Artist', + 'author_username' => $source->user?->username ?? '', + 'author_profile_url' => $source->user?->username ? '/@'.$source->user->username : null, + 'author_avatar' => AvatarUrl::forUser( + (int) ($source->user_id ?? 0), + $source->user?->profile?->avatar_hash ?? null, + 80 + ), + 'category_name' => $primaryCat?->name ?? '', + 'category_slug' => $primaryCat?->slug ?? '', 'content_type_name' => $primaryCat?->contentType?->name ?? '', 'content_type_slug' => $primaryCat?->contentType?->slug ?? '', - 'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'), - 'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(), - 'width' => $source->width ?? null, - 'height' => $source->height ?? null, + 'browse_url' => $primaryCat?->contentType?->slug ? url('/'.$primaryCat->contentType->slug) : url('/explore'), + 'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(), + 'width' => $source->width ?? null, + 'height' => $source->height ?? null, ]; return view('gallery.similar', [ - 'sourceArtwork' => $sourceCard, - 'gallery_type' => 'similar', - 'gallery_nav_section' => 'artworks', - 'mainCategories' => collect(), - 'subcategories' => collect(), - 'contentType' => null, - 'category' => null, - 'spotlight' => collect(), - 'current_sort' => 'trending', - 'sort_options' => [], - 'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase', - 'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.', - 'page_canonical' => $baseUrl, - 'page_robots' => 'index,follow', - 'breadcrumbs' => collect([ + 'sourceArtwork' => $sourceCard, + 'gallery_type' => 'similar', + 'gallery_nav_section' => 'artworks', + 'mainCategories' => collect(), + 'subcategories' => collect(), + 'contentType' => null, + 'category' => null, + 'spotlight' => collect(), + 'current_sort' => 'trending', + 'sort_options' => [], + 'page_title' => 'Similar to "'.$sourceTitle.'" — Skinbase', + 'page_meta_description' => 'Discover artworks similar to "'.$sourceTitle.'" on Skinbase.', + 'page_canonical' => $baseUrl, + 'page_robots' => 'index,follow', + 'breadcrumbs' => collect([ (object) ['name' => 'Explore', 'url' => '/explore'], (object) ['name' => $sourceTitle, 'url' => $sourceUrl], (object) ['name' => 'Similar Artworks', 'url' => $baseUrl], @@ -130,37 +130,37 @@ final class SimilarArtworksPageController extends Controller ]) ->findOrFail($id); - $page = max(1, (int) $request->query('page', 1)); + $page = max(1, (int) $request->query('page', 1)); $baseUrl = url("/art/{$id}/similar"); [$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl); $galleryItems = $artworks->getCollection()->map(fn ($art) => [ - 'id' => $art->id ?? null, - 'name' => $art->name ?? null, - 'thumb' => $art->thumb_url ?? $art->thumb ?? null, - 'thumb_srcset' => $art->thumb_srcset ?? null, - 'uname' => $art->uname ?? '', - 'username' => $art->username ?? $art->uname ?? '', - 'avatar_url' => $art->avatar_url ?? null, + 'id' => $art->id ?? null, + 'name' => $art->name ?? null, + 'thumb' => $art->thumb_url ?? $art->thumb ?? null, + 'thumb_srcset' => $art->thumb_srcset ?? null, + 'uname' => $art->uname ?? '', + 'username' => $art->username ?? $art->uname ?? '', + 'avatar_url' => $art->avatar_url ?? null, 'category_name' => $art->category_name ?? '', 'category_slug' => $art->category_slug ?? '', - 'slug' => $art->slug ?? '', - 'width' => $art->width ?? null, - 'height' => $art->height ?? null, - 'maturity' => $art->maturity ?? null, + 'slug' => $art->slug ?? '', + 'width' => $art->width ?? null, + 'height' => $art->height ?? null, + 'maturity' => $art->maturity ?? null, ])->values(); $galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values(); return response()->json([ - 'data' => $galleryItems, + 'data' => $galleryItems, 'similarity_source' => $similaritySource, - 'total' => $artworks->total(), - 'current_page' => $artworks->currentPage(), - 'last_page' => $artworks->lastPage(), - 'next_page_url' => $artworks->nextPageUrl(), - 'prev_page_url' => $artworks->previousPageUrl(), + 'total' => $artworks->total(), + 'current_page' => $artworks->currentPage(), + 'last_page' => $artworks->lastPage(), + 'next_page_url' => $artworks->nextPageUrl(), + 'prev_page_url' => $artworks->previousPageUrl(), ]); } @@ -171,16 +171,13 @@ final class SimilarArtworksPageController extends Controller */ private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array { - // Priority 1 — Qdrant visual (vision) similarity + // Priority 1 — Qdrant visual (vision) similarity. + // Paginate IDs before hydrating/presenting full Artwork models so a + // request only pays the relation/presenter cost for the current page. if ($this->vectors->isConfigured()) { - $qdrantItems = $this->resolveViaQdrant($source); - if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) { - $paginator = $this->paginateCollection( - $qdrantItems->map(fn ($a) => $this->presentArtwork($a)), - $page, - $baseUrl, - ); - return [$paginator, 'visual']; + $qdrantPaginator = $this->resolveViaQdrant($source, $page, $baseUrl); + if ($qdrantPaginator !== null && $qdrantPaginator->total() > 0) { + return [$qdrantPaginator, 'visual']; } } @@ -192,54 +189,106 @@ final class SimilarArtworksPageController extends Controller $page, $baseUrl, ); + return [$paginator, 'hybrid']; } // Priority 3 — Meilisearch tag/category overlap $paginator = $this->meilisearchFallback($source, $page); + return [$paginator, 'tags']; } /** - * Query Qdrant via VectorGateway, then re-hydrate full Artwork models - * (so we have category/dimension data for the masonry grid). + * Query Qdrant via VectorGateway, preserve relevance order, then paginate + * candidate IDs before hydrating full Artwork models for the current page. * - * Returns null when the gateway call fails, so the caller can fall through. + * The light eligibility query keeps the existing public/published semantics + * for cached Qdrant results without loading relations for every candidate. + * Returns null when the gateway call fails or no eligible candidates remain, + * allowing the caller to continue to the existing hybrid/tag fallbacks. */ - private function resolveViaQdrant(Artwork $source): ?Collection - { + private function resolveViaQdrant( + Artwork $source, + int $page, + string $baseUrl, + ): ?LengthAwarePaginator { try { $raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT); } catch (RuntimeException) { return null; } - if (empty($raw)) { + if ($raw === []) { return null; } - // Preserve Qdrant relevance order; IDs are already filtered to public+published - $orderedIds = array_column($raw, 'id'); + // Preserve Qdrant relevance order while normalising IDs defensively. + $orderedIds = collect(array_column($raw, 'id')) + ->map(static fn ($id): int => is_numeric($id) ? (int) $id : 0) + ->filter(static fn (int $id): bool => $id > 0 && $id !== (int) $source->id) + ->unique() + ->values(); - $artworks = Artwork::query() - ->whereIn('id', $orderedIds) - ->where('id', '!=', $source->id) // belt-and-braces exclusion + if ($orderedIds->isEmpty()) { + return null; + } + + // Keep cached Qdrant results honest if an artwork has since become + // private/unpublished. Fetch IDs only; do not hydrate relations yet. + $eligibleSet = Artwork::query() + ->whereIn('id', $orderedIds->all()) ->public() ->published() - ->with([ - 'categories:id,slug,name,content_type_id', - 'categories.contentType:id,name,slug', - 'user:id,name,username', - 'user.profile:user_id,avatar_hash', - 'group:id,name,slug,avatar_path', - ]) - ->get() - ->keyBy('id'); + ->pluck('id') + ->mapWithKeys(static fn ($id): array => [(int) $id => true]); - return collect($orderedIds) - ->map(fn (int $id) => $artworks->get($id)) - ->filter() + $eligibleIds = $orderedIds + ->filter(static fn (int $id): bool => $eligibleSet->has($id)) ->values(); + + if ($eligibleIds->isEmpty()) { + return null; + } + + $perPage = self::PER_PAGE; + $total = $eligibleIds->count(); + $pageIds = $eligibleIds->forPage($page, $perPage)->values(); + + $items = collect(); + + if ($pageIds->isNotEmpty()) { + $artworks = Artwork::query() + ->whereIn('id', $pageIds->all()) + ->public() + ->published() + ->with([ + 'categories:id,slug,name,content_type_id', + 'categories.contentType:id,name,slug', + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', + ]) + ->get() + ->keyBy('id'); + + $items = $pageIds + ->map(fn (int $id) => $artworks->get($id)) + ->filter() + ->map(fn (Artwork $artwork) => $this->presentArtwork($artwork)) + ->values(); + } + + return new LengthAwarePaginator( + $items, + $total, + $perPage, + $page, + [ + 'path' => $baseUrl, + 'query' => [], + ], + ); } /** @@ -247,26 +296,26 @@ final class SimilarArtworksPageController extends Controller */ private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator { - $tagSlugs = $source->tags->pluck('slug')->values()->all(); + $tagSlugs = $source->tags->pluck('slug')->values()->all(); $categorySlugs = $source->categories->pluck('slug')->values()->all(); $filterParts = [ 'is_public = true', 'is_approved = true', - 'id != ' . $source->id, + 'id != '.$source->id, ]; if ($tagSlugs !== []) { - $quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs); - $filterParts[] = '(' . implode(' OR ', $quoted) . ')'; + $quoted = array_map(fn (string $t): string => 'tags = "'.addslashes($t).'"', $tagSlugs); + $filterParts[] = '('.implode(' OR ', $quoted).')'; } elseif ($categorySlugs !== []) { - $quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs); - $filterParts[] = '(' . implode(' OR ', $quoted) . ')'; + $quoted = array_map(fn (string $c): string => '(category = "'.addslashes($c).'" OR categories = "'.addslashes($c).'")', $categorySlugs); + $filterParts[] = '('.implode(' OR ', $quoted).')'; } $results = Artwork::search('')->options([ 'filter' => implode(' AND ', $filterParts), - 'sort' => ['trending_score_7d:desc', 'created_at:desc'], + 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ])->paginate(self::PER_PAGE, 'page', $page); $results->getCollection()->load([ @@ -287,15 +336,15 @@ final class SimilarArtworksPageController extends Controller */ private function paginateCollection( Collection $items, - int $page, - string $path, + int $page, + string $path, ): LengthAwarePaginator { $perPage = self::PER_PAGE; - $total = $items->count(); - $slice = $items->forPage($page, $perPage)->values(); + $total = $items->count(); + $slice = $items->forPage($page, $perPage)->values(); return new LengthAwarePaginator($slice, $total, $perPage, $page, [ - 'path' => $path, + 'path' => $path, 'query' => [], ]); } @@ -304,9 +353,9 @@ final class SimilarArtworksPageController extends Controller private function presentArtwork(Artwork $artwork): object { - $primary = $artwork->categories->sortBy('sort_order')->first(); - $present = ThumbnailPresenter::present($artwork, 'md'); - $group = $artwork->group; + $primary = $artwork->categories->sortBy('sort_order')->first(); + $present = ThumbnailPresenter::present($artwork, 'md'); + $group = $artwork->group; $isGroupPublisher = $group !== null; $avatarUrl = $isGroupPublisher ? $group->avatarUrl() @@ -317,33 +366,33 @@ final class SimilarArtworksPageController extends Controller ); $displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase'); $username = $isGroupPublisher ? '' : ($artwork->user?->username ?? ''); - $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null); + $profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@'.$username : null); return (object) $this->maturity->decoratePayload([ - 'id' => $artwork->id, - 'name' => $artwork->title, + 'id' => $artwork->id, + 'name' => $artwork->title, 'content_type_name' => $primary?->contentType?->name ?? '', 'content_type_slug' => $primary?->contentType?->slug ?? '', - 'category_name' => $primary?->name ?? '', - 'category_slug' => $primary?->slug ?? '', - 'thumb_url' => $present['url'], - 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $displayName, - 'username' => $username, - 'avatar_url' => $avatarUrl, - 'profile_url' => $profileUrl, + 'category_name' => $primary?->name ?? '', + 'category_slug' => $primary?->slug ?? '', + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $displayName, + 'username' => $username, + 'avatar_url' => $avatarUrl, + 'profile_url' => $profileUrl, 'published_as_type' => $isGroupPublisher ? 'group' : 'user', - 'publisher' => [ + 'publisher' => [ 'type' => $isGroupPublisher ? 'group' : 'user', 'name' => $displayName, 'username' => $username, 'avatar_url' => $avatarUrl, 'profile_url' => $profileUrl, ], - 'published_at' => $artwork->published_at, - 'slug' => $artwork->slug ?? '', - 'width' => $artwork->width ?? null, - 'height' => $artwork->height ?? null, + 'published_at' => $artwork->published_at, + 'slug' => $artwork->slug ?? '', + 'width' => $artwork->width ?? null, + 'height' => $artwork->height ?? null, ], $artwork, request()->user()); } } diff --git a/app/Services/ArtworkRelatedService.php b/app/Services/ArtworkRelatedService.php new file mode 100644 index 00000000..86fffd2c --- /dev/null +++ b/app/Services/ArtworkRelatedService.php @@ -0,0 +1,163 @@ +categories->pluck('id')->filter()->values(); + $tagIds = $artwork->tags->pluck('id')->filter()->values(); + + if ($categoryIds->isEmpty() || $this->matchingPivotRows($categoryIds) > self::SPLIT_PIVOT_ROW_THRESHOLD) { + return $this->originalQuery($artwork, $viewer, $categoryIds, $tagIds, $limit)->get(); + } + + $candidateRows = collect() + ->merge($this->topAuthorCandidates($artwork, $viewer, $limit)) + ->merge($this->topGroupCandidates($artwork, $viewer, $limit)) + ->merge($this->topCategoryCandidates($artwork, $viewer, $categoryIds, $limit)) + ->merge($this->topTagCandidates($artwork, $viewer, $tagIds, $limit)) + ->reject(fn (object $row): bool => (int) $row->id === (int) $artwork->id) + ->unique(fn (object $row): string => (string) $row->id) + ->sortByDesc(fn (object $row): mixed => $row->published_at) + ->take($limit) + ->values(); + + $ids = $candidateRows->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + if ($ids === []) { + return new EloquentCollection; + } + + $hydrated = Artwork::query() + ->with(self::RELATIONS) + ->whereIn('id', $ids) + ->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer)) + ->get() + ->keyBy('id'); + + return new EloquentCollection(collect($ids) + ->map(fn (int $id): ?Artwork => $hydrated->get($id)) + ->filter() + ->values() + ->all()); + } + + private function matchingPivotRows(Collection $categoryIds): int + { + return (int) DB::table('artwork_category') + ->whereIn('category_id', $categoryIds->all()) + ->count(); + } + + private function candidateQuery(Artwork $artwork, ?User $viewer): Builder + { + return Artwork::query() + ->select(['artworks.id', 'artworks.published_at']) + ->whereKeyNot($artwork->id) + ->public() + ->published() + ->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer)); + } + + private function topAuthorCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection + { + return $this->candidateQuery($artwork, $viewer) + ->where('user_id', $artwork->user_id) + ->latest('published_at') + ->limit($limit) + ->get(); + } + + private function topGroupCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection + { + if (! $artwork->group_id) { + return collect(); + } + + return $this->candidateQuery($artwork, $viewer) + ->where('group_id', $artwork->group_id) + ->latest('published_at') + ->limit($limit) + ->get(); + } + + private function topCategoryCandidates(Artwork $artwork, ?User $viewer, Collection $categoryIds, int $limit): Collection + { + return $this->candidateQuery($artwork, $viewer) + ->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id') + ->whereIn('artwork_category.category_id', $categoryIds->all()) + ->select(['artworks.id', 'artworks.published_at']) + ->distinct() + ->orderByDesc('artworks.published_at') + ->limit($limit) + ->get(); + } + + private function topTagCandidates(Artwork $artwork, ?User $viewer, Collection $tagIds, int $limit): Collection + { + if ($tagIds->isEmpty()) { + return collect(); + } + + return $this->candidateQuery($artwork, $viewer) + ->whereHas('tags', fn ($query) => $query->whereIn('tags.id', $tagIds->all())) + ->latest('published_at') + ->limit($limit) + ->get(); + } + + private function originalQuery(Artwork $artwork, ?User $viewer, Collection $categoryIds, Collection $tagIds, int $limit): Builder + { + return Artwork::query() + ->with(self::RELATIONS) + ->whereKeyNot($artwork->id) + ->public() + ->published() + ->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer)) + ->where(function ($query) use ($artwork, $categoryIds, $tagIds): void { + $query->where('user_id', $artwork->user_id); + + if ($artwork->group_id) { + $query->orWhere('group_id', $artwork->group_id); + } + + if ($categoryIds->isNotEmpty()) { + $query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void { + $categoryQuery->whereIn('categories.id', $categoryIds->all()); + }); + } + + if ($tagIds->isNotEmpty()) { + $query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void { + $tagQuery->whereIn('tags.id', $tagIds->all()); + }); + } + }) + ->latest('published_at') + ->limit($limit); + } +} diff --git a/tests/Feature/ArtworkRelatedServiceTest.php b/tests/Feature/ArtworkRelatedServiceTest.php new file mode 100644 index 00000000..136bea4a --- /dev/null +++ b/tests/Feature/ArtworkRelatedServiceTest.php @@ -0,0 +1,115 @@ +create([ + 'name' => 'Related '.$suffix, + 'slug' => $suffix.'-type', + 'description' => '', + ]); + + return Category::query()->create([ + 'content_type_id' => $type->id, + 'name' => 'Related '.$suffix, + 'slug' => $suffix, + 'description' => '', + 'is_active' => true, + 'sort_order' => 1, + ]); +} + +function relatedArtwork(User $user, string $slug, int $minutes): Artwork +{ + return Artwork::factory()->for($user)->create([ + 'slug' => $slug, + 'title' => $slug, + 'published_at' => now()->subMinutes($minutes), + ]); +} + +it('uses the split path for rare categories, deduplicates, orders, excludes source, and loads page relations', function (): void { + $author = User::factory()->create(); + $category = relatedCategory('rare'); + $source = relatedArtwork($author, 'source', 100); + $source->categories()->attach($category); + + $sameAuthor = relatedArtwork($author, 'same-author', 5); + $sameAuthor->categories()->attach($category); + $sameCategory = relatedArtwork(User::factory()->create(), 'same-category', 2); + $sameCategory->categories()->attach($category); + + $queries = []; + DB::listen(function ($query) use (&$queries): void { + $queries[] = $query->sql; + }); + + $results = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null); + + expect($results->pluck('id')->all())->toBe([$sameCategory->id, $sameAuthor->id]) + ->and($results->first()->relationLoaded('user'))->toBeTrue() + ->and($results->first()->relationLoaded('group'))->toBeTrue() + ->and($results->first()->relationLoaded('categories'))->toBeTrue() + ->and($results->first()->categories->first()->relationLoaded('contentType'))->toBeTrue() + ->and(collect($queries)->contains(fn (string $sql): bool => str_contains(strtolower($sql), 'join') && str_contains(strtolower($sql), 'artwork_category')))->toBeTrue() + ->and($results->pluck('id'))->not->toContain($source->id); +}); + +it('keeps the original OR/EXISTS path when there are no categories', function (): void { + $author = User::factory()->create(); + $source = relatedArtwork($author, 'source-without-category', 100); + $related = relatedArtwork($author, 'author-without-category', 1); + + DB::enableQueryLog(); + $results = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null); + $sql = collect(DB::getQueryLog())->pluck('query')->implode("\n"); + + expect($results->pluck('id')->all())->toContain($related->id) + ->and(strtolower($sql))->toContain('select'); +}); + +it('applies the same maturity filter to split candidates for guests and viewers', function (): void { + config()->set('maturity.viewer.guest_mode', ArtworkMaturityService::VIEW_HIDE); + config()->set('maturity.viewer.default_mode', ArtworkMaturityService::VIEW_HIDE); + + $author = User::factory()->create(); + $category = relatedCategory('maturity'); + $source = relatedArtwork($author, 'maturity-source', 100); + $source->categories()->attach($category); + $safe = relatedArtwork(User::factory()->create(), 'safe-related', 2); + $safe->categories()->attach($category); + $mature = relatedArtwork(User::factory()->create(), 'mature-related', 1); + $mature->forceFill(['is_mature' => true])->save(); + $mature->categories()->attach($category); + + $guestIds = app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null)->pluck('id'); + expect($guestIds)->toContain($safe->id)->not->toContain($mature->id); +}); + +it('retains the current query above the 1000 matching pivot-row threshold', function (): void { + $author = User::factory()->create(); + $category = relatedCategory('threshold'); + $source = relatedArtwork($author, 'threshold-source', 2000); + $source->categories()->attach($category); + + $rows = Artwork::factory()->count(1000)->create()->each(function (Artwork $artwork) use ($category): void { + $artwork->categories()->attach($category); + }); + + DB::enableQueryLog(); + app(ArtworkRelatedService::class)->related($source->fresh(['categories', 'tags']), null); + $sql = collect(DB::getQueryLog())->pluck('query')->implode("\n"); + + expect($rows)->toHaveCount(1000) + ->and(strtolower($sql))->toContain('exists (select'); +});