From 67be537c868177df838ac73601d3586a6b4d977f Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 25 Apr 2026 08:36:03 +0200 Subject: [PATCH] new test files --- test-results/legacy-password-export.sqlite | Bin 0 -> 8192 bytes tests/Feature/ArtworkSearchDocumentTest.php | 62 ++ tests/Feature/BrowseGallerySortTest.php | 48 + .../ExportLegacyPasswordsCommandTest.php | 90 ++ .../Console/MeilisearchConfigurationTest.php | 2 + tests/Feature/Groups/GroupsFeatureTest.php | 110 ++ .../Profile/WorldProfileHistoryTest.php | 226 +++++ .../Profile/WorldRewardsProfileTest.php | 118 +++ tests/Feature/Studio/StudioWorldPagesTest.php | 955 +++++++++++++++++- tests/Feature/StudioUploadQueueTest.php | 657 ++++++++++++ .../Uploads/UploadFinishAiDispatchTest.php | 2 +- tests/Feature/Worlds/WorldAnalyticsTest.php | 306 ++++++ .../Worlds/WorldChallengeRewardSyncTest.php | 348 +++++++ .../Feature/Worlds/WorldLaunchSeederTest.php | 28 +- tests/Feature/Worlds/WorldPagesTest.php | 752 +++++++++++++- .../Worlds/WorldRecurrenceWorkflowTest.php | 176 ++++ .../Worlds/WorldSubmissionsWorkflowTest.php | 213 +++- 17 files changed, 4075 insertions(+), 18 deletions(-) create mode 100644 test-results/legacy-password-export.sqlite create mode 100644 tests/Feature/ArtworkSearchDocumentTest.php create mode 100644 tests/Feature/BrowseGallerySortTest.php create mode 100644 tests/Feature/Console/ExportLegacyPasswordsCommandTest.php create mode 100644 tests/Feature/Profile/WorldProfileHistoryTest.php create mode 100644 tests/Feature/Profile/WorldRewardsProfileTest.php create mode 100644 tests/Feature/StudioUploadQueueTest.php create mode 100644 tests/Feature/Worlds/WorldAnalyticsTest.php create mode 100644 tests/Feature/Worlds/WorldChallengeRewardSyncTest.php create mode 100644 tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php diff --git a/test-results/legacy-password-export.sqlite b/test-results/legacy-password-export.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..3f40ecf0c4fc09723d50efbf7a30a61b6fd96a09 GIT binary patch literal 8192 zcmeI#O;5r=5C-5ah)RqpH{xy5kRXN_DSGfGh5Csalsj4IQY!tx?G`9EJou~pah9e{ znrK4e$-_*topw5T(>%An_PUl}F!s1Z1vE*CND{dMAcW+jlS_`2^qr`uwV7?JJURV5 zIoxcO2$4J6*6}?Gpa2S>01BW03ZMWApa2S>01EtHf%T=dS1y;OkDj1In*~1OetgNb zU-VXAhki@z>Y&6k1&-sJfn_LQxq_LDgX;-!1KX~HGNZn~@VKEWFsFPpp*%Cp&isiN z*v7!IOil&+nFR(L)4&!`J*(DX#w~~P5T-25d_b+1s>)yTQl6$y*iirlPyhu`00mG0 z1yBG5Pyhu`00sUia9|e55h>E)sG&Bs@h({x$bLl)D-E@>`n6hGyQ5#<+}_!F;g{S5dinsertGetId([ + 'name' => $name, + 'slug' => Str::slug($name) . '-' . Str::lower(Str::random(6)), + 'description' => null, + 'created_at' => now(), + 'updated_at' => now(), + 'sort_order' => $sortOrder, + ]); +} + +function createSearchDocumentCategory(int $contentTypeId, string $name, int $sortOrder = 0): int +{ + return (int) DB::table('categories')->insertGetId([ + 'content_type_id' => $contentTypeId, + 'parent_id' => null, + 'name' => $name, + 'slug' => Str::slug($name) . '-' . Str::lower(Str::random(6)), + 'description' => null, + 'image' => null, + 'is_active' => true, + 'sort_order' => $sortOrder, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +it('indexes all attached categories and content types while preserving a primary category', function (): void { + $user = User::factory()->create(); + $artwork = Artwork::factory()->for($user)->create([ + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subMinute(), + ]); + + $wallpapersId = createSearchDocumentContentType('Wallpapers'); + $digitalArtId = createSearchDocumentContentType('Digital Art'); + + $fantasyId = createSearchDocumentCategory($wallpapersId, 'Fantasy', 10); + $mattePaintingId = createSearchDocumentCategory($digitalArtId, 'Matte Painting', 20); + + $artwork->categories()->sync([$mattePaintingId, $fantasyId]); + + $payload = $artwork->fresh(['categories.contentType'])->toSearchableArray(); + $fantasySlug = (string) DB::table('categories')->where('id', $fantasyId)->value('slug'); + $mattePaintingSlug = (string) DB::table('categories')->where('id', $mattePaintingId)->value('slug'); + $wallpapersSlug = (string) DB::table('content_types')->where('id', $wallpapersId)->value('slug'); + $digitalArtSlug = (string) DB::table('content_types')->where('id', $digitalArtId)->value('slug'); + + expect($payload['category'])->toBe($fantasySlug) + ->and($payload['content_type'])->toBe($wallpapersSlug) + ->and($payload['categories'])->toBe([$fantasySlug, $mattePaintingSlug]) + ->and($payload['content_types'])->toBe([$wallpapersSlug, $digitalArtSlug]); +}); \ No newline at end of file diff --git a/tests/Feature/BrowseGallerySortTest.php b/tests/Feature/BrowseGallerySortTest.php new file mode 100644 index 00000000..9f5e5be4 --- /dev/null +++ b/tests/Feature/BrowseGallerySortTest.php @@ -0,0 +1,48 @@ +getConstant('SORT_MAP'); + + expect($sortMap['latest'] ?? null)->toBe(['published_at_ts:desc']); + expect($sortMap['oldest'] ?? null)->toBe(['published_at_ts:asc']); +}); + +it('uses published date as the recency tie-breaker on default content-type explore pages', function (): void { + $sortMap = (new ReflectionClass(BrowseGalleryController::class))->getConstant('SORT_MAP'); + $cacheVersion = (new ReflectionClass(BrowseGalleryController::class))->getConstant('CACHE_VERSION'); + + expect($sortMap['trending'] ?? null)->toBe([ + 'trending_score_24h:desc', + 'trending_score_7d:desc', + 'favorites_count:desc', + 'published_at_ts:desc', + ]); + + expect($sortMap['fresh'] ?? null)->toBe([ + 'published_at_ts:desc', + 'trending_score_7d:desc', + 'favorites_count:desc', + ]); + + expect($cacheVersion)->toBe('v4'); +}); + +it('anchors category gallery filters to the content type and all descendant category slugs', function (): void { + $controller = app(BrowseGalleryController::class); + $method = new ReflectionMethod(BrowseGalleryController::class, 'categoryPageFilterExpression'); + $method->setAccessible(true); + + $filter = $method->invoke($controller, 'skins', ['audio', 'winamp', 'aplayer']); + + expect($filter)->toBe( + 'is_public = true AND is_approved = true AND ' + . '(content_type = "skins" OR content_types = "skins") ' + . 'AND (' + . '(category = "audio" OR categories = "audio") OR ' + . '(category = "winamp" OR categories = "winamp") OR ' + . '(category = "aplayer" OR categories = "aplayer")' + . ')' + ); +}); \ No newline at end of file diff --git a/tests/Feature/Console/ExportLegacyPasswordsCommandTest.php b/tests/Feature/Console/ExportLegacyPasswordsCommandTest.php new file mode 100644 index 00000000..e13edca3 --- /dev/null +++ b/tests/Feature/Console/ExportLegacyPasswordsCommandTest.php @@ -0,0 +1,90 @@ +set('database.connections.legacy', [ + 'driver' => 'sqlite', + 'database' => $legacyDatabasePath, + 'prefix' => '', + 'foreign_key_constraints' => false, + ]); + + DB::purge('legacy'); + + Schema::connection('legacy')->create('users', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id')->primary(); + $table->string('password2')->nullable(); + $table->string('password')->nullable(); + $table->unsignedTinyInteger('should_migrate')->default(0); + }); +}); + +afterEach(function (): void { + Schema::connection('legacy')->dropIfExists('users'); + + $legacyDatabasePath = base_path('test-results/legacy-password-export.sqlite'); + @unlink($legacyDatabasePath); + + $sqlPath = base_path('test-results/export-legacy-passwords.sql'); + @unlink($sqlPath); +}); + +it('exports only legacy users flagged with should_migrate=1', function (): void { + DB::connection('legacy')->table('users')->insert([ + [ + 'user_id' => 101, + 'password2' => '$2y$12$abcdefghijklmnopqrstuvABCDEFGHIJKLMNOpqrstuvwxyz12345', + 'password' => null, + 'should_migrate' => 1, + ], + [ + 'user_id' => 102, + 'password2' => '$2y$12$zzzzzzzzzzzzzzzzzzzzzzABCDEFGHIJKLMNOpqrstuvwxyz12345', + 'password' => null, + 'should_migrate' => 0, + ], + [ + 'user_id' => 103, + 'password2' => 'abc123', + 'password' => null, + 'should_migrate' => 1, + ], + ]); + + $sqlPath = base_path('test-results/export-legacy-passwords.sql'); + + $code = Artisan::call('skinbase:export-legacy-passwords', [ + '--sql' => $sqlPath, + '--chunk' => 1, + ]); + + $output = Artisan::output(); + $sql = file_get_contents($sqlPath); + + expect($code)->toBe(0) + ->and($output)->toContain('Wrote 1 rows to: ' . $sqlPath) + ->and($sql)->not->toBeFalse() + ->and($sql)->toContain('user_id=101') + ->and($sql)->not->toContain('user_id=102') + ->and($sql)->not->toContain('user_id=103') + ->and($sql)->toContain('-- Exported: 1 user(s)'); +}); \ No newline at end of file diff --git a/tests/Feature/Console/MeilisearchConfigurationTest.php b/tests/Feature/Console/MeilisearchConfigurationTest.php index 1a091d53..41604f72 100644 --- a/tests/Feature/Console/MeilisearchConfigurationTest.php +++ b/tests/Feature/Console/MeilisearchConfigurationTest.php @@ -14,4 +14,6 @@ it('artworks scout index settings include maturity filter fields used by search expect($filterableAttributes)->toContain('maturity_level'); expect($filterableAttributes)->toContain('maturity_status'); expect($filterableAttributes)->toContain('published_as_type'); + expect($filterableAttributes)->toContain('categories'); + expect($filterableAttributes)->toContain('content_types'); }); \ No newline at end of file diff --git a/tests/Feature/Groups/GroupsFeatureTest.php b/tests/Feature/Groups/GroupsFeatureTest.php index 508af28e..24b05cee 100644 --- a/tests/Feature/Groups/GroupsFeatureTest.php +++ b/tests/Feature/Groups/GroupsFeatureTest.php @@ -318,6 +318,116 @@ it('lets owners manage releases, contributors, milestones, and publishing throug ->and(GroupReleaseMilestone::query()->where('group_release_id', $release->id)->count())->toBe(1); }); +it('lets owners manage challenge outcomes and exposes them on public challenge pages', function () { + $owner = User::factory()->create(); + $group = Group::factory()->for($owner, 'owner')->create([ + 'visibility' => Group::VISIBILITY_PUBLIC, + ]); + + app(GroupMembershipService::class)->ensureOwnerMembership($group); + + $winner = Artwork::factory()->for($owner, 'user')->create([ + 'group_id' => $group->id, + 'uploaded_by_user_id' => $owner->id, + 'primary_author_user_id' => $owner->id, + 'artwork_status' => 'published', + 'visibility' => Artwork::VISIBILITY_PUBLIC, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now(), + ]); + $finalist = Artwork::factory()->for($owner, 'user')->create([ + 'group_id' => $group->id, + 'uploaded_by_user_id' => $owner->id, + 'primary_author_user_id' => $owner->id, + 'artwork_status' => 'published', + 'visibility' => Artwork::VISIBILITY_PUBLIC, + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now(), + ]); + + $this->actingAs($owner) + ->post(route('studio.groups.challenges.store', ['group' => $group]), [ + 'title' => 'Pixel Week Results', + 'summary' => 'Challenge summary', + 'description' => 'Challenge description', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ACTIVE, + 'start_at' => now()->subDay()->toDateTimeString(), + 'end_at' => now()->addDay()->toDateTimeString(), + 'judging_mode' => 'curated', + ]) + ->assertRedirect(); + + $challenge = GroupChallenge::query()->where('group_id', $group->id)->latest('id')->firstOrFail(); + + $this->actingAs($owner) + ->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [ + 'artwork_id' => $winner->id, + ]) + ->assertRedirect(); + + $this->actingAs($owner) + ->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [ + 'artwork_id' => $finalist->id, + ]) + ->assertRedirect(); + + $this->actingAs($owner) + ->patch(route('studio.groups.challenges.update', ['group' => $group, 'challenge' => $challenge]), [ + 'title' => 'Pixel Week Results', + 'summary' => 'Challenge summary', + 'description' => 'Challenge description', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ACTIVE, + 'start_at' => now()->subDay()->toDateTimeString(), + 'end_at' => now()->addDay()->toDateTimeString(), + 'judging_mode' => 'curated', + 'outcomes' => [ + [ + 'artwork_id' => $winner->id, + 'outcome_type' => 'winner', + 'position' => 1, + 'sort_order' => 0, + 'title_override' => 'Grand Winner', + 'note' => 'Top overall result.', + ], + [ + 'artwork_id' => $finalist->id, + 'outcome_type' => 'finalist', + 'sort_order' => 1, + 'note' => 'Strong finalist showing.', + ], + ], + ]) + ->assertRedirect(); + + $challenge->refresh(); + + expect($challenge->featured_artwork_id)->toBe($winner->id); + + $this->assertDatabaseHas('group_challenge_outcomes', [ + 'group_challenge_id' => $challenge->id, + 'artwork_id' => $winner->id, + 'outcome_type' => 'winner', + ]); + $this->assertDatabaseHas('group_challenge_outcomes', [ + 'group_challenge_id' => $challenge->id, + 'artwork_id' => $finalist->id, + 'outcome_type' => 'finalist', + ]); + + $this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Group/GroupChallengeShow') + ->where('challenge.outcome_sections.winner.items.0.title', $winner->title) + ->where('challenge.outcome_sections.finalist.items.0.title', $finalist->title)); +}); + it('renders public release pages and the studio reputation dashboard with v4 payloads', function () { $viewer = User::factory()->create(); $owner = User::factory()->create(); diff --git a/tests/Feature/Profile/WorldProfileHistoryTest.php b/tests/Feature/Profile/WorldProfileHistoryTest.php new file mode 100644 index 00000000..ef2764c2 --- /dev/null +++ b/tests/Feature/Profile/WorldProfileHistoryTest.php @@ -0,0 +1,226 @@ +create(array_merge([ + 'title' => 'World History ' . strtolower(fake()->bothify('####')), + 'slug' => 'world-history-' . strtolower(fake()->bothify('####')), + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDay(), + ], $overrides)); +} + +function createPublicArtworkForProfileHistory(User $creator, array $overrides = []): Artwork +{ + return Artwork::factory()->for($creator)->create(array_merge([ + 'title' => 'World Artwork ' . strtolower(fake()->bothify('####')), + 'slug' => 'world-artwork-' . strtolower(fake()->bothify('####')), + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + 'is_approved' => true, + ], $overrides)); +} + +it('exposes normalized world history on public profile pages', function (): void { + $creator = User::factory()->create([ + 'username' => 'worldhist-' . strtolower(fake()->bothify('####')), + ]); + + $groupOwner = User::factory()->create(); + $group = Group::factory()->create([ + 'owner_user_id' => $groupOwner->id, + 'visibility' => Group::VISIBILITY_PUBLIC, + 'status' => Group::LIFECYCLE_ACTIVE, + ]); + + $challenge = GroupChallenge::query()->create([ + 'group_id' => $group->id, + 'title' => 'Autumn Finals', + 'slug' => 'autumn-finals-' . strtolower(fake()->bothify('####')), + 'summary' => 'Final round challenge.', + 'description' => 'Final round challenge.', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ACTIVE, + 'start_at' => now()->subDays(2), + 'end_at' => now()->addDays(2), + 'created_by_user_id' => $groupOwner->id, + ]); + + $world = createPublicWorldForProfileHistory([ + 'title' => 'Autumn Finals 2026', + 'slug' => 'autumn-finals-2026-' . strtolower(fake()->bothify('####')), + 'linked_challenge_id' => $challenge->id, + 'recurrence_key' => 'autumn-finals', + 'edition_year' => 2026, + ]); + + $artwork = createPublicArtworkForProfileHistory($creator, [ + 'title' => 'Autumn Skyline', + 'slug' => 'autumn-skyline-' . strtolower(fake()->bothify('####')), + ]); + + WorldSubmission::query()->create([ + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'submitted_by_user_id' => $creator->id, + 'status' => WorldSubmission::STATUS_LIVE, + 'is_featured' => false, + 'reviewed_at' => now()->subDay(), + ]); + + GroupChallengeOutcome::query()->create([ + 'group_challenge_id' => $challenge->id, + 'artwork_id' => $artwork->id, + 'user_id' => $creator->id, + 'outcome_type' => GroupChallengeOutcome::TYPE_WINNER, + 'awarded_by_user_id' => $groupOwner->id, + 'awarded_at' => now()->subHour(), + ]); + + $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('worldHistory.summary.available', true) + ->where('worldHistory.summary.world_appearances', 1) + ->where('worldHistory.summary.worlds_joined', 1) + ->where('worldHistory.summary.winner_appearances', 1) + ->where('worldHistory.summary.most_recent_world_activity.primary_recognition.key', 'winner') + ->where('worldHistory.entries.0.world.title', 'Autumn Finals 2026') + ->where('worldHistory.entries.0.primary_recognition.key', 'winner') + ->where('worldHistory.entries.0.recognition_keys.0', 'winner') + ->where('worldHistory.entries.0.challenge.title', 'Autumn Finals') + ->where('worldHistory.entries.0.linked_artwork.title', 'Autumn Skyline')); +}); + +it('filters stale public world rewards while preserving owner-only context counts', function (): void { + $creator = User::factory()->create([ + 'username' => 'worldfilter-' . strtolower(fake()->bothify('####')), + ]); + + $staleWorld = createPublicWorldForProfileHistory([ + 'title' => 'Removed Entry World', + 'slug' => 'removed-entry-world-' . strtolower(fake()->bothify('####')), + ]); + + $pendingWorld = createPublicWorldForProfileHistory([ + 'title' => 'Pending Entry World', + 'slug' => 'pending-entry-world-' . strtolower(fake()->bothify('####')), + ]); + + $removedArtwork = createPublicArtworkForProfileHistory($creator, [ + 'title' => 'Removed Entry Artwork', + 'slug' => 'removed-entry-artwork-' . strtolower(fake()->bothify('####')), + ]); + + $pendingArtwork = createPublicArtworkForProfileHistory($creator, [ + 'title' => 'Pending Entry Artwork', + 'slug' => 'pending-entry-artwork-' . strtolower(fake()->bothify('####')), + ]); + + $removedSubmission = WorldSubmission::query()->create([ + 'world_id' => $staleWorld->id, + 'artwork_id' => $removedArtwork->id, + 'submitted_by_user_id' => $creator->id, + 'status' => WorldSubmission::STATUS_REMOVED, + 'removed_at' => now()->subHour(), + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $creator->id, + 'world_id' => $staleWorld->id, + 'artwork_id' => $removedArtwork->id, + 'world_submission_id' => $removedSubmission->id, + 'reward_type' => 'participant', + 'grant_source' => 'automatic', + 'granted_at' => now()->subMinutes(30), + ]); + + WorldSubmission::query()->create([ + 'world_id' => $pendingWorld->id, + 'artwork_id' => $pendingArtwork->id, + 'submitted_by_user_id' => $creator->id, + 'status' => WorldSubmission::STATUS_PENDING, + ]); + + $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('worldHistory.summary.available', false) + ->where('worldHistory.entries', []) + ->where('worldHistory.owner_context', null)); + + $this->actingAs($creator) + ->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('worldHistory.summary.available', false) + ->where('worldHistory.owner_context.pending_submissions', 1) + ->where('worldHistory.owner_context.removed_or_blocked_submissions', 1) + ->where('worldHistory.owner_context.hidden_public_entries', 1)); +}); + +it('supports the canonical worlds profile tab route', function (): void { + $creator = User::factory()->create([ + 'username' => 'worldtab-' . strtolower(fake()->bothify('####')), + ]); + + $world = createPublicWorldForProfileHistory([ + 'title' => 'Featured Worlds 2026', + 'slug' => 'featured-worlds-2026-' . strtolower(fake()->bothify('####')), + 'edition_year' => 2026, + ]); + + $artwork = createPublicArtworkForProfileHistory($creator, [ + 'title' => 'Featured Worlds Artwork', + 'slug' => 'featured-worlds-artwork-' . strtolower(fake()->bothify('####')), + ]); + + $submission = WorldSubmission::query()->create([ + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'submitted_by_user_id' => $creator->id, + 'status' => WorldSubmission::STATUS_LIVE, + 'is_featured' => true, + 'featured_at' => now()->subMinutes(10), + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $creator->id, + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'world_submission_id' => $submission->id, + 'reward_type' => 'featured', + 'grant_source' => 'automatic', + 'granted_at' => now()->subMinutes(5), + ]); + + $this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds'])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('initialTab', 'worlds') + ->where('profileTabUrls.worlds', url('/@' . strtolower((string) $creator->username) . '/worlds')) + ->where('worldHistory.summary.available', true) + ->where('worldHistory.entries.0.primary_recognition.key', 'featured')); +}); \ No newline at end of file diff --git a/tests/Feature/Profile/WorldRewardsProfileTest.php b/tests/Feature/Profile/WorldRewardsProfileTest.php new file mode 100644 index 00000000..87bd8d68 --- /dev/null +++ b/tests/Feature/Profile/WorldRewardsProfileTest.php @@ -0,0 +1,118 @@ +create([ + 'username' => 'profilerewards-' . strtolower(fake()->bothify('####')), + ]); + $worldOwner = User::factory()->create(); + $world = World::factory()->create([ + 'title' => 'Spring Vibes 2026', + 'slug' => 'spring-vibes-2026-' . strtolower(fake()->bothify('####')), + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDay(), + 'created_by_user_id' => $worldOwner->id, + ]); + $artwork = Artwork::factory()->for($creator)->create([ + 'title' => 'Profile Reward Artwork', + 'slug' => 'profile-reward-artwork', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $creator->id, + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'reward_type' => 'featured', + 'grant_source' => 'automatic', + 'granted_at' => now()->subHour(), + ]); + + $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('worldRewards.count', 1) + ->where('worldRewards.items.0.badge_label', 'Spring Vibes 2026 Featured')); +}); + +it('prioritizes higher-signal world rewards ahead of participation on profile reward grids while keeping recents chronological', function (): void { + $creator = User::factory()->create([ + 'username' => 'profilepriority-' . strtolower(fake()->bothify('####')), + ]); + $worldOwner = User::factory()->create(); + + $participantWorld = World::factory()->create([ + 'title' => 'Retro Month 2026', + 'slug' => 'retro-month-2026-' . strtolower(fake()->bothify('####')), + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDay(), + 'created_by_user_id' => $worldOwner->id, + ]); + + $winnerWorld = World::factory()->create([ + 'title' => 'Pixel Week 2026', + 'slug' => 'pixel-week-2026-' . strtolower(fake()->bothify('####')), + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDay(), + 'created_by_user_id' => $worldOwner->id, + ]); + + $participantArtwork = Artwork::factory()->for($creator)->create([ + 'title' => 'Participant Artwork', + 'slug' => 'participant-artwork', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + + $winnerArtwork = Artwork::factory()->for($creator)->create([ + 'title' => 'Winner Artwork', + 'slug' => 'winner-artwork', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $creator->id, + 'world_id' => $participantWorld->id, + 'artwork_id' => $participantArtwork->id, + 'reward_type' => 'participant', + 'grant_source' => 'automatic', + 'granted_at' => now()->subMinutes(10), + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $creator->id, + 'world_id' => $winnerWorld->id, + 'artwork_id' => $winnerArtwork->id, + 'reward_type' => 'winner', + 'grant_source' => 'manual', + 'granted_at' => now()->subHour(), + ]); + + $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('worldRewards.count', 2) + ->where('worldRewards.items.0.badge_label', 'Pixel Week 2026 Winner') + ->where('worldRewards.items.1.badge_label', 'Retro Month 2026 Participant') + ->where('worldRewards.recent.0.badge_label', 'Retro Month 2026 Participant')); +}); \ No newline at end of file diff --git a/tests/Feature/Studio/StudioWorldPagesTest.php b/tests/Feature/Studio/StudioWorldPagesTest.php index ef1790a7..dd010aee 100644 --- a/tests/Feature/Studio/StudioWorldPagesTest.php +++ b/tests/Feature/Studio/StudioWorldPagesTest.php @@ -4,13 +4,27 @@ declare(strict_types=1); use App\Models\User; use App\Models\Artwork; +use App\Models\ArtworkStats; use App\Models\Category; +use App\Models\Collection; use App\Models\ContentType; use App\Models\Group; +use App\Models\GroupChallenge; +use App\Models\GroupChallengeOutcome; +use App\Models\Tag; use App\Models\World; +use App\Models\WorldAnalyticsEvent; +use App\Models\WorldEditorialSuggestionState; +use App\Models\WorldRelation; +use App\Models\WorldRewardGrant; +use App\Models\WorldSubmission; +use App\Services\Worlds\WorldAnalyticsService; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia; +use cPad\Plugins\News\Models\NewsArticle; +use cPad\Plugins\News\Models\NewsCategory; function studioWorld(array $attributes = []): World { @@ -35,6 +49,254 @@ function studioWorld(array $attributes = []): World ], $attributes)); } +function studioWorldNewsCategory(array $attributes = []): NewsCategory +{ + return NewsCategory::query()->create(array_merge([ + 'name' => 'Studio World Updates', + 'slug' => 'studio-world-updates-' . Str::lower(Str::random(6)), + 'description' => 'Editorial context for studio-managed worlds.', + 'position' => 0, + 'is_active' => true, + ], $attributes)); +} + +function studioPublishedWorldNews(User $author, NewsCategory $category, array $attributes = []): NewsArticle +{ + return NewsArticle::query()->create(array_merge([ + 'title' => 'Studio recap story', + 'slug' => 'studio-recap-story-' . Str::lower(Str::random(6)), + 'excerpt' => 'A published recap story linked from the world editor.', + 'content' => "# Studio recap story\n\nLinked from the world editor.", + 'author_id' => $author->id, + 'category_id' => $category->id, + 'type' => NewsArticle::TYPE_ANNOUNCEMENT, + 'status' => 'published', + 'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED, + 'published_at' => now()->subHour(), + 'is_featured' => true, + 'is_pinned' => false, + ], $attributes)); +} + +function studioSuggestionArtwork(User $creator, Tag $tag, array $attributes = [], array $stats = []): Artwork +{ + $title = $attributes['title'] ?? ('Retro Artwork ' . Str::title(Str::lower(Str::random(4)))); + + $artwork = Artwork::factory()->for($creator)->create(array_merge([ + 'title' => $title, + 'slug' => Str::slug($title) . '-' . Str::lower(Str::random(6)), + 'description' => 'Retro neon artwork for world suggestion scoring.', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ], $attributes)); + + $artwork->tags()->attach($tag->id, ['source' => 'user']); + + ArtworkStats::query()->create(array_merge([ + 'artwork_id' => $artwork->id, + 'views' => 320, + 'downloads' => 14, + 'favorites' => 42, + 'rating_avg' => 4.8, + 'rating_count' => 18, + 'comments_count' => 6, + 'shares_count' => 4, + ], $stats)); + + return $artwork->fresh(['tags', 'stats', 'user.profile']); +} + +function studioWorldSuggestionFixture(User $moderator): array +{ + $tag = Tag::factory()->create([ + 'name' => 'Retro', + 'slug' => 'retro', + ]); + + $communityCreator = User::factory()->create([ + 'username' => 'retrocaptain', + 'name' => 'Retro Captain', + 'nova_featured_creator' => true, + ]); + + $artworkCreator = User::factory()->create([ + 'username' => 'neondrifter', + 'name' => 'Neon Drifter', + ]); + + $previousWorld = studioWorld([ + 'creator' => $moderator, + 'title' => 'Retro Month 2025', + 'slug' => 'retro-month-2025', + 'summary' => 'An archived edition with strong retro signals.', + 'status' => World::STATUS_ARCHIVED, + 'published_at' => now()->subDays(45), + 'starts_at' => now()->subDays(40), + 'ends_at' => now()->subDays(30), + 'is_recurring' => true, + 'recurrence_key' => 'retro-month', + 'edition_year' => 2025, + 'related_tags_json' => ['retro', 'neon'], + ]); + + $previousWorld->worldRelations()->create([ + 'section_key' => 'featured_creators', + 'related_type' => WorldRelation::TYPE_USER, + 'related_id' => $communityCreator->id, + 'sort_order' => 0, + 'is_featured' => true, + ]); + + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Retro Month 2026', + 'slug' => 'retro-month-2026', + 'summary' => 'Editors are curating retro neon stories, creators, and challenge highlights.', + 'description' => 'A recurring world focused on retro, neon, and synth aesthetics.', + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDays(3), + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDays(8), + 'accepts_submissions' => true, + 'community_section_enabled' => true, + 'is_recurring' => true, + 'recurrence_key' => 'retro-month', + 'edition_year' => 2026, + 'related_tags_json' => ['retro', 'neon'], + ]); + + $communityArtwork = studioSuggestionArtwork($communityCreator, $tag, [ + 'title' => 'Retro Skyline Community Entry', + 'description' => 'A retro neon skyline submitted to the community showcase.', + 'published_at' => now()->subHours(12), + ], [ + 'views' => 620, + 'favorites' => 88, + 'comments_count' => 11, + 'shares_count' => 9, + ]); + + WorldSubmission::query()->create([ + 'world_id' => $world->id, + 'artwork_id' => $communityArtwork->id, + 'submitted_by_user_id' => $communityCreator->id, + 'status' => WorldSubmission::STATUS_LIVE, + 'is_featured' => true, + 'featured_at' => now()->subHours(10), + 'created_at' => now()->subHours(16), + 'updated_at' => now()->subHours(10), + ]); + + $challengeGroup = Group::factory()->create([ + 'owner_user_id' => $communityCreator->id, + 'name' => 'Retro Signal Crew', + 'slug' => 'retro-signal-crew-' . Str::lower(Str::random(6)), + 'headline' => 'Retro and synth challenge specialists.', + 'bio' => 'A public group behind the linked retro challenge.', + 'followers_count' => 240, + 'artworks_count' => 12, + 'collections_count' => 4, + 'is_verified' => true, + ]); + + $linkedChallenge = GroupChallenge::query()->create([ + 'group_id' => $challengeGroup->id, + 'title' => 'Retro Signal Finals', + 'slug' => 'retro-signal-finals-' . Str::lower(Str::random(6)), + 'summary' => 'Linked challenge for retro-world finalists.', + 'description' => 'Editors can highlight finalists from this challenge.', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ENDED, + 'start_at' => now()->subDays(6), + 'end_at' => now()->subDays(1), + 'created_by_user_id' => $moderator->id, + ]); + + $world->forceFill(['linked_challenge_id' => $linkedChallenge->id])->save(); + + $challengeArtwork = studioSuggestionArtwork($communityCreator, $tag, [ + 'title' => 'Retro Circuit Finalist', + 'description' => 'A finalist artwork from the linked retro challenge.', + 'published_at' => now()->subDays(2), + ], [ + 'views' => 540, + 'favorites' => 73, + 'comments_count' => 8, + 'shares_count' => 6, + ]); + + $linkedChallenge->artworks()->attach($challengeArtwork->id, [ + 'submitted_by_user_id' => $communityCreator->id, + 'sort_order' => 0, + ]); + + GroupChallengeOutcome::query()->create([ + 'group_challenge_id' => $linkedChallenge->id, + 'artwork_id' => $challengeArtwork->id, + 'user_id' => $communityCreator->id, + 'outcome_type' => GroupChallengeOutcome::TYPE_FINALIST, + 'position' => 1, + 'sort_order' => 0, + 'awarded_by_user_id' => $moderator->id, + 'awarded_at' => now()->subHours(18), + ]); + + $artworkSuggestion = studioSuggestionArtwork($artworkCreator, $tag, [ + 'title' => 'Neon Drift Poster', + 'description' => 'A retro neon poster aligned with the world brief.', + 'published_at' => now()->subDays(3), + ], [ + 'views' => 410, + 'favorites' => 57, + 'comments_count' => 5, + 'shares_count' => 5, + ]); + + $collection = Collection::factory()->create([ + 'user_id' => $communityCreator->id, + 'title' => 'Retro Signal Collection', + 'slug' => 'retro-signal-collection-' . Str::lower(Str::random(6)), + 'summary' => 'A curated collection of retro signal artwork.', + 'description' => 'Strong collection engagement around retro and neon work.', + 'views_count' => 520, + 'likes_count' => 140, + 'followers_count' => 86, + 'saves_count' => 33, + 'is_featured' => true, + 'published_at' => now()->subDays(2), + 'featured_at' => now()->subDay(), + ]); + + $category = studioWorldNewsCategory([ + 'name' => 'Retro World Updates', + ]); + + $article = studioPublishedWorldNews($moderator, $category, [ + 'title' => 'Retro Month results roundup', + 'slug' => 'retro-month-results-' . Str::lower(Str::random(6)), + 'excerpt' => 'Recap and results for the latest retro month challenge and showcase.', + 'content' => '# Retro Month results roundup\n\nRetro month recap with challenge finalists and community highlights.', + 'published_at' => now()->subHours(8), + ]); + + return [ + 'world' => $world->fresh(['worldRelations', 'linkedChallenge']), + 'previous_world' => $previousWorld, + 'community_creator' => $communityCreator, + 'artwork_creator' => $artworkCreator, + 'community_artwork' => $communityArtwork, + 'challenge_artwork' => $challengeArtwork, + 'artwork_suggestion' => $artworkSuggestion, + 'collection' => $collection, + 'group' => $challengeGroup, + 'challenge' => $linkedChallenge, + 'article' => $article, + ]; +} + it('forbids world studio pages for non moderators', function (): void { $user = User::factory()->create(); @@ -76,6 +338,7 @@ it('renders world studio pages for moderators', function (): void { ->component('Studio/StudioWorldsIndex') ->where('title', 'Worlds') ->where('listing.items.0.title', 'Halloween World 2026') + ->where('analytics.default_range', '30d') ->where('createUrl', route('studio.worlds.create'))); $this->actingAs($moderator) @@ -101,7 +364,8 @@ it('renders world studio pages for moderators', function (): void { ->where('world.title', 'Halloween World 2026') ->where('world.slug', 'halloween-world-2026') ->where('world.section_visibility_json.featured_artworks', true) - ->where('duplicateActions.canCreateEdition', false)); + ->where('duplicateActions.canCreateEdition', false) + ->where('duplicateActions.duplicateModeOptions.0.value', 'structure_only')); $this->actingAs($moderator) ->get(route('studio.worlds.preview', ['world' => $world->id])) @@ -141,6 +405,221 @@ it('renders world studio pages for legacy admin accounts', function (): void { ->where('title', 'Create world')); }); +it('surfaces recap workflow data in the studio editor and preview payload', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'recapstudiomod', + 'name' => 'Recap Studio Moderator', + ]); + $category = studioWorldNewsCategory(); + $article = studioPublishedWorldNews($moderator, $category, [ + 'title' => 'Halloween World 2025 recap story', + ]); + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Halloween World 2025', + 'slug' => 'halloween-world-2025', + 'status' => World::STATUS_ARCHIVED, + 'starts_at' => now()->subDays(20), + 'ends_at' => now()->subDays(3), + 'published_at' => now()->subDays(25), + 'recap_status' => World::RECAP_STATUS_DRAFT, + 'recap_title' => 'Halloween World 2025 recap', + 'recap_summary' => 'Draft summary for the archived edition.', + 'recap_intro' => '

Draft recap intro.

', + 'recap_editor_note' => 'Internal recap note for archive cleanup.', + 'recap_cover_path' => 'worlds/recaps/halloween-world-2025-cover.jpg', + 'recap_article_id' => $article->id, + ]); + + $this->actingAs($moderator) + ->get(route('studio.worlds.edit', ['world' => $world->id])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioWorldEditor') + ->where('world.recap_status', 'draft') + ->where('world.recap_status_label', 'Draft recap') + ->where('world.recap_title', 'Halloween World 2025 recap') + ->where('world.recap_editor_note', 'Internal recap note for archive cleanup.') + ->where('world.recap_cover_path', 'worlds/recaps/halloween-world-2025-cover.jpg') + ->where('world.recap_cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/halloween-world-2025-cover.jpg') + ->where('world.recap_article.title', 'Halloween World 2025 recap story') + ->where('publishRecapUrl', route('studio.worlds.recap.publish', ['world' => $world]))); + + $this->actingAs($moderator) + ->get(route('studio.worlds.preview', ['world' => $world->id])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('World/WorldShow') + ->where('previewMode', true) + ->where('recap.status', 'draft_preview') + ->where('recap.title', 'Halloween World 2025 recap') + ->where('recap.cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/halloween-world-2025-cover.jpg') + ->where('recap.article.title', 'Halloween World 2025 recap story')); +}); + +it('publishes recap snapshots for ended worlds in studio', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'publishrecapmod', + 'name' => 'Publish Recap Moderator', + ]); + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Retro Month 2025', + 'slug' => 'retro-month-2025', + 'status' => World::STATUS_ARCHIVED, + 'is_active_campaign' => true, + 'is_homepage_featured' => true, + 'starts_at' => now()->subDays(35), + 'ends_at' => now()->subDays(7), + 'published_at' => now()->subDays(40), + 'accepts_submissions' => true, + 'community_section_enabled' => true, + ]); + + $artwork = Artwork::factory()->for($moderator)->create([ + 'title' => 'Retro Month Winner', + 'slug' => 'retro-month-winner-' . Str::lower(Str::random(6)), + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + + $submission = WorldSubmission::query()->create([ + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'submitted_by_user_id' => $moderator->id, + 'status' => WorldSubmission::STATUS_LIVE, + 'is_featured' => true, + 'featured_at' => now()->subDay(), + 'created_at' => now()->subDay(), + 'updated_at' => now()->subDay(), + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $moderator->id, + 'world_id' => $world->id, + 'world_submission_id' => $submission->id, + 'artwork_id' => $artwork->id, + 'reward_type' => 'winner', + 'grant_source' => 'manual', + 'granted_at' => now()->subHours(8), + ]); + + $this->actingAs($moderator) + ->from(route('studio.worlds.edit', ['world' => $world->id])) + ->post(route('studio.worlds.recap.publish', ['world' => $world])) + ->assertRedirect(route('studio.worlds.edit', ['world' => $world->id])) + ->assertSessionHas('success', 'World recap published.'); + + $world->refresh(); + + expect($world->recap_status)->toBe(World::RECAP_STATUS_PUBLISHED); + expect($world->recap_published_at)->not->toBeNull(); + expect($world->recap_stats_snapshot_json)->toBeArray(); + expect($world->is_active_campaign)->toBeFalse(); + expect($world->is_homepage_featured)->toBeFalse(); + expect(data_get($world->recap_stats_snapshot_json, 'summary.live_participations'))->toBeGreaterThanOrEqual(1); + expect(data_get($world->recap_stats_snapshot_json, 'summary.reward_grants'))->toBeGreaterThanOrEqual(1); +}); + +it('includes portfolio analytics leaderboards on the studio worlds index', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'portfolioanalyticsmod', + 'name' => 'Portfolio Analytics Moderator', + ]); + $artwork = Artwork::factory()->for($moderator)->create([ + 'title' => 'Portfolio Artwork', + 'slug' => 'portfolio-artwork', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Portfolio World', + 'slug' => 'portfolio-world', + 'status' => World::STATUS_PUBLISHED, + 'published_at' => now()->subDays(5), + ]); + + WorldAnalyticsEvent::query()->create([ + 'world_id' => $world->id, + 'world_slug' => $world->slug, + 'world_type' => $world->type, + 'recurrence_key' => $world->recurrence_key, + 'edition_year' => $world->edition_year, + 'event_type' => 'world_source_impression', + 'section_key' => 'spotlight', + 'source_surface' => 'homepage_spotlight', + 'source_detail' => 'primary', + 'viewer_type' => 'guest', + 'visitor_key' => hash('sha256', 'visitor:portfolio-impression'), + 'occurred_at' => now()->subHours(3), + ]); + + WorldAnalyticsEvent::query()->create([ + 'world_id' => $world->id, + 'world_slug' => $world->slug, + 'world_type' => $world->type, + 'recurrence_key' => $world->recurrence_key, + 'edition_year' => $world->edition_year, + 'event_type' => 'world_viewed', + 'source_surface' => 'homepage_spotlight', + 'source_detail' => 'primary', + 'viewer_type' => 'guest', + 'visitor_key' => hash('sha256', 'visitor:portfolio-viewer'), + 'occurred_at' => now()->subHours(2), + ]); + + WorldAnalyticsEvent::query()->create([ + 'world_id' => $world->id, + 'world_slug' => $world->slug, + 'world_type' => $world->type, + 'recurrence_key' => $world->recurrence_key, + 'edition_year' => $world->edition_year, + 'event_type' => 'world_source_clicked', + 'source_surface' => 'homepage_spotlight', + 'source_detail' => 'primary', + 'viewer_type' => 'guest', + 'visitor_key' => hash('sha256', 'visitor:portfolio-click'), + 'occurred_at' => now()->subHours(2), + ]); + + WorldSubmission::query()->create([ + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'submitted_by_user_id' => $moderator->id, + 'status' => WorldSubmission::STATUS_LIVE, + 'created_at' => now()->subHour(), + 'updated_at' => now()->subHour(), + ]); + + WorldRewardGrant::query()->create([ + 'user_id' => $moderator->id, + 'world_id' => $world->id, + 'artwork_id' => $artwork->id, + 'reward_type' => 'featured', + 'grant_source' => 'manual', + 'granted_at' => now()->subMinutes(30), + ]); + + $this->actingAs($moderator) + ->get(route('studio.worlds.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioWorldsIndex') + ->where('analytics.ranges.30d.summary.tracked_worlds', 1) + ->where('analytics.ranges.30d.summary.promotion_impressions', 1) + ->where('analytics.ranges.30d.leaderboards.views.0.world_id', $world->id) + ->where('analytics.ranges.30d.leaderboards.submissions.0.world_id', $world->id) + ->where('analytics.ranges.30d.leaderboards.conversion.0.world_id', $world->id)); +}); + it('searches artwork relations by creator and project context in the worlds picker', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', @@ -264,6 +743,154 @@ it('stores a world draft through the studio flow', function (): void { ]); }); +it('stores hidden linked challenge entries and exposes them back in the editor payload', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'challengeoverridemod', + 'name' => 'Challenge Override Moderator', + ]); + $groupOwner = User::factory()->create([ + 'username' => 'challengeoverrideowner', + ]); + $group = Group::factory()->for($groupOwner, 'owner')->create(); + $hiddenEntry = Artwork::factory()->for($groupOwner)->create([ + 'group_id' => $group->id, + 'title' => 'Hide Me', + 'slug' => 'hide-me', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + $visibleEntry = Artwork::factory()->for($groupOwner)->create([ + 'group_id' => $group->id, + 'title' => 'Keep Me Live', + 'slug' => 'keep-me-live', + 'artwork_status' => 'published', + 'published_at' => now()->subDay(), + 'is_public' => true, + 'visibility' => Artwork::VISIBILITY_PUBLIC, + ]); + + $challenge = GroupChallenge::query()->create([ + 'group_id' => $group->id, + 'title' => 'Studio Override Challenge', + 'slug' => 'studio-override-challenge', + 'summary' => 'Challenge summary', + 'description' => 'Challenge description', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ACTIVE, + 'start_at' => now()->subDay(), + 'end_at' => now()->addDays(2), + 'created_by_user_id' => $groupOwner->id, + ]); + + $challenge->artworks()->attach($hiddenEntry->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]); + $challenge->artworks()->attach($visibleEntry->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 1]); + + $response = $this->actingAs($moderator)->post(route('studio.worlds.store'), [ + 'title' => 'Challenge Override World', + 'slug' => 'challenge-override-world', + 'summary' => 'World summary', + 'description' => 'World description', + 'status' => World::STATUS_DRAFT, + 'type' => World::TYPE_CAMPAIGN, + 'linked_challenge_id' => $challenge->id, + 'hidden_linked_challenge_artwork_ids_json' => [$hiddenEntry->id], + 'relations' => [], + ]); + + $world = World::query()->where('slug', 'challenge-override-world')->firstOrFail(); + + $response->assertRedirect(route('studio.worlds.edit', ['world' => $world->id])); + + expect($world->fresh()->hidden_linked_challenge_artwork_ids_json)->toBe([$hiddenEntry->id]); + + $this->actingAs($moderator) + ->get(route('studio.worlds.edit', ['world' => $world->id])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioWorldEditor') + ->where('world.hidden_linked_challenge_artwork_ids_json.0', $hiddenEntry->id) + ->where('world.linked_challenge.entry_preview_items.0.title', 'Hide Me') + ->where('world.linked_challenge.entry_preview_items.1.title', 'Keep Me Live')); +}); + +it('rejects archived and time-mismatched linked challenges for published worlds', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'linkedchallengevalidationmod', + 'name' => 'Linked Challenge Validation Moderator', + ]); + $groupOwner = User::factory()->create([ + 'username' => 'linkedchallengevalidationowner', + ]); + $group = Group::factory()->for($groupOwner, 'owner')->create(); + + $archivedChallenge = GroupChallenge::query()->create([ + 'group_id' => $group->id, + 'title' => 'Archived Challenge', + 'slug' => 'archived-challenge', + 'summary' => 'Archived summary', + 'description' => 'Archived description', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ARCHIVED, + 'start_at' => now()->subDays(10), + 'end_at' => now()->subDays(5), + 'created_by_user_id' => $groupOwner->id, + ]); + + $this->actingAs($moderator) + ->from(route('studio.worlds.create')) + ->post(route('studio.worlds.store'), [ + 'title' => 'Live World With Archived Challenge', + 'slug' => 'live-world-with-archived-challenge', + 'summary' => 'World summary', + 'description' => 'World description', + 'status' => World::STATUS_PUBLISHED, + 'type' => World::TYPE_CAMPAIGN, + 'starts_at' => now()->subDay()->format('Y-m-d H:i:s'), + 'ends_at' => now()->addDays(5)->format('Y-m-d H:i:s'), + 'linked_challenge_id' => $archivedChallenge->id, + 'relations' => [], + ]) + ->assertRedirect(route('studio.worlds.create')) + ->assertSessionHasErrors(['linked_challenge_id']); + + $activeMismatchChallenge = GroupChallenge::query()->create([ + 'group_id' => $group->id, + 'title' => 'Mismatched Challenge', + 'slug' => 'mismatched-challenge', + 'summary' => 'Mismatch summary', + 'description' => 'Mismatch description', + 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, + 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, + 'status' => GroupChallenge::STATUS_ACTIVE, + 'start_at' => now()->addDays(30), + 'end_at' => now()->addDays(40), + 'created_by_user_id' => $groupOwner->id, + ]); + + $this->actingAs($moderator) + ->from(route('studio.worlds.create')) + ->post(route('studio.worlds.store'), [ + 'title' => 'Live World With Mismatched Challenge', + 'slug' => 'live-world-with-mismatched-challenge', + 'summary' => 'World summary', + 'description' => 'World description', + 'status' => World::STATUS_PUBLISHED, + 'type' => World::TYPE_CAMPAIGN, + 'starts_at' => now()->subDay()->format('Y-m-d H:i:s'), + 'ends_at' => now()->addDays(5)->format('Y-m-d H:i:s'), + 'linked_challenge_id' => $activeMismatchChallenge->id, + 'relations' => [], + ]) + ->assertRedirect(route('studio.worlds.create')) + ->assertSessionHasErrors(['linked_challenge_id']); +}); + it('rejects reserved world slugs in the studio flow', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', @@ -329,6 +956,36 @@ it('requires recurrence metadata and blocks duplicate recurrence editions', func ]) ->assertRedirect(route('studio.worlds.create')) ->assertSessionHasErrors(['edition_year']); + + studioWorld([ + 'creator' => $moderator, + 'title' => 'Halloween World Current', + 'slug' => 'halloween-world-current', + 'status' => World::STATUS_PUBLISHED, + 'is_recurring' => true, + 'recurrence_key' => 'halloween', + 'edition_year' => 2027, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDays(5), + 'published_at' => now()->subDays(2), + ]); + + $this->actingAs($moderator) + ->from(route('studio.worlds.create')) + ->post(route('studio.worlds.store'), [ + 'title' => 'Halloween World Duplicate Current', + 'slug' => 'halloween-world-duplicate-current', + 'status' => World::STATUS_PUBLISHED, + 'type' => World::TYPE_SEASONAL, + 'is_recurring' => true, + 'recurrence_key' => 'halloween', + 'edition_year' => 2028, + 'starts_at' => now()->subHour(), + 'ends_at' => now()->addDays(7), + 'relations' => [], + ]) + ->assertRedirect(route('studio.worlds.create')) + ->assertSessionHasErrors(['status']); }); it('duplicates worlds and preserves editorial structure in a new draft', function (): void { @@ -350,6 +1007,13 @@ it('duplicates worlds and preserves editorial structure in a new draft', functio ], 'starts_at' => Carbon::parse('2026-07-01 09:00:00'), 'published_at' => Carbon::parse('2026-06-28 18:00:00'), + 'recap_status' => World::RECAP_STATUS_PUBLISHED, + 'recap_title' => 'Pixel Week 2026 recap', + 'recap_summary' => 'Archived summary', + 'recap_intro' => '

Archived intro

', + 'recap_editor_note' => 'Reset this note on duplicate.', + 'recap_cover_path' => 'worlds/recaps/pixel-week-2026-cover.jpg', + 'recap_published_at' => Carbon::parse('2026-07-11 10:00:00'), ]); $world->worldRelations()->create([ @@ -371,6 +1035,11 @@ it('duplicates worlds and preserves editorial structure in a new draft', functio expect($duplicate->is_featured)->toBeFalse(); expect($duplicate->starts_at)->toBeNull(); expect($duplicate->published_at)->toBeNull(); + expect($duplicate->recap_status)->toBe(World::RECAP_STATUS_DRAFT); + expect($duplicate->recap_title)->toBeNull(); + expect($duplicate->recap_editor_note)->toBeNull(); + expect($duplicate->recap_cover_path)->toBeNull(); + expect($duplicate->recap_published_at)->toBeNull(); expect($duplicate->section_visibility_json)->toMatchArray([ 'featured_artworks' => true, 'featured_collections' => false, @@ -379,6 +1048,44 @@ it('duplicates worlds and preserves editorial structure in a new draft', functio expect($duplicate->worldRelations()->count())->toBe(1); }); +it('can duplicate a world as a structural shell without copying curated relations', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'structuralworldmod', + 'name' => 'Structural World Moderator', + ]); + + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Spring Vibes 2026', + 'slug' => 'spring-vibes-2026', + 'is_recurring' => true, + 'recurrence_key' => 'spring-vibes', + 'edition_year' => 2026, + ]); + + $world->worldRelations()->create([ + 'section_key' => 'featured_creators', + 'related_type' => 'user', + 'related_id' => $moderator->id, + 'context_label' => 'Spring lead', + 'sort_order' => 0, + 'is_featured' => true, + ]); + + $response = $this->actingAs($moderator)->post(route('studio.worlds.duplicate', ['world' => $world->id]), [ + 'copy_mode' => 'structure_only', + ]); + + $duplicate = World::query()->where('slug', 'like', 'spring-vibes-2026-copy%')->latest('id')->firstOrFail(); + + $response->assertRedirect(route('studio.worlds.edit', ['world' => $duplicate->id])); + expect($duplicate->is_recurring)->toBeFalse(); + expect($duplicate->recurrence_key)->toBeNull(); + expect($duplicate->edition_year)->toBeNull(); + expect($duplicate->worldRelations()->count())->toBe(0); +}); + it('creates the next edition draft for recurring worlds', function (): void { $moderator = User::factory()->create([ 'role' => 'moderator', @@ -393,6 +1100,15 @@ it('creates the next edition draft for recurring worlds', function (): void { 'is_recurring' => true, 'recurrence_key' => 'halloween', 'edition_year' => 2026, + 'submission_starts_at' => Carbon::parse('2026-09-15 00:00:00'), + 'submission_ends_at' => Carbon::parse('2026-10-30 00:00:00'), + 'cta_url' => 'https://skinbase.test/worlds/halloween-2026', + 'badge_url' => 'https://skinbase.test/badges/halloween-2026', + 'recap_status' => World::RECAP_STATUS_PUBLISHED, + 'recap_title' => 'Halloween 2026 recap', + 'recap_editor_note' => 'Clear this note for the next edition.', + 'recap_cover_path' => 'worlds/recaps/halloween-2026-cover.jpg', + 'recap_published_at' => Carbon::parse('2026-11-01 12:00:00'), ]); $response = $this->actingAs($moderator)->post(route('studio.worlds.new-edition', ['world' => $world->id])); @@ -404,4 +1120,241 @@ it('creates the next edition draft for recurring worlds', function (): void { expect($edition->status)->toBe(World::STATUS_DRAFT); expect($edition->is_recurring)->toBeTrue(); expect($edition->slug)->toContain('2027'); + expect($edition->submission_starts_at)->toBeNull(); + expect($edition->submission_ends_at)->toBeNull(); + expect($edition->cta_url)->toBeNull(); + expect($edition->badge_url)->toBeNull(); + expect($edition->recap_status)->toBe(World::RECAP_STATUS_DRAFT); + expect($edition->recap_title)->toBeNull(); + expect($edition->recap_editor_note)->toBeNull(); + expect($edition->recap_cover_path)->toBeNull(); + expect($edition->recap_published_at)->toBeNull(); +}); + +it('shows recurring family context in the studio editor for recurring worlds', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'familycontextmod', + 'name' => 'Family Context Moderator', + ]); + + studioWorld([ + 'creator' => $moderator, + 'title' => 'Pixel Week 2025', + 'slug' => 'pixel-week-2025', + 'status' => World::STATUS_ARCHIVED, + 'is_recurring' => true, + 'recurrence_key' => 'pixel-week', + 'edition_year' => 2025, + 'starts_at' => Carbon::parse('2025-07-01 00:00:00'), + 'ends_at' => Carbon::parse('2025-07-10 00:00:00'), + 'published_at' => Carbon::parse('2025-06-28 18:00:00'), + ]); + + $world = studioWorld([ + 'creator' => $moderator, + 'title' => 'Pixel Week 2026', + 'slug' => 'pixel-week-2026', + 'status' => World::STATUS_PUBLISHED, + 'is_recurring' => true, + 'recurrence_key' => 'pixel-week', + 'edition_year' => 2026, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDays(7), + 'published_at' => now()->subDays(2), + ]); + + $this->actingAs($moderator) + ->get(route('studio.worlds.edit', ['world' => $world->id])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioWorldEditor') + ->where('world.family_title', 'Pixel Week') + ->where('world.is_canonical_edition', true) + ->where('world.family_edition_count', 2) + ->where('world.previous_edition.title', 'Pixel Week 2025')); +}); + +it('surfaces editorial suggestions across the expected world content categories', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'suggestionsmod', + 'name' => 'Suggestions Moderator', + ]); + + $fixture = studioWorldSuggestionFixture($moderator); + $world = $fixture['world']; + $communityArtwork = $fixture['community_artwork']; + + foreach (range(1, 4) as $index) { + WorldAnalyticsEvent::query()->create([ + 'world_id' => $world->id, + 'event_type' => WorldAnalyticsService::EVENT_ENTITY_CLICKED, + 'world_slug' => $world->slug, + 'world_type' => $world->type, + 'recurrence_key' => $world->recurrence_key, + 'edition_year' => $world->edition_year, + 'section_key' => 'community_submissions', + 'entity_type' => WorldRelation::TYPE_ARTWORK, + 'entity_id' => $communityArtwork->id, + 'entity_title' => $communityArtwork->title, + 'viewer_type' => 'user', + 'user_id' => $moderator->id, + 'visitor_key' => hash('sha256', 'suggestion-analytics-' . $index), + 'occurred_at' => now()->subMinutes($index), + ]); + } + + $response = $this->actingAs($moderator) + ->get(route('studio.worlds.edit', ['world' => $world->id])) + ->assertOk(); + + $suggestions = data_get($response->viewData('page'), 'props.suggestions'); + $groups = collect((array) data_get($suggestions, 'groups'))->keyBy('key'); + + expect(data_get($suggestions, 'enabled'))->toBeTrue(); + expect(data_get($suggestions, 'summary.has_linked_challenge'))->toBeTrue(); + expect(data_get($suggestions, 'summary.world_is_recurring'))->toBeTrue(); + expect(data_get($suggestions, 'summary.family_signal_count'))->toBeGreaterThanOrEqual(1); + expect(data_get($suggestions, 'summary.community_submission_count'))->toBe(1); + expect(data_get($suggestions, 'summary.analytics_signal_count'))->toBeGreaterThanOrEqual(1); + expect(collect((array) data_get($suggestions, 'filters.sort_options'))->pluck('value')->all()) + ->toContain('relevance', 'newest', 'performance'); + + expect(data_get($groups->get('challenge'), 'items.0.title'))->toBe('Retro Circuit Finalist'); + expect(collect((array) data_get($groups->get('challenge'), 'items.0.reasons'))->pluck('label')->all()) + ->toContain('Challenge finalist'); + + expect(data_get($groups->get('community'), 'items.0.title'))->toBe('Retro Skyline Community Entry'); + expect(collect((array) data_get($groups->get('community'), 'items.0.reasons'))->pluck('label')->all()) + ->toContain('Already a featured community submission', 'Top-clicked in this world'); + expect(data_get($groups->get('community'), 'items.0.signals.analytics_informed'))->toBeTrue(); + + expect(data_get($groups->get('artworks'), 'items.0.title'))->toBe('Neon Drift Poster'); + expect(collect((array) data_get($groups->get('artworks'), 'items.0.reasons'))->pluck('label')->all()) + ->toContain('Matches world tags'); + + expect(collect((array) data_get($groups->get('creators'), 'items'))->contains(function (array $item): bool { + return (int) ($item['id'] ?? 0) > 0 + && (string) ($item['title'] ?? '') === 'Retro Captain' + && collect((array) ($item['reasons'] ?? []))->pluck('label')->contains('Strong in this world family'); + }))->toBeTrue(); + + expect(data_get($groups->get('collections'), 'items.0.title'))->toBe('Retro Signal Collection'); + expect(data_get($groups->get('groups'), 'items.0.title'))->toBe('Retro Signal Crew'); + expect(data_get($groups->get('news'), 'items.0.title'))->toBe('Retro Month results roundup'); +}); + +it('stores suggestion feedback state and converts suggestions into world relations', function (): void { + $moderator = User::factory()->create([ + 'role' => 'moderator', + 'username' => 'suggestionactionsmod', + 'name' => 'Suggestion Actions Moderator', + ]); + + $fixture = studioWorldSuggestionFixture($moderator); + $world = $fixture['world']; + $artwork = $fixture['artwork_suggestion']; + $collection = $fixture['collection']; + $group = $fixture['group']; + + $this->actingAs($moderator) + ->postJson(route('studio.worlds.suggestions.pin', ['world' => $world->id]), [ + 'related_type' => WorldRelation::TYPE_ARTWORK, + 'related_id' => $artwork->id, + 'section_key' => 'featured_artworks', + ]) + ->assertOk() + ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_PINNED) + ->assertJsonPath('state.section_key', 'featured_artworks'); + + $this->assertDatabaseHas('world_editorial_suggestion_states', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_ARTWORK, + 'related_id' => $artwork->id, + 'status' => WorldEditorialSuggestionState::STATUS_PINNED, + 'section_key' => 'featured_artworks', + ]); + + $this->actingAs($moderator) + ->postJson(route('studio.worlds.suggestions.dismiss', ['world' => $world->id]), [ + 'related_type' => WorldRelation::TYPE_COLLECTION, + 'related_id' => $collection->id, + ]) + ->assertOk() + ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_DISMISSED); + + $this->assertDatabaseHas('world_editorial_suggestion_states', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_COLLECTION, + 'related_id' => $collection->id, + 'status' => WorldEditorialSuggestionState::STATUS_DISMISSED, + ]); + + $this->actingAs($moderator) + ->postJson(route('studio.worlds.suggestions.not-relevant', ['world' => $world->id]), [ + 'related_type' => WorldRelation::TYPE_GROUP, + 'related_id' => $group->id, + ]) + ->assertOk() + ->assertJsonPath('state.status', WorldEditorialSuggestionState::STATUS_NOT_RELEVANT); + + $this->assertDatabaseHas('world_editorial_suggestion_states', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_GROUP, + 'related_id' => $group->id, + 'status' => WorldEditorialSuggestionState::STATUS_NOT_RELEVANT, + ]); + + $response = $this->actingAs($moderator) + ->get(route('studio.worlds.edit', ['world' => $world->id])) + ->assertOk(); + + $suggestions = data_get($response->viewData('page'), 'props.suggestions'); + + expect(data_get($suggestions, 'summary.pinned_count'))->toBe(1); + expect(data_get($suggestions, 'summary.suppressed_count'))->toBe(2); + expect(collect((array) data_get($suggestions, 'pinned_items'))->pluck('key')->all()) + ->toContain('artwork:' . $artwork->id); + + $this->actingAs($moderator) + ->postJson(route('studio.worlds.suggestions.restore', ['world' => $world->id]), [ + 'related_type' => WorldRelation::TYPE_COLLECTION, + 'related_id' => $collection->id, + ]) + ->assertOk(); + + $this->assertDatabaseMissing('world_editorial_suggestion_states', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_COLLECTION, + 'related_id' => $collection->id, + ]); + + $this->actingAs($moderator) + ->postJson(route('studio.worlds.suggestions.add', ['world' => $world->id]), [ + 'related_type' => WorldRelation::TYPE_ARTWORK, + 'related_id' => $artwork->id, + 'section_key' => 'featured_artworks', + 'is_featured' => true, + ]) + ->assertOk() + ->assertJsonPath('already_attached', false) + ->assertJsonPath('relation.related_type', WorldRelation::TYPE_ARTWORK) + ->assertJsonPath('relation.related_id', $artwork->id) + ->assertJsonPath('relation.section_key', 'featured_artworks') + ->assertJsonPath('relation.is_featured', true); + + $this->assertDatabaseHas('world_relations', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_ARTWORK, + 'related_id' => $artwork->id, + 'section_key' => 'featured_artworks', + 'is_featured' => 1, + ]); + + $this->assertDatabaseMissing('world_editorial_suggestion_states', [ + 'world_id' => $world->id, + 'related_type' => WorldRelation::TYPE_ARTWORK, + 'related_id' => $artwork->id, + ]); }); \ No newline at end of file diff --git a/tests/Feature/StudioUploadQueueTest.php b/tests/Feature/StudioUploadQueueTest.php new file mode 100644 index 00000000..a6ae446b --- /dev/null +++ b/tests/Feature/StudioUploadQueueTest.php @@ -0,0 +1,657 @@ + Artwork::factory()->create($attributes)); +} + +function uploadQueueCategory(string $typeName = 'Photography', string $categoryName = 'Portraits'): Category +{ + $suffix = Str::lower(Str::random(6)); + + $contentType = ContentType::query()->create([ + 'name' => $typeName, + 'slug' => Str::slug($typeName) . '-' . $suffix, + 'order' => 1, + 'hide_from_menu' => false, + ]); + + return Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => $categoryName, + 'slug' => Str::slug($categoryName) . '-' . $suffix, + 'is_active' => true, + 'sort_order' => 1, + ]); +} + +beforeEach(function () { + if (DB::connection()->getDriverName() === 'sqlite') { + DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) { + return max($args); + }, -1); + } + + $this->user = User::factory()->create(); + $this->actingAs($this->user); +}); + +test('studio upload queue page loads', function () { + $this->get('/studio/upload-queue') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Studio/StudioUploadQueue') + ->where('title', 'Upload Queue') + ->has('queue.status_options') + ->has('queue.sort_options')); +}); + +test('upload queue batch creation creates draft artworks and queue items with defaults', function () { + $contentType = ContentType::query()->create([ + 'name' => 'Photography', + 'slug' => 'photography', + 'order' => 1, + 'hide_from_menu' => false, + ]); + + $category = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => 'Landscapes', + 'slug' => 'landscapes', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $response = $this->postJson('/api/studio/upload-queue/batches', [ + 'name' => 'Spring Set', + 'files' => [ + ['name' => 'forest-light.png'], + ['name' => 'city-night.webp'], + ], + 'defaults' => [ + 'category_id' => $category->id, + 'tags' => ['forest', 'set'], + 'visibility' => 'unlisted', + ], + ]); + + $response->assertCreated() + ->assertJsonPath('batch.name', 'Spring Set') + ->assertJsonCount(2, 'items'); + + $batch = UploadBatch::query()->firstOrFail(); + expect($batch->total_items)->toBe(2); + + $items = UploadBatchItem::query()->with(['artwork.categories', 'artwork.tags'])->get(); + expect($items)->toHaveCount(2); + + foreach ($items as $item) { + expect($item->artwork)->not->toBeNull() + ->and($item->artwork->visibility)->toBe('unlisted') + ->and($item->artwork->categories->pluck('id')->all())->toBe([$category->id]) + ->and($item->artwork->tags->pluck('slug')->sort()->values()->all())->toBe(['forest', 'set']); + } +}); + +test('upload finish updates queue item when batch item id is supplied', function () { + config()->set('forum_bot_protection.enabled', false); + config()->set('uploads.queue_derivatives', false); + config()->set('uploads.storage_root', storage_path('framework/testing/uploads')); + + Queue::fake(); + File::deleteDirectory((string) config('uploads.storage_root')); + + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Queue batch', + 'status' => 'uploading', + 'total_items' => 1, + ]); + + $artwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'is_public' => false, + 'is_approved' => false, + 'published_at' => null, + 'artwork_status' => 'draft', + ]); + + $item = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => 'queue-test.png', + ]); + + $sessionId = (string) Str::uuid(); + $tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png'); + $sourceImage = base_path('public/favicon/favicon-96x96.png'); + File::ensureDirectoryExists(dirname($tmpPath)); + File::copy($sourceImage, $tmpPath); + + app(UploadSessionRepository::class)->create( + $sessionId, + $this->user->id, + $tmpPath, + UploadSessionStatus::TMP, + '127.0.0.1' + ); + + $token = app(UploadTokenService::class)->generate($sessionId, $this->user->id); + + $this->withHeader('X-Upload-Token', $token) + ->postJson('/api/uploads/finish', [ + 'session_id' => $sessionId, + 'artwork_id' => $artwork->id, + 'batch_item_id' => $item->id, + 'file_name' => 'queue-test.png', + ]) + ->assertOk() + ->assertJsonPath('artwork_id', $artwork->id) + ->assertJsonPath('status', UploadSessionStatus::PROCESSED); + + $item->refresh(); + + expect($item->status)->toBe('processing') + ->and($item->processing_stage)->toBe('maturity_check'); +}); + +test('upload queue bulk publish only publishes ready items', function () { + $contentType = ContentType::query()->create([ + 'name' => 'Photography', + 'slug' => 'photography', + 'order' => 1, + 'hide_from_menu' => false, + ]); + + $category = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => 'Portraits', + 'slug' => 'portraits', + 'is_active' => true, + 'sort_order' => 1, + ]); + + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Publish batch', + 'status' => 'processing', + 'total_items' => 2, + ]); + + $readyArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Ready artwork', + 'file_name' => 'ready.webp', + 'file_path' => 'artworks/test/ready.webp', + 'hash' => str_repeat('a', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'visibility' => 'public', + 'is_public' => false, + 'is_approved' => false, + 'artwork_status' => 'draft', + 'published_at' => null, + 'maturity_status' => 'clear', + 'maturity_ai_status' => 'succeeded', + ]); + $readyArtwork->categories()->sync([$category->id]); + + $blockedArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Blocked artwork', + 'file_name' => 'blocked.webp', + 'file_path' => 'artworks/test/blocked.webp', + 'hash' => str_repeat('b', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'visibility' => 'public', + 'is_public' => false, + 'is_approved' => false, + 'artwork_status' => 'draft', + 'published_at' => null, + 'maturity_status' => 'suspected', + 'maturity_ai_status' => 'succeeded', + ]); + $blockedArtwork->categories()->sync([$category->id]); + + $readyItem = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $readyArtwork->id, + 'original_filename' => 'ready.webp', + ]); + + $blockedItem = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $blockedArtwork->id, + 'original_filename' => 'blocked.webp', + ]); + + $this->postJson('/api/studio/upload-queue/bulk', [ + 'action' => 'publish', + 'item_ids' => [$readyItem->id, $blockedItem->id], + ]) + ->assertOk() + ->assertJsonPath('success', 1) + ->assertJsonPath('failed', 1); + + $readyArtwork->refresh(); + $blockedArtwork->refresh(); + + expect($readyArtwork->artwork_status)->toBe('published') + ->and($readyArtwork->published_at)->not->toBeNull() + ->and($blockedArtwork->artwork_status)->toBe('draft') + ->and($blockedArtwork->published_at)->toBeNull(); +}); + +test('upload queue bulk delete only affects owned drafts', function () { + $otherUser = User::factory()->create(); + + $ownedBatch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Delete batch', + 'status' => 'processing', + 'total_items' => 1, + ]); + + $ownedArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + ]); + + $foreignBatch = UploadBatch::query()->create([ + 'user_id' => $otherUser->id, + 'name' => 'Foreign batch', + 'status' => 'processing', + 'total_items' => 1, + ]); + + $foreignArtwork = uploadQueueArtwork([ + 'user_id' => $otherUser->id, + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + ]); + + $ownedItem = UploadBatchItem::query()->create([ + 'upload_batch_id' => $ownedBatch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $ownedArtwork->id, + 'original_filename' => 'owned.webp', + ]); + + $foreignItem = UploadBatchItem::query()->create([ + 'upload_batch_id' => $foreignBatch->id, + 'user_id' => $otherUser->id, + 'artwork_id' => $foreignArtwork->id, + 'original_filename' => 'foreign.webp', + ]); + + $this->postJson('/api/studio/upload-queue/bulk', [ + 'action' => 'delete', + 'item_ids' => [$ownedItem->id, $foreignItem->id], + 'confirm' => 'DELETE', + ]) + ->assertOk() + ->assertJsonPath('success', 1); + + $ownedItem->refresh(); + $foreignItem->refresh(); + + expect($ownedItem->status)->toBe('deleted') + ->and(Artwork::withTrashed()->find($ownedArtwork->id)?->trashed())->toBeTrue() + ->and($foreignItem->status)->not->toBe('deleted') + ->and(Artwork::find($foreignArtwork->id))->not->toBeNull(); +}); + +test('upload queue retry rejects drafts without processed media', function () { + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Retry batch', + 'status' => 'completed_with_errors', + 'total_items' => 1, + ]); + + $artwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'file_path' => '', + 'hash' => '', + ]); + + $item = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => 'retry.webp', + 'status' => 'failed', + ]); + + $this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry') + ->assertStatus(422) + ->assertJsonValidationErrors(['item']); +}); + +test('upload queue item failure does not break the rest of the batch', function () { + $response = $this->postJson('/api/studio/upload-queue/batches', [ + 'name' => 'Mixed batch', + 'files' => [ + ['name' => 'good.webp'], + ['name' => 'bad.webp'], + ], + ]); + + $batchId = (int) $response->json('batch.id'); + $items = UploadBatchItem::query()->where('upload_batch_id', $batchId)->orderBy('id')->get(); + + expect($items)->toHaveCount(2); + + $this->postJson('/api/studio/upload-queue/items/' . $items[1]->id . '/fail', [ + 'error_code' => 'invalid_file', + 'error_message' => 'Invalid image payload.', + ])->assertOk(); + + $payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batchId]); + $queueItems = collect($payload['items'])->keyBy('id'); + + expect($queueItems)->toHaveCount(2) + ->and($queueItems[$items[0]->id]['status'])->not->toBe('failed') + ->and($queueItems[$items[1]->id]['status'])->toBe('failed') + ->and($queueItems[$items[1]->id]['error_message'])->toBe('Invalid image payload.'); +}); + +test('upload queue processing states update correctly per item', function () { + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Processing batch', + 'status' => 'uploading', + 'total_items' => 1, + ]); + + $artwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Processing artwork', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, + ]); + + $item = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => 'processing.webp', + 'status' => 'uploaded', + 'processing_stage' => 'queued', + ]); + + $queue = app(UploadQueueService::class); + + $queued = $queue->markItemProcessingQueued($item->id); + expect($queued->status)->toBe('processing') + ->and($queued->processing_stage)->toBe('thumbnails'); + + $artwork->forceFill([ + 'file_name' => 'processing.webp', + 'file_path' => 'artworks/test/processing.webp', + 'hash' => str_repeat('c', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + ])->saveQuietly(); + + $processed = $queue->markItemMediaProcessed($item->id); + expect($processed->status)->toBe('processing') + ->and($processed->processing_stage)->toBe('maturity_check'); +}); + +test('upload queue publish readiness respects metadata and maturity review rules', function () { + $category = uploadQueueCategory(); + + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Readiness batch', + 'status' => 'processing', + 'total_items' => 4, + ]); + + $readyArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Ready artwork', + 'file_name' => 'ready.webp', + 'file_path' => 'artworks/test/ready.webp', + 'hash' => str_repeat('d', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, + ]); + $readyArtwork->categories()->sync([$category->id]); + + $missingMetadataArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => '', + 'file_name' => 'metadata.webp', + 'file_path' => 'artworks/test/metadata.webp', + 'hash' => str_repeat('e', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, + ]); + $missingMetadataArtwork->categories()->sync([$category->id]); + + $reviewArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Review artwork', + 'file_name' => 'review.webp', + 'file_path' => 'artworks/test/review.webp', + 'hash' => str_repeat('f', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, + ]); + $reviewArtwork->categories()->sync([$category->id]); + + $processingArtwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Processing artwork', + 'file_name' => 'pending', + 'file_path' => '', + 'hash' => '', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, + ]); + $processingArtwork->categories()->sync([$category->id]); + + $items = collect([ + [$readyArtwork, 'ready.webp'], + [$missingMetadataArtwork, 'metadata.webp'], + [$reviewArtwork, 'review.webp'], + [$processingArtwork, 'processing.webp'], + ])->map(function (array $entry) use ($batch) { + [$artwork, $filename] = $entry; + + return UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => $filename, + 'status' => 'processing', + 'processing_stage' => 'maturity_check', + ]); + }); + + $payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batch->id]); + $byFilename = collect($payload['items'])->keyBy('original_filename'); + + expect($byFilename['ready.webp']['status'])->toBe('ready') + ->and($byFilename['ready.webp']['is_ready_to_publish'])->toBeTrue() + ->and($byFilename['metadata.webp']['status'])->toBe('needs_metadata') + ->and($byFilename['metadata.webp']['is_ready_to_publish'])->toBeFalse() + ->and($byFilename['review.webp']['status'])->toBe('needs_review') + ->and($byFilename['review.webp']['is_ready_to_publish'])->toBeFalse() + ->and($byFilename['processing.webp']['status'])->toBe('processing') + ->and($byFilename['processing.webp']['is_ready_to_publish'])->toBeFalse(); +}); + +test('upload queue retry works for safe failure cases', function () { + Queue::fake(); + + $category = uploadQueueCategory(); + + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'Retry safe batch', + 'status' => 'completed_with_errors', + 'total_items' => 1, + ]); + + $artwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Retry safe artwork', + 'file_name' => 'retry-safe.webp', + 'file_path' => 'artworks/test/retry-safe.webp', + 'hash' => str_repeat('g', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, + ]); + $artwork->categories()->sync([$category->id]); + + $item = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => 'retry-safe.webp', + 'status' => 'failed', + 'processing_stage' => 'finalized', + 'error_code' => 'vision_timeout', + 'error_message' => 'Vision analysis timed out.', + ]); + + $this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry') + ->assertOk() + ->assertJsonPath('ok', true); + + $item->refresh(); + + expect($item->status)->toBe('processing') + ->and($item->processing_stage)->toBe('maturity_check') + ->and($item->error_code)->toBeNull() + ->and($item->error_message)->toBeNull(); + + Queue::assertPushed(AutoTagArtworkJob::class); + Queue::assertPushed(DetectArtworkMaturityJob::class); + Queue::assertPushed(GenerateArtworkEmbeddingJob::class); + Queue::assertPushed(AnalyzeArtworkAiAssistJob::class); +}); + +test('upload queue AI generation does not overwrite manual metadata silently', function () { + Queue::fake(); + + $category = uploadQueueCategory(); + + $batch = UploadBatch::query()->create([ + 'user_id' => $this->user->id, + 'name' => 'AI batch', + 'status' => 'completed_with_errors', + 'total_items' => 1, + ]); + + $artwork = uploadQueueArtwork([ + 'user_id' => $this->user->id, + 'title' => 'Manual title', + 'description' => 'Manual description', + 'file_name' => 'manual.webp', + 'file_path' => 'artworks/test/manual.webp', + 'hash' => str_repeat('h', 64), + 'thumb_ext' => 'webp', + 'file_ext' => 'webp', + 'artwork_status' => 'draft', + 'is_public' => false, + 'published_at' => null, + 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, + 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, + ]); + $artwork->categories()->sync([$category->id]); + app(TagService::class)->syncStudioTags($artwork, ['manual-tag']); + + $item = UploadBatchItem::query()->create([ + 'upload_batch_id' => $batch->id, + 'user_id' => $this->user->id, + 'artwork_id' => $artwork->id, + 'original_filename' => 'manual.webp', + 'status' => 'failed', + 'processing_stage' => 'finalized', + 'error_code' => 'metadata_failed', + 'error_message' => 'AI metadata generation failed.', + ]); + + $this->postJson('/api/studio/upload-queue/bulk', [ + 'action' => 'generate_ai', + 'item_ids' => [$item->id], + ]) + ->assertOk() + ->assertJsonPath('success', 1) + ->assertJsonPath('failed', 0); + + $artwork->refresh(); + + expect($artwork->title)->toBe('Manual title') + ->and($artwork->description)->toBe('Manual description') + ->and($artwork->categories()->pluck('categories.id')->all())->toBe([$category->id]) + ->and($artwork->tags()->pluck('tags.slug')->all())->toBe(['manual-tag']); + + Queue::assertPushed(AnalyzeArtworkAiAssistJob::class); +}); \ No newline at end of file diff --git a/tests/Feature/Uploads/UploadFinishAiDispatchTest.php b/tests/Feature/Uploads/UploadFinishAiDispatchTest.php index e78bf390..91f99aea 100644 --- a/tests/Feature/Uploads/UploadFinishAiDispatchTest.php +++ b/tests/Feature/Uploads/UploadFinishAiDispatchTest.php @@ -69,7 +69,7 @@ it('dispatches AI processing jobs after upload finish publishes successfully', f ->and($artwork->width)->toBeGreaterThan(0) ->and($artwork->height)->toBeGreaterThan(0); - expect(File::exists((string) $tmpPath))->toBeTrue(); + expect(File::exists((string) $tmpPath))->toBeFalse(); }); it('blocks upload finish only when the hash already belongs to a published artwork', function () { diff --git a/tests/Feature/Worlds/WorldAnalyticsTest.php b/tests/Feature/Worlds/WorldAnalyticsTest.php new file mode 100644 index 00000000..31f0dac7 --- /dev/null +++ b/tests/Feature/Worlds/WorldAnalyticsTest.php @@ -0,0 +1,306 @@ +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)); +}); \ No newline at end of file diff --git a/tests/Feature/Worlds/WorldChallengeRewardSyncTest.php b/tests/Feature/Worlds/WorldChallengeRewardSyncTest.php new file mode 100644 index 00000000..9993a4cb --- /dev/null +++ b/tests/Feature/Worlds/WorldChallengeRewardSyncTest.php @@ -0,0 +1,348 @@ +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', + ]); +}); \ No newline at end of file diff --git a/tests/Feature/Worlds/WorldLaunchSeederTest.php b/tests/Feature/Worlds/WorldLaunchSeederTest.php index b1d64fdc..1a02ca3b 100644 --- a/tests/Feature/Worlds/WorldLaunchSeederTest.php +++ b/tests/Feature/Worlds/WorldLaunchSeederTest.php @@ -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); }); \ No newline at end of file diff --git a/tests/Feature/Worlds/WorldPagesTest.php b/tests/Feature/Worlds/WorldPagesTest.php index 61598c8a..13640c25 100644 --- a/tests/Feature/Worlds/WorldPagesTest.php +++ b/tests/Feature/Worlds/WorldPagesTest.php @@ -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' => '

The edition closed with standout artworks, community participation, and a published editorial recap.

', + '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)); }); \ No newline at end of file diff --git a/tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php b/tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php new file mode 100644 index 00000000..0c23b8c1 --- /dev/null +++ b/tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php @@ -0,0 +1,176 @@ +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); +}); \ No newline at end of file diff --git a/tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php b/tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php index 1b7dc6b6..73ac74d7 100644 --- a/tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php +++ b/tests/Feature/Worlds/WorldSubmissionsWorkflowTest.php @@ -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'); }); \ No newline at end of file