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

1277 lines
54 KiB
PHP

<?php
use App\Models\Category;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\CollectionHistory;
use App\Models\CollectionMergeAction;
use App\Models\CollectionSavedNote;
use App\Models\CollectionRecommendationSnapshot;
use App\Models\ContentType;
use App\Models\Story;
use App\Models\Tag;
use App\Models\User;
use App\Jobs\RefreshCollectionHealthJob;
use App\Jobs\RefreshCollectionQualityJob;
use App\Jobs\RefreshCollectionRecommendationJob;
use App\Jobs\ScanCollectionDuplicateCandidatesJob;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
it('owner can view collection health while non owners cannot', function () {
$owner = User::factory()->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);
});