new test files

This commit is contained in:
2026-04-25 08:36:03 +02:00
parent 19d5a9ed3e
commit 67be537c86
17 changed files with 4075 additions and 18 deletions

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\World;
use App\Models\WorldAnalyticsEvent;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function analyticsWorld(User $creator, array $attributes = []): World
{
$slugSuffix = Str::lower(Str::random(6));
return World::query()->create(array_merge([
'title' => 'Analytics World ' . $slugSuffix,
'slug' => 'analytics-world-' . $slugSuffix,
'tagline' => 'Measured campaign storytelling.',
'summary' => 'A world used to verify analytics reporting.',
'description' => 'Analytics world description',
'theme_key' => 'summer',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_CAMPAIGN,
'is_featured' => true,
'published_at' => now()->subDays(5),
'starts_at' => now()->subDays(3),
'ends_at' => now()->addDays(10),
'created_by_user_id' => $creator->id,
], $attributes));
}
it('records worlds analytics events through the public api', function (): void {
$creator = User::factory()->create();
$world = analyticsWorld($creator);
$this->postJson(route('api.worlds.analytics.events.store'), [
'world_id' => $world->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'entity_type' => 'world',
'entity_id' => $world->id,
'entity_title' => $world->title,
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_token' => 'guest-analytics-token',
])->assertAccepted()->assertJson(['ok' => true]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'entity_type' => 'world',
'entity_id' => $world->id,
'entity_title' => $world->title,
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'viewer_type' => 'guest',
'visitor_key' => hash('sha256', 'visitor:guest-analytics-token'),
]);
$this->postJson(route('api.worlds.analytics.events.store'), [
'world_id' => $world->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_token' => 'guest-analytics-impression',
])->assertAccepted()->assertJson(['ok' => true]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'viewer_type' => 'guest',
'visitor_key' => hash('sha256', 'visitor:guest-analytics-impression'),
]);
});
it('includes analytics summaries and edition comparison on studio world pages', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'analyticsmod-' . Str::lower(Str::random(6)),
]);
$groupOwner = User::factory()->create([
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
]);
$group = Group::factory()->create([
'name' => 'Retro Group ' . Str::upper(Str::random(4)),
'slug' => 'retro-group-' . Str::lower(Str::random(4)),
]);
$challenge = GroupChallenge::query()->create([
'group_id' => $group->id,
'title' => 'Retro Challenge ' . Str::upper(Str::random(4)),
'slug' => 'retro-challenge-' . Str::lower(Str::random(4)),
'summary' => 'A linked challenge for analytics verification.',
'description' => 'Challenge description',
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
'status' => GroupChallenge::STATUS_ACTIVE,
'start_at' => now()->subDay(),
'end_at' => now()->addDays(7),
'created_by_user_id' => $groupOwner->id,
]);
$currentWorld = analyticsWorld($moderator, [
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026-' . Str::lower(Str::random(4)),
'recurrence_key' => 'retro-month',
'edition_year' => 2026,
'linked_challenge_id' => $challenge->id,
]);
$previousWorld = analyticsWorld($moderator, [
'title' => 'Retro Month 2025',
'slug' => 'retro-month-2025-' . Str::lower(Str::random(4)),
'recurrence_key' => 'retro-month',
'edition_year' => 2025,
'starts_at' => now()->subYear(),
'ends_at' => now()->subYear()->addDays(10),
'published_at' => now()->subYear()->subDays(2),
]);
$artwork = Artwork::factory()->for($moderator)->create([
'title' => 'Analytics Artwork',
'slug' => 'analytics-artwork-' . Str::lower(Str::random(4)),
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $currentWorld->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $moderator->id,
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => true,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
WorldRewardGrant::query()->create([
'user_id' => $moderator->id,
'world_id' => $currentWorld->id,
'artwork_id' => $artwork->id,
'reward_type' => 'winner',
'grant_source' => 'manual',
'granted_at' => now()->subHours(6),
]);
WorldRewardGrant::query()->create([
'user_id' => $moderator->id,
'world_id' => $previousWorld->id,
'artwork_id' => $artwork->id,
'reward_type' => 'featured',
'grant_source' => 'manual',
'granted_at' => now()->subYear(),
]);
collect([
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_impression',
'section_key' => 'spotlight',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_key' => hash('sha256', 'visitor:impression-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_impression',
'section_key' => 'card',
'source_surface' => 'worlds_index',
'source_detail' => 'featured',
'visitor_key' => hash('sha256', 'visitor:impression-two'),
'occurred_at' => Carbon::now()->subHours(3),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'worlds_index',
'source_detail' => 'featured',
'visitor_key' => hash('sha256', 'visitor:viewer-two'),
'occurred_at' => Carbon::now()->subHours(3),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_source_clicked',
'source_surface' => 'homepage_spotlight',
'source_detail' => 'primary',
'entity_type' => 'world',
'entity_id' => $currentWorld->id,
'entity_title' => $currentWorld->title,
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_cta_clicked',
'section_key' => 'hero',
'cta_key' => 'main_world_cta',
'source_surface' => 'homepage_spotlight',
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
'occurred_at' => Carbon::now()->subHours(4),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_challenge_cta_clicked',
'section_key' => 'challenge',
'challenge_id' => $challenge->id,
'visitor_key' => hash('sha256', 'visitor:challenge-viewer'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_submission_created',
'section_key' => 'community_submissions',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:submission'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_submission_approved',
'section_key' => 'community_submissions',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:approval'),
'occurred_at' => Carbon::now()->subHours(2),
],
[
'world_id' => $currentWorld->id,
'event_type' => 'world_reward_granted',
'section_key' => 'rewards',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => $artwork->title,
'visitor_key' => hash('sha256', 'system:reward'),
'occurred_at' => Carbon::now()->subHours(1),
],
[
'world_id' => $previousWorld->id,
'event_type' => 'world_viewed',
'source_surface' => 'navigation',
'source_detail' => 'archive',
'visitor_key' => hash('sha256', 'visitor:archive-viewer'),
'occurred_at' => Carbon::now()->subYear(),
],
])->each(function (array $attributes) use ($currentWorld, $previousWorld): void {
$world = (int) $attributes['world_id'] === (int) $currentWorld->id ? $currentWorld : $previousWorld;
WorldAnalyticsEvent::query()->create(array_merge([
'world_slug' => $world->slug,
'world_type' => $world->type,
'recurrence_key' => $world->recurrence_key,
'edition_year' => $world->edition_year,
'viewer_type' => 'guest',
'user_id' => null,
'meta' => null,
], $attributes));
});
$this->actingAs($moderator)
->get(route('studio.worlds.edit', ['world' => $currentWorld->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioWorldEditor')
->where('world.analytics.ranges.30d.summary.views', 2)
->where('world.analytics.ranges.30d.summary.unique_visitors', 2)
->where('world.analytics.ranges.30d.summary.promotion_impressions', 2)
->where('world.analytics.ranges.30d.summary.cta_clicks', 1)
->where('world.analytics.ranges.30d.summary.reward_grants', 1)
->where('world.analytics.ranges.30d.participation.live', 1)
->where('world.analytics.ranges.30d.sources.0.impressions', 1)
->where('world.analytics.ranges.30d.sources.0.clickthrough_rate', 1)
->where('world.analytics.ranges.30d.challenge.linked_challenge_id', $challenge->id)
->where('world.analytics.ranges.30d.challenge.click_to_submission_conversion', 1)
->where('world.analytics.ranges.30d.sources.0.source_surface', 'homepage_spotlight')
->where('world.analytics.edition_comparison.recurrence_key', 'retro-month')
->has('world.analytics.edition_comparison.editions', 2));
});

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Services\GroupChallengeService;
use App\Services\Worlds\WorldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function challengeLinkedWorld(?User $moderator = null, array $attributes = []): World
{
$moderator ??= User::factory()->create([
'role' => 'moderator',
'username' => 'worldchallenge-' . Str::lower(Str::random(6)),
]);
return World::factory()->create(array_merge([
'created_by_user_id' => $moderator->id,
'status' => World::STATUS_PUBLISHED,
'published_at' => now()->subDay(),
'accepts_submissions' => true,
'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
'submission_note_enabled' => true,
'community_section_enabled' => true,
'allow_readd_after_removal' => true,
'submission_starts_at' => now()->subDay(),
'submission_ends_at' => now()->addDays(7),
], $attributes));
}
function worldUpdatePayload(World $world, array $overrides = []): array
{
return array_merge([
'title' => $world->title,
'status' => $world->status,
'type' => $world->type,
'tagline' => $world->tagline,
'summary' => $world->summary,
'description' => $world->description,
'accepts_submissions' => (bool) $world->accepts_submissions,
'participation_mode' => $world->participation_mode,
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'community_section_enabled' => (bool) $world->community_section_enabled,
'allow_readd_after_removal' => (bool) $world->allow_readd_after_removal,
'is_featured' => (bool) $world->is_featured,
'is_active_campaign' => (bool) $world->is_active_campaign,
'is_homepage_featured' => (bool) $world->is_homepage_featured,
'is_recurring' => (bool) $world->is_recurring,
'cta_label' => $world->cta_label,
'cta_url' => $world->cta_url,
'badge_label' => $world->badge_label,
'badge_description' => $world->badge_description,
'badge_url' => $world->badge_url,
'linked_challenge_id' => $world->linked_challenge_id,
'show_linked_challenge_section' => (bool) ($world->show_linked_challenge_section ?? true),
'show_linked_challenge_entries' => (bool) ($world->show_linked_challenge_entries ?? true),
'show_linked_challenge_winners' => (bool) ($world->show_linked_challenge_winners ?? true),
'show_linked_challenge_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true),
'auto_grant_challenge_world_rewards' => (bool) ($world->auto_grant_challenge_world_rewards ?? true),
'challenge_teaser_override' => $world->challenge_teaser_override,
'relations' => [],
], $overrides);
}
function linkedGroupChallenge(Group $group, User $owner, array $attributes = []): GroupChallenge
{
return GroupChallenge::query()->create(array_merge([
'group_id' => $group->id,
'title' => 'Pixel Week Finals',
'slug' => 'pixel-week-finals-' . Str::lower(Str::random(6)),
'summary' => 'Challenge finale.',
'description' => 'Challenge finale 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,
], $attributes));
}
it('syncs winner rewards from linked challenge outcomes', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Challenge Winner Artwork',
'slug' => 'challenge-winner-artwork',
'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' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
'outcomes' => [[
'artwork_id' => $artwork->id,
'outcome_type' => 'winner',
'position' => 1,
'sort_order' => 0,
'title_override' => 'Grand Winner',
]],
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'world_submission_id' => $submission->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('syncs finalist rewards from linked challenge outcomes', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Challenge Finalist Artwork',
'slug' => 'challenge-finalist-artwork',
'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' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
'outcomes' => [[
'artwork_id' => $artwork->id,
'outcome_type' => 'finalist',
'sort_order' => 0,
'note' => 'Finalist award.',
]],
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'world_submission_id' => $submission->id,
'reward_type' => 'finalist',
'grant_source' => 'challenge',
]);
});
it('syncs challenge winner rewards when challenge relations are added to a world', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Relation Sync Artwork',
'slug' => 'relation-sync-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner, [
'featured_artwork_id' => $artwork->id,
]);
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
'relations' => [[
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]],
]));
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('syncs challenge winner rewards when a primary linked challenge is set on a world', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Primary Challenge Sync Artwork',
'slug' => 'primary-challenge-sync-artwork',
'artwork_status' => 'published',
'published_at' => now()->subDay(),
'is_public' => true,
'visibility' => Artwork::VISIBILITY_PUBLIC,
]);
WorldSubmission::query()->create([
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'submitted_by_user_id' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner, [
'featured_artwork_id' => $artwork->id,
]);
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
'linked_challenge_id' => $challenge->id,
]));
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});
it('revokes challenge-sourced winner rewards when linked challenge winners are cleared', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$groupOwner = User::factory()->create();
$group = Group::factory()->for($groupOwner, 'owner')->create();
$world = challengeLinkedWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'group_id' => $group->id,
'title' => 'Revoked Challenge Winner',
'slug' => 'revoked-challenge-winner',
'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' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$challenge = linkedGroupChallenge($group, $groupOwner);
$world->worldRelations()->create([
'section_key' => 'related_programming',
'related_type' => 'challenge',
'related_id' => $challenge->id,
'context_label' => 'Challenge finale',
'sort_order' => 0,
'is_featured' => true,
]);
$challengeService = app(GroupChallengeService::class);
$challengeService->update($challenge, $groupOwner, ['featured_artwork_id' => $artwork->id]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'world_submission_id' => $submission->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
$challengeService->update($challenge->fresh(), $groupOwner, ['featured_artwork_id' => null]);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
'grant_source' => 'challenge',
]);
});

View File

@@ -5,23 +5,33 @@ declare(strict_types=1);
use App\Models\World;
use Database\Seeders\WorldLaunchSeeder;
it('seeds launch worlds with a featured current world and archived recurrence', function (): void {
it('seeds a live Spring Vibes activation and recurring archive editions', function (): void {
$this->seed(WorldLaunchSeeder::class);
$featuredCurrent = World::query()
->where('slug', 'like', 'retro-month-%')
->where('is_featured', true)
->current()
$springVibes = World::query()
->where('slug', 'like', 'spring-vibes-%')
->campaignActive()
->where('is_homepage_featured', true)
->first();
expect($featuredCurrent)->not->toBeNull();
expect($featuredCurrent?->worldRelations()->count())->toBeGreaterThan(0);
expect($springVibes)->not->toBeNull();
expect($springVibes?->title)->toStartWith('Spring Vibes');
expect($springVibes?->worldRelations()->count())->toBeGreaterThan(0);
expect($springVibes?->campaign_priority)->toBeGreaterThan(0);
expect($springVibes?->teaser_title)->toBe('Now live: Spring Vibes');
$archivedEdition = World::query()
->where('parent_world_id', $featuredCurrent?->id)
->where('parent_world_id', $springVibes?->id)
->where('status', World::STATUS_ARCHIVED)
->first();
$upcomingCampaign = World::query()
->where('slug', 'like', 'pixel-week-%')
->first();
expect($archivedEdition)->not->toBeNull();
expect(World::query()->count())->toBeGreaterThanOrEqual(6);
expect($upcomingCampaign)->not->toBeNull();
expect($upcomingCampaign?->is_active_campaign)->toBeTrue();
expect($upcomingCampaign?->promotion_starts_at)->not->toBeNull();
expect(World::query()->count())->toBeGreaterThanOrEqual(8);
});

View File

@@ -4,14 +4,22 @@ 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 Illuminate\Support\Carbon;
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',
'username' => 'publicworlds-' . Str::lower(Str::random(6)),
'name' => 'Public Worlds',
]);
@@ -22,18 +30,55 @@ function publicWorld(array $attributes = []): World
'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,
'starts_at' => Carbon::parse('2026-06-01 00:00:00'),
'ends_at' => Carbon::parse('2026-08-31 23:59:59'),
'published_at' => Carbon::parse('2026-04-01 10:00:00'),
'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();
@@ -41,7 +86,8 @@ it('renders public worlds index and detail pages', function (): void {
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldIndex')
->where('featuredWorld.title', 'Summer Slam 2026')
->where('spotlightWorld.title', 'Summer Slam 2026')
->where('spotlightWorld.campaign_state_label', 'Live now')
->has('activeWorlds'));
$this->get(route('worlds.show', ['world' => $world->slug]))
@@ -52,6 +98,545 @@ it('renders public worlds index and detail pages', function (): void {
->where('world.slug', 'summer-slam-2026'));
});
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',
@@ -111,11 +696,107 @@ it('keeps archived worlds publicly visible', function (): void {
->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();
@@ -125,4 +806,65 @@ it('exposes a homepage world spotlight when a featured world exists', function (
->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));
});

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Models\World;
use App\Models\WorldRelation;
use App\Services\Worlds\WorldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function recurringWorldModerator(): User
{
return User::factory()->create([
'role' => 'moderator',
'username' => 'recurrence-mod-' . Str::lower(Str::random(6)),
'name' => 'Recurrence Moderator',
]);
}
function studioRecurringWorld(User $creator, array $attributes = []): World
{
return World::factory()->create(array_merge([
'created_by_user_id' => $creator->id,
'title' => 'Retro Month 2026',
'slug' => 'retro-month-2026',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'starts_at' => Carbon::now()->subDays(14),
'ends_at' => Carbon::now()->addDays(7),
'promotion_starts_at' => Carbon::now()->subDays(10),
'promotion_ends_at' => Carbon::now()->addDays(5),
'submission_starts_at' => Carbon::now()->subDays(7),
'submission_ends_at' => Carbon::now()->addDays(5),
'published_at' => Carbon::now()->subDays(21),
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 200,
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'recurrence_rule' => 'yearly',
'edition_year' => 2026,
'cta_url' => 'https://skinbase.test/worlds/retro-month',
'badge_url' => 'https://skinbase.test/badges/retro-month',
], $attributes));
}
it('creates a clean next edition draft for recurring worlds', function (): void {
$moderator = recurringWorldModerator();
$source = studioRecurringWorld($moderator);
WorldRelation::query()->create([
'world_id' => $source->id,
'section_key' => 'featured_artworks',
'related_type' => WorldRelation::TYPE_ARTWORK,
'related_id' => 123,
'context_label' => 'Carry-over candidate',
'sort_order' => 0,
'is_featured' => true,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $source->id]))
->post(route('studio.worlds.new-edition', ['world' => $source->id]), [
'copy_mode' => WorldService::COPY_MODE_STRUCTURE_ONLY,
]);
$edition = World::query()
->whereKeyNot($source->id)
->latest('id')
->firstOrFail();
expect($edition->title)->toBe('Retro Month 2027')
->and($edition->slug)->toBe('retro-month-2027')
->and($edition->status)->toBe(World::STATUS_DRAFT)
->and($edition->is_recurring)->toBeTrue()
->and($edition->recurrence_key)->toBe('retro-month')
->and($edition->recurrence_rule)->toBe('yearly')
->and($edition->edition_year)->toBe(2027)
->and($edition->parent_world_id)->toBe($source->id)
->and($edition->starts_at)->toBeNull()
->and($edition->ends_at)->toBeNull()
->and($edition->promotion_starts_at)->toBeNull()
->and($edition->promotion_ends_at)->toBeNull()
->and($edition->submission_starts_at)->toBeNull()
->and($edition->submission_ends_at)->toBeNull()
->and($edition->published_at)->toBeNull()
->and($edition->is_active_campaign)->toBeFalse()
->and($edition->is_homepage_featured)->toBeFalse()
->and($edition->campaign_priority)->toBeNull()
->and($edition->cta_url)->toBeNull()
->and($edition->badge_url)->toBeNull()
->and($edition->worldRelations()->count())->toBe(0);
});
it('rejects next edition creation for non-recurring worlds', function (): void {
$moderator = recurringWorldModerator();
$world = World::factory()->create([
'created_by_user_id' => $moderator->id,
'title' => 'One-Off Showcase 2026',
'slug' => 'one-off-showcase-2026',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'is_recurring' => false,
'recurrence_key' => null,
'recurrence_rule' => null,
'edition_year' => null,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $world->id]))
->post(route('studio.worlds.new-edition', ['world' => $world->id]))
->assertSessionHasErrors(['recurrence_key']);
expect(World::query()->count())->toBe(1);
});
it('rejects duplicate recurrence years when storing worlds', function (): void {
$moderator = recurringWorldModerator();
studioRecurringWorld($moderator, [
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Pixel Week Draft',
'slug' => 'pixel-week-draft',
'status' => World::STATUS_DRAFT,
'type' => World::TYPE_EVENT,
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
])
->assertSessionHasErrors(['edition_year']);
expect(World::query()->count())->toBe(1);
});
it('rejects publishing a second current edition for the same recurrence family', function (): void {
$moderator = recurringWorldModerator();
studioRecurringWorld($moderator, [
'title' => 'Spring Vibes 2026',
'slug' => 'spring-vibes-2026',
'recurrence_key' => 'spring-vibes',
'edition_year' => 2026,
'status' => World::STATUS_PUBLISHED,
'starts_at' => Carbon::now()->subDays(3),
'ends_at' => Carbon::now()->addDays(10),
]);
$this->actingAs($moderator)
->from(route('studio.worlds.create'))
->post(route('studio.worlds.store'), [
'title' => 'Spring Vibes 2027',
'slug' => 'spring-vibes-2027',
'status' => World::STATUS_PUBLISHED,
'type' => World::TYPE_EVENT,
'starts_at' => Carbon::now()->subDay()->toIso8601String(),
'ends_at' => Carbon::now()->addDays(14)->toIso8601String(),
'is_recurring' => true,
'recurrence_key' => 'spring-vibes',
'edition_year' => 2027,
])
->assertSessionHasErrors(['status']);
expect(World::query()->count())->toBe(1);
});

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldRewardGrant;
use App\Models\WorldRelation;
use App\Models\WorldSubmission;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -82,7 +83,7 @@ it('creates pending world submissions when publishing an artwork draft', functio
'category' => $categoryId,
'tags' => ['world', 'submission'],
'world_submissions' => [
['world_id' => $world->id, 'note' => 'Fits the active theme.'],
['world_id' => $world->id, 'note' => 'Fits the active theme.', 'source_surface' => 'upload_flow'],
],
])
->assertOk()
@@ -96,6 +97,15 @@ it('creates pending world submissions when publishing an artwork draft', functio
'is_featured' => false,
'note' => 'Fits the active theme.',
]);
$this->assertDatabaseHas('world_analytics_events', [
'world_id' => $world->id,
'event_type' => 'world_submission_created',
'source_surface' => 'upload_flow',
'entity_type' => 'artwork',
'entity_id' => $artwork->id,
'entity_title' => 'World Upload',
]);
});
it('creates live world participation immediately for auto-add worlds', function (): void {
@@ -133,6 +143,14 @@ it('creates live world participation immediately for auto-add worlds', function
'status' => WorldSubmission::STATUS_LIVE,
'is_featured' => false,
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'artwork_id' => $artwork->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
});
it('syncs world submissions from the studio artwork editor update flow', function (): void {
@@ -319,7 +337,8 @@ it('shows and reviews world participation in the studio world editor', function
->component('Studio/StudioWorldEditor')
->where('world.participation_mode', World::PARTICIPATION_MODE_MANUAL_APPROVAL)
->where('world.submission_review_queue.counts.pending', 1)
->where('world.submission_review_queue.items.0.artwork.title', 'Queue Artwork'));
->where('world.submission_review_queue.items.0.artwork.title', 'Queue Artwork')
->where('world.submission_review_queue.items.0.can_grant_manual_rewards', false));
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.approve', ['world' => $world->id, 'submission' => $submission->id]))
@@ -336,6 +355,20 @@ it('shows and reviews world participation in the studio world editor', function
'reviewed_by_user_id' => $moderator->id,
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'featured',
'grant_source' => 'automatic',
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.block', ['world' => $world->id, 'submission' => $submission->id]), [
'review_note' => 'Off brief for this world.',
@@ -348,6 +381,114 @@ it('shows and reviews world participation in the studio world editor', function
'moderation_reason' => 'Off brief for this world.',
'is_featured' => false,
]);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'featured',
]);
$this->assertDatabaseHas('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'participant',
'grant_source' => 'automatic',
]);
});
it('allows moderators to grant and revoke manual world rewards', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'worldrewardmod-' . Str::lower(Str::random(6)),
]);
$creator = User::factory()->create();
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Reward Artwork',
'slug' => 'reward-artwork',
'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' => $creator->id,
'status' => WorldSubmission::STATUS_LIVE,
'reviewed_by_user_id' => $moderator->id,
'reviewed_at' => now()->subHour(),
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.rewards.grant', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']), [
'review_note' => 'Editorial pick for the final showcase.',
])
->assertRedirect();
$grant = WorldRewardGrant::query()->where('user_id', $creator->id)->where('world_id', $world->id)->where('reward_type', 'winner')->first();
expect($grant)->not->toBeNull();
$this->assertDatabaseHas('notifications', [
'type' => 'world_reward_granted',
]);
$this->assertDatabaseHas('user_activities', [
'user_id' => $creator->id,
'type' => 'world_reward',
'entity_type' => 'world_reward',
'entity_id' => $grant->id,
]);
$this->actingAs($moderator)
->post(route('studio.worlds.submissions.rewards.revoke', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']))
->assertRedirect();
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
]);
});
it('rejects manual world rewards for non-live submissions', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'worldrewardpending-' . Str::lower(Str::random(6)),
]);
$creator = User::factory()->create();
$world = acceptingWorld($moderator);
$artwork = Artwork::factory()->for($creator)->create([
'title' => 'Pending Reward Artwork',
'slug' => 'pending-reward-artwork',
'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' => $creator->id,
'status' => WorldSubmission::STATUS_PENDING,
]);
$this->actingAs($moderator)
->from(route('studio.worlds.edit', ['world' => $world->id]))
->post(route('studio.worlds.submissions.rewards.grant', ['world' => $world->id, 'submission' => $submission->id, 'rewardType' => 'winner']), [
'review_note' => 'Tried to award too early.',
])
->assertRedirect(route('studio.worlds.edit', ['world' => $world->id]))
->assertSessionHasErrors(['submission']);
$this->assertDatabaseMissing('world_reward_grants', [
'user_id' => $creator->id,
'world_id' => $world->id,
'reward_type' => 'winner',
]);
});
it('renders only live community submissions on public world pages and hides pending or blocked ones', function (): void {
@@ -478,4 +619,72 @@ it('exposes world participation badges on the artwork page for curated and live
return $items->count() === 1
&& $items->contains(fn (array $item): bool => ($item['badge_label'] ?? null) === 'Featured in Retro Month');
});
});
it('prioritizes active campaign worlds in creator submission options', function (): void {
$creator = User::factory()->create();
$liveCampaign = acceptingWorld(attributes: [
'title' => 'Spring Vibes',
'slug' => 'spring-vibes',
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 500,
'campaign_label' => 'Live now',
'teaser_title' => 'Now live: Spring Vibes',
'teaser_summary' => 'Fresh spring palettes and active submissions.',
'promotion_starts_at' => now()->subHour(),
'promotion_ends_at' => now()->addDays(5),
]);
$regularWorld = acceptingWorld(attributes: [
'title' => 'Open Worlds Lab',
'slug' => 'open-worlds-lab',
'is_active_campaign' => false,
'is_homepage_featured' => false,
'campaign_priority' => null,
]);
$options = app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($creator);
expect($options)->toHaveCount(2)
->and($options[0]['id'])->toBe($liveCampaign->id)
->and($options[0]['teaser_title'])->toBe('Now live: Spring Vibes')
->and(collect($options[0]['status_badges'])->pluck('label')->all())->toContain('Live now', 'Featured')
->and($options[1]['id'])->toBe($regularWorld->id);
});
it('only exposes the canonical current edition for recurring submission options', function (): void {
$creator = User::factory()->create();
acceptingWorld(attributes: [
'title' => 'Pixel Week 2025',
'slug' => 'pixel-week-2025',
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2025,
'is_active_campaign' => false,
'is_homepage_featured' => false,
'campaign_priority' => 50,
'starts_at' => now()->subDays(30),
'ends_at' => now()->addDays(2),
]);
$currentEdition = acceptingWorld(attributes: [
'title' => 'Pixel Week 2026',
'slug' => 'pixel-week-2026',
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'edition_year' => 2026,
'is_active_campaign' => true,
'is_homepage_featured' => true,
'campaign_priority' => 500,
'teaser_title' => 'Now live: Pixel Week 2026',
]);
$options = app(\App\Services\Worlds\WorldSubmissionService::class)->eligibleWorldOptions($creator);
expect($options)->toHaveCount(1)
->and($options[0]['id'])->toBe($currentEdition->id)
->and($options[0]['title'])->toBe('Pixel Week 2026');
});