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)); } function studioWorldNewsCategory(array $attributes = []): NewsCategory { return NewsCategory::query()->create(array_merge([ 'name' => 'Studio World Updates', 'slug' => 'studio-world-updates-' . Str::lower(Str::random(6)), 'description' => 'Editorial context for studio-managed worlds.', 'position' => 0, 'is_active' => true, ], $attributes)); } function studioPublishedWorldNews(User $author, NewsCategory $category, array $attributes = []): NewsArticle { return NewsArticle::query()->create(array_merge([ 'title' => 'Studio recap story', 'slug' => 'studio-recap-story-' . Str::lower(Str::random(6)), 'excerpt' => 'A published recap story linked from the world editor.', 'content' => "# Studio recap story\n\nLinked from the world editor.", 'author_id' => $author->id, 'category_id' => $category->id, 'type' => NewsArticle::TYPE_ANNOUNCEMENT, 'status' => 'published', 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, 'published_at' => now()->subHour(), 'is_featured' => true, 'is_pinned' => false, ], $attributes)); } function studioSuggestionArtwork(User $creator, Tag $tag, array $attributes = [], array $stats = []): Artwork { $title = $attributes['title'] ?? ('Retro Artwork ' . Str::title(Str::lower(Str::random(4)))); $artwork = Artwork::factory()->for($creator)->create(array_merge([ 'title' => $title, 'slug' => Str::slug($title) . '-' . Str::lower(Str::random(6)), 'description' => 'Retro neon artwork for world suggestion scoring.', 'artwork_status' => 'published', 'published_at' => now()->subDay(), 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, ], $attributes)); $artwork->tags()->attach($tag->id, ['source' => 'user']); ArtworkStats::query()->create(array_merge([ 'artwork_id' => $artwork->id, 'views' => 320, 'downloads' => 14, 'favorites' => 42, 'rating_avg' => 4.8, 'rating_count' => 18, 'comments_count' => 6, 'shares_count' => 4, ], $stats)); return $artwork->fresh(['tags', 'stats', 'user.profile']); } function studioWorldSuggestionFixture(User $moderator): array { $tag = Tag::factory()->create([ 'name' => 'Retro', 'slug' => 'retro', ]); $communityCreator = User::factory()->create([ 'username' => 'retrocaptain', 'name' => 'Retro Captain', 'nova_featured_creator' => true, ]); $artworkCreator = User::factory()->create([ 'username' => 'neondrifter', 'name' => 'Neon Drifter', ]); $previousWorld = studioWorld([ 'creator' => $moderator, 'title' => 'Retro Month 2025', 'slug' => 'retro-month-2025', 'summary' => 'An archived edition with strong retro signals.', 'status' => World::STATUS_ARCHIVED, 'published_at' => now()->subDays(45), 'starts_at' => now()->subDays(40), 'ends_at' => now()->subDays(30), 'is_recurring' => true, 'recurrence_key' => 'retro-month', 'edition_year' => 2025, 'related_tags_json' => ['retro', 'neon'], ]); $previousWorld->worldRelations()->create([ 'section_key' => 'featured_creators', 'related_type' => WorldRelation::TYPE_USER, 'related_id' => $communityCreator->id, 'sort_order' => 0, 'is_featured' => true, ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Retro Month 2026', 'slug' => 'retro-month-2026', 'summary' => 'Editors are curating retro neon stories, creators, and challenge highlights.', 'description' => 'A recurring world focused on retro, neon, and synth aesthetics.', 'status' => World::STATUS_PUBLISHED, 'published_at' => now()->subDays(3), 'starts_at' => now()->subDay(), 'ends_at' => now()->addDays(8), 'accepts_submissions' => true, 'community_section_enabled' => true, 'is_recurring' => true, 'recurrence_key' => 'retro-month', 'edition_year' => 2026, 'related_tags_json' => ['retro', 'neon'], ]); $communityArtwork = studioSuggestionArtwork($communityCreator, $tag, [ 'title' => 'Retro Skyline Community Entry', 'description' => 'A retro neon skyline submitted to the community showcase.', 'published_at' => now()->subHours(12), ], [ 'views' => 620, 'favorites' => 88, 'comments_count' => 11, 'shares_count' => 9, ]); WorldSubmission::query()->create([ 'world_id' => $world->id, 'artwork_id' => $communityArtwork->id, 'submitted_by_user_id' => $communityCreator->id, 'status' => WorldSubmission::STATUS_LIVE, 'is_featured' => true, 'featured_at' => now()->subHours(10), 'created_at' => now()->subHours(16), 'updated_at' => now()->subHours(10), ]); $challengeGroup = Group::factory()->create([ 'owner_user_id' => $communityCreator->id, 'name' => 'Retro Signal Crew', 'slug' => 'retro-signal-crew-' . Str::lower(Str::random(6)), 'headline' => 'Retro and synth challenge specialists.', 'bio' => 'A public group behind the linked retro challenge.', 'followers_count' => 240, 'artworks_count' => 12, 'collections_count' => 4, 'is_verified' => true, ]); $linkedChallenge = GroupChallenge::query()->create([ 'group_id' => $challengeGroup->id, 'title' => 'Retro Signal Finals', 'slug' => 'retro-signal-finals-' . Str::lower(Str::random(6)), 'summary' => 'Linked challenge for retro-world finalists.', 'description' => 'Editors can highlight finalists from this challenge.', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, 'status' => GroupChallenge::STATUS_ENDED, 'start_at' => now()->subDays(6), 'end_at' => now()->subDays(1), 'created_by_user_id' => $moderator->id, ]); $world->forceFill(['linked_challenge_id' => $linkedChallenge->id])->save(); $challengeArtwork = studioSuggestionArtwork($communityCreator, $tag, [ 'title' => 'Retro Circuit Finalist', 'description' => 'A finalist artwork from the linked retro challenge.', 'published_at' => now()->subDays(2), ], [ 'views' => 540, 'favorites' => 73, 'comments_count' => 8, 'shares_count' => 6, ]); $linkedChallenge->artworks()->attach($challengeArtwork->id, [ 'submitted_by_user_id' => $communityCreator->id, 'sort_order' => 0, ]); GroupChallengeOutcome::query()->create([ 'group_challenge_id' => $linkedChallenge->id, 'artwork_id' => $challengeArtwork->id, 'user_id' => $communityCreator->id, 'outcome_type' => GroupChallengeOutcome::TYPE_FINALIST, 'position' => 1, 'sort_order' => 0, 'awarded_by_user_id' => $moderator->id, 'awarded_at' => now()->subHours(18), ]); $artworkSuggestion = studioSuggestionArtwork($artworkCreator, $tag, [ 'title' => 'Neon Drift Poster', 'description' => 'A retro neon poster aligned with the world brief.', 'published_at' => now()->subDays(3), ], [ 'views' => 410, 'favorites' => 57, 'comments_count' => 5, 'shares_count' => 5, ]); $collection = Collection::factory()->create([ 'user_id' => $communityCreator->id, 'title' => 'Retro Signal Collection', 'slug' => 'retro-signal-collection-' . Str::lower(Str::random(6)), 'summary' => 'A curated collection of retro signal artwork.', 'description' => 'Strong collection engagement around retro and neon work.', 'views_count' => 520, 'likes_count' => 140, 'followers_count' => 86, 'saves_count' => 33, 'is_featured' => true, 'published_at' => now()->subDays(2), 'featured_at' => now()->subDay(), ]); $category = studioWorldNewsCategory([ 'name' => 'Retro World Updates', ]); $article = studioPublishedWorldNews($moderator, $category, [ 'title' => 'Retro Month results roundup', 'slug' => 'retro-month-results-' . Str::lower(Str::random(6)), 'excerpt' => 'Recap and results for the latest retro month challenge and showcase.', 'content' => '# Retro Month results roundup\n\nRetro month recap with challenge finalists and community highlights.', 'published_at' => now()->subHours(8), ]); return [ 'world' => $world->fresh(['worldRelations', 'linkedChallenge']), 'previous_world' => $previousWorld, 'community_creator' => $communityCreator, 'artwork_creator' => $artworkCreator, 'community_artwork' => $communityArtwork, 'challenge_artwork' => $challengeArtwork, 'artwork_suggestion' => $artworkSuggestion, 'collection' => $collection, 'group' => $challengeGroup, 'challenge' => $linkedChallenge, 'article' => $article, ]; } 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('analytics.default_range', '30d') ->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) ->where('duplicateActions.duplicateModeOptions.0.value', 'structure_only')); $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('surfaces recap workflow data in the studio editor and preview payload', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'recapstudiomod', 'name' => 'Recap Studio Moderator', ]); $category = studioWorldNewsCategory(); $article = studioPublishedWorldNews($moderator, $category, [ 'title' => 'Halloween World 2025 recap story', ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Halloween World 2025', 'slug' => 'halloween-world-2025', 'status' => World::STATUS_ARCHIVED, 'starts_at' => now()->subDays(20), 'ends_at' => now()->subDays(3), 'published_at' => now()->subDays(25), 'recap_status' => World::RECAP_STATUS_DRAFT, 'recap_title' => 'Halloween World 2025 recap', 'recap_summary' => 'Draft summary for the archived edition.', 'recap_intro' => '
Draft recap intro.
', 'recap_editor_note' => 'Internal recap note for archive cleanup.', 'recap_cover_path' => 'worlds/recaps/halloween-world-2025-cover.jpg', 'recap_article_id' => $article->id, ]); $this->actingAs($moderator) ->get(route('studio.worlds.edit', ['world' => $world->id])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioWorldEditor') ->where('world.recap_status', 'draft') ->where('world.recap_status_label', 'Draft recap') ->where('world.recap_title', 'Halloween World 2025 recap') ->where('world.recap_editor_note', 'Internal recap note for archive cleanup.') ->where('world.recap_cover_path', 'worlds/recaps/halloween-world-2025-cover.jpg') ->where('world.recap_cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/halloween-world-2025-cover.jpg') ->where('world.recap_article.title', 'Halloween World 2025 recap story') ->where('publishRecapUrl', route('studio.worlds.recap.publish', ['world' => $world]))); $this->actingAs($moderator) ->get(route('studio.worlds.preview', ['world' => $world->id])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('World/WorldShow') ->where('previewMode', true) ->where('recap.status', 'draft_preview') ->where('recap.title', 'Halloween World 2025 recap') ->where('recap.cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/halloween-world-2025-cover.jpg') ->where('recap.article.title', 'Halloween World 2025 recap story')); }); it('publishes recap snapshots for ended worlds in studio', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'publishrecapmod', 'name' => 'Publish Recap Moderator', ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Retro Month 2025', 'slug' => 'retro-month-2025', 'status' => World::STATUS_ARCHIVED, 'is_active_campaign' => true, 'is_homepage_featured' => true, 'starts_at' => now()->subDays(35), 'ends_at' => now()->subDays(7), 'published_at' => now()->subDays(40), 'accepts_submissions' => true, 'community_section_enabled' => true, ]); $artwork = Artwork::factory()->for($moderator)->create([ 'title' => 'Retro Month Winner', 'slug' => 'retro-month-winner-' . Str::lower(Str::random(6)), 'artwork_status' => 'published', 'published_at' => now()->subDay(), 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, ]); $submission = WorldSubmission::query()->create([ 'world_id' => $world->id, 'artwork_id' => $artwork->id, 'submitted_by_user_id' => $moderator->id, 'status' => WorldSubmission::STATUS_LIVE, 'is_featured' => true, 'featured_at' => now()->subDay(), 'created_at' => now()->subDay(), 'updated_at' => now()->subDay(), ]); WorldRewardGrant::query()->create([ 'user_id' => $moderator->id, 'world_id' => $world->id, 'world_submission_id' => $submission->id, 'artwork_id' => $artwork->id, 'reward_type' => 'winner', 'grant_source' => 'manual', 'granted_at' => now()->subHours(8), ]); $this->actingAs($moderator) ->from(route('studio.worlds.edit', ['world' => $world->id])) ->post(route('studio.worlds.recap.publish', ['world' => $world])) ->assertRedirect(route('studio.worlds.edit', ['world' => $world->id])) ->assertSessionHas('success', 'World recap published.'); $world->refresh(); expect($world->recap_status)->toBe(World::RECAP_STATUS_PUBLISHED); expect($world->recap_published_at)->not->toBeNull(); expect($world->recap_stats_snapshot_json)->toBeArray(); expect($world->is_active_campaign)->toBeFalse(); expect($world->is_homepage_featured)->toBeFalse(); expect(data_get($world->recap_stats_snapshot_json, 'summary.live_participations'))->toBeGreaterThanOrEqual(1); expect(data_get($world->recap_stats_snapshot_json, 'summary.reward_grants'))->toBeGreaterThanOrEqual(1); }); it('includes portfolio analytics leaderboards on the studio worlds index', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'portfolioanalyticsmod', 'name' => 'Portfolio Analytics Moderator', ]); $artwork = Artwork::factory()->for($moderator)->create([ 'title' => 'Portfolio Artwork', 'slug' => 'portfolio-artwork', 'artwork_status' => 'published', 'published_at' => now()->subDay(), 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Portfolio World', 'slug' => 'portfolio-world', 'status' => World::STATUS_PUBLISHED, 'published_at' => now()->subDays(5), ]); WorldAnalyticsEvent::query()->create([ 'world_id' => $world->id, 'world_slug' => $world->slug, 'world_type' => $world->type, 'recurrence_key' => $world->recurrence_key, 'edition_year' => $world->edition_year, 'event_type' => 'world_source_impression', 'section_key' => 'spotlight', 'source_surface' => 'homepage_spotlight', 'source_detail' => 'primary', 'viewer_type' => 'guest', 'visitor_key' => hash('sha256', 'visitor:portfolio-impression'), 'occurred_at' => now()->subHours(3), ]); WorldAnalyticsEvent::query()->create([ 'world_id' => $world->id, 'world_slug' => $world->slug, 'world_type' => $world->type, 'recurrence_key' => $world->recurrence_key, 'edition_year' => $world->edition_year, 'event_type' => 'world_viewed', 'source_surface' => 'homepage_spotlight', 'source_detail' => 'primary', 'viewer_type' => 'guest', 'visitor_key' => hash('sha256', 'visitor:portfolio-viewer'), 'occurred_at' => now()->subHours(2), ]); WorldAnalyticsEvent::query()->create([ 'world_id' => $world->id, 'world_slug' => $world->slug, 'world_type' => $world->type, 'recurrence_key' => $world->recurrence_key, 'edition_year' => $world->edition_year, 'event_type' => 'world_source_clicked', 'source_surface' => 'homepage_spotlight', 'source_detail' => 'primary', 'viewer_type' => 'guest', 'visitor_key' => hash('sha256', 'visitor:portfolio-click'), 'occurred_at' => now()->subHours(2), ]); WorldSubmission::query()->create([ 'world_id' => $world->id, 'artwork_id' => $artwork->id, 'submitted_by_user_id' => $moderator->id, 'status' => WorldSubmission::STATUS_LIVE, 'created_at' => now()->subHour(), 'updated_at' => now()->subHour(), ]); WorldRewardGrant::query()->create([ 'user_id' => $moderator->id, 'world_id' => $world->id, 'artwork_id' => $artwork->id, 'reward_type' => 'featured', 'grant_source' => 'manual', 'granted_at' => now()->subMinutes(30), ]); $this->actingAs($moderator) ->get(route('studio.worlds.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioWorldsIndex') ->where('analytics.ranges.30d.summary.tracked_worlds', 1) ->where('analytics.ranges.30d.summary.promotion_impressions', 1) ->where('analytics.ranges.30d.leaderboards.views.0.world_id', $world->id) ->where('analytics.ranges.30d.leaderboards.submissions.0.world_id', $world->id) ->where('analytics.ranges.30d.leaderboards.conversion.0.world_id', $world->id)); }); 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', '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('stores hidden linked challenge entries and exposes them back in the editor payload', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'challengeoverridemod', 'name' => 'Challenge Override Moderator', ]); $groupOwner = User::factory()->create([ 'username' => 'challengeoverrideowner', ]); $group = Group::factory()->for($groupOwner, 'owner')->create(); $hiddenEntry = Artwork::factory()->for($groupOwner)->create([ 'group_id' => $group->id, 'title' => 'Hide Me', 'slug' => 'hide-me', 'artwork_status' => 'published', 'published_at' => now()->subDay(), 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, ]); $visibleEntry = Artwork::factory()->for($groupOwner)->create([ 'group_id' => $group->id, 'title' => 'Keep Me Live', 'slug' => 'keep-me-live', 'artwork_status' => 'published', 'published_at' => now()->subDay(), 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, ]); $challenge = GroupChallenge::query()->create([ 'group_id' => $group->id, 'title' => 'Studio Override Challenge', 'slug' => 'studio-override-challenge', 'summary' => 'Challenge summary', 'description' => 'Challenge description', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, 'status' => GroupChallenge::STATUS_ACTIVE, 'start_at' => now()->subDay(), 'end_at' => now()->addDays(2), 'created_by_user_id' => $groupOwner->id, ]); $challenge->artworks()->attach($hiddenEntry->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]); $challenge->artworks()->attach($visibleEntry->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 1]); $response = $this->actingAs($moderator)->post(route('studio.worlds.store'), [ 'title' => 'Challenge Override World', 'slug' => 'challenge-override-world', 'summary' => 'World summary', 'description' => 'World description', 'status' => World::STATUS_DRAFT, 'type' => World::TYPE_CAMPAIGN, 'linked_challenge_id' => $challenge->id, 'hidden_linked_challenge_artwork_ids_json' => [$hiddenEntry->id], 'relations' => [], ]); $world = World::query()->where('slug', 'challenge-override-world')->firstOrFail(); $response->assertRedirect(route('studio.worlds.edit', ['world' => $world->id])); expect($world->fresh()->hidden_linked_challenge_artwork_ids_json)->toBe([$hiddenEntry->id]); $this->actingAs($moderator) ->get(route('studio.worlds.edit', ['world' => $world->id])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioWorldEditor') ->where('world.hidden_linked_challenge_artwork_ids_json.0', $hiddenEntry->id) ->where('world.linked_challenge.entry_preview_items.0.title', 'Hide Me') ->where('world.linked_challenge.entry_preview_items.1.title', 'Keep Me Live')); }); it('rejects archived and time-mismatched linked challenges for published worlds', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'linkedchallengevalidationmod', 'name' => 'Linked Challenge Validation Moderator', ]); $groupOwner = User::factory()->create([ 'username' => 'linkedchallengevalidationowner', ]); $group = Group::factory()->for($groupOwner, 'owner')->create(); $archivedChallenge = GroupChallenge::query()->create([ 'group_id' => $group->id, 'title' => 'Archived Challenge', 'slug' => 'archived-challenge', 'summary' => 'Archived summary', 'description' => 'Archived description', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, 'status' => GroupChallenge::STATUS_ARCHIVED, 'start_at' => now()->subDays(10), 'end_at' => now()->subDays(5), 'created_by_user_id' => $groupOwner->id, ]); $this->actingAs($moderator) ->from(route('studio.worlds.create')) ->post(route('studio.worlds.store'), [ 'title' => 'Live World With Archived Challenge', 'slug' => 'live-world-with-archived-challenge', 'summary' => 'World summary', 'description' => 'World description', 'status' => World::STATUS_PUBLISHED, 'type' => World::TYPE_CAMPAIGN, 'starts_at' => now()->subDay()->format('Y-m-d H:i:s'), 'ends_at' => now()->addDays(5)->format('Y-m-d H:i:s'), 'linked_challenge_id' => $archivedChallenge->id, 'relations' => [], ]) ->assertRedirect(route('studio.worlds.create')) ->assertSessionHasErrors(['linked_challenge_id']); $activeMismatchChallenge = GroupChallenge::query()->create([ 'group_id' => $group->id, 'title' => 'Mismatched Challenge', 'slug' => 'mismatched-challenge', 'summary' => 'Mismatch summary', 'description' => 'Mismatch description', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, 'status' => GroupChallenge::STATUS_ACTIVE, 'start_at' => now()->addDays(30), 'end_at' => now()->addDays(40), 'created_by_user_id' => $groupOwner->id, ]); $this->actingAs($moderator) ->from(route('studio.worlds.create')) ->post(route('studio.worlds.store'), [ 'title' => 'Live World With Mismatched Challenge', 'slug' => 'live-world-with-mismatched-challenge', 'summary' => 'World summary', 'description' => 'World description', 'status' => World::STATUS_PUBLISHED, 'type' => World::TYPE_CAMPAIGN, 'starts_at' => now()->subDay()->format('Y-m-d H:i:s'), 'ends_at' => now()->addDays(5)->format('Y-m-d H:i:s'), 'linked_challenge_id' => $activeMismatchChallenge->id, 'relations' => [], ]) ->assertRedirect(route('studio.worlds.create')) ->assertSessionHasErrors(['linked_challenge_id']); }); 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']); studioWorld([ 'creator' => $moderator, 'title' => 'Halloween World Current', 'slug' => 'halloween-world-current', 'status' => World::STATUS_PUBLISHED, 'is_recurring' => true, 'recurrence_key' => 'halloween', 'edition_year' => 2027, 'starts_at' => now()->subDay(), 'ends_at' => now()->addDays(5), 'published_at' => now()->subDays(2), ]); $this->actingAs($moderator) ->from(route('studio.worlds.create')) ->post(route('studio.worlds.store'), [ 'title' => 'Halloween World Duplicate Current', 'slug' => 'halloween-world-duplicate-current', 'status' => World::STATUS_PUBLISHED, 'type' => World::TYPE_SEASONAL, 'is_recurring' => true, 'recurrence_key' => 'halloween', 'edition_year' => 2028, 'starts_at' => now()->subHour(), 'ends_at' => now()->addDays(7), 'relations' => [], ]) ->assertRedirect(route('studio.worlds.create')) ->assertSessionHasErrors(['status']); }); 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'), 'recap_status' => World::RECAP_STATUS_PUBLISHED, 'recap_title' => 'Pixel Week 2026 recap', 'recap_summary' => 'Archived summary', 'recap_intro' => 'Archived intro
', 'recap_editor_note' => 'Reset this note on duplicate.', 'recap_cover_path' => 'worlds/recaps/pixel-week-2026-cover.jpg', 'recap_published_at' => Carbon::parse('2026-07-11 10: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->recap_status)->toBe(World::RECAP_STATUS_DRAFT); expect($duplicate->recap_title)->toBeNull(); expect($duplicate->recap_editor_note)->toBeNull(); expect($duplicate->recap_cover_path)->toBeNull(); expect($duplicate->recap_published_at)->toBeNull(); expect($duplicate->section_visibility_json)->toMatchArray([ 'featured_artworks' => true, 'featured_collections' => false, 'events' => true, ]); expect($duplicate->worldRelations()->count())->toBe(1); }); it('can duplicate a world as a structural shell without copying curated relations', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'structuralworldmod', 'name' => 'Structural World Moderator', ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Spring Vibes 2026', 'slug' => 'spring-vibes-2026', 'is_recurring' => true, 'recurrence_key' => 'spring-vibes', 'edition_year' => 2026, ]); $world->worldRelations()->create([ 'section_key' => 'featured_creators', 'related_type' => 'user', 'related_id' => $moderator->id, 'context_label' => 'Spring lead', 'sort_order' => 0, 'is_featured' => true, ]); $response = $this->actingAs($moderator)->post(route('studio.worlds.duplicate', ['world' => $world->id]), [ 'copy_mode' => 'structure_only', ]); $duplicate = World::query()->where('slug', 'like', 'spring-vibes-2026-copy%')->latest('id')->firstOrFail(); $response->assertRedirect(route('studio.worlds.edit', ['world' => $duplicate->id])); expect($duplicate->is_recurring)->toBeFalse(); expect($duplicate->recurrence_key)->toBeNull(); expect($duplicate->edition_year)->toBeNull(); expect($duplicate->worldRelations()->count())->toBe(0); }); 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, 'submission_starts_at' => Carbon::parse('2026-09-15 00:00:00'), 'submission_ends_at' => Carbon::parse('2026-10-30 00:00:00'), 'cta_url' => 'https://skinbase.test/worlds/halloween-2026', 'badge_url' => 'https://skinbase.test/badges/halloween-2026', 'recap_status' => World::RECAP_STATUS_PUBLISHED, 'recap_title' => 'Halloween 2026 recap', 'recap_editor_note' => 'Clear this note for the next edition.', 'recap_cover_path' => 'worlds/recaps/halloween-2026-cover.jpg', 'recap_published_at' => Carbon::parse('2026-11-01 12:00:00'), ]); $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'); expect($edition->submission_starts_at)->toBeNull(); expect($edition->submission_ends_at)->toBeNull(); expect($edition->cta_url)->toBeNull(); expect($edition->badge_url)->toBeNull(); expect($edition->recap_status)->toBe(World::RECAP_STATUS_DRAFT); expect($edition->recap_title)->toBeNull(); expect($edition->recap_editor_note)->toBeNull(); expect($edition->recap_cover_path)->toBeNull(); expect($edition->recap_published_at)->toBeNull(); }); it('shows recurring family context in the studio editor for recurring worlds', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'familycontextmod', 'name' => 'Family Context Moderator', ]); studioWorld([ 'creator' => $moderator, 'title' => 'Pixel Week 2025', 'slug' => 'pixel-week-2025', 'status' => World::STATUS_ARCHIVED, 'is_recurring' => true, 'recurrence_key' => 'pixel-week', 'edition_year' => 2025, 'starts_at' => Carbon::parse('2025-07-01 00:00:00'), 'ends_at' => Carbon::parse('2025-07-10 00:00:00'), 'published_at' => Carbon::parse('2025-06-28 18:00:00'), ]); $world = studioWorld([ 'creator' => $moderator, 'title' => 'Pixel Week 2026', 'slug' => 'pixel-week-2026', 'status' => World::STATUS_PUBLISHED, 'is_recurring' => true, 'recurrence_key' => 'pixel-week', 'edition_year' => 2026, 'starts_at' => now()->subDay(), 'ends_at' => now()->addDays(7), 'published_at' => now()->subDays(2), ]); $this->actingAs($moderator) ->get(route('studio.worlds.edit', ['world' => $world->id])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioWorldEditor') ->where('world.family_title', 'Pixel Week') ->where('world.is_canonical_edition', true) ->where('world.family_edition_count', 2) ->where('world.previous_edition.title', 'Pixel Week 2025')); }); it('surfaces editorial suggestions across the expected world content categories', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'suggestionsmod', 'name' => 'Suggestions Moderator', ]); $fixture = studioWorldSuggestionFixture($moderator); $world = $fixture['world']; $communityArtwork = $fixture['community_artwork']; foreach (range(1, 4) as $index) { WorldAnalyticsEvent::query()->create([ 'world_id' => $world->id, 'event_type' => WorldAnalyticsService::EVENT_ENTITY_CLICKED, 'world_slug' => $world->slug, 'world_type' => $world->type, 'recurrence_key' => $world->recurrence_key, 'edition_year' => $world->edition_year, 'section_key' => 'community_submissions', 'entity_type' => WorldRelation::TYPE_ARTWORK, 'entity_id' => $communityArtwork->id, 'entity_title' => $communityArtwork->title, 'viewer_type' => 'user', 'user_id' => $moderator->id, 'visitor_key' => hash('sha256', 'suggestion-analytics-' . $index), 'occurred_at' => now()->subMinutes($index), ]); } $response = $this->actingAs($moderator) ->get(route('studio.worlds.edit', ['world' => $world->id])) ->assertOk(); $suggestions = data_get($response->viewData('page'), 'props.suggestions'); $groups = collect((array) data_get($suggestions, 'groups'))->keyBy('key'); expect(data_get($suggestions, 'enabled'))->toBeTrue(); expect(data_get($suggestions, 'summary.has_linked_challenge'))->toBeTrue(); expect(data_get($suggestions, 'summary.world_is_recurring'))->toBeTrue(); expect(data_get($suggestions, 'summary.family_signal_count'))->toBeGreaterThanOrEqual(1); expect(data_get($suggestions, 'summary.community_submission_count'))->toBe(1); expect(data_get($suggestions, 'summary.analytics_signal_count'))->toBeGreaterThanOrEqual(1); expect(collect((array) data_get($suggestions, 'filters.sort_options'))->pluck('value')->all()) ->toContain('relevance', 'newest', 'performance'); expect(data_get($groups->get('challenge'), 'items.0.title'))->toBe('Retro Circuit Finalist'); expect(collect((array) data_get($groups->get('challenge'), 'items.0.reasons'))->pluck('label')->all()) ->toContain('Challenge finalist'); expect(data_get($groups->get('community'), 'items.0.title'))->toBe('Retro Skyline Community Entry'); expect(collect((array) data_get($groups->get('community'), 'items.0.reasons'))->pluck('label')->all()) ->toContain('Already a featured community submission', 'Top-clicked in this world'); expect(data_get($groups->get('community'), 'items.0.signals.analytics_informed'))->toBeTrue(); expect(data_get($groups->get('artworks'), 'items.0.title'))->toBe('Neon Drift Poster'); expect(collect((array) data_get($groups->get('artworks'), 'items.0.reasons'))->pluck('label')->all()) ->toContain('Matches world tags'); expect(collect((array) data_get($groups->get('creators'), 'items'))->contains(function (array $item): bool { return (int) ($item['id'] ?? 0) > 0 && (string) ($item['title'] ?? '') === 'Retro Captain' && collect((array) ($item['reasons'] ?? []))->pluck('label')->contains('Strong in this world family'); }))->toBeTrue(); expect(data_get($groups->get('collections'), 'items.0.title'))->toBe('Retro Signal Collection'); expect(data_get($groups->get('groups'), 'items.0.title'))->toBe('Retro Signal Crew'); expect(data_get($groups->get('news'), 'items.0.title'))->toBe('Retro Month results roundup'); }); it('stores suggestion feedback state and converts suggestions into world relations', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', 'username' => 'suggestionactionsmod', 'name' => 'Suggestion Actions Moderator', ]); $fixture = studioWorldSuggestionFixture($moderator); $world = $fixture['world']; $artwork = $fixture['artwork_suggestion']; $collection = $fixture['collection']; $group = $fixture['group']; $this->actingAs($moderator) ->postJson(route('studio.worlds.suggestions.pin', ['world' => $world->id]), [ 'related_type' => WorldRelation::TYPE_ARTWORK, 'related_id' => $artwork->id, 'section_key' => 'featured_artworks', ]) ->assertOk() ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_PINNED) ->assertJsonPath('state.section_key', 'featured_artworks'); $this->assertDatabaseHas('world_editorial_suggestion_states', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_ARTWORK, 'related_id' => $artwork->id, 'status' => WorldEditorialSuggestionState::STATUS_PINNED, 'section_key' => 'featured_artworks', ]); $this->actingAs($moderator) ->postJson(route('studio.worlds.suggestions.dismiss', ['world' => $world->id]), [ 'related_type' => WorldRelation::TYPE_COLLECTION, 'related_id' => $collection->id, ]) ->assertOk() ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_DISMISSED); $this->assertDatabaseHas('world_editorial_suggestion_states', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_COLLECTION, 'related_id' => $collection->id, 'status' => WorldEditorialSuggestionState::STATUS_DISMISSED, ]); $this->actingAs($moderator) ->postJson(route('studio.worlds.suggestions.not-relevant', ['world' => $world->id]), [ 'related_type' => WorldRelation::TYPE_GROUP, 'related_id' => $group->id, ]) ->assertOk() ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_NOT_RELEVANT); $this->assertDatabaseHas('world_editorial_suggestion_states', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_GROUP, 'related_id' => $group->id, 'status' => WorldEditorialSuggestionState::STATUS_NOT_RELEVANT, ]); $response = $this->actingAs($moderator) ->get(route('studio.worlds.edit', ['world' => $world->id])) ->assertOk(); $suggestions = data_get($response->viewData('page'), 'props.suggestions'); expect(data_get($suggestions, 'summary.pinned_count'))->toBe(1); expect(data_get($suggestions, 'summary.suppressed_count'))->toBe(2); expect(collect((array) data_get($suggestions, 'pinned_items'))->pluck('key')->all()) ->toContain('artwork:' . $artwork->id); $this->actingAs($moderator) ->postJson(route('studio.worlds.suggestions.restore', ['world' => $world->id]), [ 'related_type' => WorldRelation::TYPE_COLLECTION, 'related_id' => $collection->id, ]) ->assertOk(); $this->assertDatabaseMissing('world_editorial_suggestion_states', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_COLLECTION, 'related_id' => $collection->id, ]); $this->actingAs($moderator) ->postJson(route('studio.worlds.suggestions.add', ['world' => $world->id]), [ 'related_type' => WorldRelation::TYPE_ARTWORK, 'related_id' => $artwork->id, 'section_key' => 'featured_artworks', 'is_featured' => true, ]) ->assertOk() ->assertJsonPath('already_attached', false) ->assertJsonPath('relation.related_type', WorldRelation::TYPE_ARTWORK) ->assertJsonPath('relation.related_id', $artwork->id) ->assertJsonPath('relation.section_key', 'featured_artworks') ->assertJsonPath('relation.is_featured', true); $this->assertDatabaseHas('world_relations', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_ARTWORK, 'related_id' => $artwork->id, 'section_key' => 'featured_artworks', 'is_featured' => 1, ]); $this->assertDatabaseMissing('world_editorial_suggestion_states', [ 'world_id' => $world->id, 'related_type' => WorldRelation::TYPE_ARTWORK, 'related_id' => $artwork->id, ]); });