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.
This commit is contained in:
@@ -13,7 +13,6 @@ function createSearchDocumentContentType(string $name, int $sortOrder = 0): int
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -48,14 +47,17 @@ it('indexes all attached categories and content types while preserving a primary
|
||||
$mattePaintingId = createSearchDocumentCategory($digitalArtId, 'Matte Painting', 20);
|
||||
|
||||
$artwork->categories()->sync([$mattePaintingId, $fantasyId]);
|
||||
$artwork->forceFill(['primary_category_id' => $fantasyId])->save();
|
||||
|
||||
$payload = $artwork->fresh(['categories.contentType'])->toSearchableArray();
|
||||
$payload = $artwork->fresh(['categories.contentType', 'primaryCategory.contentType'])->toSearchableArray();
|
||||
$fantasySlug = (string) DB::table('categories')->where('id', $fantasyId)->value('slug');
|
||||
$mattePaintingSlug = (string) DB::table('categories')->where('id', $mattePaintingId)->value('slug');
|
||||
$wallpapersSlug = (string) DB::table('content_types')->where('id', $wallpapersId)->value('slug');
|
||||
$digitalArtSlug = (string) DB::table('content_types')->where('id', $digitalArtId)->value('slug');
|
||||
|
||||
expect($payload['category'])->toBe($fantasySlug)
|
||||
->and($payload['primary_category_id'])->toBe($fantasyId)
|
||||
->and($payload['category_ids'])->toEqualCanonicalizing([$fantasyId, $mattePaintingId])
|
||||
->and($payload['content_type'])->toBe($wallpapersSlug)
|
||||
->and($payload['categories'])->toBe([$fantasySlug, $mattePaintingSlug])
|
||||
->and($payload['content_types'])->toBe([$wallpapersSlug, $digitalArtSlug]);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<?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);
|
||||
});
|
||||
Reference in New Issue
Block a user