1277 lines
54 KiB
PHP
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);
|
|
}); |