Save workspace changes
This commit is contained in:
49
tests/Feature/Studio/ScheduledArtworkPublicationTest.php
Normal file
49
tests/Feature/Studio/ScheduledArtworkPublicationTest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
it('publishes overdue scheduled artwork when opening the studio edit page', function (): void {
|
||||
Carbon::setTestNow('2026-04-16 08:00:00');
|
||||
|
||||
try {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->private()->create([
|
||||
'user_id' => $user->id,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_approved' => true,
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'scheduled',
|
||||
'publish_at' => now()->subHour(),
|
||||
'published_at' => null,
|
||||
'artwork_timezone' => 'Europe/Ljubljana',
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
get('/studio/artworks/' . $artwork->id . '/edit')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioArtworkEdit')
|
||||
->where('artwork.artwork_status', 'published')
|
||||
->where('artwork.publish_mode', 'now')
|
||||
->where('artwork.publish_at', null)
|
||||
->where('artwork.is_public', true));
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
expect($artwork->artwork_status)->toBe('published')
|
||||
->and($artwork->is_public)->toBeTrue()
|
||||
->and($artwork->publish_at)->toBeNull()
|
||||
->and($artwork->artwork_timezone)->toBeNull()
|
||||
->and($artwork->published_at)->not->toBeNull();
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
});
|
||||
@@ -107,8 +107,8 @@ it('can analyze artwork ai suggestions directly without queueing', function ():
|
||||
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
|
||||
->assertJsonPath('data.debug.request.hash', 'syncaa112233')
|
||||
->assertJsonPath('data.debug.request.intent', 'title')
|
||||
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/artworks/md/sy/nc/syncaa112233.webp')
|
||||
->assertJsonPath('data.debug.vision_debug.calls.0.service', 'gateway_all');
|
||||
|
||||
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
|
||||
@@ -127,6 +127,124 @@ it('can analyze artwork ai suggestions directly without queueing', function ():
|
||||
expect($completedEvent?->meta['intent'] ?? null)->toBe('title');
|
||||
});
|
||||
|
||||
it('accepts a together provider override for direct studio ai analysis', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('vision.together.base_url', 'https://api.together.xyz');
|
||||
config()->set('vision.together.endpoint', '/v1/chat/completions');
|
||||
config()->set('vision.together.model', 'meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo');
|
||||
config()->set('vision.together.api_key', 'together-test-key');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'content_type_id' => $photography->id,
|
||||
'name' => 'Flowers',
|
||||
'slug' => 'flowers',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'togaa112233',
|
||||
'file_name' => 'rose-closeup.jpg',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.local/analyze/all' => Http::response([
|
||||
'clip' => [
|
||||
['tag' => 'rose', 'confidence' => 0.96],
|
||||
['tag' => 'flower', 'confidence' => 0.91],
|
||||
],
|
||||
'yolo' => [
|
||||
['label' => 'flower', 'confidence' => 0.79],
|
||||
],
|
||||
'blip' => 'a close up photograph of a rose bud with soft natural background',
|
||||
], 200),
|
||||
'https://api.together.xyz/v1/chat/completions' => Http::response([
|
||||
'choices' => [
|
||||
[
|
||||
'message' => [
|
||||
'content' => json_encode([
|
||||
'rose macro',
|
||||
'flower close-up',
|
||||
'soft petals',
|
||||
'natural light',
|
||||
'botanical photography',
|
||||
'pink tones',
|
||||
'shallow depth',
|
||||
'floral detail',
|
||||
'macro photography',
|
||||
'garden bloom',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
|
||||
'direct' => true,
|
||||
'provider' => 'together',
|
||||
'intent' => 'tags',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('direct', true)
|
||||
->assertJsonPath('data.debug.request.provider', 'together')
|
||||
->assertJsonPath('data.debug.tag_generation.provider', 'together')
|
||||
->assertJsonPath('data.debug.tag_generation.endpoint', 'https://api.together.xyz/v1/chat/completions');
|
||||
|
||||
Http::assertSent(function (\Illuminate\Http\Client\Request $request): bool {
|
||||
return $request->url() === 'https://api.together.xyz/v1/chat/completions'
|
||||
&& $request->hasHeader('Authorization', 'Bearer together-test-key');
|
||||
});
|
||||
|
||||
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
|
||||
});
|
||||
|
||||
it('passes a provider override into queued studio ai analysis', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'hash' => 'queueprov1122',
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
|
||||
'provider' => 'together',
|
||||
'intent' => 'tags',
|
||||
])
|
||||
->assertStatus(202)
|
||||
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
|
||||
|
||||
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
|
||||
$property = new \ReflectionProperty($job, 'provider');
|
||||
$property->setAccessible(true);
|
||||
|
||||
return $property->getValue($job) === 'together';
|
||||
});
|
||||
|
||||
$requestedEvent = ArtworkAiAssistEvent::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('event_type', 'analysis_requested')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($requestedEvent)->not->toBeNull();
|
||||
expect($requestedEvent?->meta['provider'] ?? null)->toBe('together');
|
||||
});
|
||||
|
||||
it('persists upload-style visibility options from studio save', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
@@ -269,6 +387,8 @@ it('can analyze artwork directly when exact and vector similar matches are both
|
||||
it('builds and exposes normalized studio ai suggestions', function (): void {
|
||||
config()->set('vision.enabled', true);
|
||||
config()->set('vision.gateway.base_url', 'https://vision.local');
|
||||
config()->set('vision.lm_studio.base_url', 'https://lmstudio.local');
|
||||
config()->set('vision.lm_studio.model', 'google/gemma-3-4b');
|
||||
config()->set('cdn.files_url', 'https://files.local');
|
||||
|
||||
$photography = ContentType::query()->create([
|
||||
@@ -302,6 +422,26 @@ it('builds and exposes normalized studio ai suggestions', function (): void {
|
||||
],
|
||||
'blip' => 'a close up photograph of a rose bud with soft natural background',
|
||||
], 200),
|
||||
'https://lmstudio.local/v1/chat/completions' => Http::response([
|
||||
'choices' => [
|
||||
[
|
||||
'message' => [
|
||||
'content' => json_encode([
|
||||
'rose macro',
|
||||
'flower close-up',
|
||||
'soft petals',
|
||||
'natural light',
|
||||
'botanical photography',
|
||||
'pink tones',
|
||||
'shallow depth',
|
||||
'floral detail',
|
||||
'macro photography',
|
||||
'garden bloom',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
|
||||
@@ -314,9 +454,10 @@ it('builds and exposes normalized studio ai suggestions', function (): void {
|
||||
->assertJsonPath('data.mode', 'artwork')
|
||||
->assertJsonPath('data.content_type.value', 'photography')
|
||||
->assertJsonPath('data.category.value', 'flowers')
|
||||
->assertJsonPath('data.debug.tag_generation.raw_content', '["rose macro","flower close-up","soft petals","natural light","botanical photography","pink tones","shallow depth","floral detail","macro photography","garden bloom"]')
|
||||
->assertJson(fn ($json) => $json
|
||||
->has('data.title_suggestions', 5)
|
||||
->where('data.tag_suggestions.0.tag', 'rose')
|
||||
->where('data.tag_suggestions', fn ($tags): bool => collect($tags)->contains(fn (array $row): bool => ($row['tag'] ?? null) === 'rose-macro'))
|
||||
->has('data.description_suggestions', 3));
|
||||
});
|
||||
|
||||
|
||||
407
tests/Feature/Studio/StudioWorldPagesTest.php
Normal file
407
tests/Feature/Studio/StudioWorldPagesTest.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Group;
|
||||
use App\Models\World;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
function studioWorld(array $attributes = []): World
|
||||
{
|
||||
$creator = $attributes['creator'] ?? User::factory()->create([
|
||||
'username' => 'worldbuilder',
|
||||
'name' => 'World Builder',
|
||||
]);
|
||||
|
||||
unset($attributes['creator']);
|
||||
|
||||
return World::query()->create(array_merge([
|
||||
'title' => 'Halloween World 2026',
|
||||
'slug' => 'halloween-world-2026',
|
||||
'tagline' => 'Night drives, haunted pixels, and autumn launches.',
|
||||
'summary' => 'A curated seasonal destination for Halloween programming.',
|
||||
'description' => 'World description',
|
||||
'theme_key' => 'halloween',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_SEASONAL,
|
||||
'is_featured' => true,
|
||||
'created_by_user_id' => $creator->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('forbids world studio pages for non moderators', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('studio.worlds.index'))
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('studio.worlds.create'))
|
||||
->assertRedirect(route('worlds.index'));
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/worlds/create')
|
||||
->assertRedirect(route('worlds.index'));
|
||||
});
|
||||
|
||||
it('sends guests from the public worlds create shortcut to login', function (): void {
|
||||
$this->get('/worlds/create')
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('renders world studio pages for moderators', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'modworlds',
|
||||
'name' => 'Moderator Worlds',
|
||||
]);
|
||||
$world = studioWorld([
|
||||
'creator' => $moderator,
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'published_at' => Carbon::parse('2026-10-01 10:00:00'),
|
||||
'starts_at' => Carbon::parse('2026-10-15 00:00:00'),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.worlds.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldsIndex')
|
||||
->where('title', 'Worlds')
|
||||
->where('listing.items.0.title', 'Halloween World 2026')
|
||||
->where('createUrl', route('studio.worlds.create')));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get('/worlds/create')
|
||||
->assertRedirect(route('studio.worlds.create'));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.worlds.create'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldEditor')
|
||||
->where('title', 'Create world')
|
||||
->has('themeOptions')
|
||||
->has('sectionOptions')
|
||||
->has('relationTypeOptions')
|
||||
->where('mediaSupport.picker_available', false));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.worlds.edit', ['world' => $world->id]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldEditor')
|
||||
->where('world.title', 'Halloween World 2026')
|
||||
->where('world.slug', 'halloween-world-2026')
|
||||
->where('world.section_visibility_json.featured_artworks', true)
|
||||
->where('duplicateActions.canCreateEdition', false));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.worlds.preview', ['world' => $world->id]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('previewMode', true)
|
||||
->where('world.title', 'Halloween World 2026'));
|
||||
});
|
||||
|
||||
it('renders world studio pages for legacy admin accounts', function (): void {
|
||||
$admin = User::factory()->create([
|
||||
'role' => 'user',
|
||||
'username' => 'legacyadminworlds',
|
||||
'name' => 'Legacy Admin Worlds',
|
||||
]);
|
||||
|
||||
DB::table('users')
|
||||
->where('id', $admin->id)
|
||||
->update(['isAdmin' => 1]);
|
||||
|
||||
$admin->refresh();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('studio.worlds.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldsIndex')
|
||||
->where('title', 'Worlds')
|
||||
->where('createUrl', route('studio.worlds.create')));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('studio.worlds.create'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldEditor')
|
||||
->where('title', 'Create world'));
|
||||
});
|
||||
|
||||
it('searches artwork relations by creator and project context in the worlds picker', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'searchworldsmod',
|
||||
'name' => 'Search Worlds Moderator',
|
||||
]);
|
||||
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'springartist',
|
||||
'name' => 'Spring Artist',
|
||||
]);
|
||||
|
||||
$group = Group::factory()->create([
|
||||
'name' => 'Spring Project',
|
||||
'slug' => 'spring-project',
|
||||
]);
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Pixel Art',
|
||||
'slug' => 'pixel-art',
|
||||
'description' => 'Pixel art content type',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'name' => 'Seasonal Spring',
|
||||
'slug' => 'seasonal-spring',
|
||||
'description' => 'Spring showcase',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Morning Dew',
|
||||
'slug' => 'morning-dew',
|
||||
'description' => 'A calm scene with no direct spring keyword in the artwork copy.',
|
||||
'artwork_status' => 'published',
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson(route('studio.worlds.entity-search', ['type' => 'artwork', 'q' => 'spring']))
|
||||
->assertOk()
|
||||
->assertJsonPath('items.0.id', $artwork->id)
|
||||
->assertJsonPath('items.0.title', 'Morning Dew')
|
||||
->assertJsonPath('items.0.subtitle', 'Spring Artist');
|
||||
});
|
||||
|
||||
it('stores a world draft through the studio flow', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'editorworlds',
|
||||
'name' => 'Editor Worlds',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($moderator)->post(route('studio.worlds.store'), [
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026',
|
||||
'tagline' => 'Scanlines, diskmag culture, and old-school launches.',
|
||||
'summary' => 'A recurring world for retro platform activity.',
|
||||
'description' => 'World body copy',
|
||||
'theme_key' => 'retro-month',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_CAMPAIGN,
|
||||
'is_featured' => true,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'recurrence_rule' => 'annual:04',
|
||||
'edition_year' => 2026,
|
||||
'cta_label' => 'Explore Retro Month',
|
||||
'cta_url' => 'https://skinbase.test/worlds/retro-month-2026',
|
||||
'badge_label' => 'Editorial pick',
|
||||
'badge_description' => 'Featured by the Nova editorial team.',
|
||||
'badge_url' => 'https://skinbase.test/badges/retro',
|
||||
'seo_title' => 'Retro Month 2026 - Skinbase Nova',
|
||||
'seo_description' => 'Retro Month seasonal campaign',
|
||||
'published_at' => '2026-03-20T10:00',
|
||||
'related_tags_json' => ['retro', 'demoscene'],
|
||||
'section_order_json' => ['featured_artworks', 'featured_collections', 'news'],
|
||||
'section_visibility_json' => [
|
||||
'featured_artworks' => true,
|
||||
'featured_collections' => true,
|
||||
'featured_creators' => false,
|
||||
'featured_groups' => false,
|
||||
'news' => true,
|
||||
'challenge' => false,
|
||||
'events' => false,
|
||||
'releases' => false,
|
||||
'cards' => false,
|
||||
],
|
||||
'relations' => [],
|
||||
]);
|
||||
|
||||
$world = World::query()->where('slug', 'retro-month-2026')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('studio.worlds.edit', ['world' => $world->id]));
|
||||
|
||||
$this->assertDatabaseHas('worlds', [
|
||||
'id' => $world->id,
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_CAMPAIGN,
|
||||
'is_featured' => true,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2026,
|
||||
'published_at' => '2026-03-20 10:00:00',
|
||||
'created_by_user_id' => $moderator->id,
|
||||
]);
|
||||
|
||||
expect($world->fresh()->section_visibility_json)->toMatchArray([
|
||||
'featured_artworks' => true,
|
||||
'featured_collections' => true,
|
||||
'featured_creators' => false,
|
||||
'news' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects reserved world slugs in the studio flow', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'reservedslugmod',
|
||||
'name' => 'Reserved Slug Moderator',
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.create'))
|
||||
->post(route('studio.worlds.store'), [
|
||||
'title' => 'Create',
|
||||
'slug' => 'create',
|
||||
'summary' => 'Reserved slug attempt',
|
||||
'description' => 'Should fail validation',
|
||||
'theme_key' => 'retro-month',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_CAMPAIGN,
|
||||
'relations' => [],
|
||||
])
|
||||
->assertRedirect(route('studio.worlds.create'))
|
||||
->assertSessionHasErrors(['slug']);
|
||||
});
|
||||
|
||||
it('requires recurrence metadata and blocks duplicate recurrence editions', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'recurrencemod',
|
||||
'name' => 'Recurrence Moderator',
|
||||
]);
|
||||
|
||||
studioWorld([
|
||||
'creator' => $moderator,
|
||||
'title' => 'Halloween World 2026',
|
||||
'slug' => 'halloween-world-2026-existing',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'halloween',
|
||||
'edition_year' => 2026,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.create'))
|
||||
->post(route('studio.worlds.store'), [
|
||||
'title' => 'Recurring World Without Metadata',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_CAMPAIGN,
|
||||
'is_recurring' => true,
|
||||
'relations' => [],
|
||||
])
|
||||
->assertRedirect(route('studio.worlds.create'))
|
||||
->assertSessionHasErrors(['recurrence_key', 'edition_year']);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.create'))
|
||||
->post(route('studio.worlds.store'), [
|
||||
'title' => 'Halloween World Clone',
|
||||
'slug' => 'halloween-world-clone',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_SEASONAL,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'halloween',
|
||||
'edition_year' => 2026,
|
||||
'relations' => [],
|
||||
])
|
||||
->assertRedirect(route('studio.worlds.create'))
|
||||
->assertSessionHasErrors(['edition_year']);
|
||||
});
|
||||
|
||||
it('duplicates worlds and preserves editorial structure in a new draft', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'duplicateworldmod',
|
||||
'name' => 'Duplicate World Moderator',
|
||||
]);
|
||||
|
||||
$world = studioWorld([
|
||||
'creator' => $moderator,
|
||||
'title' => 'Pixel Week 2026',
|
||||
'slug' => 'pixel-week-2026',
|
||||
'theme_key' => 'pixel-week',
|
||||
'section_visibility_json' => [
|
||||
'featured_artworks' => true,
|
||||
'featured_collections' => false,
|
||||
'events' => true,
|
||||
],
|
||||
'starts_at' => Carbon::parse('2026-07-01 09:00:00'),
|
||||
'published_at' => Carbon::parse('2026-06-28 18:00:00'),
|
||||
]);
|
||||
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'featured_creators',
|
||||
'related_type' => 'user',
|
||||
'related_id' => $moderator->id,
|
||||
'context_label' => 'Lead pixel artist',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($moderator)->post(route('studio.worlds.duplicate', ['world' => $world->id]));
|
||||
|
||||
$duplicate = World::query()->where('slug', 'like', 'pixel-week-2026-copy%')->latest('id')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('studio.worlds.edit', ['world' => $duplicate->id]));
|
||||
|
||||
expect($duplicate->status)->toBe(World::STATUS_DRAFT);
|
||||
expect($duplicate->is_featured)->toBeFalse();
|
||||
expect($duplicate->starts_at)->toBeNull();
|
||||
expect($duplicate->published_at)->toBeNull();
|
||||
expect($duplicate->section_visibility_json)->toMatchArray([
|
||||
'featured_artworks' => true,
|
||||
'featured_collections' => false,
|
||||
'events' => true,
|
||||
]);
|
||||
expect($duplicate->worldRelations()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('creates the next edition draft for recurring worlds', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'editionworldmod',
|
||||
'name' => 'Edition World Moderator',
|
||||
]);
|
||||
|
||||
$world = studioWorld([
|
||||
'creator' => $moderator,
|
||||
'title' => 'Halloween 2026',
|
||||
'slug' => 'halloween-2026',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'halloween',
|
||||
'edition_year' => 2026,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($moderator)->post(route('studio.worlds.new-edition', ['world' => $world->id]));
|
||||
|
||||
$edition = World::query()->where('recurrence_key', 'halloween')->where('edition_year', 2027)->latest('id')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('studio.worlds.edit', ['world' => $edition->id]));
|
||||
expect($edition->parent_world_id)->toBe($world->id);
|
||||
expect($edition->status)->toBe(World::STATUS_DRAFT);
|
||||
expect($edition->is_recurring)->toBeTrue();
|
||||
expect($edition->slug)->toContain('2027');
|
||||
});
|
||||
Reference in New Issue
Block a user