Files
SkinbaseNova/tests/Feature/Collections/CollectionsFeatureTest.php
2026-03-28 19:15:39 +01:00

3847 lines
151 KiB
PHP

<?php
use App\Events\Collections\CollectionArtworkAttached;
use App\Events\Collections\CollectionArtworkRemoved;
use App\Events\Collections\CollectionCreated;
use App\Events\Collections\CollectionDeleted;
use App\Events\Collections\CollectionUpdated;
use App\Events\Collections\CollectionViewed;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Collection;
use App\Models\CollectionSurfaceDefinition;
use App\Models\CollectionSurfacePlacement;
use App\Models\Report;
use App\Models\ContentType;
use App\Models\Tag;
use App\Models\User;
use App\Services\CollectionService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
it('owner can create collections and duplicate titles get unique per-user slugs', function () {
$user = User::factory()->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();
});