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); });