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.
This commit is contained in:
2026-09-20 14:49:32 +02:00
parent 1053a38eee
commit a5e51617a6
3 changed files with 449 additions and 122 deletions
+115
View File
@@ -0,0 +1,115 @@
<?php
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\ArtworkRelatedService;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function relatedCategory(string $suffix = 'related-service'): Category
{
$type = ContentType::query()->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');
});