Files
SkinbaseNova/tests/Feature/Artworks/ArtworkCategoryClassificationTest.php
klevze 6b00652a20 Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00

285 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\Artworks\ArtworkCategoryService;
use App\Services\ArtworkService;
use App\Services\Studio\StudioAiAssistService;
use App\Services\Studio\StudioAiCategoryMapper;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Inertia\Testing\AssertableInertia as Assert;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
function snCatContentType(string $name = 'Wallpapers'): ContentType
{
return ContentType::query()->create([
'name' => $name,
'slug' => strtolower($name).'-'.uniqid(),
'description' => $name,
]);
}
function snCatCategory(ContentType $type, string $name, ?Category $parent = null): Category
{
return Category::query()->create([
'content_type_id' => $type->id,
'parent_id' => $parent?->id,
'name' => $name,
'slug' => strtolower($name).'-'.uniqid(),
'description' => $name,
'is_active' => true,
'sort_order' => 0,
]);
}
it('syncs a deterministic primary category into the pivot and rejects invalid combinations', function (): void {
$user = User::factory()->create();
$wallpapers = snCatContentType('Wallpapers');
$photography = snCatContentType('Photography');
$cityscape = snCatCategory($wallpapers, 'Cityscape');
$architecture = snCatCategory($wallpapers, 'Architecture');
$travel = snCatCategory($wallpapers, 'Travel');
$night = snCatCategory($wallpapers, 'Night');
$weather = snCatCategory($wallpapers, 'Weather');
$interior = snCatCategory($wallpapers, 'Interior');
$abstract = snCatCategory($wallpapers, 'Abstract');
$landscape = snCatCategory($photography, 'Landscape');
$artwork = Artwork::factory()->for($user)->create();
$service = app(ArtworkCategoryService::class);
$service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $cityscape->id], 'user', false);
$artwork->refresh()->load('categories');
expect($artwork->primary_category_id)->toBe($cityscape->id)
->and($artwork->categories->pluck('id')->sort()->values()->all())
->toEqualCanonicalizing([$cityscape->id, $architecture->id, $travel->id])
->and($artwork->resolvedPrimaryCategory()?->id)->toBe($cityscape->id);
$service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $night->id, $weather->id, $interior->id], 'user', false);
expect($artwork->fresh()->categories()->count())->toBe(6);
expect(fn () => $service->sync($artwork, $cityscape->id, [$architecture->id, $travel->id, $night->id, $weather->id, $interior->id, $abstract->id], 'user', false))
->toThrow(ValidationException::class);
expect(fn () => $service->sync($artwork, $cityscape->id, [$landscape->id], 'user', false))
->toThrow(ValidationException::class);
$artwork->refresh();
expect($artwork->primary_category_id)->toBe($cityscape->id)
->and($artwork->categories()->count())->toBe(6);
});
it('rolls back invalid category writes and keeps the previous membership', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$primary = snCatCategory($type, 'Primary');
$secondary = snCatCategory($type, 'Secondary');
$otherType = snCatContentType('Photography');
$invalid = snCatCategory($otherType, 'Invalid');
$artwork = Artwork::factory()->for($user)->create();
$service = app(ArtworkCategoryService::class);
$service->sync($artwork, $primary->id, [$secondary->id], 'user', false);
try {
$service->sync($artwork, $primary->id, [$invalid->id], 'user', false);
expect(false)->toBeTrue();
} catch (ValidationException) {
// expected
}
$artwork->refresh()->load('categories');
expect($artwork->primary_category_id)->toBe($primary->id)
->and($artwork->categories->pluck('id')->all())->toEqualCanonicalizing([$primary->id, $secondary->id]);
});
it('promotes a secondary category to primary without dropping the previous primary', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$cityscape = snCatCategory($type, 'Cityscape');
$architecture = snCatCategory($type, 'Architecture');
$artwork = Artwork::factory()->for($user)->create();
$service = app(ArtworkCategoryService::class);
$service->sync($artwork, $cityscape->id, [$architecture->id], 'user', false);
$service->sync($artwork, $architecture->id, [$cityscape->id], 'user', false);
$artwork->refresh();
expect($artwork->primary_category_id)->toBe($architecture->id)
->and($artwork->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$architecture->id, $cityscape->id]);
});
it('exposes primary category compatibly on the artwork API and category pages', function (): void {
$user = User::factory()->create();
$type = snCatContentType('Wallpapers');
$cityscape = snCatCategory($type, 'Cityscape');
$architecture = snCatCategory($type, 'Architecture');
$artwork = Artwork::factory()->for($user)->create([
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
'slug' => 'morning-coffee',
'title' => 'Morning Coffee',
]);
app(ArtworkCategoryService::class)->sync($artwork, $cityscape->id, [$architecture->id], 'user', false);
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('ArtworkPage')
->where('artwork.canonical_url', route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
->where('artwork.category.id', $cityscape->id)
->where('artwork.primary_category.id', $cityscape->id)
->where('artwork.categories.0.is_primary', true)
->where('seo.json_ld', function ($schemas): bool {
$json = json_encode($schemas, JSON_UNESCAPED_SLASHES);
return str_contains($json, '"@type":"BreadcrumbList"')
&& str_contains($json, '"name":"Cityscape"')
&& ! str_contains($json, '"name":"Architecture"');
}));
$cityPage = app(ArtworkService::class)->getArtworksByCategoryPath([$type->slug, $cityscape->slug], 24);
$archPage = app(ArtworkService::class)->getArtworksByCategoryPath([$type->slug, $architecture->slug], 24);
expect($cityPage->pluck('id')->all())->toContain($artwork->id)
->and($archPage->pluck('id')->all())->toContain($artwork->id)
->and($cityPage->pluck('id')->filter(fn ($id) => (int) $id === (int) $artwork->id)->count())->toBe(1);
});
it('finalizes upload staging through the central category service', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$primary = snCatCategory($type, 'Cityscape');
$secondary = snCatCategory($type, 'Architecture');
$this->actingAs($user)
->postJson('/api/artworks', [
'title' => 'Staged artwork',
'category' => $primary->id,
'secondary_category_ids' => [$secondary->id],
'description' => 'A valid description for the draft.',
])
->assertCreated();
$artwork = Artwork::query()->latest('id')->first();
expect($artwork?->primary_category_id)->toBe($primary->id)
->and($artwork?->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$primary->id, $secondary->id]);
});
it('updates studio artwork categories with an explicit primary and secondary contract', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$primary = snCatCategory($type, 'Cityscape');
$secondary = snCatCategory($type, 'Architecture');
$promoted = snCatCategory($type, 'Travel');
$artwork = Artwork::factory()->for($user)->create([
'user_id' => $user->id,
'is_public' => false,
'visibility' => Artwork::VISIBILITY_PRIVATE,
]);
app(ArtworkCategoryService::class)->sync($artwork, $primary->id, [$secondary->id], 'user', false);
$this->actingAs($user)
->putJson('/api/studio/artworks/'.$artwork->id, [
'primary_category_id' => $promoted->id,
'secondary_category_ids' => [$primary->id],
])
->assertOk()
->assertJsonPath('artwork.primary_category_id', $promoted->id)
->assertJsonPath('artwork.category_id', $promoted->id);
$artwork->refresh();
expect($artwork->primary_category_id)->toBe($promoted->id)
->and($artwork->categories()->pluck('categories.id')->all())->toEqualCanonicalizing([$promoted->id, $primary->id]);
});
it('does not let nova overwrite a confirmed primary unless the editor applies a category', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$confirmed = snCatCategory($type, 'Cityscape');
$suggested = snCatCategory($type, 'Architecture');
$artwork = Artwork::factory()->for($user)->create();
app(ArtworkCategoryService::class)->sync($artwork, $confirmed->id, [], 'manual', false);
$mapper = app(StudioAiCategoryMapper::class);
$payload = $mapper->map(['architecture', 'building'], $confirmed);
expect(count($payload['category']['secondary_categories'] ?? []))->toBeLessThanOrEqual((int) config('categories.max_secondary_categories', 5));
if (! empty($payload['category']['content_type_id'])) {
expect($payload['category']['content_type_id'])->toBe($type->id);
}
app(StudioAiAssistService::class)->applySuggestions($artwork->fresh(['tags', 'categories.contentType']), [
'title' => 'Keep my category',
]);
expect($artwork->fresh()->primary_category_id)->toBe($confirmed->id);
app(StudioAiAssistService::class)->applySuggestions($artwork->fresh(['tags', 'categories.contentType']), [
'category_id' => $suggested->id,
'secondary_category_ids' => [$confirmed->id],
]);
expect($artwork->fresh()->primary_category_id)->toBe($suggested->id);
});
it('backfills only unique memberships and stays idempotent', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$one = snCatCategory($type, 'One');
$two = snCatCategory($type, 'Two');
$single = Artwork::factory()->for($user)->create();
$conflict = Artwork::factory()->for($user)->create();
$empty = Artwork::factory()->for($user)->create();
$single->categories()->sync([$one->id]);
$conflict->categories()->sync([$one->id, $two->id]);
artisan('artworks:backfill-primary-category', ['--report' => true])
->assertSuccessful()
->expectsOutputToContain('Dry run only');
expect($single->fresh()->primary_category_id)->toBeNull()
->and($conflict->fresh()->primary_category_id)->toBeNull();
artisan('artworks:backfill-primary-category', ['--apply' => true, '--report' => true])
->assertSuccessful();
expect($single->fresh()->primary_category_id)->toBe($one->id)
->and($conflict->fresh()->primary_category_id)->toBeNull()
->and($empty->fresh()->primary_category_id)->toBeNull();
$single->forceFill(['primary_category_id' => $two->id])->save();
artisan('artworks:backfill-primary-category', ['--apply' => true])
->assertSuccessful();
expect($single->fresh()->primary_category_id)->toBe($two->id);
});
it('clears the primary category reference when a category is deleted and cascades artwork deletion', function (): void {
$user = User::factory()->create();
$type = snCatContentType();
$category = snCatCategory($type, 'Temporary');
$artwork = Artwork::factory()->for($user)->create();
app(ArtworkCategoryService::class)->sync($artwork, $category->id, [], 'user', false);
$category->forceDelete();
expect($artwork->fresh()->primary_category_id)->toBeNull()
->and(DB::table('artwork_category')->where('artwork_id', $artwork->id)->count())->toBe(0);
$other = snCatCategory($type, 'Kept');
app(ArtworkCategoryService::class)->sync($artwork->fresh(), $other->id, [], 'user', false);
$artwork->forceDelete();
expect(DB::table('artwork_category')->where('artwork_id', $artwork->id)->count())->toBe(0);
});