891 lines
36 KiB
PHP
891 lines
36 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Models\World;
|
|
use App\Models\WorldRewardGrant;
|
|
use App\Models\WorldSubmission;
|
|
use App\Models\Artwork;
|
|
use App\Models\Group;
|
|
use App\Models\GroupChallenge;
|
|
use App\Services\HomepageService;
|
|
use App\Services\Worlds\WorldService;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
use cPad\Plugins\News\Models\NewsArticle;
|
|
use cPad\Plugins\News\Models\NewsCategory;
|
|
|
|
function publicWorld(array $attributes = []): World
|
|
{
|
|
$creator = $attributes['creator'] ?? User::factory()->create([
|
|
'username' => 'publicworlds-' . Str::lower(Str::random(6)),
|
|
'name' => 'Public Worlds',
|
|
]);
|
|
|
|
unset($attributes['creator']);
|
|
|
|
return World::query()->create(array_merge([
|
|
'title' => 'Summer Slam 2026',
|
|
'slug' => 'summer-slam-2026',
|
|
'tagline' => 'Sunlit publishing and warm-color campaigns.',
|
|
'summary' => 'A bright world for summer culture across the platform.',
|
|
'teaser_title' => 'Now live: Summer Slam',
|
|
'teaser_summary' => 'A bright world for summer culture across the platform.',
|
|
'description' => 'Public world description',
|
|
'theme_key' => 'summer',
|
|
'status' => World::STATUS_PUBLISHED,
|
|
'type' => World::TYPE_SEASONAL,
|
|
'is_featured' => true,
|
|
'is_active_campaign' => true,
|
|
'is_homepage_featured' => true,
|
|
'campaign_priority' => 250,
|
|
'campaign_label' => 'Seasonal spotlight',
|
|
'starts_at' => Carbon::now()->subDays(2),
|
|
'ends_at' => Carbon::now()->addDays(14),
|
|
'promotion_starts_at' => Carbon::now()->subDay(),
|
|
'promotion_ends_at' => Carbon::now()->addDays(7),
|
|
'published_at' => Carbon::now()->subDays(10),
|
|
'created_by_user_id' => $creator->id,
|
|
], $attributes));
|
|
}
|
|
|
|
function worldNewsCategory(array $attributes = []): NewsCategory
|
|
{
|
|
return NewsCategory::query()->create(array_merge([
|
|
'name' => 'World Updates',
|
|
'slug' => 'world-updates-' . Str::lower(Str::random(6)),
|
|
'description' => 'Editorial context for worlds and linked campaigns.',
|
|
'position' => 0,
|
|
'is_active' => true,
|
|
], $attributes));
|
|
}
|
|
|
|
function publishedWorldNews(User $author, NewsCategory $category, array $attributes = []): NewsArticle
|
|
{
|
|
return NewsArticle::query()->create(array_merge([
|
|
'title' => 'World challenge update',
|
|
'slug' => 'world-challenge-update-' . Str::lower(Str::random(6)),
|
|
'excerpt' => 'An editorial update for the linked world challenge.',
|
|
'content' => "# World challenge update\n\nEditorial context for the linked challenge.",
|
|
'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));
|
|
}
|
|
|
|
it('renders public worlds index and detail pages', function (): void {
|
|
$world = publicWorld();
|
|
|
|
$this->get(route('worlds.index'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldIndex')
|
|
->where('spotlightWorld.title', 'Summer Slam 2026')
|
|
->where('spotlightWorld.campaign_state_label', 'Live now')
|
|
->has('activeWorlds'));
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Summer Slam 2026')
|
|
->where('world.slug', 'summer-slam-2026'));
|
|
});
|
|
|
|
it('returns a relative latest world navigation link regardless of request host', function (): void {
|
|
Cache::forget('worlds.navigation_campaign');
|
|
|
|
$world = publicWorld([
|
|
'title' => 'Hello Again',
|
|
'slug' => 'hello-again',
|
|
'campaign_priority' => 999,
|
|
]);
|
|
|
|
$this->get('https://fooyd0.skinbase.org/worlds')
|
|
->assertOk();
|
|
|
|
$campaign = app(WorldService::class)->navigationCampaign();
|
|
|
|
expect($campaign)->not->toBeNull()
|
|
->and($campaign['title'])->toBe('Hello Again')
|
|
->and($campaign['url'])->toBe('/worlds/hello-again');
|
|
});
|
|
|
|
it('includes rewarded contributors on public world pages', function (): void {
|
|
$creator = User::factory()->create([
|
|
'username' => 'rewardedcreator-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$world = publicWorld();
|
|
$artwork = \App\Models\Artwork::factory()->for($creator)->create([
|
|
'title' => 'World Winner Artwork',
|
|
'slug' => 'world-winner-artwork',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => \App\Models\Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
WorldRewardGrant::query()->create([
|
|
'user_id' => $creator->id,
|
|
'world_id' => $world->id,
|
|
'artwork_id' => $artwork->id,
|
|
'reward_type' => 'winner',
|
|
'grant_source' => 'manual',
|
|
'granted_at' => now()->subHour(),
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('rewardedContributors.count', 1)
|
|
->where('rewardedContributors.creator_count', 1)
|
|
->where('rewardedContributors.counts.winner', 1)
|
|
->where('rewardedContributors.items.0.badge_label', $world->title . ' Winner'));
|
|
});
|
|
|
|
it('renders recap payloads for ended worlds with published recaps', function (): void {
|
|
$creator = User::factory()->create([
|
|
'username' => 'recapcreator-' . Str::lower(Str::random(6)),
|
|
'name' => 'Recap Creator',
|
|
]);
|
|
$world = publicWorld([
|
|
'creator' => $creator,
|
|
'title' => 'Summer Slam 2025',
|
|
'slug' => 'summer-slam-2025',
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'accepts_submissions' => true,
|
|
'community_section_enabled' => true,
|
|
'starts_at' => now()->subDays(30),
|
|
'ends_at' => now()->subDays(5),
|
|
'recap_status' => World::RECAP_STATUS_PUBLISHED,
|
|
'recap_title' => 'Summer Slam 2025 recap',
|
|
'recap_summary' => 'A tighter archive-facing summary for the edition.',
|
|
'recap_intro' => '<p>The edition closed with standout artworks, community participation, and a published editorial recap.</p>',
|
|
'recap_cover_path' => 'worlds/recaps/summer-slam-2025-cover.jpg',
|
|
'recap_published_at' => now()->subDay(),
|
|
'recap_stats_snapshot_json' => [
|
|
'captured_at' => now()->subDay()->toIso8601String(),
|
|
'summary' => [
|
|
'views' => 1200,
|
|
'unique_visitors' => 640,
|
|
'submissions' => 18,
|
|
'live_participations' => 18,
|
|
'featured_participations' => 3,
|
|
'reward_grants' => 1,
|
|
'challenge_clicks' => 42,
|
|
'winner_count' => 1,
|
|
'finalist_count' => 0,
|
|
'featured_artwork_count' => 2,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$category = worldNewsCategory([
|
|
'name' => 'Recap Stories',
|
|
'slug' => 'recap-stories-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$article = publishedWorldNews($creator, $category, [
|
|
'title' => 'Summer Slam 2025 closing recap',
|
|
'slug' => 'summer-slam-2025-closing-recap-' . Str::lower(Str::random(6)),
|
|
'excerpt' => 'The final recap story for Summer Slam 2025.',
|
|
]);
|
|
$world->update(['recap_article_id' => $article->id]);
|
|
|
|
$featuredArtwork = Artwork::factory()->for($creator)->create([
|
|
'title' => 'Curated Edition Highlight',
|
|
'slug' => 'curated-edition-highlight-' . Str::lower(Str::random(6)),
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
$communityArtwork = Artwork::factory()->for($creator)->create([
|
|
'title' => 'Community Spotlight Piece',
|
|
'slug' => 'community-spotlight-piece-' . Str::lower(Str::random(6)),
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
$group = Group::factory()->for($creator, 'owner')->create([
|
|
'name' => 'Recap Crew',
|
|
'slug' => 'recap-crew-' . Str::lower(Str::random(6)),
|
|
]);
|
|
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'featured_artworks',
|
|
'related_type' => 'artwork',
|
|
'related_id' => $featuredArtwork->id,
|
|
'context_label' => 'Editorial highlight',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'featured_creators',
|
|
'related_type' => 'user',
|
|
'related_id' => $creator->id,
|
|
'context_label' => 'Edition lead',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'featured_groups',
|
|
'related_type' => 'group',
|
|
'related_id' => $group->id,
|
|
'context_label' => 'Community group',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'news',
|
|
'related_type' => 'news',
|
|
'related_id' => $article->id,
|
|
'context_label' => 'Closing story',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
|
|
$submission = WorldSubmission::query()->create([
|
|
'world_id' => $world->id,
|
|
'artwork_id' => $communityArtwork->id,
|
|
'submitted_by_user_id' => $creator->id,
|
|
'status' => WorldSubmission::STATUS_LIVE,
|
|
'is_featured' => true,
|
|
'featured_at' => now()->subHours(12),
|
|
'created_at' => now()->subDay(),
|
|
'updated_at' => now()->subDay(),
|
|
]);
|
|
|
|
WorldRewardGrant::query()->create([
|
|
'user_id' => $creator->id,
|
|
'world_id' => $world->id,
|
|
'world_submission_id' => $submission->id,
|
|
'artwork_id' => $communityArtwork->id,
|
|
'reward_type' => 'winner',
|
|
'grant_source' => 'manual',
|
|
'granted_at' => now()->subHours(6),
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Summer Slam 2025')
|
|
->where('world.has_recap', true)
|
|
->where('world.cta_label', 'Read full recap')
|
|
->where('world.cta_url', route('news.show', ['slug' => $article->slug]))
|
|
->where('recap.status', 'published')
|
|
->where('recap.title', 'Summer Slam 2025 recap')
|
|
->where('recap.summary', 'A tighter archive-facing summary for the edition.')
|
|
->where('recap.cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/summer-slam-2025-cover.jpg')
|
|
->where('recap.article.title', 'Summer Slam 2025 closing recap')
|
|
->where('recap.featured_artworks.items.0.title', 'Curated Edition Highlight')
|
|
->where('recap.community_highlights.items.0.title', 'Community Spotlight Piece')
|
|
->where('recap.creators.items.0.title', 'Recap Creator')
|
|
->where('recap.creators.rewarded.0.badge_label', 'Summer Slam 2025 Winner')
|
|
->where('recap.stats.source', 'snapshot')
|
|
->where('recap.stats.items.0.key', 'views')
|
|
->where('sections', []));
|
|
});
|
|
|
|
it('exposes linked challenge panels, derived entries, and challenge backlinks', function (): void {
|
|
$owner = User::factory()->create([
|
|
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$group = Group::factory()->for($owner, 'owner')->create();
|
|
$world = publicWorld([
|
|
'linked_challenge_id' => null,
|
|
'show_linked_challenge_section' => true,
|
|
'show_linked_challenge_entries' => true,
|
|
'show_linked_challenge_winners' => true,
|
|
'challenge_teaser_override' => 'World-specific framing for the linked challenge.',
|
|
]);
|
|
|
|
$winner = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Challenge Champion',
|
|
'slug' => 'challenge-champion',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
$entry = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Challenge Entry Two',
|
|
'slug' => 'challenge-entry-two',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
$finalist = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Challenge Finalist',
|
|
'slug' => 'challenge-finalist',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
$challenge = GroupChallenge::query()->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'World Challenge Finals',
|
|
'slug' => 'world-challenge-finals-' . Str::lower(Str::random(6)),
|
|
'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()->addDay(),
|
|
'created_by_user_id' => $owner->id,
|
|
'featured_artwork_id' => null,
|
|
]);
|
|
|
|
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
|
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
|
$challenge->artworks()->attach($entry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 2]);
|
|
$challenge->outcomes()->create([
|
|
'artwork_id' => $winner->id,
|
|
'user_id' => $owner->id,
|
|
'outcome_type' => 'winner',
|
|
'position' => 1,
|
|
'sort_order' => 0,
|
|
'title_override' => 'Grand Winner',
|
|
'awarded_by_user_id' => $owner->id,
|
|
'awarded_at' => now(),
|
|
]);
|
|
$challenge->outcomes()->create([
|
|
'artwork_id' => $finalist->id,
|
|
'user_id' => $owner->id,
|
|
'outcome_type' => 'finalist',
|
|
'sort_order' => 1,
|
|
'note' => 'Outstanding finalist selection.',
|
|
'awarded_by_user_id' => $owner->id,
|
|
'awarded_at' => now(),
|
|
]);
|
|
|
|
$world->update([
|
|
'linked_challenge_id' => $challenge->id,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('linkedChallenge.title', 'World Challenge Finals')
|
|
->where('linkedChallenge.summary', 'World-specific framing for the linked challenge.')
|
|
->where('linkedChallenge.state_label', 'Winners announced')
|
|
->where('linkedChallengeEntries.items.0.title', 'Challenge Champion')
|
|
->where('linkedChallengeWinners.item.title', 'Challenge Champion')
|
|
->where('linkedChallengeFinalists.items.0.title', 'Challenge Finalist')
|
|
->where('world.challenge_cta_label', 'See results'));
|
|
|
|
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Group/GroupChallengeShow')
|
|
->where('linkedWorld.title', $world->title)
|
|
->where('linkedWorld.public_url', route('worlds.show', ['world' => $world->slug]))
|
|
->where('linkedWorld.campaign_label', 'Seasonal spotlight')
|
|
->where('linkedWorld.challenge_cta_label', 'See results'));
|
|
});
|
|
|
|
it('hides selected linked challenge entries from the derived world feed', function (): void {
|
|
$owner = User::factory()->create([
|
|
'username' => 'challenge-hide-owner-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$group = Group::factory()->for($owner, 'owner')->create();
|
|
$world = publicWorld();
|
|
|
|
$hiddenEntry = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Hidden Challenge Entry',
|
|
'slug' => 'hidden-challenge-entry',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
$visibleEntry = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Visible Challenge Entry',
|
|
'slug' => 'visible-challenge-entry',
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
$challenge = GroupChallenge::query()->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Hidden Entries Challenge',
|
|
'slug' => 'hidden-entries-challenge-' . Str::lower(Str::random(6)),
|
|
'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()->addDay(),
|
|
'created_by_user_id' => $owner->id,
|
|
]);
|
|
|
|
$challenge->artworks()->attach($hiddenEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
|
$challenge->artworks()->attach($visibleEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
|
|
|
$world->update([
|
|
'linked_challenge_id' => $challenge->id,
|
|
'hidden_linked_challenge_artwork_ids_json' => [$hiddenEntry->id],
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('linkedChallengeEntries.hidden_count', 1)
|
|
->where('linkedChallengeEntries.items.0.title', 'Visible Challenge Entry')
|
|
->has('linkedChallengeEntries.items', 1));
|
|
});
|
|
|
|
it('maps community-vote challenges to a voting state on the world page', function (): void {
|
|
$owner = User::factory()->create([
|
|
'username' => 'challenge-vote-owner-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$group = Group::factory()->for($owner, 'owner')->create();
|
|
$world = publicWorld();
|
|
|
|
$challenge = GroupChallenge::query()->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Vote Live Challenge',
|
|
'slug' => 'vote-live-challenge-' . Str::lower(Str::random(6)),
|
|
'summary' => 'Vote for the best entry.',
|
|
'description' => 'Challenge description',
|
|
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
|
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
|
'status' => GroupChallenge::STATUS_ENDED,
|
|
'judging_mode' => 'community_vote',
|
|
'start_at' => now()->subDays(7),
|
|
'end_at' => now()->subHour(),
|
|
'created_by_user_id' => $owner->id,
|
|
]);
|
|
|
|
$world->update([
|
|
'linked_challenge_id' => $challenge->id,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('linkedChallenge.state', 'voting')
|
|
->where('linkedChallenge.state_label', 'Voting live')
|
|
->where('linkedChallenge.cta_label', 'View entries')
|
|
->where('world.challenge_cta_label', 'View entries'));
|
|
});
|
|
|
|
it('shifts linked challenge CTAs into recap mode for archived worlds', function (): void {
|
|
$owner = User::factory()->create([
|
|
'username' => 'challenge-archive-owner-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$group = Group::factory()->for($owner, 'owner')->create();
|
|
$world = publicWorld([
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => now()->subDays(20),
|
|
'ends_at' => now()->subDays(3),
|
|
]);
|
|
|
|
$challenge = GroupChallenge::query()->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Archive Recap Challenge',
|
|
'slug' => 'archive-recap-challenge-' . Str::lower(Str::random(6)),
|
|
'summary' => 'Challenge summary',
|
|
'description' => 'Challenge description',
|
|
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
|
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
|
'status' => GroupChallenge::STATUS_ACTIVE,
|
|
'start_at' => now()->subDays(5),
|
|
'end_at' => now()->addDays(2),
|
|
'created_by_user_id' => $owner->id,
|
|
]);
|
|
|
|
$world->update([
|
|
'linked_challenge_id' => $challenge->id,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('linkedChallenge.state', 'closed')
|
|
->where('linkedChallenge.cta_label', 'View challenge recap')
|
|
->where('world.challenge_cta_label', 'View challenge recap'));
|
|
});
|
|
|
|
it('surfaces linked challenge recap stories and keeps derived sections in recap mode for archived worlds', function (): void {
|
|
$owner = User::factory()->create([
|
|
'username' => 'challenge-recap-owner-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$group = Group::factory()->for($owner, 'owner')->create();
|
|
$world = publicWorld([
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => now()->subDays(30),
|
|
'ends_at' => now()->subDays(5),
|
|
'show_linked_challenge_section' => true,
|
|
'show_linked_challenge_entries' => true,
|
|
'show_linked_challenge_winners' => true,
|
|
'show_linked_challenge_finalists' => true,
|
|
]);
|
|
|
|
$winner = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Recap Winner',
|
|
'slug' => 'recap-winner-' . Str::lower(Str::random(6)),
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
$finalist = Artwork::factory()->for($owner)->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'Recap Finalist',
|
|
'slug' => 'recap-finalist-' . Str::lower(Str::random(6)),
|
|
'artwork_status' => 'published',
|
|
'published_at' => now()->subDay(),
|
|
'is_public' => true,
|
|
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
$challenge = GroupChallenge::query()->create([
|
|
'group_id' => $group->id,
|
|
'title' => 'World Recap Challenge',
|
|
'slug' => 'world-recap-challenge-' . Str::lower(Str::random(6)),
|
|
'summary' => 'Challenge summary',
|
|
'description' => 'Challenge description',
|
|
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
|
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
|
'status' => GroupChallenge::STATUS_ACTIVE,
|
|
'start_at' => now()->subDays(10),
|
|
'end_at' => now()->subDays(2),
|
|
'created_by_user_id' => $owner->id,
|
|
'featured_artwork_id' => null,
|
|
]);
|
|
|
|
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
|
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
|
$challenge->outcomes()->create([
|
|
'artwork_id' => $winner->id,
|
|
'user_id' => $owner->id,
|
|
'outcome_type' => 'winner',
|
|
'position' => 1,
|
|
'sort_order' => 0,
|
|
'awarded_by_user_id' => $owner->id,
|
|
'awarded_at' => now(),
|
|
]);
|
|
$challenge->outcomes()->create([
|
|
'artwork_id' => $finalist->id,
|
|
'user_id' => $owner->id,
|
|
'outcome_type' => 'finalist',
|
|
'sort_order' => 1,
|
|
'awarded_by_user_id' => $owner->id,
|
|
'awarded_at' => now(),
|
|
]);
|
|
|
|
$category = worldNewsCategory([
|
|
'name' => 'Challenge Recaps',
|
|
'slug' => 'challenge-recaps-' . Str::lower(Str::random(6)),
|
|
]);
|
|
$recap = publishedWorldNews($owner, $category, [
|
|
'title' => 'World Recap Challenge results recap',
|
|
'slug' => 'world-recap-challenge-results-' . Str::lower(Str::random(6)),
|
|
'excerpt' => 'Winner highlights and finalist recap from the linked challenge.',
|
|
'content' => "# Results recap\n\nWinner highlights and finalist recap.",
|
|
]);
|
|
|
|
$world->update([
|
|
'linked_challenge_id' => $challenge->id,
|
|
]);
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'news',
|
|
'related_type' => 'news',
|
|
'related_id' => $recap->id,
|
|
'context_label' => 'Challenge recap',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('linkedChallenge.state', 'closed')
|
|
->where('linkedChallenge.cta_label', 'View challenge recap')
|
|
->where('linkedChallenge.cta_url', route('news.show', ['slug' => $recap->slug]))
|
|
->where('linkedChallenge.challenge_url', route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
|
->where('linkedChallenge.story.title', 'World Recap Challenge results recap')
|
|
->where('linkedChallenge.story.intent', 'recap')
|
|
->where('linkedChallengeEntries.description', 'Entries from the linked challenge remain visible here so the world recap preserves the full field of work.')
|
|
->where('linkedChallengeWinners.description', 'This world is carrying the linked challenge result forward so the campaign recap stays visible here too.')
|
|
->where('linkedChallengeFinalists.description', 'Finalists from the linked challenge remain visible here so the archived world keeps the complete recap in view.')
|
|
->where('world.challenge_cta_label', 'View challenge recap')
|
|
->where('world.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
|
|
|
|
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Group/GroupChallengeShow')
|
|
->where('linkedWorld.title', $world->title)
|
|
->where('linkedWorld.challenge_cta_label', 'View challenge recap')
|
|
->where('linkedWorld.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
|
|
});
|
|
|
|
it('falls back to the theme icon when the stored world icon is blank whitespace', function (): void {
|
|
$world = publicWorld([
|
|
'title' => 'Spring Vibes',
|
|
'slug' => 'spring-vibes',
|
|
'theme_key' => 'summer',
|
|
'icon_name' => ' ',
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Spring Vibes')
|
|
->where('world.icon_name', 'fa-solid fa-sun')
|
|
->where('world.theme.icon_name', 'fa-solid fa-sun'));
|
|
});
|
|
|
|
it('omits disabled sections from the public world payload', function (): void {
|
|
$world = publicWorld([
|
|
'title' => 'Curated Autumn 2026',
|
|
'slug' => 'curated-autumn-2026',
|
|
'section_visibility_json' => [
|
|
'featured_creators' => false,
|
|
],
|
|
]);
|
|
|
|
$world->worldRelations()->create([
|
|
'section_key' => 'featured_creators',
|
|
'related_type' => 'user',
|
|
'related_id' => $world->created_by_user_id,
|
|
'context_label' => 'Editorial spotlight',
|
|
'sort_order' => 0,
|
|
'is_featured' => true,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Curated Autumn 2026')
|
|
->where('sections', []));
|
|
});
|
|
|
|
it('keeps archived worlds publicly visible', function (): void {
|
|
$world = publicWorld([
|
|
'title' => 'Halloween World 2025',
|
|
'slug' => 'halloween-world-2025',
|
|
'theme_key' => 'halloween',
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'starts_at' => Carbon::parse('2025-10-01 00:00:00'),
|
|
'ends_at' => Carbon::parse('2025-11-01 00:00:00'),
|
|
'published_at' => Carbon::parse('2025-09-20 10:00:00'),
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => $world->slug]))
|
|
->assertOk()
|
|
->assertSee('Halloween World 2025');
|
|
});
|
|
|
|
it('resolves recurring family and archived edition routes to the correct edition', function (): void {
|
|
publicWorld([
|
|
'title' => 'Spring Vibes 2025',
|
|
'slug' => 'spring-vibes-2025',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'spring-vibes',
|
|
'edition_year' => 2025,
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => Carbon::parse('2025-03-01 00:00:00'),
|
|
'ends_at' => Carbon::parse('2025-04-01 00:00:00'),
|
|
'published_at' => Carbon::parse('2025-02-20 10:00:00'),
|
|
]);
|
|
|
|
$currentEdition = publicWorld([
|
|
'title' => 'Spring Vibes 2026',
|
|
'slug' => 'spring-vibes-2026',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'spring-vibes',
|
|
'edition_year' => 2026,
|
|
'campaign_priority' => 600,
|
|
]);
|
|
|
|
$this->get(route('worlds.show', ['world' => 'spring-vibes']))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Spring Vibes 2026')
|
|
->where('world.public_url', route('worlds.show', ['world' => 'spring-vibes']))
|
|
->has('archiveEditions', 1)
|
|
->where('archiveEditions.0.title', 'Spring Vibes 2025'));
|
|
|
|
$this->get(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Spring Vibes 2025')
|
|
->where('currentEdition.title', 'Spring Vibes 2026')
|
|
->where('archiveNotice.current_edition.title', 'Spring Vibes 2026'));
|
|
|
|
$this->get(route('worlds.show', ['world' => $currentEdition->slug]))
|
|
->assertRedirect(route('worlds.show', ['world' => 'spring-vibes']));
|
|
|
|
$this->get(route('worlds.show', ['world' => 'spring-vibes-2025']))
|
|
->assertRedirect(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]));
|
|
});
|
|
|
|
it('exposes adjacent previous and next editions inside the archive payload', function (): void {
|
|
publicWorld([
|
|
'title' => 'Retro Month 2024',
|
|
'slug' => 'retro-month-2024',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'retro-month',
|
|
'edition_year' => 2024,
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => Carbon::parse('2024-04-01 00:00:00'),
|
|
'ends_at' => Carbon::parse('2024-04-30 00:00:00'),
|
|
'published_at' => Carbon::parse('2024-03-20 10:00:00'),
|
|
]);
|
|
|
|
publicWorld([
|
|
'title' => 'Retro Month 2025',
|
|
'slug' => 'retro-month-2025',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'retro-month',
|
|
'edition_year' => 2025,
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => Carbon::parse('2025-04-01 00:00:00'),
|
|
'ends_at' => Carbon::parse('2025-04-30 00:00:00'),
|
|
'published_at' => Carbon::parse('2025-03-20 10:00:00'),
|
|
]);
|
|
|
|
publicWorld([
|
|
'title' => 'Retro Month 2026',
|
|
'slug' => 'retro-month-2026',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'retro-month',
|
|
'edition_year' => 2026,
|
|
'campaign_priority' => 200,
|
|
]);
|
|
|
|
$this->get(route('worlds.editions.show', ['world' => 'retro-month', 'year' => 2025]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldShow')
|
|
->where('world.title', 'Retro Month 2025')
|
|
->where('previousEdition.title', 'Retro Month 2024')
|
|
->where('nextEdition.title', 'Retro Month 2026'));
|
|
});
|
|
|
|
it('exposes a homepage world spotlight when a featured world exists', function (): void {
|
|
publicWorld([
|
|
'title' => 'Pixel Week 2026',
|
|
'slug' => 'pixel-week-2026',
|
|
'theme_key' => 'pixel-week',
|
|
'teaser_title' => 'Pixel Week is open for submissions',
|
|
]);
|
|
|
|
app(HomepageService::class)->clearGuestPayloadCache();
|
|
|
|
$this->get(route('index'))
|
|
->assertOk()
|
|
->assertSee(route('worlds.index'), false)
|
|
->assertSee('pixel-week-2026')
|
|
->assertSee('Pixel Week 2026');
|
|
});
|
|
|
|
it('splits live, upcoming, and archived worlds on the public index', function (): void {
|
|
publicWorld([
|
|
'title' => 'Spring Vibes',
|
|
'slug' => 'spring-vibes',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'spring-vibes',
|
|
'edition_year' => 2026,
|
|
'campaign_priority' => 500,
|
|
]);
|
|
|
|
publicWorld([
|
|
'title' => 'Spring Vibes 2025',
|
|
'slug' => 'spring-vibes-2025',
|
|
'is_recurring' => true,
|
|
'recurrence_key' => 'spring-vibes',
|
|
'edition_year' => 2025,
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => Carbon::now()->subDays(400),
|
|
'ends_at' => Carbon::now()->subDays(365),
|
|
'promotion_starts_at' => null,
|
|
'promotion_ends_at' => null,
|
|
'published_at' => Carbon::now()->subDays(420),
|
|
]);
|
|
|
|
publicWorld([
|
|
'title' => 'Retro Month 2026',
|
|
'slug' => 'retro-month-2026',
|
|
'starts_at' => Carbon::now()->addDays(10),
|
|
'ends_at' => Carbon::now()->addDays(24),
|
|
'promotion_starts_at' => Carbon::now()->addDays(8),
|
|
'promotion_ends_at' => Carbon::now()->addDays(18),
|
|
'teaser_title' => 'Retro Month is coming up',
|
|
]);
|
|
|
|
publicWorld([
|
|
'title' => 'Halloween World 2025',
|
|
'slug' => 'halloween-world-2025',
|
|
'theme_key' => 'halloween',
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'starts_at' => Carbon::now()->subDays(40),
|
|
'ends_at' => Carbon::now()->subDays(20),
|
|
'promotion_starts_at' => null,
|
|
'promotion_ends_at' => null,
|
|
'published_at' => Carbon::now()->subDays(50),
|
|
]);
|
|
|
|
$this->get(route('worlds.index'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('World/WorldIndex')
|
|
->where('spotlightWorld.title', 'Spring Vibes')
|
|
->has('upcomingWorlds', 1)
|
|
->has('recurringWorldFamilies', 1)
|
|
->where('recurringWorldFamilies.0.title', 'Spring Vibes')
|
|
->has('archivedWorlds', 2));
|
|
}); |