create(['username' => 'healthowner']); $viewer = User::factory()->create(['username' => 'healthviewer']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Health Check Set', 'slug' => 'health-check-set', 'workflow_state' => Collection::WORKFLOW_IN_REVIEW, 'health_state' => Collection::HEALTH_NEEDS_REVIEW, 'placement_eligibility' => false, ]); $this->actingAs($owner) ->getJson(route('settings.collections.health', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('collection.id', $collection->id) ->assertJsonPath('health.health_state', Collection::HEALTH_NEEDS_REVIEW) ->assertJsonPath('health.placement_eligibility', false); $this->actingAs($viewer) ->getJson(route('settings.collections.health', ['collection' => $collection->id])) ->assertForbidden(); }); it('staff can restore a supported collection history entry', function () { $admin = User::factory()->create(['username' => 'historyrestoreadmin', 'role' => 'admin']); $collection = Collection::factory()->for($admin)->create([ 'title' => 'Restore Workflow Collection', 'slug' => 'restore-workflow-collection', 'workflow_state' => Collection::WORKFLOW_DRAFT, 'placement_eligibility' => false, 'program_key' => null, 'partner_key' => null, 'experiment_key' => null, ]); $this->actingAs($admin) ->postJson(route('settings.collections.workflow', ['collection' => $collection->id]), [ 'workflow_state' => Collection::WORKFLOW_APPROVED, 'program_key' => 'spring-homepage', 'partner_key' => 'official-partner', 'experiment_key' => 'discover-v5-a', 'placement_eligibility' => true, ]) ->assertOk() ->assertJsonPath('collection.workflow_state', Collection::WORKFLOW_APPROVED) ->assertJsonPath('collection.program_key', 'spring-homepage'); $history = CollectionHistory::query() ->where('collection_id', $collection->id) ->where('action_type', 'workflow_updated') ->latest('id') ->firstOrFail(); $this->actingAs($admin) ->postJson(route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => $history->id])) ->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('collection.workflow_state', Collection::WORKFLOW_DRAFT) ->assertJsonPath('collection.program_key', null) ->assertJsonPath('collection.partner_key', null) ->assertJsonPath('collection.experiment_key', null) ->assertJsonPath('collection.placement_eligibility', false) ->assertJsonPath('restored_history_entry_id', $history->id); $collection->refresh(); expect($collection->workflow_state)->toBe(Collection::WORKFLOW_DRAFT) ->and($collection->program_key)->toBeNull() ->and($collection->partner_key)->toBeNull() ->and($collection->experiment_key)->toBeNull() ->and((bool) $collection->placement_eligibility)->toBeFalse(); }); it('non-staff owners cannot restore collection history entries', function () { $owner = User::factory()->create(['username' => 'historyrestoreowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Owner Restore Blocked', 'slug' => 'owner-restore-blocked', ]); $history = CollectionHistory::query()->create([ 'collection_id' => $collection->id, 'actor_user_id' => $owner->id, 'action_type' => 'updated', 'summary' => 'Collection settings updated.', 'before_json' => ['title' => 'Original title'], 'after_json' => ['title' => 'Updated title'], 'created_at' => now(), ]); $this->actingAs($owner) ->postJson(route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => $history->id])) ->assertForbidden(); }); it('owner search only returns owned collections and staff fields stay protected', function () { $owner = User::factory()->create(['username' => 'searchowner']); $other = User::factory()->create(['username' => 'searchother']); $owned = Collection::factory()->for($owner)->create([ 'title' => 'Aurora Program Set', 'slug' => 'aurora-program-set', 'workflow_state' => Collection::WORKFLOW_APPROVED, 'program_key' => 'spring-homepage', ]); Collection::factory()->for($other)->create([ 'title' => 'Aurora Program Set External', 'slug' => 'aurora-program-set-external', 'workflow_state' => Collection::WORKFLOW_APPROVED, 'program_key' => 'spring-homepage', ]); $this->actingAs($owner) ->getJson(route('settings.collections.search', ['q' => 'Aurora', 'program_key' => 'spring-homepage'])) ->assertOk() ->assertJsonCount(1, 'collections') ->assertJsonPath('collections.0.id', $owned->id); $this->actingAs($owner) ->postJson(route('settings.collections.workflow', ['collection' => $owned->id]), [ 'workflow_state' => Collection::WORKFLOW_IN_REVIEW, 'partner_key' => 'sponsored-partner', 'sponsorship_state' => 'sponsored', 'ownership_domain' => 'partner', 'commercial_review_state' => 'approved', 'legal_review_state' => 'approved', 'placement_eligibility' => false, ]) ->assertOk() ->assertJsonPath('collection.workflow_state', Collection::WORKFLOW_IN_REVIEW) ->assertJsonPath('collection.partner_key', null); $owned->refresh(); expect($owned->workflow_state)->toBe(Collection::WORKFLOW_IN_REVIEW); expect($owned->partner_key)->toBeNull(); expect($owned->sponsorship_state)->toBeNull(); expect($owned->ownership_domain)->toBeNull(); expect($owned->commercial_review_state)->toBeNull(); expect($owned->legal_review_state)->toBeNull(); }); it('owner search validates filter values cleanly', function () { $owner = User::factory()->create(['username' => 'searchvalidationowner']); $this->actingAs($owner) ->getJson(route('settings.collections.search', [ 'mode' => 'invalid-mode', 'placement_eligibility' => 'not-a-boolean', ])) ->assertStatus(422) ->assertJsonValidationErrors(['mode', 'placement_eligibility']); }); it('owner can canonicalize and merge collections safely', function () { $owner = User::factory()->create(['username' => 'mergeowner']); $artworkA = Artwork::factory()->for($owner)->create(); $artworkB = Artwork::factory()->for($owner)->create(); $source = Collection::factory()->for($owner)->create([ 'title' => 'Winter Capsule Draft', 'slug' => 'winter-capsule-draft', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $target = Collection::factory()->for($owner)->create([ 'title' => 'Winter Capsule Canonical', 'slug' => 'winter-capsule-canonical', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); app(\App\Services\CollectionService::class)->attachArtworks($source, $owner, [$artworkA->id, $artworkB->id]); $this->actingAs($owner) ->postJson(route('settings.collections.canonicalize', ['collection' => $source->id]), [ 'target_collection_id' => $target->id, ]) ->assertOk() ->assertJsonPath('collection.canonical_collection_id', $target->id); $this->actingAs($owner) ->postJson(route('settings.collections.merge', ['collection' => $source->id]), [ 'target_collection_id' => $target->id, ]) ->assertOk() ->assertJsonPath('source.lifecycle_state', Collection::LIFECYCLE_ARCHIVED) ->assertJsonPath('target.id', $target->id); $source->refresh(); $target->refresh(); expect($source->canonical_collection_id)->toBe($target->id); expect($source->lifecycle_state)->toBe(Collection::LIFECYCLE_ARCHIVED); expect($target->artworks()->count())->toBe(2); }); it('public collection routes redirect to the canonical target after canonicalization', function () { $owner = User::factory()->create(['username' => 'canonredirector']); $source = Collection::factory()->for($owner)->create([ 'title' => 'Original Canonical Route', 'slug' => 'original-canonical-route', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $target = Collection::factory()->for($owner)->create([ 'title' => 'Canonical Destination Route', 'slug' => 'canonical-destination-route', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); app(\App\Services\CollectionCanonicalService::class)->designate($source, $target, $owner); $this->get(route('profile.collections.show', ['username' => $owner->username, 'slug' => $source->slug])) ->assertRedirect(route('profile.collections.show', ['username' => strtolower((string) $owner->username), 'slug' => $target->slug])); }); it('owners can dismiss duplicate candidates from merge review', function () { $owner = User::factory()->create(['username' => 'mergedismissowner']); $source = Collection::factory()->for($owner)->create([ 'title' => 'Neon Futures', 'slug' => 'neon-futures', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $target = Collection::factory()->for($owner)->create([ 'title' => 'Neon Futures', 'slug' => 'neon-futures-alt', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $this->actingAs($owner) ->get(route('settings.collections.show', ['collection' => $source->id])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionManage') ->has('duplicateCandidates', 1) ->where('duplicateCandidates.0.collection.id', $target->id) ->where('endpoints.rejectDuplicate', route('settings.collections.merge.reject', ['collection' => $source->id]))); $this->actingAs($owner) ->postJson(route('settings.collections.merge.reject', ['collection' => $source->id]), [ 'target_collection_id' => $target->id, ]) ->assertOk() ->assertJsonCount(0, 'duplicate_candidates'); expect(CollectionMergeAction::query() ->where('source_collection_id', $source->id) ->where('target_collection_id', $target->id) ->where('action_type', 'rejected') ->exists())->toBeTrue(); $this->actingAs($owner) ->get(route('settings.collections.show', ['collection' => $source->id])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionManage') ->has('duplicateCandidates', 0)); }); it('staff can manage programming assignments and previews', function () { Queue::fake(); $admin = User::factory()->create(['username' => 'programadmin', 'role' => 'admin']); $viewer = User::factory()->create(['username' => 'programviewer']); $owner = User::factory()->create(['username' => 'programowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Program Ready Set', 'slug' => 'program-ready-set', 'workflow_state' => Collection::WORKFLOW_PROGRAMMED, 'program_key' => 'discover-spring', 'placement_eligibility' => true, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, ]); $mergeSource = Collection::factory()->for($owner)->create([ 'title' => 'Studio Merge Candidate', 'slug' => 'studio-merge-candidate', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $mergeTarget = Collection::factory()->for($owner)->create([ 'title' => 'Studio Merge Candidate', 'slug' => 'studio-merge-target', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); app(\App\Services\CollectionMergeService::class)->syncSuggestedCandidates($mergeSource, $admin); $this->actingAs($admin) ->get(route('staff.collections.programming')) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionStaffProgramming') ->where('collectionOptions', fn ($options): bool => collect($options)->contains(fn ($option): bool => (int) data_get($option, 'id') === (int) $collection->id)) ->where('endpoints.preview', route('staff.collections.surfaces.preview')) ->where('observabilitySummary.counts.stale_health', fn ($count): bool => is_int($count)) ->where('mergeQueue.summary.pending', fn ($pending): bool => (int) $pending >= 1) ->where('mergeQueue.pending', fn ($pending): bool => collect($pending)->contains(function ($item) use ($mergeSource, $mergeTarget): bool { return (int) data_get($item, 'source.id') === (int) $mergeSource->id && (int) data_get($item, 'target.id') === (int) $mergeTarget->id; }))); $this->actingAs($admin) ->postJson(route('staff.collections.merge-queue.reject'), [ 'source_collection_id' => $mergeSource->id, 'target_collection_id' => $mergeTarget->id, ]) ->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('mergeQueue.summary.rejected', 1); $canonicalSource = Collection::factory()->for($owner)->create([ 'title' => 'Studio Canonical Pair', 'slug' => 'studio-canonical-pair', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $canonicalTarget = Collection::factory()->for($owner)->create([ 'title' => 'Studio Canonical Pair', 'slug' => 'studio-canonical-target', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); app(\App\Services\CollectionMergeService::class)->syncSuggestedCandidates($canonicalSource, $admin); $this->actingAs($admin) ->postJson(route('staff.collections.merge-queue.canonicalize'), [ 'source_collection_id' => $canonicalSource->id, 'target_collection_id' => $canonicalTarget->id, ]) ->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('target.id', $canonicalTarget->id) ->assertJsonPath('mergeQueue.summary.approved', 1); expect($canonicalSource->fresh()->canonical_collection_id)->toBe($canonicalTarget->id); $mergeNowSource = Collection::factory()->for($owner)->create([ 'title' => 'Studio Merge Source Queue', 'slug' => 'studio-merge-source-queue', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $mergeNowTarget = Collection::factory()->for($owner)->create([ 'title' => 'Studio Merge Target Queue', 'slug' => 'studio-merge-target-queue', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $movedArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Merged Artwork', 'slug' => 'merged-artwork', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), ]); $mergeNowSource->artworks()->attach($movedArtwork->id, ['order_num' => 1]); app(\App\Services\CollectionMergeService::class)->syncSuggestedCandidates($mergeNowSource, $admin); $this->actingAs($admin) ->postJson(route('staff.collections.merge-queue.merge'), [ 'source_collection_id' => $mergeNowSource->id, 'target_collection_id' => $mergeNowTarget->id, ]) ->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('target.id', $mergeNowTarget->id) ->assertJsonPath('attached_artwork_ids.0', $movedArtwork->id) ->assertJsonPath('mergeQueue.summary.completed', 1); expect($mergeNowSource->fresh()->canonical_collection_id)->toBe($mergeNowTarget->id) ->and($mergeNowSource->fresh()->lifecycle_state)->toBe(Collection::LIFECYCLE_ARCHIVED) ->and($mergeNowTarget->fresh()->artworks()->where('artworks.id', $movedArtwork->id)->exists())->toBeTrue(); $this->actingAs($viewer) ->get(route('staff.collections.programming')) ->assertForbidden(); $storeResponse = $this->actingAs($admin) ->postJson(route('staff.collections.programs.store'), [ 'collection_id' => $collection->id, 'program_key' => 'discover-spring', 'placement_scope' => 'homepage.hero', 'priority' => 10, ]) ->assertOk(); $assignmentId = (int) $storeResponse->json('assignment.id'); $this->actingAs($admin) ->patchJson(route('staff.collections.programs.update', ['program' => $assignmentId]), [ 'collection_id' => $collection->id, 'program_key' => 'discover-spring', 'placement_scope' => 'homepage.hero', 'priority' => 12, 'notes' => 'Promote on launch weekend.', ]) ->assertOk() ->assertJsonPath('assignment.priority', 12); $this->actingAs($admin) ->postJson(route('staff.collections.surfaces.preview'), [ 'program_key' => 'discover-spring', 'limit' => 6, ]) ->assertOk() ->assertJsonPath('collections.0.id', $collection->id); $this->actingAs($admin) ->postJson(route('staff.collections.eligibility.refresh'), [ 'collection_id' => $collection->id, ]) ->assertOk() ->assertJsonPath('queued', true) ->assertJsonPath('result.status', 'queued') ->assertJsonPath('result.count', 1); Queue::assertPushed(RefreshCollectionHealthJob::class, function (RefreshCollectionHealthJob $job) use ($admin, $collection): bool { return $job->actorUserId === $admin->id && $job->collectionId === $collection->id; }); $this->actingAs($admin) ->postJson(route('staff.collections.duplicate-scan'), [ 'collection_id' => $collection->id, ]) ->assertOk() ->assertJsonPath('queued', true) ->assertJsonPath('result.status', 'queued') ->assertJsonPath('result.count', 1); Queue::assertPushed(ScanCollectionDuplicateCandidatesJob::class, function (ScanCollectionDuplicateCandidatesJob $job) use ($admin, $collection): bool { return $job->actorUserId === $admin->id && $job->collectionId === $collection->id; }); $this->actingAs($admin) ->postJson(route('staff.collections.recommendation-refresh'), [ 'collection_id' => $collection->id, ]) ->assertOk() ->assertJsonPath('queued', true) ->assertJsonPath('result.status', 'queued') ->assertJsonPath('result.count', 1); Queue::assertPushed(RefreshCollectionRecommendationJob::class, function (RefreshCollectionRecommendationJob $job) use ($admin, $collection): bool { return $job->actorUserId === $admin->id && $job->collectionId === $collection->id; }); $this->actingAs($admin) ->postJson(route('staff.collections.metadata.update'), [ 'collection_id' => $collection->id, 'experiment_key' => 'discover-v5-a', 'experiment_treatment' => 'treatment-b', 'placement_variant' => 'homepage_dense', 'ranking_mode_variant' => 'quality_first', 'collection_pool_version' => '2026.03.26', 'test_label' => 'Spring Program Rollout', 'promotion_tier' => 'priority', 'partner_key' => 'official-partner', 'trust_tier' => 'trusted', 'sponsorship_state' => 'approved', 'ownership_domain' => 'partner_programs', 'commercial_review_state' => 'approved', 'legal_review_state' => 'cleared', 'placement_eligibility' => true, ]) ->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('collection.experiment_key', 'discover-v5-a') ->assertJsonPath('collection.experiment_treatment', 'treatment-b') ->assertJsonPath('collection.placement_variant', 'homepage_dense') ->assertJsonPath('collection.ranking_mode_variant', 'quality_first') ->assertJsonPath('collection.collection_pool_version', '2026.03.26') ->assertJsonPath('collection.test_label', 'Spring Program Rollout') ->assertJsonPath('collection.partner_key', 'official-partner') ->assertJsonPath('collection.trust_tier', 'trusted') ->assertJsonPath('collection.promotion_tier', 'priority') ->assertJsonPath('collection.sponsorship_state', 'approved') ->assertJsonPath('collection.ownership_domain', 'partner_programs') ->assertJsonPath('collection.commercial_review_state', 'approved') ->assertJsonPath('collection.legal_review_state', 'cleared') ->assertJsonPath('diagnostics.collection_id', $collection->id) ->assertJsonPath('diagnostics.placement_eligibility', true) ->assertJsonPath('diagnostics.experiment_treatment', 'treatment-b') ->assertJsonPath('diagnostics.placement_variant', 'homepage_dense') ->assertJsonPath('diagnostics.ranking_mode_variant', 'quality_first') ->assertJsonPath('diagnostics.collection_pool_version', '2026.03.26') ->assertJsonPath('diagnostics.test_label', 'Spring Program Rollout') ->assertJsonPath('diagnostics.sponsorship_state', 'approved') ->assertJsonPath('diagnostics.ownership_domain', 'partner_programs') ->assertJsonPath('diagnostics.commercial_review_state', 'approved') ->assertJsonPath('diagnostics.legal_review_state', 'cleared'); $collection->refresh(); expect($collection->experiment_key)->toBe('discover-v5-a') ->and($collection->experiment_treatment)->toBe('treatment-b') ->and($collection->placement_variant)->toBe('homepage_dense') ->and($collection->ranking_mode_variant)->toBe('quality_first') ->and($collection->collection_pool_version)->toBe('2026.03.26') ->and($collection->test_label)->toBe('Spring Program Rollout') ->and($collection->partner_key)->toBe('official-partner') ->and($collection->trust_tier)->toBe('trusted') ->and($collection->promotion_tier)->toBe('priority') ->and($collection->sponsorship_state)->toBe('approved') ->and($collection->ownership_domain)->toBe('partner_programs') ->and($collection->commercial_review_state)->toBe('approved') ->and($collection->legal_review_state)->toBe('cleared'); }); it('quality refresh queues background recomputation for owners', function () { Queue::fake(); $owner = User::factory()->create(['username' => 'qualityqueueowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Queued Quality Collection', 'slug' => 'queued-quality-collection', ]); $this->actingAs($owner) ->postJson(route('settings.collections.quality-refresh', ['collection' => $collection->id])) ->assertOk() ->assertJsonPath('queued', true) ->assertJsonPath('result.status', 'queued') ->assertJsonPath('result.collection_ids.0', $collection->id); Queue::assertPushed(RefreshCollectionQualityJob::class, function (RefreshCollectionQualityJob $job) use ($owner, $collection): bool { return $job->actorUserId === $owner->id && $job->collectionId === $collection->id; }); }); it('recommendation snapshots stay idempotent for the same context and day', function () { $owner = User::factory()->create(['username' => 'recommendationidempotentowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Recommendation Snapshot Collection', 'slug' => 'recommendation-snapshot-collection', 'placement_eligibility' => true, 'visibility' => Collection::VISIBILITY_PUBLIC, 'moderation_status' => Collection::MODERATION_ACTIVE, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, ]); $ranking = app(\App\Services\CollectionRankingService::class); $ranking->refresh($collection, 'default'); $ranking->refresh($collection->fresh(), 'default'); expect(CollectionRecommendationSnapshot::query() ->where('collection_id', $collection->id) ->where('context_key', 'default') ->count())->toBe(1); }); it('public search only returns safe public collections', function () { $owner = User::factory()->create(['username' => 'publicsearchowner']); Collection::factory()->for($owner)->create([ 'title' => 'Visible Search Result', 'slug' => 'visible-search-result', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); Collection::factory()->for($owner)->create([ 'title' => 'Private Search Result', 'slug' => 'private-search-result', 'visibility' => Collection::VISIBILITY_PRIVATE, ]); Collection::factory()->for($owner)->create([ 'title' => 'Placement Blocked Search Result', 'slug' => 'placement-blocked-search-result', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => false, ]); $this->get(route('collections.search', ['q' => 'Search Result'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->has('collections', 1) ->where('collections.0.title', 'Visible Search Result')); }); it('saved collections support private saved notes', function () { $user = User::factory()->create(['username' => 'savednoteowner']); $curator = User::factory()->create(['username' => 'savednotecurator']); $collection = Collection::factory()->for($curator)->create([ 'title' => 'Saved Note Candidate', 'slug' => 'saved-note-candidate', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($user) ->postJson(route('collections.save', ['collection' => $collection->id]), [ 'context' => 'collection_detail', ]) ->assertOk(); $this->actingAs($user) ->get(route('profile.collections.show', ['username' => strtolower((string) $curator->username), 'slug' => $collection->slug])) ->assertOk(); $this->actingAs($user) ->patchJson(route('me.saved.collections.notes.update', ['collection' => $collection->id]), [ 'note' => 'Strong seasonal inspiration for homepage direction.', ]) ->assertOk() ->assertJsonPath('note.collection_id', $collection->id) ->assertJsonPath('note.note', 'Strong seasonal inspiration for homepage direction.'); $this->actingAs($user) ->get(route('me.saved.collections')) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/SavedCollections') ->where('collections.0.saved_note', 'Strong seasonal inspiration for homepage direction.') ->where('collections.0.saved_because', 'Saved from the collection page') ->has('recentlyRevisited', 1) ->where('recentlyRevisited.0.id', $collection->id)); }); it('browse surfaces expose save state and featured saves preserve surface context', function () { $user = User::factory()->create(['username' => 'featuredbrowseviewer']); $owner = User::factory()->create(['username' => 'featuredbrowseowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Featured Browse Save Search Target', 'slug' => 'featured-browse-save-target', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($user) ->get(route('collections.search', ['q' => 'Featured Browse Save Search Target'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->where('collections.0.id', $collection->id) ->where('collections.0.saved', false) ->where('collections.0.save_url', route('collections.save', ['collection' => $collection->id]))); $this->actingAs($user) ->postJson(route('collections.save', ['collection' => $collection->id]), [ 'context' => 'featured_landing', 'context_meta' => [ 'surface_label' => 'featured collections', ], ]) ->assertOk() ->assertJsonPath('saved', true); $this->actingAs($user) ->get(route('me.saved.collections')) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/SavedCollections') ->where('collections.0.saved_because', 'Saved from featured collections')); }); it('saved collections support richer filtering search and sorting', function () { $user = User::factory()->create(['username' => 'savedlibraryfilteruser']); $owner = User::factory()->create(['username' => 'savedlibraryfilterowner']); $alpha = Collection::factory()->for($owner)->create([ 'title' => 'Alpha Personal Board', 'slug' => 'alpha-personal-board', 'type' => Collection::TYPE_PERSONAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $beta = Collection::factory()->for($owner)->create([ 'title' => 'Beta Community Capsule', 'slug' => 'beta-community-capsule', 'type' => Collection::TYPE_COMMUNITY, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $gamma = Collection::factory()->for($owner)->create([ 'title' => 'Gamma Editorial Campaign', 'slug' => 'gamma-editorial-campaign', 'type' => Collection::TYPE_EDITORIAL, 'campaign_key' => 'spring-spotlight', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($user)->postJson(route('collections.save', ['collection' => $gamma->id]), [ 'context' => 'recommended_landing', ])->assertOk(); $this->actingAs($user)->postJson(route('collections.save', ['collection' => $beta->id]), [ 'context' => 'community_landing', ])->assertOk(); $this->actingAs($user)->postJson(route('collections.save', ['collection' => $alpha->id]), [ 'context' => 'collection_detail', ])->assertOk(); CollectionSavedNote::query()->create([ 'user_id' => $user->id, 'collection_id' => $alpha->id, 'note' => 'Anchor reference for the homepage refresh.', ]); DB::table('collection_saves') ->where('user_id', $user->id) ->where('collection_id', $alpha->id) ->update([ 'created_at' => now()->subDays(3), 'last_viewed_at' => now(), ]); DB::table('collection_saves') ->where('user_id', $user->id) ->whereIn('collection_id', [$beta->id, $gamma->id]) ->update([ 'last_viewed_at' => DB::raw('created_at'), ]); $this->actingAs($user) ->get(route('me.saved.collections', ['filter' => 'noted', 'q' => 'alpha'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/SavedCollections') ->has('collections', 1) ->where('collections.0.id', $alpha->id) ->where('activeFilters.q', 'alpha') ->where('activeFilters.filter', 'noted')); $this->actingAs($user) ->get(route('me.saved.collections', ['filter' => 'revisited'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/SavedCollections') ->has('collections', 1) ->where('collections.0.id', $alpha->id) ->where('filterOptions.5.count', 1) ->where('filterOptions.6.count', 1)); $this->actingAs($user) ->get(route('me.saved.collections', ['sort' => 'title_asc'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/SavedCollections') ->where('collections.0.id', $alpha->id) ->where('collections.1.id', $beta->id) ->where('collections.2.id', $gamma->id) ->where('sortOptions.5.key', 'title_asc')); }); it('bulk actions validate campaign and lifecycle requirements', function () { $owner = User::factory()->create(['username' => 'bulkvalidationowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Bulk Validation Target', 'slug' => 'bulk-validation-target', ]); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'assign_campaign', 'collection_ids' => [$collection->id], ]) ->assertStatus(422) ->assertJsonValidationErrors(['campaign_key']); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'update_lifecycle', 'collection_ids' => [$collection->id], ]) ->assertStatus(422) ->assertJsonValidationErrors(['lifecycle_state']); }); it('owner collection target actions reject using the same collection as target', function () { $owner = User::factory()->create(['username' => 'selftargetowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Self Target Collection', 'slug' => 'self-target-collection', 'workflow_state' => Collection::WORKFLOW_APPROVED, ]); $this->actingAs($owner) ->postJson(route('settings.collections.canonicalize', ['collection' => $collection->id]), [ 'target_collection_id' => $collection->id, ]) ->assertStatus(422) ->assertJsonValidationErrors(['target_collection_id']); $this->actingAs($owner) ->postJson(route('settings.collections.merge', ['collection' => $collection->id]), [ 'target_collection_id' => $collection->id, ]) ->assertStatus(422) ->assertJsonValidationErrors(['target_collection_id']); }); it('staff programming validates assignment schedule windows', function () { $admin = User::factory()->create(['username' => 'assignmentvalidator', 'role' => 'admin']); $owner = User::factory()->create(['username' => 'assignmenttargetowner']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Assignment Validation Target', 'slug' => 'assignment-validation-target', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($admin) ->postJson(route('staff.collections.programs.store'), [ 'collection_id' => $collection->id, 'program_key' => 'discover-spring', 'starts_at' => now()->addDay()->toISOString(), 'ends_at' => now()->subDay()->toISOString(), ]) ->assertStatus(422) ->assertJsonValidationErrors(['ends_at']); }); it('collection entity links can be managed and render safely on public pages', function () { $owner = User::factory()->create(['username' => 'entitylinkowner']); $linkedCreator = User::factory()->create(['username' => 'linkedentitycreator']); $collection = Collection::factory()->for($owner)->create([ 'title' => 'Linked Context Collection', 'slug' => 'linked-context-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'spring-launch', 'campaign_label' => 'Spring Launch', 'event_key' => 'launch-week', 'event_label' => 'Launch Week', ]); $story = Story::query()->create([ 'creator_id' => $owner->id, 'slug' => 'linked-story-brief', 'title' => 'Linked Story Brief', 'excerpt' => 'A companion story for this collection.', 'status' => 'published', 'story_type' => 'creator_story', 'published_at' => now(), ]); $publicArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Linked Artwork Poster', 'slug' => 'linked-artwork-poster', 'is_public' => true, 'is_approved' => true, 'published_at' => now(), ]); $hiddenArtwork = Artwork::factory()->for($owner)->create([ 'title' => 'Hidden Artwork Poster', 'slug' => 'hidden-artwork-poster', 'is_public' => false, 'is_approved' => true, 'published_at' => now(), ]); $draftStory = Story::query()->create([ 'creator_id' => $owner->id, 'slug' => 'hidden-story-brief', 'title' => 'Hidden Story Brief', 'excerpt' => 'This should not appear publicly.', 'status' => 'draft', 'story_type' => 'creator_story', ]); $contentType = ContentType::query()->create([ 'name' => 'Wallpapers', 'slug' => 'wallpapers', 'description' => 'Wallpaper categories', ]); $category = Category::query()->create([ 'content_type_id' => $contentType->id, 'name' => 'Cyberpunk', 'slug' => 'cyberpunk', 'is_active' => true, 'sort_order' => 1, ]); $tag = Tag::factory()->create([ 'name' => 'Neon', 'slug' => 'neon', 'usage_count' => 44, 'is_active' => true, ]); $this->actingAs($owner) ->postJson(route('settings.collections.entity-links.sync', ['collection' => $collection->id]), [ 'entity_links' => [ ['linked_type' => 'creator', 'linked_id' => $linkedCreator->id, 'relationship_type' => 'featured creator'], ['linked_type' => 'artwork', 'linked_id' => $publicArtwork->id, 'relationship_type' => 'hero artwork'], ['linked_type' => 'story', 'linked_id' => $story->id, 'relationship_type' => 'behind the scenes'], ['linked_type' => 'category', 'linked_id' => $category->id, 'relationship_type' => 'genre fit'], ['linked_type' => 'campaign', 'linked_id' => (int) hexdec(substr(md5('campaign:spring-launch'), 0, 7)), 'relationship_type' => 'campaign hub'], ['linked_type' => 'event', 'linked_id' => (int) hexdec(substr(md5('event:launch-week'), 0, 7)), 'relationship_type' => 'event spotlight'], ['linked_type' => 'tag', 'linked_id' => $tag->id, 'relationship_type' => 'theme tag'], ['linked_type' => 'story', 'linked_id' => $draftStory->id, 'relationship_type' => 'internal draft'], ['linked_type' => 'artwork', 'linked_id' => $hiddenArtwork->id, 'relationship_type' => 'internal artwork'], ], ]) ->assertOk() ->assertJsonCount(9, 'entityLinks') ->assertJsonPath('entityLinks.0.linked_type', 'creator') ->assertJsonPath('entityLinks.1.title', 'Linked Artwork Poster') ->assertJsonPath('entityLinks.2.title', 'Linked Story Brief') ->assertJsonPath('entityLinks.3.title', 'Cyberpunk') ->assertJsonPath('entityLinks.4.title', 'Spring Launch') ->assertJsonPath('entityLinks.5.title', 'Launch Week') ->assertJsonPath('entityLinks.6.title', 'Neon'); $this->actingAs($owner) ->get(route('settings.collections.show', ['collection' => $collection->id])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionManage') ->has('entityLinks', 9) ->where('entityLinks.0.relationship_type', 'featured creator') ->where('entityLinks.1.relationship_type', 'hero artwork') ->has('entityLinkOptions.artwork') ->has('entityLinkOptions.campaign') ->has('entityLinkOptions.event') ->has('entityLinkOptions.tag') ->where('endpoints.syncEntityLinks', route('settings.collections.entity-links.sync', ['collection' => $collection->id]))); $this->get(route('profile.collections.show', ['username' => strtolower((string) $owner->username), 'slug' => $collection->slug])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionShow') ->has('entityLinks', 7) ->where('entityLinks.0.title', $linkedCreator->name ?: $linkedCreator->username) ->where('entityLinks.1.title', 'Linked Artwork Poster') ->where('entityLinks.2.title', 'Linked Story Brief') ->where('entityLinks.3.title', 'Cyberpunk') ->where('entityLinks.4.title', 'Spring Launch') ->where('entityLinks.5.title', 'Launch Week') ->where('entityLinks.6.title', 'Neon')); }); it('public program landing shows only placement-eligible public collections for a program key', function () { $owner = User::factory()->create(['username' => 'programlandingowner']); Collection::factory()->for($owner)->create([ 'title' => 'Program Hero Collection', 'slug' => 'program-hero-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => true, 'program_key' => 'discover-spring', 'partner_label' => 'Official Partner', 'sponsorship_label' => 'Sponsored Placement', 'promotion_tier' => 'priority', 'trust_tier' => 'trusted', 'banner_text' => 'Discover Spring', ]); Collection::factory()->for($owner)->create([ 'title' => 'Program Blocked Collection', 'slug' => 'program-blocked-collection', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => false, 'program_key' => 'discover-spring', ]); $this->get(route('collections.program.show', ['programKey' => 'discover-spring'])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->where('program.key', 'discover-spring') ->where('program.partner_labels.0', 'Official Partner') ->where('program.sponsorship_labels.0', 'Sponsored Placement') ->has('collections', 1) ->where('collections.0.title', 'Program Hero Collection')); }); it('dashboard exposes v5 health warning summaries', function () { $owner = User::factory()->create(['username' => 'dashboardv5owner']); Collection::factory()->for($owner)->create([ 'title' => 'Needs Review Collection', 'slug' => 'needs-review-collection', 'health_state' => Collection::HEALTH_NEEDS_REVIEW, 'placement_eligibility' => false, ]); Collection::factory()->for($owner)->create([ 'title' => 'Duplicate Risk Collection', 'slug' => 'duplicate-risk-collection', 'health_state' => Collection::HEALTH_DUPLICATE_RISK, 'placement_eligibility' => false, ]); $this->actingAs($owner) ->get(route('settings.collections.dashboard')) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionDashboard') ->where('summary.needs_review', 1) ->where('summary.duplicate_risk', 1) ->where('summary.placement_blocked', 2) ->has('healthWarnings', 2) ->where('filterOptions.workflowStates.0', Collection::WORKFLOW_DRAFT) ->where('endpoints.search', route('settings.collections.search')) ->where('endpoints.bulkActions', route('settings.collections.bulk-actions'))); }); it('owner can apply safe bulk actions from the dashboard', function () { Queue::fake(); $owner = User::factory()->create(['username' => 'bulkactionowner']); $first = Collection::factory()->for($owner)->create([ 'title' => 'Bulk Action First', 'slug' => 'bulk-action-first', 'workflow_state' => Collection::WORKFLOW_APPROVED, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'visibility' => Collection::VISIBILITY_PUBLIC, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $second = Collection::factory()->for($owner)->create([ 'title' => 'Bulk Action Second', 'slug' => 'bulk-action-second', 'workflow_state' => Collection::WORKFLOW_APPROVED, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'visibility' => Collection::VISIBILITY_PUBLIC, 'moderation_status' => Collection::MODERATION_ACTIVE, ]); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'assign_campaign', 'collection_ids' => [$first->id, $second->id], 'campaign_key' => 'spring-launch', 'campaign_label' => 'Spring Launch', ]) ->assertOk() ->assertJsonPath('count', 2) ->assertJsonPath('collections.0.campaign_key', 'spring-launch'); expect($first->fresh()->campaign_key)->toBe('spring-launch') ->and($second->fresh()->campaign_key)->toBe('spring-launch'); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'mark_editorial_review', 'collection_ids' => [$first->id, $second->id], ]) ->assertOk() ->assertJsonPath('collections.0.workflow_state', Collection::WORKFLOW_IN_REVIEW); expect($first->fresh()->workflow_state)->toBe(Collection::WORKFLOW_IN_REVIEW) ->and($second->fresh()->workflow_state)->toBe(Collection::WORKFLOW_IN_REVIEW); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'request_ai_review', 'collection_ids' => [$first->id, $second->id], ]) ->assertOk() ->assertJsonPath('count', 2); Queue::assertPushed(RefreshCollectionQualityJob::class, 2); $this->actingAs($owner) ->postJson(route('settings.collections.bulk-actions'), [ 'action' => 'archive', 'collection_ids' => [$first->id, $second->id], ]) ->assertOk() ->assertJsonPath('summary.archived', 2); expect($first->fresh()->lifecycle_state)->toBe(Collection::LIFECYCLE_ARCHIVED) ->and($second->fresh()->lifecycle_state)->toBe(Collection::LIFECYCLE_ARCHIVED); }); it('public collection search returns expanded filter payloads for the search ui', function () { $owner = User::factory()->create(['username' => 'filtersearchowner']); Collection::factory()->for($owner)->create([ 'title' => 'Editorial Search Target', 'slug' => 'editorial-search-target', 'type' => Collection::TYPE_EDITORIAL, 'mode' => Collection::MODE_MANUAL, 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'campaign_key' => 'winter-drop', 'program_key' => 'homepage-hero', 'health_state' => Collection::HEALTH_LOW_CONTENT, ]); $this->get(route('collections.search', [ 'q' => 'Search', 'type' => Collection::TYPE_EDITORIAL, 'mode' => Collection::MODE_MANUAL, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'health_state' => Collection::HEALTH_LOW_CONTENT, 'campaign_key' => 'winter-drop', 'program_key' => 'homepage-hero', 'sort' => 'quality', ])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->where('search.filters.q', 'Search') ->where('search.filters.type', Collection::TYPE_EDITORIAL) ->where('search.filters.mode', Collection::MODE_MANUAL) ->where('search.filters.lifecycle_state', Collection::LIFECYCLE_PUBLISHED) ->where('search.filters.health_state', Collection::HEALTH_LOW_CONTENT) ->where('search.filters.campaign_key', 'winter-drop') ->where('search.filters.program_key', 'homepage-hero') ->where('search.filters.sort', 'quality') ->has('search.options.category') ->has('search.options.style') ->has('search.options.theme') ->has('search.options.color') ->has('search.options.quality_tier') ->has('collections', 1)); }); it('public collection search supports category style theme color and quality tier filters', function () { $owner = User::factory()->create(['username' => 'advsearchowner']); $contentType = ContentType::query()->create(['name' => 'Illustration', 'slug' => 'illustration']); $category = Category::query()->create([ 'name' => 'Landscape', 'slug' => 'landscape', 'content_type_id' => $contentType->id, 'is_active' => true, ]); $styleTag = Tag::factory()->create(['name' => 'Watercolor', 'slug' => 'watercolor', 'is_active' => true]); $colorTag = Tag::factory()->create(['name' => 'Blue Tones', 'slug' => 'blue-tones', 'is_active' => true]); $matching = Collection::factory()->for($owner)->create([ 'title' => 'Advanced Filter Match', 'slug' => 'advanced-filter-match', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => true, 'health_state' => Collection::HEALTH_HEALTHY, 'trust_tier' => 'high', 'theme_token' => 'amber', ]); $artwork = Artwork::factory()->for($owner)->create([ 'title' => 'Watercolor Horizon', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(), ]); $matching->artworks()->attach($artwork->id, ['order_num' => 1]); $artwork->categories()->attach($category->id); $artwork->tags()->attach([ $styleTag->id => ['source' => 'system', 'confidence' => 1], $colorTag->id => ['source' => 'system', 'confidence' => 1], ]); DB::table('collection_entity_links')->insert([ 'collection_id' => $matching->id, 'linked_type' => 'category', 'linked_id' => $category->id, 'relationship_type' => 'primary category', 'metadata_json' => null, 'created_at' => now(), 'updated_at' => now(), ]); Collection::factory()->for($owner)->create([ 'title' => 'Advanced Filter Miss', 'slug' => 'advanced-filter-miss', 'visibility' => Collection::VISIBILITY_PUBLIC, 'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED, 'moderation_status' => Collection::MODERATION_ACTIVE, 'placement_eligibility' => true, 'health_state' => Collection::HEALTH_HEALTHY, 'trust_tier' => 'limited', 'theme_token' => 'violet', ]); $this->get(route('collections.search', [ 'category' => 'landscape', 'style' => 'watercolor', 'theme' => 'amber', 'color' => 'blue tones', 'quality_tier' => 'high', ])) ->assertOk() ->assertInertia(fn ($page) => $page ->component('Collection/CollectionFeaturedIndex') ->where('search.filters.category', 'landscape') ->where('search.filters.style', 'watercolor') ->where('search.filters.theme', 'amber') ->where('search.filters.color', 'blue tones') ->where('search.filters.quality_tier', 'high') ->has('collections', 1) ->where('collections.0.id', $matching->id)); }); it('duplicate candidate scans assign a stable duplicate cluster key that health refresh preserves', function () { $owner = User::factory()->create(['username' => 'duplicateclusterowner']); $source = Collection::factory()->for($owner)->create([ 'title' => 'Duplicate Cluster Source', 'slug' => 'duplicate-cluster-source', ]); $target = Collection::factory()->for($owner)->create([ 'title' => 'Duplicate Cluster Source', 'slug' => 'duplicate-cluster-target', ]); $result = app(\App\Services\CollectionMergeService::class)->syncSuggestedCandidates($source->fresh(), $owner); $source->refresh(); $target->refresh(); expect($result['count'])->toBe(1) ->and($source->duplicate_cluster_key)->not->toBeNull() ->and($source->duplicate_cluster_key)->toBe($target->duplicate_cluster_key); app(\App\Services\CollectionHealthService::class)->refresh($source->fresh(), $owner, 'test-duplicate-cluster'); expect($source->fresh()->duplicate_cluster_key)->toBe($target->fresh()->duplicate_cluster_key); });