new test files
This commit is contained in:
BIN
test-results/legacy-password-export.sqlite
Normal file
BIN
test-results/legacy-password-export.sqlite
Normal file
Binary file not shown.
62
tests/Feature/ArtworkSearchDocumentTest.php
Normal file
62
tests/Feature/ArtworkSearchDocumentTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function createSearchDocumentContentType(string $name, int $sortOrder = 0): int
|
||||
{
|
||||
return (int) DB::table('content_types')->insertGetId([
|
||||
'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]);
|
||||
});
|
||||
48
tests/Feature/BrowseGallerySortTest.php
Normal file
48
tests/Feature/BrowseGallerySortTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Web\BrowseGalleryController;
|
||||
|
||||
it('sorts latest content-type gallery pages by published date instead of draft creation date', function (): void {
|
||||
$sortMap = (new ReflectionClass(BrowseGalleryController::class))->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")'
|
||||
. ')'
|
||||
);
|
||||
});
|
||||
90
tests/Feature/Console/ExportLegacyPasswordsCommandTest.php
Normal file
90
tests/Feature/Console/ExportLegacyPasswordsCommandTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$legacyDatabasePath = base_path('test-results/legacy-password-export.sqlite');
|
||||
$legacyDirectory = dirname($legacyDatabasePath);
|
||||
|
||||
if (! is_dir($legacyDirectory)) {
|
||||
mkdir($legacyDirectory, 0777, true);
|
||||
}
|
||||
|
||||
@unlink($legacyDatabasePath);
|
||||
touch($legacyDatabasePath);
|
||||
|
||||
config()->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)');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
226
tests/Feature/Profile/WorldProfileHistoryTest.php
Normal file
226
tests/Feature/Profile/WorldProfileHistoryTest.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\GroupChallengeOutcome;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createPublicWorldForProfileHistory(array $overrides = []): World
|
||||
{
|
||||
return World::factory()->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'));
|
||||
});
|
||||
118
tests/Feature/Profile/WorldRewardsProfileTest.php
Normal file
118
tests/Feature/Profile/WorldRewardsProfileTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('exposes world rewards on public profile pages', function (): void {
|
||||
$creator = User::factory()->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'));
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
657
tests/Feature/StudioUploadQueueTest.php
Normal file
657
tests/Feature/StudioUploadQueueTest.php
Normal file
@@ -0,0 +1,657 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function uploadQueueArtwork(array $attributes = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(fn () => 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);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
306
tests/Feature/Worlds/WorldAnalyticsTest.php
Normal file
306
tests/Feature/Worlds/WorldAnalyticsTest.php
Normal file
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldAnalyticsEvent;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function analyticsWorld(User $creator, array $attributes = []): World
|
||||
{
|
||||
$slugSuffix = Str::lower(Str::random(6));
|
||||
|
||||
return World::query()->create(array_merge([
|
||||
'title' => 'Analytics World ' . $slugSuffix,
|
||||
'slug' => 'analytics-world-' . $slugSuffix,
|
||||
'tagline' => 'Measured campaign storytelling.',
|
||||
'summary' => 'A world used to verify analytics reporting.',
|
||||
'description' => 'Analytics world description',
|
||||
'theme_key' => 'summer',
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_CAMPAIGN,
|
||||
'is_featured' => true,
|
||||
'published_at' => now()->subDays(5),
|
||||
'starts_at' => now()->subDays(3),
|
||||
'ends_at' => now()->addDays(10),
|
||||
'created_by_user_id' => $creator->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('records worlds analytics events through the public api', function (): void {
|
||||
$creator = User::factory()->create();
|
||||
$world = analyticsWorld($creator);
|
||||
|
||||
$this->postJson(route('api.worlds.analytics.events.store'), [
|
||||
'world_id' => $world->id,
|
||||
'event_type' => 'world_cta_clicked',
|
||||
'section_key' => 'hero',
|
||||
'cta_key' => 'main_world_cta',
|
||||
'entity_type' => 'world',
|
||||
'entity_id' => $world->id,
|
||||
'entity_title' => $world->title,
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'visitor_token' => 'guest-analytics-token',
|
||||
])->assertAccepted()->assertJson(['ok' => true]);
|
||||
|
||||
$this->assertDatabaseHas('world_analytics_events', [
|
||||
'world_id' => $world->id,
|
||||
'event_type' => 'world_cta_clicked',
|
||||
'section_key' => 'hero',
|
||||
'cta_key' => 'main_world_cta',
|
||||
'entity_type' => 'world',
|
||||
'entity_id' => $world->id,
|
||||
'entity_title' => $world->title,
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'viewer_type' => 'guest',
|
||||
'visitor_key' => hash('sha256', 'visitor:guest-analytics-token'),
|
||||
]);
|
||||
|
||||
$this->postJson(route('api.worlds.analytics.events.store'), [
|
||||
'world_id' => $world->id,
|
||||
'event_type' => 'world_source_impression',
|
||||
'section_key' => 'spotlight',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'visitor_token' => 'guest-analytics-impression',
|
||||
])->assertAccepted()->assertJson(['ok' => true]);
|
||||
|
||||
$this->assertDatabaseHas('world_analytics_events', [
|
||||
'world_id' => $world->id,
|
||||
'event_type' => 'world_source_impression',
|
||||
'section_key' => 'spotlight',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'viewer_type' => 'guest',
|
||||
'visitor_key' => hash('sha256', 'visitor:guest-analytics-impression'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes analytics summaries and edition comparison on studio world pages', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'analyticsmod-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$groupOwner = User::factory()->create([
|
||||
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->create([
|
||||
'name' => 'Retro Group ' . Str::upper(Str::random(4)),
|
||||
'slug' => 'retro-group-' . Str::lower(Str::random(4)),
|
||||
]);
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Retro Challenge ' . Str::upper(Str::random(4)),
|
||||
'slug' => 'retro-challenge-' . Str::lower(Str::random(4)),
|
||||
'summary' => 'A linked challenge for analytics verification.',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDay(),
|
||||
'end_at' => now()->addDays(7),
|
||||
'created_by_user_id' => $groupOwner->id,
|
||||
]);
|
||||
|
||||
$currentWorld = analyticsWorld($moderator, [
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026-' . Str::lower(Str::random(4)),
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2026,
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]);
|
||||
$previousWorld = analyticsWorld($moderator, [
|
||||
'title' => 'Retro Month 2025',
|
||||
'slug' => 'retro-month-2025-' . Str::lower(Str::random(4)),
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2025,
|
||||
'starts_at' => now()->subYear(),
|
||||
'ends_at' => now()->subYear()->addDays(10),
|
||||
'published_at' => now()->subYear()->subDays(2),
|
||||
]);
|
||||
$artwork = Artwork::factory()->for($moderator)->create([
|
||||
'title' => 'Analytics Artwork',
|
||||
'slug' => 'analytics-artwork-' . Str::lower(Str::random(4)),
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
WorldSubmission::query()->create([
|
||||
'world_id' => $currentWorld->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $moderator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'is_featured' => true,
|
||||
'created_at' => now()->subDay(),
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
WorldRewardGrant::query()->create([
|
||||
'user_id' => $moderator->id,
|
||||
'world_id' => $currentWorld->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'manual',
|
||||
'granted_at' => now()->subHours(6),
|
||||
]);
|
||||
|
||||
WorldRewardGrant::query()->create([
|
||||
'user_id' => $moderator->id,
|
||||
'world_id' => $previousWorld->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'reward_type' => 'featured',
|
||||
'grant_source' => 'manual',
|
||||
'granted_at' => now()->subYear(),
|
||||
]);
|
||||
|
||||
collect([
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_source_impression',
|
||||
'section_key' => 'spotlight',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'visitor_key' => hash('sha256', 'visitor:impression-one'),
|
||||
'occurred_at' => Carbon::now()->subHours(4),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_source_impression',
|
||||
'section_key' => 'card',
|
||||
'source_surface' => 'worlds_index',
|
||||
'source_detail' => 'featured',
|
||||
'visitor_key' => hash('sha256', 'visitor:impression-two'),
|
||||
'occurred_at' => Carbon::now()->subHours(3),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_viewed',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
|
||||
'occurred_at' => Carbon::now()->subHours(4),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_viewed',
|
||||
'source_surface' => 'worlds_index',
|
||||
'source_detail' => 'featured',
|
||||
'visitor_key' => hash('sha256', 'visitor:viewer-two'),
|
||||
'occurred_at' => Carbon::now()->subHours(3),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_source_clicked',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'source_detail' => 'primary',
|
||||
'entity_type' => 'world',
|
||||
'entity_id' => $currentWorld->id,
|
||||
'entity_title' => $currentWorld->title,
|
||||
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
|
||||
'occurred_at' => Carbon::now()->subHours(4),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_cta_clicked',
|
||||
'section_key' => 'hero',
|
||||
'cta_key' => 'main_world_cta',
|
||||
'source_surface' => 'homepage_spotlight',
|
||||
'visitor_key' => hash('sha256', 'visitor:viewer-one'),
|
||||
'occurred_at' => Carbon::now()->subHours(4),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_challenge_cta_clicked',
|
||||
'section_key' => 'challenge',
|
||||
'challenge_id' => $challenge->id,
|
||||
'visitor_key' => hash('sha256', 'visitor:challenge-viewer'),
|
||||
'occurred_at' => Carbon::now()->subHours(2),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_submission_created',
|
||||
'section_key' => 'community_submissions',
|
||||
'source_surface' => 'upload_flow',
|
||||
'entity_type' => 'artwork',
|
||||
'entity_id' => $artwork->id,
|
||||
'entity_title' => $artwork->title,
|
||||
'visitor_key' => hash('sha256', 'system:submission'),
|
||||
'occurred_at' => Carbon::now()->subHours(2),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_submission_approved',
|
||||
'section_key' => 'community_submissions',
|
||||
'source_surface' => 'upload_flow',
|
||||
'entity_type' => 'artwork',
|
||||
'entity_id' => $artwork->id,
|
||||
'entity_title' => $artwork->title,
|
||||
'visitor_key' => hash('sha256', 'system:approval'),
|
||||
'occurred_at' => Carbon::now()->subHours(2),
|
||||
],
|
||||
[
|
||||
'world_id' => $currentWorld->id,
|
||||
'event_type' => 'world_reward_granted',
|
||||
'section_key' => 'rewards',
|
||||
'entity_type' => 'artwork',
|
||||
'entity_id' => $artwork->id,
|
||||
'entity_title' => $artwork->title,
|
||||
'visitor_key' => hash('sha256', 'system:reward'),
|
||||
'occurred_at' => Carbon::now()->subHours(1),
|
||||
],
|
||||
[
|
||||
'world_id' => $previousWorld->id,
|
||||
'event_type' => 'world_viewed',
|
||||
'source_surface' => 'navigation',
|
||||
'source_detail' => 'archive',
|
||||
'visitor_key' => hash('sha256', 'visitor:archive-viewer'),
|
||||
'occurred_at' => Carbon::now()->subYear(),
|
||||
],
|
||||
])->each(function (array $attributes) use ($currentWorld, $previousWorld): void {
|
||||
$world = (int) $attributes['world_id'] === (int) $currentWorld->id ? $currentWorld : $previousWorld;
|
||||
|
||||
WorldAnalyticsEvent::query()->create(array_merge([
|
||||
'world_slug' => $world->slug,
|
||||
'world_type' => $world->type,
|
||||
'recurrence_key' => $world->recurrence_key,
|
||||
'edition_year' => $world->edition_year,
|
||||
'viewer_type' => 'guest',
|
||||
'user_id' => null,
|
||||
'meta' => null,
|
||||
], $attributes));
|
||||
});
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->get(route('studio.worlds.edit', ['world' => $currentWorld->id]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Studio/StudioWorldEditor')
|
||||
->where('world.analytics.ranges.30d.summary.views', 2)
|
||||
->where('world.analytics.ranges.30d.summary.unique_visitors', 2)
|
||||
->where('world.analytics.ranges.30d.summary.promotion_impressions', 2)
|
||||
->where('world.analytics.ranges.30d.summary.cta_clicks', 1)
|
||||
->where('world.analytics.ranges.30d.summary.reward_grants', 1)
|
||||
->where('world.analytics.ranges.30d.participation.live', 1)
|
||||
->where('world.analytics.ranges.30d.sources.0.impressions', 1)
|
||||
->where('world.analytics.ranges.30d.sources.0.clickthrough_rate', 1)
|
||||
->where('world.analytics.ranges.30d.challenge.linked_challenge_id', $challenge->id)
|
||||
->where('world.analytics.ranges.30d.challenge.click_to_submission_conversion', 1)
|
||||
->where('world.analytics.ranges.30d.sources.0.source_surface', 'homepage_spotlight')
|
||||
->where('world.analytics.edition_comparison.recurrence_key', 'retro-month')
|
||||
->has('world.analytics.edition_comparison.editions', 2));
|
||||
});
|
||||
348
tests/Feature/Worlds/WorldChallengeRewardSyncTest.php
Normal file
348
tests/Feature/Worlds/WorldChallengeRewardSyncTest.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Services\GroupChallengeService;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function challengeLinkedWorld(?User $moderator = null, array $attributes = []): World
|
||||
{
|
||||
$moderator ??= User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'worldchallenge-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
|
||||
return World::factory()->create(array_merge([
|
||||
'created_by_user_id' => $moderator->id,
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'published_at' => now()->subDay(),
|
||||
'accepts_submissions' => true,
|
||||
'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
|
||||
'submission_note_enabled' => true,
|
||||
'community_section_enabled' => true,
|
||||
'allow_readd_after_removal' => true,
|
||||
'submission_starts_at' => now()->subDay(),
|
||||
'submission_ends_at' => now()->addDays(7),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function worldUpdatePayload(World $world, array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'title' => $world->title,
|
||||
'status' => $world->status,
|
||||
'type' => $world->type,
|
||||
'tagline' => $world->tagline,
|
||||
'summary' => $world->summary,
|
||||
'description' => $world->description,
|
||||
'accepts_submissions' => (bool) $world->accepts_submissions,
|
||||
'participation_mode' => $world->participation_mode,
|
||||
'submission_note_enabled' => (bool) $world->submission_note_enabled,
|
||||
'community_section_enabled' => (bool) $world->community_section_enabled,
|
||||
'allow_readd_after_removal' => (bool) $world->allow_readd_after_removal,
|
||||
'is_featured' => (bool) $world->is_featured,
|
||||
'is_active_campaign' => (bool) $world->is_active_campaign,
|
||||
'is_homepage_featured' => (bool) $world->is_homepage_featured,
|
||||
'is_recurring' => (bool) $world->is_recurring,
|
||||
'cta_label' => $world->cta_label,
|
||||
'cta_url' => $world->cta_url,
|
||||
'badge_label' => $world->badge_label,
|
||||
'badge_description' => $world->badge_description,
|
||||
'badge_url' => $world->badge_url,
|
||||
'linked_challenge_id' => $world->linked_challenge_id,
|
||||
'show_linked_challenge_section' => (bool) ($world->show_linked_challenge_section ?? true),
|
||||
'show_linked_challenge_entries' => (bool) ($world->show_linked_challenge_entries ?? true),
|
||||
'show_linked_challenge_winners' => (bool) ($world->show_linked_challenge_winners ?? true),
|
||||
'show_linked_challenge_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true),
|
||||
'auto_grant_challenge_world_rewards' => (bool) ($world->auto_grant_challenge_world_rewards ?? true),
|
||||
'challenge_teaser_override' => $world->challenge_teaser_override,
|
||||
'relations' => [],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function linkedGroupChallenge(Group $group, User $owner, array $attributes = []): GroupChallenge
|
||||
{
|
||||
return GroupChallenge::query()->create(array_merge([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Pixel Week Finals',
|
||||
'slug' => 'pixel-week-finals-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Challenge finale.',
|
||||
'description' => 'Challenge finale description.',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDay(),
|
||||
'end_at' => now()->addDay(),
|
||||
'created_by_user_id' => $owner->id,
|
||||
'featured_artwork_id' => null,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('syncs winner rewards from linked challenge outcomes', function (): void {
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
$groupOwner = User::factory()->create();
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create();
|
||||
$world = challengeLinkedWorld($moderator);
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Challenge Winner Artwork',
|
||||
'slug' => 'challenge-winner-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$submission = WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'reviewed_by_user_id' => $moderator->id,
|
||||
'reviewed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$challenge = linkedGroupChallenge($group, $groupOwner);
|
||||
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
|
||||
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'related_programming',
|
||||
'related_type' => 'challenge',
|
||||
'related_id' => $challenge->id,
|
||||
'context_label' => 'Challenge finale',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
|
||||
'outcomes' => [[
|
||||
'artwork_id' => $artwork->id,
|
||||
'outcome_type' => 'winner',
|
||||
'position' => 1,
|
||||
'sort_order' => 0,
|
||||
'title_override' => 'Grand Winner',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'world_submission_id' => $submission->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs finalist rewards from linked challenge outcomes', function (): void {
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
$groupOwner = User::factory()->create();
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create();
|
||||
$world = challengeLinkedWorld($moderator);
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Challenge Finalist Artwork',
|
||||
'slug' => 'challenge-finalist-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$submission = WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'reviewed_by_user_id' => $moderator->id,
|
||||
'reviewed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$challenge = linkedGroupChallenge($group, $groupOwner);
|
||||
$challenge->artworks()->attach($artwork->id, ['submitted_by_user_id' => $groupOwner->id, 'sort_order' => 0]);
|
||||
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'related_programming',
|
||||
'related_type' => 'challenge',
|
||||
'related_id' => $challenge->id,
|
||||
'context_label' => 'Challenge finale',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
app(GroupChallengeService::class)->update($challenge, $groupOwner, [
|
||||
'outcomes' => [[
|
||||
'artwork_id' => $artwork->id,
|
||||
'outcome_type' => 'finalist',
|
||||
'sort_order' => 0,
|
||||
'note' => 'Finalist award.',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'world_submission_id' => $submission->id,
|
||||
'reward_type' => 'finalist',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs challenge winner rewards when challenge relations are added to a world', function (): void {
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
$groupOwner = User::factory()->create();
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create();
|
||||
$world = challengeLinkedWorld($moderator);
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Relation Sync Artwork',
|
||||
'slug' => 'relation-sync-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'reviewed_by_user_id' => $moderator->id,
|
||||
'reviewed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$challenge = linkedGroupChallenge($group, $groupOwner, [
|
||||
'featured_artwork_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
|
||||
'relations' => [[
|
||||
'section_key' => 'related_programming',
|
||||
'related_type' => 'challenge',
|
||||
'related_id' => $challenge->id,
|
||||
'context_label' => 'Challenge finale',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]],
|
||||
]));
|
||||
|
||||
$this->assertDatabaseHas('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs challenge winner rewards when a primary linked challenge is set on a world', function (): void {
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
$groupOwner = User::factory()->create();
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create();
|
||||
$world = challengeLinkedWorld($moderator);
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Primary Challenge Sync Artwork',
|
||||
'slug' => 'primary-challenge-sync-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'reviewed_by_user_id' => $moderator->id,
|
||||
'reviewed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$challenge = linkedGroupChallenge($group, $groupOwner, [
|
||||
'featured_artwork_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
app(WorldService::class)->update($world, $moderator, worldUpdatePayload($world, [
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]));
|
||||
|
||||
$this->assertDatabaseHas('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
});
|
||||
|
||||
it('revokes challenge-sourced winner rewards when linked challenge winners are cleared', function (): void {
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
$groupOwner = User::factory()->create();
|
||||
$group = Group::factory()->for($groupOwner, 'owner')->create();
|
||||
$world = challengeLinkedWorld($moderator);
|
||||
$artwork = Artwork::factory()->for($creator)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Revoked Challenge Winner',
|
||||
'slug' => 'revoked-challenge-winner',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$submission = WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'reviewed_by_user_id' => $moderator->id,
|
||||
'reviewed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$challenge = linkedGroupChallenge($group, $groupOwner);
|
||||
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'related_programming',
|
||||
'related_type' => 'challenge',
|
||||
'related_id' => $challenge->id,
|
||||
'context_label' => 'Challenge finale',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
$challengeService = app(GroupChallengeService::class);
|
||||
$challengeService->update($challenge, $groupOwner, ['featured_artwork_id' => $artwork->id]);
|
||||
|
||||
$this->assertDatabaseHas('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'world_submission_id' => $submission->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
|
||||
$challengeService->update($challenge->fresh(), $groupOwner, ['featured_artwork_id' => null]);
|
||||
|
||||
$this->assertDatabaseMissing('world_reward_grants', [
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'challenge',
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,14 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Services\HomepageService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
|
||||
function publicWorld(array $attributes = []): World
|
||||
{
|
||||
$creator = $attributes['creator'] ?? User::factory()->create([
|
||||
'username' => 'publicworlds',
|
||||
'username' => 'publicworlds-' . Str::lower(Str::random(6)),
|
||||
'name' => 'Public Worlds',
|
||||
]);
|
||||
|
||||
@@ -22,18 +30,55 @@ function publicWorld(array $attributes = []): World
|
||||
'slug' => 'summer-slam-2026',
|
||||
'tagline' => 'Sunlit publishing and warm-color campaigns.',
|
||||
'summary' => 'A bright world for summer culture across the platform.',
|
||||
'teaser_title' => 'Now live: Summer Slam',
|
||||
'teaser_summary' => 'A bright world for summer culture across the platform.',
|
||||
'description' => 'Public world description',
|
||||
'theme_key' => 'summer',
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_SEASONAL,
|
||||
'is_featured' => true,
|
||||
'starts_at' => Carbon::parse('2026-06-01 00:00:00'),
|
||||
'ends_at' => Carbon::parse('2026-08-31 23:59:59'),
|
||||
'published_at' => Carbon::parse('2026-04-01 10:00:00'),
|
||||
'is_active_campaign' => true,
|
||||
'is_homepage_featured' => true,
|
||||
'campaign_priority' => 250,
|
||||
'campaign_label' => 'Seasonal spotlight',
|
||||
'starts_at' => Carbon::now()->subDays(2),
|
||||
'ends_at' => Carbon::now()->addDays(14),
|
||||
'promotion_starts_at' => Carbon::now()->subDay(),
|
||||
'promotion_ends_at' => Carbon::now()->addDays(7),
|
||||
'published_at' => Carbon::now()->subDays(10),
|
||||
'created_by_user_id' => $creator->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function worldNewsCategory(array $attributes = []): NewsCategory
|
||||
{
|
||||
return NewsCategory::query()->create(array_merge([
|
||||
'name' => 'World Updates',
|
||||
'slug' => 'world-updates-' . Str::lower(Str::random(6)),
|
||||
'description' => 'Editorial context for worlds and linked campaigns.',
|
||||
'position' => 0,
|
||||
'is_active' => true,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function publishedWorldNews(User $author, NewsCategory $category, array $attributes = []): NewsArticle
|
||||
{
|
||||
return NewsArticle::query()->create(array_merge([
|
||||
'title' => 'World challenge update',
|
||||
'slug' => 'world-challenge-update-' . Str::lower(Str::random(6)),
|
||||
'excerpt' => 'An editorial update for the linked world challenge.',
|
||||
'content' => "# World challenge update\n\nEditorial context for the linked challenge.",
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'published',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||
'published_at' => now()->subHour(),
|
||||
'is_featured' => true,
|
||||
'is_pinned' => false,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('renders public worlds index and detail pages', function (): void {
|
||||
$world = publicWorld();
|
||||
|
||||
@@ -41,7 +86,8 @@ it('renders public worlds index and detail pages', function (): void {
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldIndex')
|
||||
->where('featuredWorld.title', 'Summer Slam 2026')
|
||||
->where('spotlightWorld.title', 'Summer Slam 2026')
|
||||
->where('spotlightWorld.campaign_state_label', 'Live now')
|
||||
->has('activeWorlds'));
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
@@ -52,6 +98,545 @@ it('renders public worlds index and detail pages', function (): void {
|
||||
->where('world.slug', 'summer-slam-2026'));
|
||||
});
|
||||
|
||||
it('includes rewarded contributors on public world pages', function (): void {
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'rewardedcreator-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$world = publicWorld();
|
||||
$artwork = \App\Models\Artwork::factory()->for($creator)->create([
|
||||
'title' => 'World Winner Artwork',
|
||||
'slug' => 'world-winner-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => \App\Models\Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
WorldRewardGrant::query()->create([
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'manual',
|
||||
'granted_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('rewardedContributors.count', 1)
|
||||
->where('rewardedContributors.creator_count', 1)
|
||||
->where('rewardedContributors.counts.winner', 1)
|
||||
->where('rewardedContributors.items.0.badge_label', $world->title . ' Winner'));
|
||||
});
|
||||
|
||||
it('renders recap payloads for ended worlds with published recaps', function (): void {
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'recapcreator-' . Str::lower(Str::random(6)),
|
||||
'name' => 'Recap Creator',
|
||||
]);
|
||||
$world = publicWorld([
|
||||
'creator' => $creator,
|
||||
'title' => 'Summer Slam 2025',
|
||||
'slug' => 'summer-slam-2025',
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'accepts_submissions' => true,
|
||||
'community_section_enabled' => true,
|
||||
'starts_at' => now()->subDays(30),
|
||||
'ends_at' => now()->subDays(5),
|
||||
'recap_status' => World::RECAP_STATUS_PUBLISHED,
|
||||
'recap_title' => 'Summer Slam 2025 recap',
|
||||
'recap_summary' => 'A tighter archive-facing summary for the edition.',
|
||||
'recap_intro' => '<p>The edition closed with standout artworks, community participation, and a published editorial recap.</p>',
|
||||
'recap_cover_path' => 'worlds/recaps/summer-slam-2025-cover.jpg',
|
||||
'recap_published_at' => now()->subDay(),
|
||||
'recap_stats_snapshot_json' => [
|
||||
'captured_at' => now()->subDay()->toIso8601String(),
|
||||
'summary' => [
|
||||
'views' => 1200,
|
||||
'unique_visitors' => 640,
|
||||
'submissions' => 18,
|
||||
'live_participations' => 18,
|
||||
'featured_participations' => 3,
|
||||
'reward_grants' => 1,
|
||||
'challenge_clicks' => 42,
|
||||
'winner_count' => 1,
|
||||
'finalist_count' => 0,
|
||||
'featured_artwork_count' => 2,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$category = worldNewsCategory([
|
||||
'name' => 'Recap Stories',
|
||||
'slug' => 'recap-stories-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$article = publishedWorldNews($creator, $category, [
|
||||
'title' => 'Summer Slam 2025 closing recap',
|
||||
'slug' => 'summer-slam-2025-closing-recap-' . Str::lower(Str::random(6)),
|
||||
'excerpt' => 'The final recap story for Summer Slam 2025.',
|
||||
]);
|
||||
$world->update(['recap_article_id' => $article->id]);
|
||||
|
||||
$featuredArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Curated Edition Highlight',
|
||||
'slug' => 'curated-edition-highlight-' . Str::lower(Str::random(6)),
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
$communityArtwork = Artwork::factory()->for($creator)->create([
|
||||
'title' => 'Community Spotlight Piece',
|
||||
'slug' => 'community-spotlight-piece-' . Str::lower(Str::random(6)),
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$group = Group::factory()->for($creator, 'owner')->create([
|
||||
'name' => 'Recap Crew',
|
||||
'slug' => 'recap-crew-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'featured_artworks',
|
||||
'related_type' => 'artwork',
|
||||
'related_id' => $featuredArtwork->id,
|
||||
'context_label' => 'Editorial highlight',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'featured_creators',
|
||||
'related_type' => 'user',
|
||||
'related_id' => $creator->id,
|
||||
'context_label' => 'Edition lead',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'featured_groups',
|
||||
'related_type' => 'group',
|
||||
'related_id' => $group->id,
|
||||
'context_label' => 'Community group',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'news',
|
||||
'related_type' => 'news',
|
||||
'related_id' => $article->id,
|
||||
'context_label' => 'Closing story',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
$submission = WorldSubmission::query()->create([
|
||||
'world_id' => $world->id,
|
||||
'artwork_id' => $communityArtwork->id,
|
||||
'submitted_by_user_id' => $creator->id,
|
||||
'status' => WorldSubmission::STATUS_LIVE,
|
||||
'is_featured' => true,
|
||||
'featured_at' => now()->subHours(12),
|
||||
'created_at' => now()->subDay(),
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
WorldRewardGrant::query()->create([
|
||||
'user_id' => $creator->id,
|
||||
'world_id' => $world->id,
|
||||
'world_submission_id' => $submission->id,
|
||||
'artwork_id' => $communityArtwork->id,
|
||||
'reward_type' => 'winner',
|
||||
'grant_source' => 'manual',
|
||||
'granted_at' => now()->subHours(6),
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('world.title', 'Summer Slam 2025')
|
||||
->where('world.has_recap', true)
|
||||
->where('world.cta_label', 'Read full recap')
|
||||
->where('world.cta_url', route('news.show', ['slug' => $article->slug]))
|
||||
->where('recap.status', 'published')
|
||||
->where('recap.title', 'Summer Slam 2025 recap')
|
||||
->where('recap.summary', 'A tighter archive-facing summary for the edition.')
|
||||
->where('recap.cover_url', rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/worlds/recaps/summer-slam-2025-cover.jpg')
|
||||
->where('recap.article.title', 'Summer Slam 2025 closing recap')
|
||||
->where('recap.featured_artworks.items.0.title', 'Curated Edition Highlight')
|
||||
->where('recap.community_highlights.items.0.title', 'Community Spotlight Piece')
|
||||
->where('recap.creators.items.0.title', 'Recap Creator')
|
||||
->where('recap.creators.rewarded.0.badge_label', 'Summer Slam 2025 Winner')
|
||||
->where('recap.stats.source', 'snapshot')
|
||||
->where('recap.stats.items.0.key', 'views')
|
||||
->where('sections', []));
|
||||
});
|
||||
|
||||
it('exposes linked challenge panels, derived entries, and challenge backlinks', function (): void {
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'challenge-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->for($owner, 'owner')->create();
|
||||
$world = publicWorld([
|
||||
'linked_challenge_id' => null,
|
||||
'show_linked_challenge_section' => true,
|
||||
'show_linked_challenge_entries' => true,
|
||||
'show_linked_challenge_winners' => true,
|
||||
'challenge_teaser_override' => 'World-specific framing for the linked challenge.',
|
||||
]);
|
||||
|
||||
$winner = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Challenge Champion',
|
||||
'slug' => 'challenge-champion',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
$entry = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Challenge Entry Two',
|
||||
'slug' => 'challenge-entry-two',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
$finalist = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Challenge Finalist',
|
||||
'slug' => 'challenge-finalist',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'World Challenge Finals',
|
||||
'slug' => 'world-challenge-finals-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Challenge summary',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDay(),
|
||||
'end_at' => now()->addDay(),
|
||||
'created_by_user_id' => $owner->id,
|
||||
'featured_artwork_id' => null,
|
||||
]);
|
||||
|
||||
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
||||
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
||||
$challenge->artworks()->attach($entry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 2]);
|
||||
$challenge->outcomes()->create([
|
||||
'artwork_id' => $winner->id,
|
||||
'user_id' => $owner->id,
|
||||
'outcome_type' => 'winner',
|
||||
'position' => 1,
|
||||
'sort_order' => 0,
|
||||
'title_override' => 'Grand Winner',
|
||||
'awarded_by_user_id' => $owner->id,
|
||||
'awarded_at' => now(),
|
||||
]);
|
||||
$challenge->outcomes()->create([
|
||||
'artwork_id' => $finalist->id,
|
||||
'user_id' => $owner->id,
|
||||
'outcome_type' => 'finalist',
|
||||
'sort_order' => 1,
|
||||
'note' => 'Outstanding finalist selection.',
|
||||
'awarded_by_user_id' => $owner->id,
|
||||
'awarded_at' => now(),
|
||||
]);
|
||||
|
||||
$world->update([
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('linkedChallenge.title', 'World Challenge Finals')
|
||||
->where('linkedChallenge.summary', 'World-specific framing for the linked challenge.')
|
||||
->where('linkedChallenge.state_label', 'Winners announced')
|
||||
->where('linkedChallengeEntries.items.0.title', 'Challenge Champion')
|
||||
->where('linkedChallengeWinners.item.title', 'Challenge Champion')
|
||||
->where('linkedChallengeFinalists.items.0.title', 'Challenge Finalist')
|
||||
->where('world.challenge_cta_label', 'See results'));
|
||||
|
||||
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Group/GroupChallengeShow')
|
||||
->where('linkedWorld.title', $world->title)
|
||||
->where('linkedWorld.public_url', route('worlds.show', ['world' => $world->slug]))
|
||||
->where('linkedWorld.campaign_label', 'Seasonal spotlight')
|
||||
->where('linkedWorld.challenge_cta_label', 'See results'));
|
||||
});
|
||||
|
||||
it('hides selected linked challenge entries from the derived world feed', function (): void {
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'challenge-hide-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->for($owner, 'owner')->create();
|
||||
$world = publicWorld();
|
||||
|
||||
$hiddenEntry = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Hidden Challenge Entry',
|
||||
'slug' => 'hidden-challenge-entry',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
$visibleEntry = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Visible Challenge Entry',
|
||||
'slug' => 'visible-challenge-entry',
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Hidden Entries Challenge',
|
||||
'slug' => 'hidden-entries-challenge-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Challenge summary',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDay(),
|
||||
'end_at' => now()->addDay(),
|
||||
'created_by_user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$challenge->artworks()->attach($hiddenEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
||||
$challenge->artworks()->attach($visibleEntry->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
||||
|
||||
$world->update([
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
'hidden_linked_challenge_artwork_ids_json' => [$hiddenEntry->id],
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('linkedChallengeEntries.hidden_count', 1)
|
||||
->where('linkedChallengeEntries.items.0.title', 'Visible Challenge Entry')
|
||||
->has('linkedChallengeEntries.items', 1));
|
||||
});
|
||||
|
||||
it('maps community-vote challenges to a voting state on the world page', function (): void {
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'challenge-vote-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->for($owner, 'owner')->create();
|
||||
$world = publicWorld();
|
||||
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Vote Live Challenge',
|
||||
'slug' => 'vote-live-challenge-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Vote for the best entry.',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ENDED,
|
||||
'judging_mode' => 'community_vote',
|
||||
'start_at' => now()->subDays(7),
|
||||
'end_at' => now()->subHour(),
|
||||
'created_by_user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$world->update([
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('linkedChallenge.state', 'voting')
|
||||
->where('linkedChallenge.state_label', 'Voting live')
|
||||
->where('linkedChallenge.cta_label', 'View entries')
|
||||
->where('world.challenge_cta_label', 'View entries'));
|
||||
});
|
||||
|
||||
it('shifts linked challenge CTAs into recap mode for archived worlds', function (): void {
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'challenge-archive-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->for($owner, 'owner')->create();
|
||||
$world = publicWorld([
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => now()->subDays(20),
|
||||
'ends_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Archive Recap Challenge',
|
||||
'slug' => 'archive-recap-challenge-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Challenge summary',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDays(5),
|
||||
'end_at' => now()->addDays(2),
|
||||
'created_by_user_id' => $owner->id,
|
||||
]);
|
||||
|
||||
$world->update([
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('linkedChallenge.state', 'closed')
|
||||
->where('linkedChallenge.cta_label', 'View challenge recap')
|
||||
->where('world.challenge_cta_label', 'View challenge recap'));
|
||||
});
|
||||
|
||||
it('surfaces linked challenge recap stories and keeps derived sections in recap mode for archived worlds', function (): void {
|
||||
$owner = User::factory()->create([
|
||||
'username' => 'challenge-recap-owner-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$group = Group::factory()->for($owner, 'owner')->create();
|
||||
$world = publicWorld([
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => now()->subDays(30),
|
||||
'ends_at' => now()->subDays(5),
|
||||
'show_linked_challenge_section' => true,
|
||||
'show_linked_challenge_entries' => true,
|
||||
'show_linked_challenge_winners' => true,
|
||||
'show_linked_challenge_finalists' => true,
|
||||
]);
|
||||
|
||||
$winner = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Recap Winner',
|
||||
'slug' => 'recap-winner-' . Str::lower(Str::random(6)),
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$finalist = Artwork::factory()->for($owner)->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'Recap Finalist',
|
||||
'slug' => 'recap-finalist-' . Str::lower(Str::random(6)),
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now()->subDay(),
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
]);
|
||||
|
||||
$challenge = GroupChallenge::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'title' => 'World Recap Challenge',
|
||||
'slug' => 'world-recap-challenge-' . Str::lower(Str::random(6)),
|
||||
'summary' => 'Challenge summary',
|
||||
'description' => 'Challenge description',
|
||||
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
||||
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
||||
'status' => GroupChallenge::STATUS_ACTIVE,
|
||||
'start_at' => now()->subDays(10),
|
||||
'end_at' => now()->subDays(2),
|
||||
'created_by_user_id' => $owner->id,
|
||||
'featured_artwork_id' => null,
|
||||
]);
|
||||
|
||||
$challenge->artworks()->attach($winner->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 0]);
|
||||
$challenge->artworks()->attach($finalist->id, ['submitted_by_user_id' => $owner->id, 'sort_order' => 1]);
|
||||
$challenge->outcomes()->create([
|
||||
'artwork_id' => $winner->id,
|
||||
'user_id' => $owner->id,
|
||||
'outcome_type' => 'winner',
|
||||
'position' => 1,
|
||||
'sort_order' => 0,
|
||||
'awarded_by_user_id' => $owner->id,
|
||||
'awarded_at' => now(),
|
||||
]);
|
||||
$challenge->outcomes()->create([
|
||||
'artwork_id' => $finalist->id,
|
||||
'user_id' => $owner->id,
|
||||
'outcome_type' => 'finalist',
|
||||
'sort_order' => 1,
|
||||
'awarded_by_user_id' => $owner->id,
|
||||
'awarded_at' => now(),
|
||||
]);
|
||||
|
||||
$category = worldNewsCategory([
|
||||
'name' => 'Challenge Recaps',
|
||||
'slug' => 'challenge-recaps-' . Str::lower(Str::random(6)),
|
||||
]);
|
||||
$recap = publishedWorldNews($owner, $category, [
|
||||
'title' => 'World Recap Challenge results recap',
|
||||
'slug' => 'world-recap-challenge-results-' . Str::lower(Str::random(6)),
|
||||
'excerpt' => 'Winner highlights and finalist recap from the linked challenge.',
|
||||
'content' => "# Results recap\n\nWinner highlights and finalist recap.",
|
||||
]);
|
||||
|
||||
$world->update([
|
||||
'linked_challenge_id' => $challenge->id,
|
||||
]);
|
||||
$world->worldRelations()->create([
|
||||
'section_key' => 'news',
|
||||
'related_type' => 'news',
|
||||
'related_id' => $recap->id,
|
||||
'context_label' => 'Challenge recap',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $world->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('linkedChallenge.state', 'closed')
|
||||
->where('linkedChallenge.cta_label', 'View challenge recap')
|
||||
->where('linkedChallenge.cta_url', route('news.show', ['slug' => $recap->slug]))
|
||||
->where('linkedChallenge.challenge_url', route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
||||
->where('linkedChallenge.story.title', 'World Recap Challenge results recap')
|
||||
->where('linkedChallenge.story.intent', 'recap')
|
||||
->where('linkedChallengeEntries.description', 'Entries from the linked challenge remain visible here so the world recap preserves the full field of work.')
|
||||
->where('linkedChallengeWinners.description', 'This world is carrying the linked challenge result forward so the campaign recap stays visible here too.')
|
||||
->where('linkedChallengeFinalists.description', 'Finalists from the linked challenge remain visible here so the archived world keeps the complete recap in view.')
|
||||
->where('world.challenge_cta_label', 'View challenge recap')
|
||||
->where('world.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
|
||||
|
||||
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Group/GroupChallengeShow')
|
||||
->where('linkedWorld.title', $world->title)
|
||||
->where('linkedWorld.challenge_cta_label', 'View challenge recap')
|
||||
->where('linkedWorld.challenge_cta_url', route('news.show', ['slug' => $recap->slug])));
|
||||
});
|
||||
|
||||
it('falls back to the theme icon when the stored world icon is blank whitespace', function (): void {
|
||||
$world = publicWorld([
|
||||
'title' => 'Spring Vibes',
|
||||
@@ -111,11 +696,107 @@ it('keeps archived worlds publicly visible', function (): void {
|
||||
->assertSee('Halloween World 2025');
|
||||
});
|
||||
|
||||
it('resolves recurring family and archived edition routes to the correct edition', function (): void {
|
||||
publicWorld([
|
||||
'title' => 'Spring Vibes 2025',
|
||||
'slug' => 'spring-vibes-2025',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2025,
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => Carbon::parse('2025-03-01 00:00:00'),
|
||||
'ends_at' => Carbon::parse('2025-04-01 00:00:00'),
|
||||
'published_at' => Carbon::parse('2025-02-20 10:00:00'),
|
||||
]);
|
||||
|
||||
$currentEdition = publicWorld([
|
||||
'title' => 'Spring Vibes 2026',
|
||||
'slug' => 'spring-vibes-2026',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2026,
|
||||
'campaign_priority' => 600,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.show', ['world' => 'spring-vibes']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('world.title', 'Spring Vibes 2026')
|
||||
->where('world.public_url', route('worlds.show', ['world' => 'spring-vibes']))
|
||||
->has('archiveEditions', 1)
|
||||
->where('archiveEditions.0.title', 'Spring Vibes 2025'));
|
||||
|
||||
$this->get(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('world.title', 'Spring Vibes 2025')
|
||||
->where('currentEdition.title', 'Spring Vibes 2026')
|
||||
->where('archiveNotice.current_edition.title', 'Spring Vibes 2026'));
|
||||
|
||||
$this->get(route('worlds.show', ['world' => $currentEdition->slug]))
|
||||
->assertRedirect(route('worlds.show', ['world' => 'spring-vibes']));
|
||||
|
||||
$this->get(route('worlds.show', ['world' => 'spring-vibes-2025']))
|
||||
->assertRedirect(route('worlds.editions.show', ['world' => 'spring-vibes', 'year' => 2025]));
|
||||
});
|
||||
|
||||
it('exposes adjacent previous and next editions inside the archive payload', function (): void {
|
||||
publicWorld([
|
||||
'title' => 'Retro Month 2024',
|
||||
'slug' => 'retro-month-2024',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2024,
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => Carbon::parse('2024-04-01 00:00:00'),
|
||||
'ends_at' => Carbon::parse('2024-04-30 00:00:00'),
|
||||
'published_at' => Carbon::parse('2024-03-20 10:00:00'),
|
||||
]);
|
||||
|
||||
publicWorld([
|
||||
'title' => 'Retro Month 2025',
|
||||
'slug' => 'retro-month-2025',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2025,
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => Carbon::parse('2025-04-01 00:00:00'),
|
||||
'ends_at' => Carbon::parse('2025-04-30 00:00:00'),
|
||||
'published_at' => Carbon::parse('2025-03-20 10:00:00'),
|
||||
]);
|
||||
|
||||
publicWorld([
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'edition_year' => 2026,
|
||||
'campaign_priority' => 200,
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.editions.show', ['world' => 'retro-month', 'year' => 2025]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldShow')
|
||||
->where('world.title', 'Retro Month 2025')
|
||||
->where('previousEdition.title', 'Retro Month 2024')
|
||||
->where('nextEdition.title', 'Retro Month 2026'));
|
||||
});
|
||||
|
||||
it('exposes a homepage world spotlight when a featured world exists', function (): void {
|
||||
publicWorld([
|
||||
'title' => 'Pixel Week 2026',
|
||||
'slug' => 'pixel-week-2026',
|
||||
'theme_key' => 'pixel-week',
|
||||
'teaser_title' => 'Pixel Week is open for submissions',
|
||||
]);
|
||||
|
||||
app(HomepageService::class)->clearGuestPayloadCache();
|
||||
@@ -125,4 +806,65 @@ it('exposes a homepage world spotlight when a featured world exists', function (
|
||||
->assertSee(route('worlds.index'), false)
|
||||
->assertSee('pixel-week-2026')
|
||||
->assertSee('Pixel Week 2026');
|
||||
});
|
||||
|
||||
it('splits live, upcoming, and archived worlds on the public index', function (): void {
|
||||
publicWorld([
|
||||
'title' => 'Spring Vibes',
|
||||
'slug' => 'spring-vibes',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2026,
|
||||
'campaign_priority' => 500,
|
||||
]);
|
||||
|
||||
publicWorld([
|
||||
'title' => 'Spring Vibes 2025',
|
||||
'slug' => 'spring-vibes-2025',
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2025,
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => Carbon::now()->subDays(400),
|
||||
'ends_at' => Carbon::now()->subDays(365),
|
||||
'promotion_starts_at' => null,
|
||||
'promotion_ends_at' => null,
|
||||
'published_at' => Carbon::now()->subDays(420),
|
||||
]);
|
||||
|
||||
publicWorld([
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026',
|
||||
'starts_at' => Carbon::now()->addDays(10),
|
||||
'ends_at' => Carbon::now()->addDays(24),
|
||||
'promotion_starts_at' => Carbon::now()->addDays(8),
|
||||
'promotion_ends_at' => Carbon::now()->addDays(18),
|
||||
'teaser_title' => 'Retro Month is coming up',
|
||||
]);
|
||||
|
||||
publicWorld([
|
||||
'title' => 'Halloween World 2025',
|
||||
'slug' => 'halloween-world-2025',
|
||||
'theme_key' => 'halloween',
|
||||
'status' => World::STATUS_ARCHIVED,
|
||||
'is_active_campaign' => false,
|
||||
'is_homepage_featured' => false,
|
||||
'starts_at' => Carbon::now()->subDays(40),
|
||||
'ends_at' => Carbon::now()->subDays(20),
|
||||
'promotion_starts_at' => null,
|
||||
'promotion_ends_at' => null,
|
||||
'published_at' => Carbon::now()->subDays(50),
|
||||
]);
|
||||
|
||||
$this->get(route('worlds.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('World/WorldIndex')
|
||||
->where('spotlightWorld.title', 'Spring Vibes')
|
||||
->has('upcomingWorlds', 1)
|
||||
->has('recurringWorldFamilies', 1)
|
||||
->where('recurringWorldFamilies.0.title', 'Spring Vibes')
|
||||
->has('archivedWorlds', 2));
|
||||
});
|
||||
176
tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php
Normal file
176
tests/Feature/Worlds/WorldRecurrenceWorkflowTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRelation;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function recurringWorldModerator(): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
'username' => 'recurrence-mod-' . Str::lower(Str::random(6)),
|
||||
'name' => 'Recurrence Moderator',
|
||||
]);
|
||||
}
|
||||
|
||||
function studioRecurringWorld(User $creator, array $attributes = []): World
|
||||
{
|
||||
return World::factory()->create(array_merge([
|
||||
'created_by_user_id' => $creator->id,
|
||||
'title' => 'Retro Month 2026',
|
||||
'slug' => 'retro-month-2026',
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_EVENT,
|
||||
'starts_at' => Carbon::now()->subDays(14),
|
||||
'ends_at' => Carbon::now()->addDays(7),
|
||||
'promotion_starts_at' => Carbon::now()->subDays(10),
|
||||
'promotion_ends_at' => Carbon::now()->addDays(5),
|
||||
'submission_starts_at' => Carbon::now()->subDays(7),
|
||||
'submission_ends_at' => Carbon::now()->addDays(5),
|
||||
'published_at' => Carbon::now()->subDays(21),
|
||||
'is_active_campaign' => true,
|
||||
'is_homepage_featured' => true,
|
||||
'campaign_priority' => 200,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'retro-month',
|
||||
'recurrence_rule' => 'yearly',
|
||||
'edition_year' => 2026,
|
||||
'cta_url' => 'https://skinbase.test/worlds/retro-month',
|
||||
'badge_url' => 'https://skinbase.test/badges/retro-month',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('creates a clean next edition draft for recurring worlds', function (): void {
|
||||
$moderator = recurringWorldModerator();
|
||||
$source = studioRecurringWorld($moderator);
|
||||
|
||||
WorldRelation::query()->create([
|
||||
'world_id' => $source->id,
|
||||
'section_key' => 'featured_artworks',
|
||||
'related_type' => WorldRelation::TYPE_ARTWORK,
|
||||
'related_id' => 123,
|
||||
'context_label' => 'Carry-over candidate',
|
||||
'sort_order' => 0,
|
||||
'is_featured' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.edit', ['world' => $source->id]))
|
||||
->post(route('studio.worlds.new-edition', ['world' => $source->id]), [
|
||||
'copy_mode' => WorldService::COPY_MODE_STRUCTURE_ONLY,
|
||||
]);
|
||||
|
||||
$edition = World::query()
|
||||
->whereKeyNot($source->id)
|
||||
->latest('id')
|
||||
->firstOrFail();
|
||||
|
||||
expect($edition->title)->toBe('Retro Month 2027')
|
||||
->and($edition->slug)->toBe('retro-month-2027')
|
||||
->and($edition->status)->toBe(World::STATUS_DRAFT)
|
||||
->and($edition->is_recurring)->toBeTrue()
|
||||
->and($edition->recurrence_key)->toBe('retro-month')
|
||||
->and($edition->recurrence_rule)->toBe('yearly')
|
||||
->and($edition->edition_year)->toBe(2027)
|
||||
->and($edition->parent_world_id)->toBe($source->id)
|
||||
->and($edition->starts_at)->toBeNull()
|
||||
->and($edition->ends_at)->toBeNull()
|
||||
->and($edition->promotion_starts_at)->toBeNull()
|
||||
->and($edition->promotion_ends_at)->toBeNull()
|
||||
->and($edition->submission_starts_at)->toBeNull()
|
||||
->and($edition->submission_ends_at)->toBeNull()
|
||||
->and($edition->published_at)->toBeNull()
|
||||
->and($edition->is_active_campaign)->toBeFalse()
|
||||
->and($edition->is_homepage_featured)->toBeFalse()
|
||||
->and($edition->campaign_priority)->toBeNull()
|
||||
->and($edition->cta_url)->toBeNull()
|
||||
->and($edition->badge_url)->toBeNull()
|
||||
->and($edition->worldRelations()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects next edition creation for non-recurring worlds', function (): void {
|
||||
$moderator = recurringWorldModerator();
|
||||
$world = World::factory()->create([
|
||||
'created_by_user_id' => $moderator->id,
|
||||
'title' => 'One-Off Showcase 2026',
|
||||
'slug' => 'one-off-showcase-2026',
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_EVENT,
|
||||
'is_recurring' => false,
|
||||
'recurrence_key' => null,
|
||||
'recurrence_rule' => null,
|
||||
'edition_year' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.edit', ['world' => $world->id]))
|
||||
->post(route('studio.worlds.new-edition', ['world' => $world->id]))
|
||||
->assertSessionHasErrors(['recurrence_key']);
|
||||
|
||||
expect(World::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects duplicate recurrence years when storing worlds', function (): void {
|
||||
$moderator = recurringWorldModerator();
|
||||
|
||||
studioRecurringWorld($moderator, [
|
||||
'title' => 'Pixel Week 2026',
|
||||
'slug' => 'pixel-week-2026',
|
||||
'recurrence_key' => 'pixel-week',
|
||||
'edition_year' => 2026,
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.create'))
|
||||
->post(route('studio.worlds.store'), [
|
||||
'title' => 'Pixel Week Draft',
|
||||
'slug' => 'pixel-week-draft',
|
||||
'status' => World::STATUS_DRAFT,
|
||||
'type' => World::TYPE_EVENT,
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'pixel-week',
|
||||
'edition_year' => 2026,
|
||||
])
|
||||
->assertSessionHasErrors(['edition_year']);
|
||||
|
||||
expect(World::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects publishing a second current edition for the same recurrence family', function (): void {
|
||||
$moderator = recurringWorldModerator();
|
||||
|
||||
studioRecurringWorld($moderator, [
|
||||
'title' => 'Spring Vibes 2026',
|
||||
'slug' => 'spring-vibes-2026',
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2026,
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'starts_at' => Carbon::now()->subDays(3),
|
||||
'ends_at' => Carbon::now()->addDays(10),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->from(route('studio.worlds.create'))
|
||||
->post(route('studio.worlds.store'), [
|
||||
'title' => 'Spring Vibes 2027',
|
||||
'slug' => 'spring-vibes-2027',
|
||||
'status' => World::STATUS_PUBLISHED,
|
||||
'type' => World::TYPE_EVENT,
|
||||
'starts_at' => Carbon::now()->subDay()->toIso8601String(),
|
||||
'ends_at' => Carbon::now()->addDays(14)->toIso8601String(),
|
||||
'is_recurring' => true,
|
||||
'recurrence_key' => 'spring-vibes',
|
||||
'edition_year' => 2027,
|
||||
])
|
||||
->assertSessionHasErrors(['status']);
|
||||
|
||||
expect(World::query()->count())->toBe(1);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user