Files
SkinbaseNova/tests/Feature/Studio/StudioWorldPagesTest.php
2026-04-25 08:36:03 +02:00

1360 lines
53 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use App\Models\Category;
use App\Models\Collection;
use App\Models\ContentType;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\GroupChallengeOutcome;
use App\Models\Tag;
use App\Models\World;
use App\Models\WorldAnalyticsEvent;
use App\Models\WorldEditorialSuggestionState;
use App\Models\WorldRelation;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Services\Worlds\WorldAnalyticsService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
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));
}
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' => '<p>Draft recap intro.</p>',
'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 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('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' => '<p>Archived intro</p>',
'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,
]);
});