create(); $this->actingAs($user) ->postJson(route('settings.collections.store'), [ 'title' => 'Dark Fantasy Series', 'slug' => '', 'description' => 'First curated set.', 'visibility' => 'public', 'sort_mode' => 'manual', ]) ->assertOk(); $this->actingAs($user) ->postJson(route('settings.collections.store'), [ 'title' => 'Dark Fantasy Series', 'slug' => '', 'description' => 'Second curated set.', 'visibility' => 'public', 'sort_mode' => 'manual', ]) ->assertOk(); expect(Collection::query()->where('user_id', $user->id)->orderBy('id')->pluck('slug')->all()) ->toBe(['dark-fantasy-series', 'dark-fantasy-series-2']); }); it('dispatches collection lifecycle events across owner actions and public views', function () { Event::fake([ CollectionCreated::class, CollectionUpdated::class, CollectionDeleted::class, CollectionArtworkAttached::class, CollectionArtworkRemoved::class, CollectionViewed::class, ]); $user = User::factory()->create(['username' => 'eventowner']); $artwork = Artwork::factory()->for($user)->create(); $createResponse = $this->actingAs($user) ->postJson(route('settings.collections.store'), [ 'title' => 'Event Driven Showcase', 'slug' => '', 'description' => 'Tracking collection lifecycle events.', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, ]) ->assertOk(); $collectionId = (int) $createResponse->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); Event::assertDispatched(CollectionCreated::class, fn (CollectionCreated $event) => (int) $event->collection->id === $collectionId); $this->actingAs($user) ->patchJson(route('settings.collections.update', ['collection' => $collectionId]), [ 'title' => 'Event Driven Showcase Updated', 'slug' => 'event-driven-showcase-updated', 'description' => 'Updated description.', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, 'cover_artwork_id' => null, ]) ->assertOk(); Event::assertDispatched(CollectionUpdated::class, fn (CollectionUpdated $event) => (int) $event->collection->id === $collectionId); $this->actingAs($user) ->postJson(route('settings.collections.artworks.attach', ['collection' => $collectionId]), [ 'artwork_ids' => [$artwork->id], ]) ->assertOk(); Event::assertDispatched(CollectionArtworkAttached::class, fn (CollectionArtworkAttached $event) => (int) $event->collection->id === $collectionId && $event->artworkIds === [$artwork->id]); $this->actingAs($user) ->deleteJson(route('settings.collections.artworks.remove', ['collection' => $collectionId, 'artwork' => $artwork->id])) ->assertOk(); Event::assertDispatched(CollectionArtworkRemoved::class, fn (CollectionArtworkRemoved $event) => (int) $event->collection->id === $collectionId && (int) $event->artworkId === (int) $artwork->id); $this->get(route('profile.collections.show', ['username' => $user->username, 'slug' => 'event-driven-showcase-updated'])) ->assertOk(); Event::assertDispatched(CollectionViewed::class, fn (CollectionViewed $event) => (int) $event->collection->id === $collectionId && (int) $event->viewerId === (int) $user->id); $this->actingAs($user) ->deleteJson(route('settings.collections.destroy', ['collection' => $collectionId])) ->assertOk(); Event::assertDispatched(CollectionDeleted::class, fn (CollectionDeleted $event) => (int) $event->collection->id === $collectionId); }); it('owner can edit a collection', function () { $user = User::factory()->create(); $collection = Collection::factory()->for($user)->create([ 'title' => 'Original Title', 'slug' => 'original-title', ]); $this->actingAs($user) ->patchJson(route('settings.collections.update', ['collection' => $collection->id]), [ 'title' => 'Featured 2026 Works', 'slug' => 'featured-2026-works', 'description' => 'Updated description.', 'visibility' => 'unlisted', 'sort_mode' => 'manual', 'cover_artwork_id' => null, ]) ->assertOk() ->assertJsonPath('collection.title', 'Featured 2026 Works'); $collection->refresh(); expect($collection->title)->toBe('Featured 2026 Works'); expect($collection->slug)->toBe('featured-2026-works'); expect($collection->visibility)->toBe('unlisted'); }); it('owner can persist campaign banner metadata and public page renders it', function () { $user = User::factory()->create(['username' => 'campaigncurator']); $response = $this->actingAs($user) ->postJson(route('settings.collections.store'), [ 'title' => 'Spring Frontpage Spotlight', 'slug' => '', 'description' => 'A seasonal campaign collection.', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, 'event_key' => 'spring-2026', 'event_label' => 'Spring 2026', 'season_key' => 'spring', 'banner_text' => 'Fresh editorial picks for the new season.', 'badge_label' => 'Seasonal Spotlight', 'spotlight_style' => Collection::SPOTLIGHT_STYLE_SEASONAL, ]) ->assertOk() ->assertJsonPath('collection.event_key', 'spring-2026') ->assertJsonPath('collection.season_key', 'spring') ->assertJsonPath('collection.banner_text', 'Fresh editorial picks for the new season.') ->assertJsonPath('collection.spotlight_style', Collection::SPOTLIGHT_STYLE_SEASONAL); $collectionId = (int) $response->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); expect($collection->event_key)->toBe('spring-2026'); expect($collection->season_key)->toBe('spring'); expect($collection->banner_text)->toBe('Fresh editorial picks for the new season.'); expect($collection->spotlight_style)->toBe(Collection::SPOTLIGHT_STYLE_SEASONAL); $this->get(route('profile.collections.show', ['username' => $user->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee('Fresh editorial picks for the new season.', false) ->assertSee('Spring 2026', false) ->assertSee('Seasonal Spotlight', false); }); it('staff can create a collection surface placement with v4 placement fields', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Homepage Editorial Placement', 'slug' => 'homepage-editorial-placement', ]); $this->actingAs($admin) ->postJson(route('settings.collections.surfaces.placements.store'), [ 'surface_key' => 'homepage-editorial', 'collection_id' => $collection->id, 'placement_type' => 'campaign', 'priority' => 8, 'campaign_key' => 'spring-homepage', 'notes' => 'Pinned during the spring editorial campaign.', 'is_active' => true, ]) ->assertOk() ->assertJsonPath('placement.surface_key', 'homepage-editorial') ->assertJsonPath('placement.placement_type', 'campaign') ->assertJsonPath('placement.campaign_key', 'spring-homepage'); $this->assertDatabaseHas('collection_surface_placements', [ 'surface_key' => 'homepage-editorial', 'collection_id' => $collection->id, 'placement_type' => 'campaign', 'campaign_key' => 'spring-homepage', 'created_by_user_id' => $admin->id, ]); $this->assertDatabaseHas('collection_history', [ 'collection_id' => $collection->id, 'actor_user_id' => $admin->id, 'action_type' => 'placement_assigned', ]); }); it('staff can update collection surface definitions and placements', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Campaign Primary Collection', 'slug' => 'campaign-primary-collection', ]); $definition = CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'discover-hero', 'title' => 'Discover Hero', 'description' => 'Original hero surface.', 'mode' => 'manual', 'rules_json' => ['campaign_key' => 'winter'], 'ranking_mode' => 'ranking_score', 'max_items' => 6, 'is_active' => true, ]); $placement = CollectionSurfacePlacement::query()->create([ 'collection_id' => $collection->id, 'surface_key' => 'discover-hero', 'placement_type' => 'manual', 'priority' => 2, 'starts_at' => now()->subDay(), 'ends_at' => now()->addDay(), 'is_active' => true, 'campaign_key' => 'winter', 'notes' => 'Original placement note.', 'created_by_user_id' => $admin->id, ]); $this->actingAs($admin) ->patchJson(route('settings.collections.surfaces.definitions.update', ['definition' => $definition->id]), [ 'surface_key' => 'ignored-by-server', 'title' => 'Discover Hero Updated', 'description' => 'Updated hero surface.', 'mode' => 'automatic', 'ranking_mode' => 'recent_activity', 'max_items' => 10, 'is_active' => false, 'starts_at' => now()->subDay()->toISOString(), 'ends_at' => now()->addDays(2)->toISOString(), 'fallback_surface_key' => 'homepage.featured_collections', 'rules_json' => [ 'campaign_key' => 'spring', 'featured_only' => true, ], ]) ->assertOk() ->assertJsonPath('definition.surface_key', 'discover-hero') ->assertJsonPath('definition.title', 'Discover Hero Updated') ->assertJsonPath('definition.mode', 'automatic') ->assertJsonPath('definition.ranking_mode', 'recent_activity') ->assertJsonPath('definition.max_items', 10) ->assertJsonPath('definition.is_active', false) ->assertJsonPath('definition.fallback_surface_key', 'homepage.featured_collections') ->assertJsonPath('definition.rules_json.campaign_key', 'spring'); $this->actingAs($admin) ->patchJson(route('settings.collections.surfaces.placements.update', ['placement' => $placement->id]), [ 'surface_key' => 'discover-hero', 'collection_id' => $collection->id, 'placement_type' => 'scheduled_override', 'priority' => 11, 'starts_at' => now()->toISOString(), 'ends_at' => now()->addDays(3)->toISOString(), 'is_active' => false, 'campaign_key' => 'spring', 'notes' => 'Updated placement note.', ]) ->assertOk() ->assertJsonPath('placement.id', $placement->id) ->assertJsonPath('placement.placement_type', 'scheduled_override') ->assertJsonPath('placement.priority', 11) ->assertJsonPath('placement.is_active', false) ->assertJsonPath('placement.campaign_key', 'spring') ->assertJsonPath('placement.notes', 'Updated placement note.'); $this->assertDatabaseHas('collection_surface_definitions', [ 'id' => $definition->id, 'surface_key' => 'discover-hero', 'title' => 'Discover Hero Updated', 'mode' => 'automatic', 'ranking_mode' => 'recent_activity', 'max_items' => 10, 'is_active' => 0, 'fallback_surface_key' => 'homepage.featured_collections', ]); $this->assertDatabaseHas('collection_surface_placements', [ 'id' => $placement->id, 'surface_key' => 'discover-hero', 'collection_id' => $collection->id, 'placement_type' => 'scheduled_override', 'priority' => 11, 'campaign_key' => 'spring', 'notes' => 'Updated placement note.', 'is_active' => 0, 'created_by_user_id' => $admin->id, ]); $this->assertDatabaseHas('collection_history', [ 'collection_id' => $collection->id, 'actor_user_id' => $admin->id, 'action_type' => 'placement_updated', ]); }); it('staff can preview batch editorial campaign and placement changes without persisting them', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(); $eligible = Collection::factory()->for($owner)->create([ 'title' => 'Batch Preview Eligible', 'slug' => 'batch-preview-eligible', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $ineligible = Collection::factory()->for($owner)->create([ 'title' => 'Batch Preview Ineligible', 'slug' => 'batch-preview-ineligible', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_DRAFT, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($admin) ->postJson(route('settings.collections.surfaces.batch-editorial'), [ 'collection_ids' => [$eligible->id, $ineligible->id], 'campaign_key' => 'summer-edit', 'campaign_label' => 'Summer Edit', 'editorial_notes' => 'Preview run for homepage campaign planning.', 'surface_key' => 'homepage-editorial', 'placement_type' => 'campaign', 'priority' => 7, 'apply' => false, ]) ->assertOk() ->assertJsonPath('mode', 'preview') ->assertJsonPath('plan.collections_count', 2) ->assertJsonPath('plan.placement_eligible_count', 1) ->assertJsonPath('plan.items.0.campaign_updates.campaign_key', 'summer-edit'); expect(Collection::query()->findOrFail($eligible->id)->campaign_key)->toBeNull(); expect(CollectionSurfacePlacement::query()->where('collection_id', $eligible->id)->exists())->toBeFalse(); }); it('staff can apply batch editorial campaign and placement changes', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(); $eligible = Collection::factory()->for($owner)->create([ 'title' => 'Batch Apply Eligible', 'slug' => 'batch-apply-eligible', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $ineligible = Collection::factory()->for($owner)->create([ 'title' => 'Batch Apply Ineligible', 'slug' => 'batch-apply-ineligible', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_DRAFT, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($admin) ->postJson(route('settings.collections.surfaces.batch-editorial'), [ 'collection_ids' => [$eligible->id, $ineligible->id], 'campaign_key' => 'spring-editorial-push', 'campaign_label' => 'Spring Editorial Push', 'editorial_notes' => 'Apply homepage editorial batch.', 'surface_key' => 'homepage-editorial', 'placement_type' => 'campaign', 'priority' => 9, 'notes' => 'Pinned by batch tooling.', 'apply' => true, ]) ->assertOk() ->assertJsonPath('mode', 'apply') ->assertJsonPath('plan.collections_count', 2) ->assertJsonPath('plan.placement_eligible_count', 1); $this->assertDatabaseHas('collections', [ 'id' => $eligible->id, 'campaign_key' => 'spring-editorial-push', 'campaign_label' => 'Spring Editorial Push', 'editorial_notes' => 'Apply homepage editorial batch.', ]); $this->assertDatabaseHas('collections', [ 'id' => $ineligible->id, 'campaign_key' => 'spring-editorial-push', 'campaign_label' => 'Spring Editorial Push', 'editorial_notes' => 'Apply homepage editorial batch.', ]); $this->assertDatabaseHas('collection_surface_placements', [ 'collection_id' => $eligible->id, 'surface_key' => 'homepage-editorial', 'placement_type' => 'campaign', 'priority' => 9, 'campaign_key' => 'spring-editorial-push', 'notes' => 'Pinned by batch tooling.', 'created_by_user_id' => $admin->id, ]); $this->assertDatabaseMissing('collection_surface_placements', [ 'collection_id' => $ineligible->id, 'surface_key' => 'homepage-editorial', ]); $this->assertDatabaseHas('collection_history', [ 'collection_id' => $eligible->id, 'actor_user_id' => $admin->id, 'action_type' => 'batch_editorial_updated', ]); $this->assertDatabaseHas('collection_history', [ 'collection_id' => $eligible->id, 'actor_user_id' => $admin->id, 'action_type' => 'placement_assigned', ]); }); it('automatic collection surfaces only resolve eligible public collections from rules', function () { $owner = User::factory()->create(); $eligible = Collection::factory()->for($owner)->create([ 'title' => 'Spring Automatic Surface Winner', 'slug' => 'spring-automatic-surface-winner', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'spring-launch', 'ranking_score' => 92, ]); Collection::factory()->for($owner)->create([ 'title' => 'Restricted Spring Collection', 'slug' => 'restricted-spring-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_RESTRICTED, 'moderation_status' => Collection::MODERATION_RESTRICTED, 'campaign_key' => 'spring-launch', 'ranking_score' => 100, ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Spring Collection', 'slug' => 'private-spring-collection', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'spring-launch', 'ranking_score' => 110, ]); CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'discover.featured_collections', 'title' => 'Automatic Featured Collections', 'description' => 'Rule-driven seasonal spotlight.', 'mode' => 'automatic', 'rules_json' => [ 'campaign_key' => 'spring-launch', ], 'ranking_mode' => 'ranking_score', 'max_items' => 8, 'is_active' => true, ]); $response = $this->get(route('collections.featured')) ->assertOk() ->assertSee('Featured collections', false) ->assertSee('Spring Automatic Surface Winner', false) ->assertDontSee('Restricted Spring Collection', false) ->assertDontSee('Private Spring Collection', false); $response->assertSee('Spring Automatic Surface Winner', false); expect(app(\App\Services\CollectionSurfaceService::class)->resolveSurfaceItems('discover.featured_collections', 8)->pluck('id')->all()) ->toBe([$eligible->id]); }); it('automatic surfaces support broader explainable rules and quality-score ordering', function () { $owner = User::factory()->create(['username' => 'signalowner']); $otherOwner = User::factory()->create(['username' => 'outsider']); $highQuality = Collection::factory()->for($owner)->create([ 'title' => 'Aurora Signature One', 'slug' => 'aurora-signature-one', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_OPEN, 'commercial_eligibility' => true, 'analytics_enabled' => true, 'quality_score' => 97, 'ranking_score' => 40, ]); $lowerQuality = Collection::factory()->for($owner)->create([ 'title' => 'Aurora Signature Two', 'slug' => 'aurora-signature-two', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_OPEN, 'commercial_eligibility' => true, 'analytics_enabled' => true, 'quality_score' => 88, 'ranking_score' => 85, ]); $excluded = Collection::factory()->for($owner)->create([ 'title' => 'Aurora Excluded', 'slug' => 'aurora-excluded', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_OPEN, 'commercial_eligibility' => true, 'analytics_enabled' => true, 'quality_score' => 99, 'ranking_score' => 99, ]); Collection::factory()->for($owner)->create([ 'title' => 'Aurora Closed Collaboration', 'slug' => 'aurora-closed-collaboration', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_CLOSED, 'commercial_eligibility' => true, 'analytics_enabled' => true, 'quality_score' => 96, ]); Collection::factory()->for($otherOwner)->create([ 'title' => 'Other Owner Aurora', 'slug' => 'other-owner-aurora', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_OPEN, 'commercial_eligibility' => true, 'analytics_enabled' => true, 'quality_score' => 95, ]); CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'homepage.aurora_signatures', 'title' => 'Aurora Signatures', 'description' => 'Rule-driven creator curation.', 'mode' => 'automatic', 'rules_json' => [ 'owner_username' => $owner->username, 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'theme_token' => 'aurora', 'collaboration_mode' => Collection::COLLABORATION_OPEN, 'commercial_eligible_only' => true, 'analytics_enabled_only' => true, 'min_quality_score' => 85, 'exclude_collection_ids' => [$excluded->id], ], 'ranking_mode' => 'quality_score', 'max_items' => 6, 'is_active' => true, ]); expect(app(\App\Services\CollectionSurfaceService::class)->resolveSurfaceItems('homepage.aurora_signatures', 6)->pluck('id')->all()) ->toBe([$highQuality->id, $lowerQuality->id]); }); it('surface definitions can fall back to another surface when they are inactive or empty', function () { $owner = User::factory()->create(); $fallbackCollection = Collection::factory()->for($owner)->create([ 'title' => 'Fallback Surface Winner', 'slug' => 'fallback-surface-winner', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'fallback-campaign', 'ranking_score' => 77, ]); CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'homepage.featured_collections', 'title' => 'Fallback Surface', 'description' => 'Always-on fallback surface.', 'mode' => 'automatic', 'rules_json' => [ 'campaign_key' => 'fallback-campaign', ], 'ranking_mode' => 'ranking_score', 'max_items' => 6, 'is_active' => true, ]); CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'homepage.editorial_collections', 'title' => 'Scheduled Primary Surface', 'description' => 'Primary campaign window.', 'mode' => 'automatic', 'rules_json' => [ 'campaign_key' => 'missing-window', ], 'ranking_mode' => 'ranking_score', 'max_items' => 6, 'is_active' => true, 'starts_at' => now()->addDay(), 'fallback_surface_key' => 'homepage.featured_collections', ]); expect(app(\App\Services\CollectionSurfaceService::class)->resolveSurfaceItems('homepage.editorial_collections', 6)->pluck('id')->all()) ->toBe([$fallbackCollection->id]); }); it('staff can delete placements and must clear placements before deleting a surface definition', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Placement Delete Candidate', 'slug' => 'placement-delete-candidate', ]); $definition = CollectionSurfaceDefinition::query()->create([ 'surface_key' => 'homepage.cleanup_test', 'title' => 'Cleanup Test', 'description' => 'Surface cleanup coverage.', 'mode' => 'manual', 'rules_json' => null, 'ranking_mode' => 'ranking_score', 'max_items' => 4, 'is_active' => true, ]); $placement = CollectionSurfacePlacement::query()->create([ 'collection_id' => $collection->id, 'surface_key' => 'homepage.cleanup_test', 'placement_type' => 'manual', 'priority' => 5, 'starts_at' => now()->subHour(), 'ends_at' => now()->addHour(), 'is_active' => true, 'campaign_key' => null, 'notes' => 'Temporary slot.', 'created_by_user_id' => $admin->id, ]); $this->actingAs($admin) ->deleteJson(route('settings.collections.surfaces.definitions.destroy', ['definition' => $definition->id])) ->assertStatus(422); $this->actingAs($admin) ->deleteJson(route('settings.collections.surfaces.placements.destroy', ['placement' => $placement->id])) ->assertOk() ->assertJsonPath('deleted_placement_id', $placement->id); $this->assertDatabaseMissing('collection_surface_placements', [ 'id' => $placement->id, ]); $this->assertDatabaseHas('collection_history', [ 'collection_id' => $collection->id, 'actor_user_id' => $admin->id, 'action_type' => 'placement_removed', ]); $this->actingAs($admin) ->deleteJson(route('settings.collections.surfaces.definitions.destroy', ['definition' => $definition->id])) ->assertOk() ->assertJsonPath('deleted_definition_id', $definition->id); $this->assertDatabaseMissing('collection_surface_definitions', [ 'id' => $definition->id, ]); }); it('surface conflict summaries flag overlapping active placements on the same surface', function () { $owner = User::factory()->create(); $first = Collection::factory()->for($owner)->create([ 'title' => 'Conflict One', 'slug' => 'conflict-one', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $second = Collection::factory()->for($owner)->create([ 'title' => 'Conflict Two', 'slug' => 'conflict-two', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); CollectionSurfacePlacement::query()->create([ 'collection_id' => $first->id, 'surface_key' => 'homepage.editorial_collections', 'placement_type' => 'manual', 'priority' => 10, 'starts_at' => now()->addHour(), 'ends_at' => now()->addHours(5), 'is_active' => true, 'created_by_user_id' => $owner->id, ]); CollectionSurfacePlacement::query()->create([ 'collection_id' => $second->id, 'surface_key' => 'homepage.editorial_collections', 'placement_type' => 'manual', 'priority' => 9, 'starts_at' => now()->addHours(2), 'ends_at' => now()->addHours(6), 'is_active' => true, 'created_by_user_id' => $owner->id, ]); $conflicts = app(\App\Services\CollectionSurfaceService::class)->placementConflicts('homepage.editorial_collections'); expect($conflicts)->toHaveCount(1); expect($conflicts->first()['surface_key'])->toBe('homepage.editorial_collections'); expect($conflicts->first()['collection_titles'])->toBe(['Conflict One', 'Conflict Two']); expect($conflicts->first()['placement_ids'])->toHaveCount(2); }); it('user can create saved lists and add a saved collection into a list', function () { $user = User::factory()->create(); $owner = User::factory()->create(['username' => 'savedowner']); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Reference Shelf Collection', 'slug' => 'reference-shelf-collection', ]); DB::table('collection_saves')->insert([ 'user_id' => $user->id, 'collection_id' => $collection->id, 'created_at' => now(), ]); $listResponse = $this->actingAs($user) ->postJson(route('me.saved.collections.lists.store'), [ 'title' => 'Spring references', ]) ->assertOk() ->assertJsonPath('list.title', 'Spring references'); $listId = (int) $listResponse->json('list.id'); $this->actingAs($user) ->postJson(route('me.saved.collections.lists.items.store', ['collection' => $collection->id]), [ 'saved_list_id' => $listId, ]) ->assertOk() ->assertJsonPath('item.saved_list_id', $listId) ->assertJsonPath('item.collection_id', $collection->id); $this->assertDatabaseHas('collection_saved_lists', [ 'id' => $listId, 'user_id' => $user->id, 'title' => 'Spring references', ]); $this->assertDatabaseHas('collection_saved_list_items', [ 'saved_list_id' => $listId, 'collection_id' => $collection->id, ]); }); it('user can browse a saved list route and remove a collection from that list', function () { $user = User::factory()->create(); $owner = User::factory()->create(['username' => 'listowner']); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'List Removal Collection', 'slug' => 'list-removal-collection', ]); DB::table('collection_saves')->insert([ 'user_id' => $user->id, 'collection_id' => $collection->id, 'created_at' => now(), ]); $listId = DB::table('collection_saved_lists')->insertGetId([ 'user_id' => $user->id, 'title' => 'Campaign watchlist', 'slug' => 'campaign-watchlist', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('collection_saved_list_items')->insert([ 'saved_list_id' => $listId, 'collection_id' => $collection->id, 'order_num' => 0, 'created_at' => now(), ]); $this->actingAs($user) ->get(route('me.saved.collections.lists.show', ['listSlug' => 'campaign-watchlist'])) ->assertOk(); $this->actingAs($user) ->deleteJson(route('me.saved.collections.lists.items.destroy', ['list' => $listId, 'collection' => $collection->id])) ->assertOk() ->assertJsonPath('removed', true) ->assertJsonPath('list.id', $listId) ->assertJsonPath('list.items_count', 0); $this->assertDatabaseMissing('collection_saved_list_items', [ 'saved_list_id' => $listId, 'collection_id' => $collection->id, ]); }); it('saved list route preserves saved list order and user can reorder items', function () { $user = User::factory()->create(); $owner = User::factory()->create(['username' => 'reorderowner']); $collectionA = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'First Ordered Collection', 'slug' => 'first-ordered-collection', ]); $collectionB = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Second Ordered Collection', 'slug' => 'second-ordered-collection', ]); $collectionC = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Third Ordered Collection', 'slug' => 'third-ordered-collection', ]); DB::table('collection_saves')->insert([ [ 'user_id' => $user->id, 'collection_id' => $collectionA->id, 'created_at' => now(), ], [ 'user_id' => $user->id, 'collection_id' => $collectionB->id, 'created_at' => now()->subMinute(), ], [ 'user_id' => $user->id, 'collection_id' => $collectionC->id, 'created_at' => now()->subMinutes(2), ], ]); $listId = DB::table('collection_saved_lists')->insertGetId([ 'user_id' => $user->id, 'title' => 'Editorial stack', 'slug' => 'editorial-stack', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('collection_saved_list_items')->insert([ [ 'saved_list_id' => $listId, 'collection_id' => $collectionB->id, 'order_num' => 0, 'created_at' => now(), ], [ 'saved_list_id' => $listId, 'collection_id' => $collectionC->id, 'order_num' => 1, 'created_at' => now(), ], [ 'saved_list_id' => $listId, 'collection_id' => $collectionA->id, 'order_num' => 2, 'created_at' => now(), ], ]); $response = $this->actingAs($user) ->get(route('me.saved.collections.lists.show', ['listSlug' => 'editorial-stack'])) ->assertOk(); $page = $response->viewData('page'); expect(collect(data_get($page, 'props.collections', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([$collectionB->id, $collectionC->id, $collectionA->id]); $this->actingAs($user) ->postJson(route('me.saved.collections.lists.items.reorder', ['list' => $listId]), [ 'collection_ids' => [$collectionA->id, $collectionB->id, $collectionC->id], ]) ->assertOk() ->assertJsonPath('ordered_collection_ids.0', $collectionA->id) ->assertJsonPath('ordered_collection_ids.1', $collectionB->id) ->assertJsonPath('ordered_collection_ids.2', $collectionC->id); expect(DB::table('collection_saved_list_items') ->where('saved_list_id', $listId) ->orderBy('order_num') ->pluck('collection_id') ->map(fn ($id) => (int) $id) ->all()) ->toBe([$collectionA->id, $collectionB->id, $collectionC->id]); }); it('unsaving a collection clears that users saved list items for it', function () { $user = User::factory()->create(); $owner = User::factory()->create(['username' => 'unsaveowner']); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'title' => 'Unsave Cleanup Collection', 'slug' => 'unsave-cleanup-collection', ]); DB::table('collection_saves')->insert([ 'user_id' => $user->id, 'collection_id' => $collection->id, 'created_at' => now(), ]); $listId = DB::table('collection_saved_lists')->insertGetId([ 'user_id' => $user->id, 'title' => 'Reference board', 'slug' => 'reference-board', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('collection_saved_list_items')->insert([ 'saved_list_id' => $listId, 'collection_id' => $collection->id, 'order_num' => 0, 'created_at' => now(), ]); $this->actingAs($user) ->deleteJson(route('collections.unsave', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('saved', false); $this->assertDatabaseMissing('collection_saves', [ 'user_id' => $user->id, 'collection_id' => $collection->id, ]); $this->assertDatabaseMissing('collection_saved_list_items', [ 'saved_list_id' => $listId, 'collection_id' => $collection->id, ]); }); it('owner can delete a collection without deleting artworks', function () { $user = User::factory()->create(); $collection = Collection::factory()->for($user)->create(); $artwork = Artwork::factory()->for($user)->create(); DB::table('collection_artwork')->insert([ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, 'order_num' => 0, 'created_at' => now(), 'updated_at' => now(), ]); $collection->update(['artworks_count' => 1]); $this->actingAs($user) ->deleteJson(route('settings.collections.destroy', ['collection' => $collection->id])) ->assertOk(); $this->assertSoftDeleted('collections', ['id' => $collection->id]); $this->assertDatabaseMissing('collection_artwork', [ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, ]); expect(Artwork::query()->find($artwork->id))->not->toBeNull(); }); it('owner can attach only their own artworks', function () { $user = User::factory()->create(); $collection = Collection::factory()->for($user)->create(); $artwork = Artwork::factory()->for($user)->create(); $this->actingAs($user) ->postJson(route('settings.collections.artworks.attach', ['collection' => $collection->id]), [ 'artwork_ids' => [$artwork->id], ]) ->assertOk() ->assertJsonPath('attachedArtworks.0.id', $artwork->id); $this->assertDatabaseHas('collection_artwork', [ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, ]); }); it('owner can load collection options for an owned artwork card action', function () { $user = User::factory()->create(); $artwork = Artwork::factory()->for($user)->create(); $attachedCollection = Collection::factory()->for($user)->create([ 'title' => 'Attached Showcase', 'slug' => 'attached-showcase', 'artworks_count' => 1, ]); $freeCollection = Collection::factory()->for($user)->create([ 'title' => 'Fresh Showcase', 'slug' => 'fresh-showcase', 'artworks_count' => 0, ]); DB::table('collection_artwork')->insert([ 'collection_id' => $attachedCollection->id, 'artwork_id' => $artwork->id, 'order_num' => 0, 'created_at' => now(), 'updated_at' => now(), ]); $response = $this->actingAs($user) ->getJson(route('settings.collections.artworks.options', ['artwork' => $artwork->id])) ->assertOk() ->json(); expect($response['meta']['artwork_id'])->toBe($artwork->id); expect($response['meta']['create_url'])->toBe(route('settings.collections.create')); expect(collect($response['data'])->pluck('title')->sort()->values()->all()) ->toBe(collect([$freeCollection->title, $attachedCollection->title])->sort()->values()->all()); expect(collect($response['data'])->firstWhere('id', $attachedCollection->id)['already_attached'])->toBeTrue(); expect(collect($response['data'])->firstWhere('id', $freeCollection->id)['already_attached'])->toBeFalse(); }); it('owner cannot load collection options for another users artwork', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $artwork = Artwork::factory()->for($otherUser)->create(); $this->actingAs($user) ->getJson(route('settings.collections.artworks.options', ['artwork' => $artwork->id])) ->assertNotFound(); }); it('owner cannot attach another users artwork', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $collection = Collection::factory()->for($user)->create(); $foreignArtwork = Artwork::factory()->for($otherUser)->create(); $this->actingAs($user) ->postJson(route('settings.collections.artworks.attach', ['collection' => $collection->id]), [ 'artwork_ids' => [$foreignArtwork->id], ]) ->assertStatus(422) ->assertJsonValidationErrors('artwork_ids'); $this->assertDatabaseMissing('collection_artwork', [ 'collection_id' => $collection->id, 'artwork_id' => $foreignArtwork->id, ]); }); it('owner can reorder artworks inside a collection', function () { $user = User::factory()->create(); $collection = Collection::factory()->for($user)->create(); $artworkA = Artwork::factory()->for($user)->create(); $artworkB = Artwork::factory()->for($user)->create(); $artworkC = Artwork::factory()->for($user)->create(); app(CollectionService::class)->attachArtworks($collection, $user, [$artworkA->id, $artworkB->id, $artworkC->id]); $this->actingAs($user) ->postJson(route('settings.collections.artworks.reorder', ['collection' => $collection->id]), [ 'ordered_artwork_ids' => [$artworkC->id, $artworkA->id, $artworkB->id], ]) ->assertOk(); expect(DB::table('collection_artwork') ->where('collection_id', $collection->id) ->orderBy('order_num') ->pluck('artwork_id') ->map(fn ($id) => (int) $id) ->all()) ->toBe([$artworkC->id, $artworkA->id, $artworkB->id]); }); it('public can view a public collection', function () { $user = User::factory()->create(['username' => 'collector']); $collection = Collection::factory()->for($user)->create([ 'title' => 'Neon Blue Experiments', 'slug' => 'neon-blue-experiments', 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $this->get(route('profile.collections.show', ['username' => $user->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee('Neon Blue Experiments', false); }); it('public series landing page lists public series collections in order and excludes hidden entries', function () { $owner = User::factory()->create(['username' => 'seriesowner']); $first = Collection::factory()->for($owner)->create([ 'title' => 'Series Start', 'slug' => 'series-start', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'series_key' => 'winter-wallpaper-run', 'series_title' => 'Winter Wallpaper Run', 'series_description' => 'A seasonal progression of desktop-ready winter showcases.', 'series_order' => 1, ]); Collection::factory()->for($owner)->create([ 'title' => 'Series Hidden Private', 'slug' => 'series-hidden-private', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'series_key' => 'winter-wallpaper-run', 'series_order' => 2, ]); $second = Collection::factory()->for($owner)->create([ 'title' => 'Series Finale', 'slug' => 'series-finale', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'series_key' => 'winter-wallpaper-run', 'series_order' => 3, ]); Collection::factory()->for($owner)->create([ 'title' => 'Series Restricted Entry', 'slug' => 'series-restricted-entry', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_RESTRICTED, 'moderation_status' => Collection::MODERATION_RESTRICTED, 'series_key' => 'winter-wallpaper-run', 'series_order' => 4, ]); $this->get(route('collections.series.show', ['seriesKey' => 'winter-wallpaper-run'])) ->assertOk() ->assertSee('Winter Wallpaper Run', false) ->assertSee('A seasonal progression of desktop-ready winter showcases.', false) ->assertSeeInOrder([$first->title, $second->title], false) ->assertDontSee('Series Hidden Private', false) ->assertDontSee('Series Restricted Entry', false); }); it('collection pages link to the full public series landing page when the collection belongs to a series', function () { $owner = User::factory()->create(['username' => 'serieslinkowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Series Link Entry', 'slug' => 'series-link-entry', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'series_key' => 'editorial-story-run', 'series_title' => 'Editorial Story Run', 'series_description' => 'A connected editorial sequence across multiple collection chapters.', 'series_order' => 2, ]); $seriesUrl = str_replace('/', '\\/', route('collections.series.show', ['seriesKey' => 'editorial-story-run'])); $this->get(route('profile.collections.show', ['username' => $owner->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee($seriesUrl, false) ->assertSee('Editorial Story Run', false) ->assertSee('A connected editorial sequence across multiple collection chapters.', false); }); it('public cannot view a private collection', function () { $user = User::factory()->create(['username' => 'privatecollector']); $collection = Collection::factory()->for($user)->create([ 'slug' => 'hidden-showcase', 'visibility' => Collection::VISIBILITY_PRIVATE, ]); $this->get(route('profile.collections.show', ['username' => $user->username, 'slug' => $collection->slug])) ->assertNotFound(); }); it('unlisted collections are hidden from public profile listings but visible to the owner', function () { $user = User::factory()->create(['username' => 'curator']); Collection::factory()->for($user)->create([ 'title' => 'Public Collection', 'slug' => 'public-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, ]); Collection::factory()->for($user)->create([ 'title' => 'Secret Drop', 'slug' => 'secret-drop', 'visibility' => Collection::VISIBILITY_UNLISTED, ]); $this->get(route('profile.tab', ['username' => $user->username, 'tab' => 'collections'])) ->assertOk() ->assertSee('Public Collection', false) ->assertDontSee('Secret Drop', false); $this->actingAs($user) ->get(route('profile.tab', ['username' => $user->username, 'tab' => 'collections'])) ->assertOk() ->assertSee('Public Collection', false) ->assertSee('Secret Drop', false); }); it('smart preview can resolve artworks by ai style tags for the owner only', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $ownedArtwork = Artwork::factory()->for($user)->create(); $foreignArtwork = Artwork::factory()->for($otherUser)->create(); $styleTag = Tag::query()->create([ 'name' => 'Digital Painting', 'slug' => 'digital-painting', 'usage_count' => 0, 'is_active' => true, ]); DB::table('artwork_tag')->insert([ [ 'artwork_id' => $ownedArtwork->id, 'tag_id' => $styleTag->id, 'source' => 'ai', 'confidence' => 0.97, 'created_at' => now(), ], [ 'artwork_id' => $foreignArtwork->id, 'tag_id' => $styleTag->id, 'source' => 'ai', 'confidence' => 0.92, 'created_at' => now(), ], ]); $response = $this->actingAs($user) ->postJson(route('settings.collections.smart.preview'), [ 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'style', 'operator' => 'equals', 'value' => 'digital-painting', ], ], ], ]) ->assertOk(); expect(collect($response->json('preview.artworks.data'))->pluck('id')->all()) ->toBe([$ownedArtwork->id]); }); it('smart preview can resolve artworks by ai color tags for the owner only', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $ownedArtwork = Artwork::factory()->for($user)->create(); $foreignArtwork = Artwork::factory()->for($otherUser)->create(); $colorTag = Tag::query()->create([ 'name' => 'Blue Tones', 'slug' => 'blue-tones', 'usage_count' => 0, 'is_active' => true, ]); DB::table('artwork_tag')->insert([ [ 'artwork_id' => $ownedArtwork->id, 'tag_id' => $colorTag->id, 'source' => 'ai', 'confidence' => 0.95, 'created_at' => now(), ], [ 'artwork_id' => $foreignArtwork->id, 'tag_id' => $colorTag->id, 'source' => 'ai', 'confidence' => 0.88, 'created_at' => now(), ], ]); $response = $this->actingAs($user) ->postJson(route('settings.collections.smart.preview'), [ 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'color', 'operator' => 'equals', 'value' => 'blue-tones', ], ], ], ]) ->assertOk(); expect(collect($response->json('preview.artworks.data'))->pluck('id')->all()) ->toBe([$ownedArtwork->id]); }); it('smart preview can resolve artworks by mature flag for the owner only', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $ownedMatureArtwork = Artwork::factory()->for($user)->create(['is_mature' => true]); Artwork::factory()->for($user)->create(['is_mature' => false]); Artwork::factory()->for($otherUser)->create(['is_mature' => true]); $response = $this->actingAs($user) ->postJson(route('settings.collections.smart.preview'), [ 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'is_mature', 'operator' => 'equals', 'value' => true, ], ], ], ]) ->assertOk(); expect(collect($response->json('preview.artworks.data'))->pluck('id')->all()) ->toBe([$ownedMatureArtwork->id]); }); it('cover fallback resolves to the first attached artwork when manual cover becomes unavailable', function () { $user = User::factory()->create(); $collection = Collection::factory()->for($user)->create(); $firstArtwork = Artwork::factory()->for($user)->create(); $secondArtwork = Artwork::factory()->for($user)->create(); $service = app(CollectionService::class); $service->attachArtworks($collection, $user, [$firstArtwork->id, $secondArtwork->id]); $collection->refresh(); expect($collection->resolvedCoverArtwork()?->id)->toBe($firstArtwork->id); $service->updateCollection($collection->loadMissing('user'), [ 'title' => $collection->title, 'slug' => $collection->slug, 'description' => $collection->description, 'visibility' => $collection->visibility, 'sort_mode' => $collection->sort_mode, 'cover_artwork_id' => $secondArtwork->id, ]); $collection->refresh(); expect($collection->resolvedCoverArtwork()?->id)->toBe($secondArtwork->id); $service->removeArtwork($collection->loadMissing('user'), $secondArtwork); $collection->refresh(); expect($collection->cover_artwork_id)->toBeNull(); expect($collection->resolvedCoverArtwork()?->id)->toBe($firstArtwork->id); }); it('owner can feature a public collection up to the configured limit', function () { $user = User::factory()->create(); $limit = (int) config('collections.featured_limit', 3); $existingFeatured = Collection::factory()->count(max($limit - 1, 0))->for($user)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => true, 'featured_at' => now()->subDay(), ]); $collection = Collection::factory()->for($user)->create([ 'title' => 'Pin Me', 'slug' => 'pin-me', 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => false, 'featured_at' => null, ]); $this->actingAs($user) ->postJson(route('settings.collections.feature', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('collection.is_featured', true); $collection->refresh(); expect($collection->is_featured)->toBeTrue(); expect($collection->featured_at)->not->toBeNull(); expect(Collection::query()->ownedBy($user->id)->where('is_featured', true)->count()) ->toBe($existingFeatured->count() + 1); }); it('owner cannot feature more than the configured limit', function () { $user = User::factory()->create(); $limit = (int) config('collections.featured_limit', 3); Collection::factory()->count($limit)->for($user)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => true, 'featured_at' => now()->subHour(), ]); $overflow = Collection::factory()->for($user)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => false, 'featured_at' => null, ]); $this->actingAs($user) ->postJson(route('settings.collections.feature', ['collection' => $overflow->id])) ->assertStatus(422) ->assertJsonValidationErrors('collection'); $overflow->refresh(); expect($overflow->is_featured)->toBeFalse(); }); it('featured discovery only shows public featured collections', function () { $owner = User::factory()->create(['username' => 'featureowner']); Collection::factory()->for($owner)->create([ 'title' => 'Public Featured Collection', 'slug' => 'public-featured-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => true, 'featured_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Featured Collection', 'slug' => 'private-featured-collection', 'visibility' => Collection::VISIBILITY_PRIVATE, 'is_featured' => true, 'featured_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Unlisted Featured Collection', 'slug' => 'unlisted-featured-collection', 'visibility' => Collection::VISIBILITY_UNLISTED, 'is_featured' => true, 'featured_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Placement Blocked Featured Collection', 'slug' => 'placement-blocked-featured-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'placement_eligibility' => false, 'is_featured' => true, 'featured_at' => now(), ]); $this->get(route('collections.featured')) ->assertOk() ->assertSee('Public Featured Collection', false) ->assertDontSee('Private Featured Collection', false) ->assertDontSee('Unlisted Featured Collection', false) ->assertDontSee('Placement Blocked Featured Collection', false); }); it('authenticated users can like and follow public collections', function () { $owner = User::factory()->create(['username' => 'showcaseowner']); $viewer = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'likes_count' => 0, 'followers_count' => 0, ]); $this->actingAs($viewer) ->postJson(route('collections.like', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('liked', true) ->assertJsonPath('likes_count', 1); $this->actingAs($viewer) ->postJson(route('collections.follow', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('following', true) ->assertJsonPath('followers_count', 1); $this->assertDatabaseHas('collection_likes', [ 'collection_id' => $collection->id, 'user_id' => $viewer->id, ]); $this->assertDatabaseHas('collection_follows', [ 'collection_id' => $collection->id, 'user_id' => $viewer->id, ]); $this->actingAs($viewer) ->deleteJson(route('collections.unlike', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('liked', false) ->assertJsonPath('likes_count', 0); $this->actingAs($viewer) ->deleteJson(route('collections.unfollow', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('following', false) ->assertJsonPath('followers_count', 0); }); it('private collections cannot be liked or followed publicly', function () { $owner = User::factory()->create(); $viewer = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PRIVATE, ]); $this->actingAs($viewer) ->postJson(route('collections.like', ['collection' => $collection->id])) ->assertNotFound(); $this->actingAs($viewer) ->postJson(route('collections.follow', ['collection' => $collection->id])) ->assertNotFound(); }); it('owners cannot like or follow their own public collections', function () { $owner = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $this->actingAs($owner) ->postJson(route('collections.like', ['collection' => $collection->id])) ->assertStatus(422) ->assertJsonValidationErrors('collection'); $this->actingAs($owner) ->postJson(route('collections.follow', ['collection' => $collection->id])) ->assertStatus(422) ->assertJsonValidationErrors('collection'); }); it('collection creation creates an active owner membership and allows collaborator invites', function () { $owner = User::factory()->create(['username' => 'curatorowner']); $invitee = User::factory()->create(['username' => 'guesteditor']); $collectionId = (int) $this->actingAs($owner) ->postJson(route('settings.collections.store'), [ 'title' => 'Community Showcase', 'slug' => 'community-showcase', 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, 'allow_comments' => true, 'allow_saves' => true, 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, ]) ->assertOk() ->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); $this->assertDatabaseHas('collection_members', [ 'collection_id' => $collection->id, 'user_id' => $owner->id, 'role' => Collection::MEMBER_ROLE_OWNER, 'status' => Collection::MEMBER_STATUS_ACTIVE, ]); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $invitee->username, 'role' => Collection::MEMBER_ROLE_EDITOR, ]) ->assertOk(); $member = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $invitee->id) ->firstOrFail(); expect($member->status)->toBe(Collection::MEMBER_STATUS_PENDING); $this->actingAs($invitee) ->postJson(route('settings.collections.members.accept', ['member' => $member->id])) ->assertOk(); $member->refresh(); expect($member->status)->toBe(Collection::MEMBER_STATUS_ACTIVE); }); it('invited user can reject a collaborator invite', function () { $owner = User::factory()->create(['username' => 'inviteowner']); $invitee = User::factory()->create(['username' => 'declineguest']); $collectionId = (int) $this->actingAs($owner) ->postJson(route('settings.collections.store'), [ 'title' => 'Invite Workflow Collection', 'slug' => 'invite-workflow-collection', 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, ]) ->assertOk() ->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $invitee->username, 'role' => Collection::MEMBER_ROLE_CONTRIBUTOR, ]) ->assertOk(); $member = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $invitee->id) ->firstOrFail(); $this->actingAs($invitee) ->postJson(route('settings.collections.members.decline', ['member' => $member->id])) ->assertOk(); $member->refresh(); expect($member->status)->toBe(Collection::MEMBER_STATUS_REVOKED); expect($member->accepted_at)->toBeNull(); expect($member->revoked_at)->not->toBeNull(); }); it('scheduled collections only become public inside their publish window', function () { $owner = User::factory()->create(['username' => 'scheduleowner']); $publishAt = now()->addHour(); $unpublishAt = now()->addHours(2); $collectionId = (int) $this->actingAs($owner) ->postJson(route('settings.collections.store'), [ 'title' => 'Scheduled Launch Collection', 'slug' => 'scheduled-launch-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, 'published_at' => $publishAt->toISOString(), 'unpublished_at' => $unpublishAt->toISOString(), ]) ->assertOk() ->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); $showRoute = route('profile.collections.show', ['username' => $owner->username, 'slug' => $collection->slug]); auth()->logout(); $this->get($showRoute)->assertNotFound(); $this->travelTo($publishAt->copy()->addMinute()); $this->get($showRoute)->assertOk(); $this->travelTo($unpublishAt->copy()->addMinute()); $this->get($showRoute)->assertNotFound(); $this->travelBack(); }); it('collection lifecycle sync expires stale invites and unfeatures unpublished collections', function () { $owner = User::factory()->create(['username' => 'lifecycleowner']); $invitee = User::factory()->create(['username' => 'staleinvitee']); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'visibility' => Collection::VISIBILITY_PUBLIC, 'is_featured' => true, 'featured_at' => now()->subDay(), 'unpublished_at' => now()->subMinute(), ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $invitee->username, 'role' => Collection::MEMBER_ROLE_VIEWER, ]) ->assertOk(); $member = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $invitee->id) ->firstOrFail(); $member->forceFill([ 'expires_at' => now()->subMinute(), ])->save(); $this->artisan('collections:sync-lifecycle')->assertExitCode(0); $member->refresh(); $collection->refresh(); expect($member->status)->toBe(Collection::MEMBER_STATUS_REVOKED); expect((bool) $collection->is_featured)->toBeFalse(); expect($collection->featured_at)->toBeNull(); }); it('expired invites cannot be accepted and ownership can be transferred to an active collaborator', function () { $owner = User::factory()->create(['username' => 'transferowner']); $invitee = User::factory()->create(['username' => 'expiredinvitee']); $newOwner = User::factory()->create(['username' => 'nextowner']); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $collaborators = app(\App\Services\CollectionCollaborationService::class); $collaborators->ensureOwnerMembership($collection); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $invitee->username, 'role' => Collection::MEMBER_ROLE_CONTRIBUTOR, ]) ->assertOk(); $expiredMember = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $invitee->id) ->firstOrFail(); $expiredMember->forceFill([ 'expires_at' => now()->subMinute(), ])->save(); $this->actingAs($invitee) ->postJson(route('settings.collections.members.accept', ['member' => $expiredMember->id])) ->assertStatus(422); $expiredMember->refresh(); expect($expiredMember->status)->toBe(Collection::MEMBER_STATUS_REVOKED); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $newOwner->username, 'role' => Collection::MEMBER_ROLE_EDITOR, ]) ->assertOk(); $transferMember = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $newOwner->id) ->firstOrFail(); $this->actingAs($newOwner) ->postJson(route('settings.collections.members.accept', ['member' => $transferMember->id])) ->assertOk(); $this->actingAs($owner) ->postJson(route('settings.collections.members.transfer', ['collection' => $collection->id, 'member' => $transferMember->id])) ->assertOk(); $collection->refresh(); $transferMember->refresh(); expect((int) $collection->user_id)->toBe((int) $newOwner->id); expect($transferMember->role)->toBe(Collection::MEMBER_ROLE_OWNER); $this->assertDatabaseHas('collection_members', [ 'collection_id' => $collection->id, 'user_id' => $owner->id, 'role' => Collection::MEMBER_ROLE_EDITOR, ]); }); it('collection owners can override invite expiry per collaborator invite', function () { $owner = User::factory()->create(['username' => 'custominviteowner']); $invitee = User::factory()->create(['username' => 'custominvitee']); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $beforeInvite = now(); $this->actingAs($owner) ->postJson(route('settings.collections.members.store', ['collection' => $collection->id]), [ 'username' => $invitee->username, 'role' => Collection::MEMBER_ROLE_VIEWER, 'expires_in_days' => 14, ]) ->assertOk(); $member = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $invitee->id) ->firstOrFail(); expect($member->expires_at)->not->toBeNull(); expect($member->expires_at->betweenIncluded($beforeInvite->copy()->addDays(13)->startOfMinute(), $beforeInvite->copy()->addDays(14)->addMinute()))->toBeTrue(); }); it('submission anti spam guard rate limits repeated collection submissions', function () { config()->set('collections.submissions.max_per_hour', 2); $owner = User::factory()->create(['username' => 'ratelimitowner']); $submitter = User::factory()->create(['username' => 'ratelimitsubmitter']); $artworkA = Artwork::factory()->for($submitter)->create(); $artworkB = Artwork::factory()->for($submitter)->create(); $artworkC = Artwork::factory()->for($submitter)->create(); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'mode' => Collection::MODE_MANUAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_submissions' => true, 'collaboration_mode' => Collection::COLLABORATION_OPEN, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $this->actingAs($submitter) ->postJson(route('collections.submissions.store', ['collection' => $collection->id]), [ 'artwork_id' => $artworkA->id, ]) ->assertOk(); $this->actingAs($submitter) ->postJson(route('collections.submissions.store', ['collection' => $collection->id]), [ 'artwork_id' => $artworkB->id, ]) ->assertOk(); $this->actingAs($submitter) ->postJson(route('collections.submissions.store', ['collection' => $collection->id]), [ 'artwork_id' => $artworkC->id, ]) ->assertStatus(422) ->assertJsonValidationErrors('collection'); }); it('authenticated viewers can save public collections', function () { $owner = User::factory()->create(); $viewer = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_saves' => true, 'saves_count' => 0, ]); $this->actingAs($viewer) ->postJson(route('collections.save', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('saved', true) ->assertJsonPath('saves_count', 1); $this->assertDatabaseHas('collection_saves', [ 'collection_id' => $collection->id, 'user_id' => $viewer->id, ]); }); it('community collections can receive submissions and managers can approve them', function () { $owner = User::factory()->create(['username' => 'collectionowner']); $submitter = User::factory()->create(['username' => 'submitter']); $artwork = Artwork::factory()->for($submitter)->create(); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'mode' => Collection::MODE_MANUAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_submissions' => true, 'collaboration_mode' => Collection::COLLABORATION_OPEN, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $response = $this->actingAs($submitter) ->postJson(route('collections.submissions.store', ['collection' => $collection->id]), [ 'artwork_id' => $artwork->id, ]) ->assertOk(); $submissionId = (int) $response->json('submission.id'); $this->actingAs($owner) ->postJson(route('collections.submissions.approve', ['submission' => $submissionId])) ->assertOk(); $this->assertDatabaseHas('collection_artwork', [ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, ]); $this->assertDatabaseHas('collection_submissions', [ 'id' => $submissionId, 'status' => Collection::SUBMISSION_APPROVED, ]); }); it('authenticated viewers can comment on collections when comments are enabled', function () { $owner = User::factory()->create(['username' => 'commentowner']); $viewer = User::factory()->create(['username' => 'commentviewer']); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_comments' => true, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $this->actingAs($viewer) ->postJson(route('collections.comments.store', ['collection' => $collection->id]), [ 'body' => 'Love the visual direction here.', ]) ->assertOk() ->assertJsonPath('comments.0.user.username', $viewer->username); $this->assertDatabaseHas('collection_comments', [ 'collection_id' => $collection->id, 'user_id' => $viewer->id, 'status' => Collection::COMMENT_VISIBLE, ]); }); it('smart preview only resolves artworks owned by the requesting user', function () { $owner = User::factory()->create(); $otherUser = User::factory()->create(); $tag = \App\Models\Tag::factory()->create([ 'name' => 'Cyberpunk', 'slug' => 'cyberpunk', ]); $ownedArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Owned Neon City', 'slug' => 'owned-neon-city', ]); $foreignArtwork = Artwork::factory()->for($otherUser)->create([ 'title' => 'Foreign Neon City', 'slug' => 'foreign-neon-city', ]); DB::table('artwork_tag')->insert([ [ 'artwork_id' => $ownedArtwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'confidence' => null, ], [ 'artwork_id' => $foreignArtwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'confidence' => null, ], ]); $response = $this->actingAs($owner) ->postJson(route('settings.collections.smart.preview'), [ 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'tags', 'operator' => 'contains', 'value' => 'cyberpunk', ], ], ], ]) ->assertOk() ->json('preview'); expect($response['count'])->toBe(1); expect(collect($response['artworks']['data'])->pluck('id')->all())->toBe([$ownedArtwork->id]); expect(collect($response['artworks']['data'])->pluck('title')->all())->toContain('Owned Neon City'); expect(collect($response['artworks']['data'])->pluck('title')->all())->not->toContain('Foreign Neon City'); }); it('smart preview can resolve artworks by medium using owned content types', function () { $owner = User::factory()->create(); $contentType = ContentType::query()->create([ 'name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => 'Wallpaper uploads', ]); $rootCategory = Category::query()->create([ 'content_type_id' => $contentType->id, 'parent_id' => null, 'name' => 'Abstract', 'slug' => 'abstract', 'description' => 'Abstract wallpapers', 'is_active' => true, 'sort_order' => 1, ]); $matchingArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Wallpaper Study', 'slug' => 'wallpaper-study', ]); $otherArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Uncategorized Study', 'slug' => 'uncategorized-study', ]); DB::table('artwork_category')->insert([ 'artwork_id' => $matchingArtwork->id, 'category_id' => $rootCategory->id, ]); $response = $this->actingAs($owner) ->postJson(route('settings.collections.smart.preview'), [ 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'medium', 'operator' => 'equals', 'value' => 'wallpapers', ], ], ], ]) ->assertOk() ->json('preview'); expect($response['count'])->toBe(1); expect(collect($response['artworks']['data'])->pluck('id')->all())->toBe([$matchingArtwork->id]); expect(collect($response['artworks']['data'])->pluck('title')->all())->toContain('Wallpaper Study'); expect(collect($response['artworks']['data'])->pluck('title')->all())->not->toContain('Uncategorized Study'); }); it('smart collections do not accept manual artwork attachments', function () { $owner = User::factory()->create(); $artwork = Artwork::factory()->for($owner)->create(); $collection = Collection::factory()->for($owner)->create([ 'mode' => Collection::MODE_SMART, 'sort_mode' => Collection::SORT_NEWEST, 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'tags', 'operator' => 'contains', 'value' => 'cyberpunk', ], ], ], ]); $this->actingAs($owner) ->postJson(route('settings.collections.artworks.attach', ['collection' => $collection->id]), [ 'artwork_ids' => [$artwork->id], ]) ->assertStatus(422) ->assertJsonValidationErrors('collection'); $this->assertDatabaseMissing('collection_artwork', [ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, ]); }); it('collection managers can request AI curation suggestions', function () { $owner = User::factory()->create(); $artwork = Artwork::factory()->for($owner)->create([ 'title' => 'Neon Horizon', 'slug' => 'neon-horizon', ]); $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'allow_submissions' => true, 'visibility' => Collection::VISIBILITY_PUBLIC, 'title' => 'Untitled Community Set', 'slug' => 'untitled-community-set', ]); $tag = Tag::query()->create([ 'name' => 'Cyberpunk', 'slug' => 'cyberpunk', 'usage_count' => 0, 'is_active' => true, ]); DB::table('artwork_tag')->insert([ 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'source' => 'ai', 'confidence' => 0.98, 'created_at' => now(), ]); app(CollectionService::class)->attachArtworks($collection, $owner, [$artwork->id]); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-title', ['collection' => $collection->id]), [ 'draft' => ['title' => $collection->title], ]) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-summary', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-cover', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.artwork.id', $artwork->id); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-grouping', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); }); it('collection AI suggestion endpoints are permission protected', function () { $owner = User::factory()->create(); $outsider = User::factory()->create(); $collection = Collection::factory()->for($owner)->create(); $this->actingAs($outsider) ->postJson(route('settings.collections.ai.suggest-title', ['collection' => $collection->id])) ->assertForbidden(); }); it('collection managers can request extended AI curation suggestions', function () { $owner = User::factory()->create(); $tag = Tag::query()->create([ 'name' => 'Minimal', 'slug' => 'minimal', 'usage_count' => 0, 'is_active' => true, ]); $attachedArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Minimal Study One', 'slug' => 'minimal-study-one', ]); $suggestedArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Minimal Study Two', 'slug' => 'minimal-study-two', ]); foreach ([$attachedArtwork, $suggestedArtwork] as $artwork) { DB::table('artwork_tag')->insert([ 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'confidence' => null, 'created_at' => now(), ]); } $collection = Collection::factory()->for($owner)->create([ 'type' => Collection::TYPE_COMMUNITY, 'mode' => Collection::MODE_SMART, 'visibility' => Collection::VISIBILITY_PUBLIC, 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [ [ 'field' => 'tags', 'operator' => 'contains', 'value' => 'minimal', ], ], ], ]); app(CollectionService::class)->attachArtworks($collection->forceFill(['mode' => Collection::MODE_MANUAL]), $owner, [$attachedArtwork->id]); $collection->forceFill([ 'mode' => Collection::MODE_SMART, 'smart_rules_json' => [ 'match' => 'all', 'sort' => 'newest', 'rules' => [[ 'field' => 'tags', 'operator' => 'contains', 'value' => 'minimal', ]], ], ])->save(); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-related-artworks', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai') ->assertJsonPath('suggestion.artworks.0.id', $suggestedArtwork->id); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-tags', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-seo-description', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.explain-smart-rules', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-split-themes', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-merge-idea', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai'); }); it('collection managers can request AI metadata diagnostics and stale refresh suggestions', function () { $owner = User::factory()->create(); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Untitled Collection', 'slug' => 'untitled-collection', 'summary' => null, 'description' => 'Short mood note.', 'cover_artwork_id' => null, 'metadata_completeness_score' => 42.0, 'freshness_score' => 18.0, 'updated_at' => now()->subDays(90), 'last_activity_at' => now()->subDays(76), ]); $this->actingAs($owner) ->postJson(route('settings.collections.ai.detect-weak-metadata', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.status', 'needs_work') ->assertJsonPath('suggestion.source', 'heuristic-ai') ->assertJson(fn ($json) => $json ->where('suggestion.issues', fn ($issues): bool => collect($issues) ->pluck('key') ->intersect(['title', 'summary', 'description', 'cover', 'theme', 'metadata_score']) ->count() >= 5)); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-stale-refresh', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.stale', true) ->assertJsonPath('suggestion.source', 'heuristic-ai') ->assertJson(fn ($json) => $json ->where('suggestion.actions', fn ($actions): bool => collect($actions) ->pluck('key') ->intersect(['refresh_summary', 'set_cover', 'add_recent_artworks']) ->count() === 3)); }); it('healthy collections return a clean AI metadata diagnostic', function () { $owner = User::factory()->create(); $artwork = Artwork::factory()->for($owner)->create([ 'title' => 'Ocean Light Study', 'slug' => 'ocean-light-study', ]); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Ocean Light Editorial', 'slug' => 'ocean-light-editorial', 'summary' => 'A focused selection of ocean-lit studies with a clean editorial rhythm.', 'description' => 'A longer curatorial description that explains the atmosphere, pacing, and recurring light studies across the featured works for discovery surfaces.', 'cover_artwork_id' => $artwork->id, 'metadata_completeness_score' => 88.0, 'freshness_score' => 82.0, 'updated_at' => now()->subDays(7), 'last_activity_at' => now()->subDays(3), ]); $tag = Tag::query()->create([ 'name' => 'Oceanic', 'slug' => 'oceanic', 'usage_count' => 0, 'is_active' => true, ]); DB::table('artwork_tag')->insert([ 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'confidence' => null, 'created_at' => now(), ]); app(CollectionService::class)->attachArtworks($collection, $owner, [$artwork->id]); $this->actingAs($owner) ->postJson(route('settings.collections.ai.detect-weak-metadata', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.status', 'healthy') ->assertJsonPath('suggestion.issues', []); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-stale-refresh', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.stale', false) ->assertJsonPath('suggestion.actions', []); }); it('collection managers can request AI campaign fit and related collection link suggestions', function () { $owner = User::factory()->create(['username' => 'campaignfitowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Spring Night Editorial', 'slug' => 'spring-night-editorial', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'season_key' => 'spring', 'event_key' => 'spring-lights-2026', 'event_label' => 'Spring Lights 2026', 'ranking_score' => 81, ]); $campaignCandidate = Collection::factory()->for($owner)->create([ 'title' => 'Spring Lights Campaign Hub', 'slug' => 'spring-lights-campaign-hub', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'spring-lights', 'campaign_label' => 'Spring Lights', 'season_key' => 'spring', 'event_key' => 'spring-lights-2026', 'event_label' => 'Spring Lights 2026', 'ranking_score' => 88, ]); $relatedLinkCandidate = Collection::factory()->for($owner)->create([ 'title' => 'Spring Night Community Picks', 'slug' => 'spring-night-community-picks', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'season_key' => 'spring', 'event_key' => 'spring-lights-2026', 'ranking_score' => 77, ]); $alreadyLinked = Collection::factory()->for($owner)->create([ 'title' => 'Already Linked Spring Feature', 'slug' => 'already-linked-spring-feature', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'season_key' => 'spring', 'event_key' => 'spring-lights-2026', 'ranking_score' => 79, ]); DB::table('collection_related_links')->insert([ 'collection_id' => $collection->id, 'related_collection_id' => $alreadyLinked->id, 'sort_order' => 0, 'created_by_user_id' => $owner->id, 'created_at' => now(), 'updated_at' => now(), ]); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-campaign-fit', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai') ->assertJsonPath('suggestion.eligibility.is_campaign_ready', true) ->assertJson(fn ($json) => $json ->where('suggestion.fits.0.campaign_key', 'spring-lights') ->where('suggestion.fits.0.campaign_label', 'Spring Lights') ->has('suggestion.recommended_surfaces')); $this->actingAs($owner) ->postJson(route('settings.collections.ai.suggest-related-collections-to-link', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('suggestion.source', 'heuristic-ai') ->assertJson(fn ($json) => $json ->where('suggestion.linked_collection_ids.0', $alreadyLinked->id) ->where('suggestion.suggestions.0.id', $campaignCandidate->id) ->where('suggestion.suggestions.0.link_type', 'same_curator') ->where('suggestion.suggestions', fn ($suggestions): bool => collect($suggestions)->pluck('id')->contains($relatedLinkCandidate->id) && ! collect($suggestions)->pluck('id')->contains($alreadyLinked->id))); }); it('admins can moderate collection visibility interactions and collaborators', function () { $admin = User::factory()->create(['role' => 'admin']); $owner = User::factory()->create(['username' => 'moderatedowner']); $contributor = User::factory()->create(['username' => 'modcollaborator']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Moderated Collection', 'slug' => 'moderated-collection', 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_comments' => true, 'allow_submissions' => true, 'allow_saves' => true, 'is_featured' => true, 'featured_at' => now(), ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); app(\App\Services\CollectionCollaborationService::class)->inviteMember($collection, $owner, $contributor, Collection::MEMBER_ROLE_CONTRIBUTOR); $member = \App\Models\CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $contributor->id) ->firstOrFail(); $this->actingAs($admin) ->patchJson(route('api.admin.collections.moderation.update', ['collection' => $collection->id]), [ 'moderation_status' => Collection::MODERATION_RESTRICTED, ]) ->assertOk() ->assertJsonPath('collection.moderation_status', Collection::MODERATION_RESTRICTED); $this->actingAs($admin) ->patchJson(route('api.admin.collections.interactions.update', ['collection' => $collection->id]), [ 'allow_comments' => false, 'allow_submissions' => false, 'allow_saves' => false, ]) ->assertOk() ->assertJsonPath('collection.allow_comments', false) ->assertJsonPath('collection.allow_submissions', false) ->assertJsonPath('collection.allow_saves', false); $this->actingAs($admin) ->postJson(route('api.admin.collections.unfeature', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('collection.is_featured', false); $this->actingAs($admin) ->deleteJson(route('api.admin.collections.members.destroy', ['collection' => $collection->id, 'member' => $member->id])) ->assertOk(); $collection->refresh(); $member->refresh(); expect($collection->moderation_status)->toBe(Collection::MODERATION_RESTRICTED); expect($collection->allow_comments)->toBeFalse(); expect($collection->allow_submissions)->toBeFalse(); expect($collection->allow_saves)->toBeFalse(); expect($collection->is_featured)->toBeFalse(); expect($member->status)->toBe(Collection::MEMBER_STATUS_REVOKED); auth()->logout(); $this->get(route('profile.collections.show', ['username' => $owner->username, 'slug' => $collection->slug])) ->assertNotFound(); }); it('saved collections page lists the viewers saved public collections only', function () { $owner = User::factory()->create(['username' => 'savedowner']); $viewer = User::factory()->create(); $publicCollection = Collection::factory()->for($owner)->create([ 'title' => 'Saved Public Collection', 'slug' => 'saved-public-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_saves' => true, ]); $privateCollection = Collection::factory()->for($owner)->create([ 'title' => 'Hidden Saved Collection', 'slug' => 'hidden-saved-collection', 'visibility' => Collection::VISIBILITY_PRIVATE, 'allow_saves' => true, ]); $this->actingAs($viewer) ->postJson(route('collections.save', ['collection' => $publicCollection->id])) ->assertOk(); DB::table('collection_saves')->insert([ 'collection_id' => $privateCollection->id, 'user_id' => $viewer->id, 'created_at' => now(), ]); $this->actingAs($viewer) ->get(route('me.saved.collections')) ->assertOk() ->assertSee('Saved Public Collection', false) ->assertDontSee('Hidden Saved Collection', false); }); it('admin can create an editorial collection under a staff account with persisted layout modules', function () { $admin = User::factory()->create([ 'username' => 'collectionsadmin', 'role' => 'admin', ]); $staffOwner = User::factory()->create([ 'username' => 'staffcurator', 'role' => 'moderator', ]); $response = $this->actingAs($admin) ->postJson(route('settings.collections.store'), [ 'title' => 'Staff Picks Cyberpunk', 'slug' => '', 'type' => Collection::TYPE_EDITORIAL, 'editorial_owner_mode' => Collection::EDITORIAL_OWNER_STAFF_ACCOUNT, 'editorial_owner_username' => $staffOwner->username, 'description' => 'Premium curation for the homepage feature strip.', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, 'layout_modules_json' => [ ['key' => 'editorial_note', 'enabled' => true, 'slot' => 'full'], ['key' => 'artwork_grid', 'enabled' => true, 'slot' => 'main'], ['key' => 'discussion', 'enabled' => false, 'slot' => 'sidebar'], ], ]) ->assertOk() ->assertJsonPath('collection.owner.username', $staffOwner->username) ->assertJsonPath('collection.owner.mode', Collection::EDITORIAL_OWNER_STAFF_ACCOUNT) ->assertJsonPath('collection.layout_modules.0.key', 'editorial_note') ->assertJsonPath('collection.layout_modules.1.key', 'artwork_grid'); $collectionId = (int) $response->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); expect((int) $collection->user_id)->toBe((int) $staffOwner->id); expect((int) $collection->managed_by_user_id)->toBe((int) $admin->id); expect((int) $collection->editorial_owner_user_id)->toBe((int) $staffOwner->id); expect($collection->editorial_owner_mode)->toBe(Collection::EDITORIAL_OWNER_STAFF_ACCOUNT); expect(collect($collection->layout_modules_json)->pluck('key')->take(2)->values()->all()) ->toBe(['editorial_note', 'artwork_grid']); expect((bool) collect($collection->layout_modules_json)->firstWhere('key', 'discussion')['enabled']) ->toBeFalse(); $this->get(route('profile.collections.show', ['username' => $staffOwner->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee('Staff Picks Cyberpunk', false); }); it('admin can create a system-owned editorial collection with a dedicated public label', function () { $admin = User::factory()->create([ 'username' => 'systemeditoradmin', 'role' => 'admin', ]); $systemOwner = User::factory()->create([ 'username' => 'skinbaseeditorial', 'role' => 'admin', ]); config()->set('collections.editorial.system_owner_username', $systemOwner->username); config()->set('collections.editorial.system_owner_label', 'Skinbase Editorial'); $response = $this->actingAs($admin) ->postJson(route('settings.collections.store'), [ 'title' => 'Nova Launch Highlights', 'slug' => '', 'type' => Collection::TYPE_EDITORIAL, 'editorial_owner_mode' => Collection::EDITORIAL_OWNER_SYSTEM, 'editorial_owner_label' => 'Nova Editorial Desk', 'description' => 'Collections supporting launch-week editorial coverage.', 'visibility' => Collection::VISIBILITY_PUBLIC, 'sort_mode' => Collection::SORT_MANUAL, ]) ->assertOk() ->assertJsonPath('collection.owner.is_system', true) ->assertJsonPath('collection.owner.name', 'Nova Editorial Desk'); $collectionId = (int) $response->json('collection.id'); $collection = Collection::query()->findOrFail($collectionId); expect((int) $collection->user_id)->toBe((int) $systemOwner->id); expect((int) $collection->managed_by_user_id)->toBe((int) $admin->id); expect((int) $collection->editorial_owner_user_id)->toBe((int) $systemOwner->id); expect($collection->editorial_owner_label)->toBe('Nova Editorial Desk'); $this->get(route('profile.collections.show', ['username' => $systemOwner->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee('Nova Editorial Desk', false); }); it('related collection recommendations do not leak private collections', function () { $owner = User::factory()->create(['username' => 'relatedowner']); $tag = Tag::query()->create([ 'name' => 'Minimal', 'slug' => 'minimal', 'usage_count' => 0, 'is_active' => true, ]); $baseCollection = Collection::factory()->for($owner)->create([ 'title' => 'Base Collection', 'slug' => 'base-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'type' => Collection::TYPE_COMMUNITY, ]); $publicRelated = Collection::factory()->for($owner)->create([ 'title' => 'Visible Related Collection', 'slug' => 'visible-related-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'type' => Collection::TYPE_COMMUNITY, ]); $privateRelated = Collection::factory()->for($owner)->create([ 'title' => 'Private Related Collection', 'slug' => 'private-related-collection', 'visibility' => Collection::VISIBILITY_PRIVATE, 'type' => Collection::TYPE_COMMUNITY, ]); $baseArtwork = Artwork::factory()->for($owner)->create(); $publicArtwork = Artwork::factory()->for($owner)->create(); $privateArtwork = Artwork::factory()->for($owner)->create(); foreach ([$baseArtwork, $publicArtwork, $privateArtwork] as $artwork) { DB::table('artwork_tag')->insert([ 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'source' => 'user', 'confidence' => null, 'created_at' => now(), ]); } app(CollectionService::class)->attachArtworks($baseCollection, $owner, [$baseArtwork->id]); app(CollectionService::class)->attachArtworks($publicRelated, $owner, [$publicArtwork->id]); app(CollectionService::class)->attachArtworks($privateRelated, $owner, [$privateArtwork->id]); $this->get(route('profile.collections.show', ['username' => $owner->username, 'slug' => $baseCollection->slug])) ->assertOk() ->assertSee('Visible Related Collection', false) ->assertDontSee('Private Related Collection', false); }); it('trending discovery route only shows public collections ordered by engagement-friendly signals', function () { $owner = User::factory()->create(['username' => 'trendowner']); Collection::factory()->for($owner)->create([ 'title' => 'Quiet Public Collection', 'slug' => 'quiet-public-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'likes_count' => 1, 'followers_count' => 0, 'saves_count' => 0, 'comments_count' => 0, 'views_count' => 1, 'last_activity_at' => now()->subDay(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Hot Public Collection', 'slug' => 'hot-public-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'likes_count' => 20, 'followers_count' => 8, 'saves_count' => 7, 'comments_count' => 5, 'views_count' => 400, 'last_activity_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Trending Collection', 'slug' => 'private-trending-collection', 'visibility' => Collection::VISIBILITY_PRIVATE, 'likes_count' => 999, 'followers_count' => 999, 'saves_count' => 999, 'comments_count' => 999, 'views_count' => 9999, 'last_activity_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Placement Blocked Trending Collection', 'slug' => 'placement-blocked-trending-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'placement_eligibility' => false, 'likes_count' => 500, 'followers_count' => 300, 'saves_count' => 200, 'comments_count' => 100, 'views_count' => 9000, 'last_activity_at' => now(), ]); $this->get(route('collections.trending')) ->assertOk() ->assertSee('Trending collections', false) ->assertSeeInOrder(['Hot Public Collection', 'Quiet Public Collection']) ->assertDontSee('Private Trending Collection', false) ->assertDontSee('Placement Blocked Trending Collection', false); }); it('editorial discovery route only shows editorial public collections', function () { $owner = User::factory()->create(['username' => 'editorialowner']); Collection::factory()->for($owner)->create([ 'title' => 'Staff Picks Spring', 'slug' => 'staff-picks-spring', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); Collection::factory()->for($owner)->create([ 'title' => 'Community Picks Spring', 'slug' => 'community-picks-spring', 'type' => Collection::TYPE_COMMUNITY, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $this->get(route('collections.editorial')) ->assertOk() ->assertSee('Editorial collections', false) ->assertSee('Staff Picks Spring', false) ->assertDontSee('Community Picks Spring', false); }); it('community discovery route only shows community public collections', function () { $owner = User::factory()->create(['username' => 'communityowner']); Collection::factory()->for($owner)->create([ 'title' => 'Open Theme Jam', 'slug' => 'open-theme-jam', 'type' => Collection::TYPE_COMMUNITY, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); Collection::factory()->for($owner)->create([ 'title' => 'Curator Portfolio', 'slug' => 'curator-portfolio', 'type' => Collection::TYPE_PERSONAL, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $this->get(route('collections.community')) ->assertOk() ->assertSee('Community collections', false) ->assertSee('Open Theme Jam', false) ->assertDontSee('Curator Portfolio', false); }); it('seasonal discovery route only shows public collections tagged for events or seasons', function () { $owner = User::factory()->create(['username' => 'seasonowner']); Collection::factory()->for($owner)->create([ 'title' => 'Winter Wallpapers 2026', 'slug' => 'winter-wallpapers-2026', 'visibility' => Collection::VISIBILITY_PUBLIC, 'season_key' => 'winter-2026', ]); Collection::factory()->for($owner)->create([ 'title' => 'Evergreen Studio Set', 'slug' => 'evergreen-studio-set', 'visibility' => Collection::VISIBILITY_PUBLIC, 'season_key' => null, 'event_key' => null, ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Holiday Vault', 'slug' => 'private-holiday-vault', 'visibility' => Collection::VISIBILITY_PRIVATE, 'event_key' => 'holiday-2026', ]); $this->get(route('collections.seasonal')) ->assertOk() ->assertSee('Seasonal and event collections', false) ->assertSee('Winter Wallpapers 2026', false) ->assertDontSee('Evergreen Studio Set', false) ->assertDontSee('Private Holiday Vault', false); }); it('campaign landing page shows public campaign collections and excludes restricted entries', function () { $owner = User::factory()->create(['username' => 'campaignlandingowner']); Collection::factory()->for($owner)->create([ 'title' => 'Winter Launch Editorial', 'slug' => 'winter-launch-editorial', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'winter-launch', 'campaign_label' => 'Winter Launch', 'event_label' => 'Winter Spotlight 2026', 'badge_label' => 'Campaign Live', 'banner_text' => 'A campaign landing page for winter launch collections.', ]); Collection::factory()->for($owner)->create([ 'title' => 'Winter Launch Community', 'slug' => 'winter-launch-community', 'type' => Collection::TYPE_COMMUNITY, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'winter-launch', 'campaign_label' => 'Winter Launch', ]); Collection::factory()->for($owner)->create([ 'title' => 'Placement Blocked Winter Launch', 'slug' => 'placement-blocked-winter-launch', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => false, 'campaign_key' => 'winter-launch', 'campaign_label' => 'Winter Launch', ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Winter Launch Vault', 'slug' => 'private-winter-launch-vault', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'winter-launch', ]); Collection::factory()->for($owner)->create([ 'title' => 'Restricted Winter Launch Set', 'slug' => 'restricted-winter-launch-set', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_RESTRICTED, 'moderation_status' => Collection::MODERATION_RESTRICTED, 'campaign_key' => 'winter-launch', ]); Collection::factory()->for($owner)->create([ 'title' => 'Different Campaign Collection', 'slug' => 'different-campaign-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'spring-launch', ]); $this->get(route('collections.campaign.show', ['campaignKey' => 'winter-launch'])) ->assertOk() ->assertSee('Winter Launch', false) ->assertSee('Winter Launch Editorial', false) ->assertSee('Winter Launch Community', false) ->assertDontSee('Placement Blocked Winter Launch', false) ->assertDontSee('Private Winter Launch Vault', false) ->assertDontSee('Restricted Winter Launch Set', false) ->assertDontSee('Different Campaign Collection', false) ->assertSee('Campaign Live', false) ->assertSee('Winter Spotlight 2026', false); }); it('program landing page shows partner and sponsor metadata for public eligible collections', function () { $owner = User::factory()->create(['username' => 'programlandingfeatureowner']); Collection::factory()->for($owner)->create([ 'title' => 'Program Landing Hero', 'slug' => 'program-landing-hero', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => true, 'program_key' => 'editorial-spring', 'partner_label' => 'Editorial Board', 'sponsorship_label' => 'Brand Partner', 'promotion_tier' => 'priority', 'trust_tier' => 'trusted', 'banner_text' => 'Editorial Spring Program', ]); Collection::factory()->for($owner)->create([ 'title' => 'Program Landing Hidden', 'slug' => 'program-landing-hidden', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => true, 'program_key' => 'editorial-spring', ]); Collection::factory()->for($owner)->create([ 'title' => 'Program Landing Ineligible', 'slug' => 'program-landing-ineligible', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => false, 'program_key' => 'editorial-spring', ]); $this->get(route('collections.program.show', ['programKey' => 'editorial-spring'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->where('program.key', 'editorial-spring') ->where('program.label', 'Editorial Spring Program') ->where('program.partner_labels.0', 'Editorial Board') ->where('program.sponsorship_labels.0', 'Brand Partner') ->has('collections', 1) ->where('collections.0.title', 'Program Landing Hero')); }); it('authenticated viewers can report visible collections comments and submissions', function () { $owner = User::factory()->create(['username' => 'reportowner']); $viewer = User::factory()->create(['username' => 'reportviewer']); $submitter = User::factory()->create(['username' => 'reportsubmitter']); $artwork = Artwork::factory()->for($submitter)->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PUBLIC, 'allow_comments' => true, 'allow_submissions' => true, 'type' => Collection::TYPE_COMMUNITY, 'mode' => Collection::MODE_MANUAL, 'collaboration_mode' => Collection::COLLABORATION_OPEN, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $comment = app(\App\Services\CollectionCommentService::class)->create($collection, $submitter, 'Reportable comment'); $submission = app(\App\Services\CollectionSubmissionService::class)->submit($collection, $submitter, $artwork); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection', 'target_id' => $collection->id, 'reason' => 'Misleading curation', ]) ->assertCreated(); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection_comment', 'target_id' => $comment->id, 'reason' => 'Harassment', ]) ->assertCreated(); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection_submission', 'target_id' => $submission->id, 'reason' => 'Abusive submission', ]) ->assertCreated(); expect(Report::query()->where('reporter_id', $viewer->id)->count())->toBe(3); }); it('users cannot report private collection entities they cannot view', function () { $owner = User::factory()->create(); $viewer = User::factory()->create(); $submitter = User::factory()->create(); $artwork = Artwork::factory()->for($submitter)->create(); $collection = Collection::factory()->for($owner)->create([ 'visibility' => Collection::VISIBILITY_PRIVATE, 'allow_comments' => true, 'allow_submissions' => true, 'type' => Collection::TYPE_COMMUNITY, 'mode' => Collection::MODE_MANUAL, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $comment = app(\App\Services\CollectionCommentService::class)->create($collection, $owner, 'Private comment'); $submission = \App\Models\CollectionSubmission::query()->create([ 'collection_id' => $collection->id, 'artwork_id' => $artwork->id, 'user_id' => $submitter->id, 'message' => 'Private submission', 'status' => Collection::SUBMISSION_PENDING, ]); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection', 'target_id' => $collection->id, 'reason' => 'Should fail', ]) ->assertForbidden(); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection_comment', 'target_id' => $comment->id, 'reason' => 'Should fail', ]) ->assertForbidden(); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'collection_submission', 'target_id' => $submission->id, 'reason' => 'Should fail', ]) ->assertForbidden(); }); it('dashboard summarises only the owners collections and highlights attention queues', function () { $owner = User::factory()->create(['username' => 'dashboardowner']); $otherOwner = User::factory()->create(['username' => 'dashboardother']); $submitter = User::factory()->create(['username' => 'dashsubmitter']); $draft = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Draft', 'slug' => 'dashboard-draft', 'lifecycle_state' => Collection::LIFECYCLE_DRAFT, 'ranking_score' => 10, ]); $scheduled = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Scheduled', 'slug' => 'dashboard-scheduled', 'lifecycle_state' => Collection::LIFECYCLE_SCHEDULED, 'ranking_score' => 40, ]); $publishedHealthy = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Healthy', 'slug' => 'dashboard-healthy', 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'ranking_score' => 99, 'quality_score' => 93, ]); $publishedLowQuality = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Low Quality', 'slug' => 'dashboard-low-quality', 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'ranking_score' => 70, 'quality_score' => 31, ]); $archivedRestricted = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Restricted', 'slug' => 'dashboard-restricted', 'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED, 'ranking_score' => 80, 'moderation_status' => Collection::MODERATION_RESTRICTED, ]); $expiringCampaign = Collection::factory()->for($owner)->create([ 'title' => 'Dashboard Expiring', 'slug' => 'dashboard-expiring', 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'ranking_score' => 85, 'unpublished_at' => now()->addDays(7), ]); Collection::factory()->for($otherOwner)->create([ 'title' => 'Other Owner Collection', 'slug' => 'other-owner-collection', 'ranking_score' => 500, 'quality_score' => 99, ]); $firstArtwork = Artwork::factory()->for($submitter)->create(); $secondArtwork = Artwork::factory()->for($submitter)->create(); $approvedArtwork = Artwork::factory()->for($submitter)->create(); DB::table('collection_submissions')->insert([ [ 'collection_id' => $scheduled->id, 'artwork_id' => $firstArtwork->id, 'user_id' => $submitter->id, 'message' => 'Pending submission one', 'status' => Collection::SUBMISSION_PENDING, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $publishedLowQuality->id, 'artwork_id' => $secondArtwork->id, 'user_id' => $submitter->id, 'message' => 'Pending submission two', 'status' => Collection::SUBMISSION_PENDING, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $publishedLowQuality->id, 'artwork_id' => $approvedArtwork->id, 'user_id' => $owner->id, 'message' => 'Approved submission', 'status' => Collection::SUBMISSION_APPROVED, 'created_at' => now(), 'updated_at' => now(), ], ]); $response = $this->actingAs($owner) ->get(route('settings.collections.dashboard')) ->assertOk(); $page = $response->viewData('page'); expect(data_get($page, 'component'))->toBe('Collection/CollectionDashboard'); expect(data_get($page, 'props.summary'))->toMatchArray([ 'total' => 6, 'drafts' => 1, 'scheduled' => 1, 'published' => 3, 'archived' => 1, 'pending_submissions' => 2, ]); expect(collect(data_get($page, 'props.topPerforming', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([ $publishedHealthy->id, $expiringCampaign->id, $archivedRestricted->id, $publishedLowQuality->id, $scheduled->id, $draft->id, ]); expect(collect(data_get($page, 'props.needsAttention', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([ $archivedRestricted->id, $publishedLowQuality->id, $draft->id, ]); expect(collect(data_get($page, 'props.expiringCampaigns', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([$expiringCampaign->id]); }); it('owners can view collection analytics with deltas timeline and top artworks', function () { $owner = User::factory()->create(['username' => 'analyticsowner']); $submitterA = User::factory()->create(['username' => 'analyticssubmittera']); $submitterB = User::factory()->create(['username' => 'analyticssubmitterb']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Analytics Collection', 'slug' => 'analytics-collection', 'views_count' => 120, 'likes_count' => 30, 'followers_count' => 10, 'saves_count' => 7, 'comments_count' => 5, 'shares_count' => 4, ]); $artworkA = Artwork::factory()->for($owner)->create([ 'title' => 'Analytics Artwork A', 'slug' => 'analytics-artwork-a', ]); $artworkB = Artwork::factory()->for($owner)->create([ 'title' => 'Analytics Artwork B', 'slug' => 'analytics-artwork-b', ]); DB::table('collection_artwork')->insert([ [ 'collection_id' => $collection->id, 'artwork_id' => $artworkA->id, 'order_num' => 0, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $collection->id, 'artwork_id' => $artworkB->id, 'order_num' => 1, 'created_at' => now(), 'updated_at' => now(), ], ]); DB::table('artwork_stats')->insert([ 'artwork_id' => $artworkA->id, 'views' => 500, 'downloads' => 0, 'favorites' => 40, 'comments_count' => 0, 'shares_count' => 12, 'rating_avg' => 0, 'rating_count' => 0, 'views_24h' => 0, 'views_7d' => 0, 'downloads_24h' => 0, 'downloads_7d' => 0, 'shares_24h' => 0, 'comments_24h' => 0, 'favourites_24h' => 0, 'ranking_score' => 88.5, 'engagement_velocity' => 0, 'heat_score' => 0, 'heat_score_updated_at' => null, 'views_1h' => 0, 'favourites_1h' => 0, 'comments_1h' => 0, 'shares_1h' => 0, 'downloads_1h' => 0, ]); DB::table('artwork_stats')->insert([ 'artwork_id' => $artworkB->id, 'views' => 150, 'downloads' => 0, 'favorites' => 9, 'comments_count' => 0, 'shares_count' => 3, 'rating_avg' => 0, 'rating_count' => 0, 'views_24h' => 0, 'views_7d' => 0, 'downloads_24h' => 0, 'downloads_7d' => 0, 'shares_24h' => 0, 'comments_24h' => 0, 'favourites_24h' => 0, 'ranking_score' => 44.2, 'engagement_velocity' => 0, 'heat_score' => 0, 'heat_score_updated_at' => null, 'views_1h' => 0, 'favourites_1h' => 0, 'comments_1h' => 0, 'shares_1h' => 0, 'downloads_1h' => 0, ]); DB::table('collection_submissions')->insert([ [ 'collection_id' => $collection->id, 'artwork_id' => $artworkA->id, 'user_id' => $submitterA->id, 'message' => 'Submission A', 'status' => Collection::SUBMISSION_PENDING, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $collection->id, 'artwork_id' => $artworkB->id, 'user_id' => $submitterB->id, 'message' => 'Submission B', 'status' => Collection::SUBMISSION_APPROVED, 'created_at' => now(), 'updated_at' => now(), ], ]); DB::table('collection_daily_stats')->insert([ [ 'collection_id' => $collection->id, 'stat_date' => now()->subDays(2)->toDateString(), 'views_count' => 20, 'likes_count' => 5, 'follows_count' => 2, 'saves_count' => 1, 'comments_count' => 1, 'shares_count' => 0, 'submissions_count' => 1, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $collection->id, 'stat_date' => now()->subDay()->toDateString(), 'views_count' => 75, 'likes_count' => 15, 'follows_count' => 6, 'saves_count' => 4, 'comments_count' => 3, 'shares_count' => 2, 'submissions_count' => 2, 'created_at' => now(), 'updated_at' => now(), ], [ 'collection_id' => $collection->id, 'stat_date' => now()->toDateString(), 'views_count' => 120, 'likes_count' => 30, 'follows_count' => 10, 'saves_count' => 7, 'comments_count' => 5, 'shares_count' => 4, 'submissions_count' => 2, 'created_at' => now(), 'updated_at' => now(), ], ]); $response = $this->actingAs($owner) ->get(route('settings.collections.analytics', ['collection' => $collection->id, 'days' => 14])) ->assertOk(); $page = $response->viewData('page'); $analytics = data_get($page, 'props.analytics'); expect(data_get($page, 'component'))->toBe('Collection/CollectionAnalytics'); expect(data_get($analytics, 'totals'))->toMatchArray([ 'views' => 120, 'likes' => 30, 'follows' => 10, 'saves' => 7, 'comments' => 5, 'shares' => 4, 'submissions' => 2, ]); expect(data_get($analytics, 'range'))->toMatchArray([ 'days' => 14, 'views_delta' => 100, 'likes_delta' => 25, 'follows_delta' => 8, 'saves_delta' => 6, 'comments_delta' => 4, ]); expect(collect(data_get($analytics, 'timeline', []))->pluck('date')->all()) ->toBe([ now()->subDays(2)->toDateString(), now()->subDay()->toDateString(), now()->toDateString(), ]); expect(collect(data_get($analytics, 'top_artworks', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([$artworkA->id, $artworkB->id]); expect(data_get($analytics, 'top_artworks.0.ranking_score'))->toBe(88.5); expect(data_get($page, 'props.historyUrl'))->toBe(route('settings.collections.history', ['collection' => $collection->id])); }); it('active collection editors can access collection analytics and history', function () { $owner = User::factory()->create(['username' => 'insightowner']); $editor = User::factory()->create(['username' => 'insighteditor']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Collaborative Insights Collection', 'slug' => 'collaborative-insights-collection', 'type' => Collection::TYPE_COMMUNITY, 'collaboration_mode' => Collection::COLLABORATION_INVITE_ONLY, ]); app(\App\Services\CollectionCollaborationService::class)->ensureOwnerMembership($collection); $member = app(\App\Services\CollectionCollaborationService::class) ->inviteMember($collection, $owner, $editor, Collection::MEMBER_ROLE_EDITOR); app(\App\Services\CollectionCollaborationService::class)->acceptInvite($member, $editor); $this->actingAs($editor) ->get(route('settings.collections.analytics', ['collection' => $collection->id])) ->assertOk(); $this->actingAs($editor) ->get(route('settings.collections.history', ['collection' => $collection->id])) ->assertOk(); }); it('non collaborators cannot access collection analytics or history', function () { $owner = User::factory()->create(['username' => 'analyticsprotectedowner']); $viewer = User::factory()->create(['username' => 'analyticsblockedviewer']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Protected Insights Collection', 'slug' => 'protected-insights-collection', ]); $this->actingAs($viewer) ->get(route('settings.collections.analytics', ['collection' => $collection->id])) ->assertForbidden(); $this->actingAs($viewer) ->get(route('settings.collections.history', ['collection' => $collection->id])) ->assertForbidden(); }); it('owner can update presentation settings through the dedicated presentation workflow route', function () { $owner = User::factory()->create(['username' => 'presentationowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Presentation Workflow Collection', 'slug' => 'presentation-workflow-collection', 'subtitle' => null, 'summary' => null, 'presentation_style' => Collection::PRESENTATION_STANDARD, 'emphasis_mode' => Collection::EMPHASIS_BALANCED, 'theme_token' => null, 'layout_modules_json' => null, ]); $response = $this->actingAs($owner) ->postJson(route('settings.collections.presentation', ['collection' => $collection->id]), [ 'subtitle' => 'Premium showcase layout', 'summary' => 'Tuned for hero-grid presentation.', 'presentation_style' => Collection::PRESENTATION_HERO_GRID, 'emphasis_mode' => Collection::EMPHASIS_COVER_HEAVY, 'theme_token' => 'amber', 'layout_modules_json' => [ ['key' => 'editorial_note', 'enabled' => true, 'slot' => 'full'], ['key' => 'artwork_grid', 'enabled' => true, 'slot' => 'main'], ], ]) ->assertOk() ->assertJsonPath('collection.subtitle', 'Premium showcase layout') ->assertJsonPath('collection.presentation_style', Collection::PRESENTATION_HERO_GRID) ->assertJsonPath('collection.emphasis_mode', Collection::EMPHASIS_COVER_HEAVY) ->assertJsonPath('collection.theme_token', 'amber'); $responseKeys = collect($response->json('collection.layout_modules', [])) ->pluck('key') ->all(); $collection->refresh(); expect($collection->subtitle)->toBe('Premium showcase layout'); expect($collection->summary)->toBe('Tuned for hero-grid presentation.'); expect($collection->presentation_style)->toBe(Collection::PRESENTATION_HERO_GRID); expect($collection->emphasis_mode)->toBe(Collection::EMPHASIS_COVER_HEAVY); expect($collection->theme_token)->toBe('amber'); expect($responseKeys)->toContain('editorial_note', 'artwork_grid'); expect(collect($collection->layout_modules_json)->pluck('key')->all()) ->toContain('editorial_note', 'artwork_grid'); }); it('public collection payload exposes premium intro and featured artwork layout modules', function () { $owner = User::factory()->create(['username' => 'presentationmodulesowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Presentation Modules Collection', 'slug' => 'presentation-modules-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'published_at' => now()->subMinute(), 'subtitle' => 'Hidden intro subtitle', 'description' => 'This intro copy should disappear when the intro block is disabled.', 'allow_comments' => true, 'layout_modules_json' => null, ]); $artworks = Artwork::factory()->count(3)->for($owner)->create([ 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinute(), ]); foreach ($artworks as $index => $artwork) { $collection->artworks()->attach($artwork->id, ['order_num' => $index + 1]); } $collection->forceFill([ 'artworks_count' => $artworks->count(), ])->save(); $response = $this->actingAs($owner) ->postJson(route('settings.collections.presentation', ['collection' => $collection->id]), [ 'layout_modules_json' => [ ['key' => 'intro_block', 'enabled' => false, 'slot' => 'full'], ['key' => 'featured_artworks', 'enabled' => true, 'slot' => 'main'], ['key' => 'artwork_grid', 'enabled' => true, 'slot' => 'full'], ['key' => 'collaborators', 'enabled' => false, 'slot' => 'sidebar'], ['key' => 'discussion', 'enabled' => false, 'slot' => 'main'], ], ]) ->assertOk() ->assertJsonPath('collection.layout_modules.0.key', 'intro_block'); $request = \Illuminate\Http\Request::create((string) $response->json('collection.public_url'), 'GET', [], [], [], [ 'HTTP_X_INERTIA' => 'true', 'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', ]); $request->setUserResolver(fn () => $owner); $httpResponse = app(\App\Http\Controllers\User\ProfileCollectionController::class) ->show($request, strtolower((string) $owner->username), (string) $collection->fresh()->slug) ->toResponse($request); expect($httpResponse->getStatusCode())->toBe(200); $page = json_decode((string) $httpResponse->getContent(), true, 512, JSON_THROW_ON_ERROR); $layoutModules = collect(data_get($page, 'props.collection.layout_modules', [])); expect((string) data_get($page, 'component'))->toBe('Collection/CollectionShow'); expect((bool) $layoutModules->firstWhere('key', 'intro_block')['enabled'])->toBeFalse(); expect((bool) $layoutModules->firstWhere('key', 'featured_artworks')['enabled'])->toBeTrue(); expect((bool) $layoutModules->firstWhere('key', 'discussion')['enabled'])->toBeFalse(); expect((bool) $layoutModules->firstWhere('key', 'collaborators')['enabled'])->toBeFalse(); }); it('owner can update campaign metadata through the dedicated campaign workflow route', function () { $owner = User::factory()->create(['username' => 'campaignworkflowowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Campaign Workflow Collection', 'slug' => 'campaign-workflow-collection', 'campaign_key' => null, 'campaign_label' => null, 'event_key' => null, 'banner_text' => null, 'commercial_eligibility' => false, ]); $this->actingAs($owner) ->postJson(route('settings.collections.campaign', ['collection' => $collection->id]), [ 'event_key' => 'winter-spotlight-2026', 'event_label' => 'Winter Spotlight 2026', 'season_key' => 'winter', 'banner_text' => 'A seasonal editorial push for desktop showcases.', 'badge_label' => 'Campaign Live', 'spotlight_style' => Collection::SPOTLIGHT_STYLE_SEASONAL, 'campaign_key' => 'winter-homepage', 'campaign_label' => 'Winter Homepage', 'commercial_eligibility' => true, 'promotion_tier' => 'hero', 'sponsorship_label' => 'Presented with Nova Partners', 'partner_label' => 'Nova Partners', 'monetization_ready_status' => 'reviewed', 'brand_safe_status' => 'safe', 'editorial_notes' => 'Primary winter homepage candidate with strong hero potential.', ]) ->assertOk() ->assertJsonPath('collection.event_key', 'winter-spotlight-2026') ->assertJsonPath('collection.campaign_key', 'winter-homepage') ->assertJsonPath('campaign.campaign_key', 'winter-homepage') ->assertJsonPath('campaign.schedule.is_scheduled', false) ->assertJsonPath('campaign.editorial_notes', 'Primary winter homepage candidate with strong hero potential.') ->assertJsonPath('collection.commercial_eligibility', true) ->assertJsonPath('collection.partner_label', 'Nova Partners'); $collection->refresh(); expect($collection->event_label)->toBe('Winter Spotlight 2026'); expect($collection->banner_text)->toBe('A seasonal editorial push for desktop showcases.'); expect($collection->spotlight_style)->toBe(Collection::SPOTLIGHT_STYLE_SEASONAL); expect($collection->campaign_label)->toBe('Winter Homepage'); expect($collection->commercial_eligibility)->toBeTrue(); expect($collection->promotion_tier)->toBe('hero'); expect($collection->editorial_notes)->toBe('Primary winter homepage candidate with strong hero potential.'); }); it('admin can persist staff commercial notes through the campaign workflow route', function () { $admin = User::factory()->create([ 'username' => 'campaignadmin', 'role' => 'admin', ]); $collection = Collection::factory()->for($admin)->create([ 'title' => 'Commercial Review Collection', 'slug' => 'commercial-review-collection', 'staff_commercial_notes' => null, ]); $this->actingAs($admin) ->postJson(route('settings.collections.campaign', ['collection' => $collection->id]), [ 'staff_commercial_notes' => 'Brand-safe, partner-approved, waiting on sponsor creative lock.', ]) ->assertOk() ->assertJsonPath('campaign.staff_commercial_notes', 'Brand-safe, partner-approved, waiting on sponsor creative lock.'); $collection->refresh(); expect($collection->staff_commercial_notes)->toBe('Brand-safe, partner-approved, waiting on sponsor creative lock.'); }); it('non admins cannot persist staff commercial notes through the campaign workflow route', function () { $owner = User::factory()->create(['username' => 'campaignnonadmin']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Owner Campaign Collection', 'slug' => 'owner-campaign-collection', ]); $this->actingAs($owner) ->postJson(route('settings.collections.campaign', ['collection' => $collection->id]), [ 'staff_commercial_notes' => 'Should be rejected for non-admin owners.', ]) ->assertStatus(422) ->assertJsonValidationErrors(['staff_commercial_notes']); }); it('quality review exposes editorial automation campaign context and surface suggestions', function () { $owner = User::factory()->create(['username' => 'editorialautomationowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Winter Editorial Picks', 'slug' => 'winter-editorial-picks', 'type' => Collection::TYPE_EDITORIAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'winter-homepage', 'campaign_label' => 'Winter Homepage', 'event_key' => 'winter-spotlight-2026', 'event_label' => 'Winter Spotlight 2026', 'season_key' => 'winter', 'ranking_score' => 82, 'artworks_count' => 6, 'summary' => 'A tight editorial pass across winter-themed desktop showcases.', ]); $this->actingAs($owner) ->postJson(route('settings.collections.ai.quality-review', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('review.campaign_summary.campaign_key', 'winter-homepage') ->assertJsonPath('review.campaign_summary.eligibility.is_campaign_ready', true) ->assertJsonPath('review.suggested_surface_assignments.0.surface_key', 'homepage.featured_collections'); }); it('owner can update lifecycle scheduling through the dedicated lifecycle workflow route', function () { $owner = User::factory()->create(['username' => 'lifecycleworkflowowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Lifecycle Workflow Collection', 'slug' => 'lifecycle-workflow-collection', 'lifecycle_state' => Collection::LIFECYCLE_DRAFT, 'visibility' => Collection::VISIBILITY_PRIVATE, 'published_at' => null, 'unpublished_at' => null, 'archived_at' => null, 'expired_at' => null, ]); $publishAt = now()->addDay()->startOfHour(); $unpublishAt = now()->addDays(10)->startOfHour(); $this->actingAs($owner) ->postJson(route('settings.collections.lifecycle', ['collection' => $collection->id]), [ 'lifecycle_state' => Collection::LIFECYCLE_SCHEDULED, 'visibility' => Collection::VISIBILITY_PUBLIC, 'published_at' => $publishAt->toIso8601String(), 'unpublished_at' => $unpublishAt->toIso8601String(), ]) ->assertOk() ->assertJsonPath('collection.lifecycle_state', Collection::LIFECYCLE_SCHEDULED) ->assertJsonPath('collection.visibility', Collection::VISIBILITY_PUBLIC); $collection->refresh(); expect($collection->lifecycle_state)->toBe(Collection::LIFECYCLE_SCHEDULED); expect($collection->visibility)->toBe(Collection::VISIBILITY_PUBLIC); expect($collection->published_at?->toISOString())->toBe($publishAt->toISOString()); expect($collection->unpublished_at?->toISOString())->toBe($unpublishAt->toISOString()); expect($collection->archived_at)->toBeNull(); }); it('owner can update series metadata through the dedicated series workflow route', function () { $owner = User::factory()->create(['username' => 'seriesworkflowowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Series Workflow Collection', 'slug' => 'series-workflow-collection', 'series_key' => null, 'series_title' => null, 'series_description' => null, 'series_order' => null, ]); $this->actingAs($owner) ->postJson(route('settings.collections.series', ['collection' => $collection->id]), [ 'series_key' => 'winter-story-arc', 'series_title' => 'Winter Story Arc', 'series_description' => 'A multi-part sequence of winter collection chapters.', 'series_order' => 2, ]) ->assertOk() ->assertJsonPath('collection.series_key', 'winter-story-arc') ->assertJsonPath('series.key', 'winter-story-arc') ->assertJsonPath('series.title', 'Winter Story Arc') ->assertJsonPath('series.order', 2); $collection->refresh(); expect($collection->series_key)->toBe('winter-story-arc'); expect($collection->series_title)->toBe('Winter Story Arc'); expect($collection->series_description)->toBe('A multi-part sequence of winter collection chapters.'); expect($collection->series_order)->toBe(2); }); it('non owners cannot use dedicated collection workflow routes', function () { $owner = User::factory()->create(['username' => 'workflowowner']); $viewer = User::factory()->create(['username' => 'workflowviewer']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Protected Workflow Collection', 'slug' => 'protected-workflow-collection', ]); $this->actingAs($viewer) ->postJson(route('settings.collections.presentation', ['collection' => $collection->id]), [ 'presentation_style' => Collection::PRESENTATION_MASONRY, ]) ->assertForbidden(); $this->actingAs($viewer) ->postJson(route('settings.collections.campaign', ['collection' => $collection->id]), [ 'campaign_key' => 'blocked-campaign', ]) ->assertForbidden(); $this->actingAs($viewer) ->postJson(route('settings.collections.lifecycle', ['collection' => $collection->id]), [ 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, ]) ->assertForbidden(); $this->actingAs($viewer) ->postJson(route('settings.collections.series', ['collection' => $collection->id]), [ 'series_key' => 'blocked-series', ]) ->assertForbidden(); }); it('owner can sync manual linked collections and the manage studio hydrates them in order', function () { $owner = User::factory()->create(['username' => 'linkedowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Base Linked Collection', 'slug' => 'base-linked-collection', ]); $linkedA = Collection::factory()->for($owner)->create([ 'title' => 'Linked Collection A', 'slug' => 'linked-collection-a', ]); $linkedB = Collection::factory()->for($owner)->create([ 'title' => 'Linked Collection B', 'slug' => 'linked-collection-b', ]); $unlinkedOption = Collection::factory()->for($owner)->create([ 'title' => 'Unlinked Candidate Collection', 'slug' => 'unlinked-candidate-collection', ]); $this->actingAs($owner) ->postJson(route('settings.collections.linked.sync', ['collection' => $collection->id]), [ 'related_collection_ids' => [$linkedB->id, $linkedA->id], ]) ->assertOk() ->assertJsonPath('linkedCollections.0.id', $linkedB->id) ->assertJsonPath('linkedCollections.1.id', $linkedA->id); expect(DB::table('collection_related_links') ->where('collection_id', $collection->id) ->orderBy('sort_order') ->pluck('related_collection_id') ->map(fn ($id) => (int) $id) ->all()) ->toBe([$linkedB->id, $linkedA->id]); $response = $this->actingAs($owner) ->get(route('settings.collections.show', ['collection' => $collection->id])) ->assertOk(); $page = $response->viewData('page'); expect(collect(data_get($page, 'props.linkedCollections', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toBe([$linkedB->id, $linkedA->id]); expect(collect(data_get($page, 'props.linkedCollectionOptions', []))->pluck('id')->map(fn ($id) => (int) $id)->all()) ->toContain($unlinkedOption->id); }); it('manual linked collections appear on public pages without leaking private linked targets', function () { $owner = User::factory()->create(['username' => 'publiclinkedowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Public Linked Hub', 'slug' => 'public-linked-hub', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $publicLinked = Collection::factory()->for($owner)->create([ 'title' => 'Visible Manual Link', 'slug' => 'visible-manual-link', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $privateLinked = Collection::factory()->for($owner)->create([ 'title' => 'Hidden Manual Link', 'slug' => 'hidden-manual-link', 'visibility' => Collection::VISIBILITY_PRIVATE, 'lifecycle_state' => Collection::LIFECYCLE_DRAFT, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($owner) ->postJson(route('settings.collections.linked.sync', ['collection' => $collection->id]), [ 'related_collection_ids' => [$publicLinked->id, $privateLinked->id], ]) ->assertOk(); $this->get(route('profile.collections.show', ['username' => $owner->username, 'slug' => $collection->slug])) ->assertOk() ->assertSee('Visible Manual Link', false) ->assertDontSee('Hidden Manual Link', false); }); it('non owners cannot sync manual linked collections', function () { $owner = User::factory()->create(['username' => 'protectedlinkedowner']); $viewer = User::factory()->create(['username' => 'protectedlinkedviewer']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Protected Linked Collection', 'slug' => 'protected-linked-collection', ]); $otherCollection = Collection::factory()->for($owner)->create([ 'title' => 'Other Managed Collection', 'slug' => 'other-managed-collection', ]); $this->actingAs($viewer) ->postJson(route('settings.collections.linked.sync', ['collection' => $collection->id]), [ 'related_collection_ids' => [$otherCollection->id], ]) ->assertForbidden(); });