Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

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