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); });