1360 lines
53 KiB
PHP
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',
|
|
'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,
|
|
]);
|
|
}); |