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.
285 lines
12 KiB
PHP
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);
|
|
});
|