Artwork::factory()->create($attributes)); } function uploadQueueCategory(string $typeName = 'Photography', string $categoryName = 'Portraits'): Category { $suffix = Str::lower(Str::random(6)); $contentType = ContentType::query()->create([ 'name' => $typeName, 'slug' => Str::slug($typeName) . '-' . $suffix, 'order' => 1, 'hide_from_menu' => false, ]); return Category::query()->create([ 'content_type_id' => $contentType->id, 'name' => $categoryName, 'slug' => Str::slug($categoryName) . '-' . $suffix, 'is_active' => true, 'sort_order' => 1, ]); } beforeEach(function () { if (DB::connection()->getDriverName() === 'sqlite') { DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) { return max($args); }, -1); } $this->user = User::factory()->create(); $this->actingAs($this->user); }); test('studio upload queue page loads', function () { $this->get('/studio/upload-queue') ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioUploadQueue') ->where('title', 'Upload Queue') ->has('queue.status_options') ->has('queue.sort_options')); }); test('upload queue batch creation creates draft artworks and queue items with defaults', function () { $contentType = ContentType::query()->create([ 'name' => 'Photography', 'slug' => 'photography', 'order' => 1, 'hide_from_menu' => false, ]); $category = Category::query()->create([ 'content_type_id' => $contentType->id, 'name' => 'Landscapes', 'slug' => 'landscapes', 'is_active' => true, 'sort_order' => 1, ]); $response = $this->postJson('/api/studio/upload-queue/batches', [ 'name' => 'Spring Set', 'files' => [ ['name' => 'forest-light.png'], ['name' => 'city-night.webp'], ], 'defaults' => [ 'category_id' => $category->id, 'tags' => ['forest', 'set'], 'visibility' => 'unlisted', ], ]); $response->assertCreated() ->assertJsonPath('batch.name', 'Spring Set') ->assertJsonCount(2, 'items'); $batch = UploadBatch::query()->firstOrFail(); expect($batch->total_items)->toBe(2); $items = UploadBatchItem::query()->with(['artwork.categories', 'artwork.tags'])->get(); expect($items)->toHaveCount(2); foreach ($items as $item) { expect($item->artwork)->not->toBeNull() ->and($item->artwork->visibility)->toBe('unlisted') ->and($item->artwork->categories->pluck('id')->all())->toBe([$category->id]) ->and($item->artwork->tags->pluck('slug')->sort()->values()->all())->toBe(['forest', 'set']); } }); test('upload finish updates queue item when batch item id is supplied', function () { config()->set('forum_bot_protection.enabled', false); config()->set('uploads.queue_derivatives', false); config()->set('uploads.storage_root', storage_path('framework/testing/uploads')); Queue::fake(); File::deleteDirectory((string) config('uploads.storage_root')); $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Queue batch', 'status' => 'uploading', 'total_items' => 1, ]); $artwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'is_public' => false, 'is_approved' => false, 'published_at' => null, 'artwork_status' => 'draft', ]); $item = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => 'queue-test.png', ]); $sessionId = (string) Str::uuid(); $tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png'); $sourceImage = base_path('public/favicon/favicon-96x96.png'); File::ensureDirectoryExists(dirname($tmpPath)); File::copy($sourceImage, $tmpPath); app(UploadSessionRepository::class)->create( $sessionId, $this->user->id, $tmpPath, UploadSessionStatus::TMP, '127.0.0.1' ); $token = app(UploadTokenService::class)->generate($sessionId, $this->user->id); $this->withHeader('X-Upload-Token', $token) ->postJson('/api/uploads/finish', [ 'session_id' => $sessionId, 'artwork_id' => $artwork->id, 'batch_item_id' => $item->id, 'file_name' => 'queue-test.png', ]) ->assertOk() ->assertJsonPath('artwork_id', $artwork->id) ->assertJsonPath('status', UploadSessionStatus::PROCESSED); $item->refresh(); expect($item->status)->toBe('processing') ->and($item->processing_stage)->toBe('maturity_check'); }); test('upload queue bulk publish only publishes ready items', function () { $contentType = ContentType::query()->create([ 'name' => 'Photography', 'slug' => 'photography', 'order' => 1, 'hide_from_menu' => false, ]); $category = Category::query()->create([ 'content_type_id' => $contentType->id, 'name' => 'Portraits', 'slug' => 'portraits', 'is_active' => true, 'sort_order' => 1, ]); $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Publish batch', 'status' => 'processing', 'total_items' => 2, ]); $readyArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Ready artwork', 'file_name' => 'ready.webp', 'file_path' => 'artworks/test/ready.webp', 'hash' => str_repeat('a', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'visibility' => 'public', 'is_public' => false, 'is_approved' => false, 'artwork_status' => 'draft', 'published_at' => null, 'maturity_status' => 'clear', 'maturity_ai_status' => 'succeeded', ]); $readyArtwork->categories()->sync([$category->id]); $blockedArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Blocked artwork', 'file_name' => 'blocked.webp', 'file_path' => 'artworks/test/blocked.webp', 'hash' => str_repeat('b', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'visibility' => 'public', 'is_public' => false, 'is_approved' => false, 'artwork_status' => 'draft', 'published_at' => null, 'maturity_status' => 'suspected', 'maturity_ai_status' => 'succeeded', ]); $blockedArtwork->categories()->sync([$category->id]); $readyItem = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $readyArtwork->id, 'original_filename' => 'ready.webp', ]); $blockedItem = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $blockedArtwork->id, 'original_filename' => 'blocked.webp', ]); $this->postJson('/api/studio/upload-queue/bulk', [ 'action' => 'publish', 'item_ids' => [$readyItem->id, $blockedItem->id], ]) ->assertOk() ->assertJsonPath('success', 1) ->assertJsonPath('failed', 1); $readyArtwork->refresh(); $blockedArtwork->refresh(); expect($readyArtwork->artwork_status)->toBe('published') ->and($readyArtwork->published_at)->not->toBeNull() ->and($blockedArtwork->artwork_status)->toBe('draft') ->and($blockedArtwork->published_at)->toBeNull(); }); test('upload queue bulk delete only affects owned drafts', function () { $otherUser = User::factory()->create(); $ownedBatch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Delete batch', 'status' => 'processing', 'total_items' => 1, ]); $ownedArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, ]); $foreignBatch = UploadBatch::query()->create([ 'user_id' => $otherUser->id, 'name' => 'Foreign batch', 'status' => 'processing', 'total_items' => 1, ]); $foreignArtwork = uploadQueueArtwork([ 'user_id' => $otherUser->id, 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, ]); $ownedItem = UploadBatchItem::query()->create([ 'upload_batch_id' => $ownedBatch->id, 'user_id' => $this->user->id, 'artwork_id' => $ownedArtwork->id, 'original_filename' => 'owned.webp', ]); $foreignItem = UploadBatchItem::query()->create([ 'upload_batch_id' => $foreignBatch->id, 'user_id' => $otherUser->id, 'artwork_id' => $foreignArtwork->id, 'original_filename' => 'foreign.webp', ]); $this->postJson('/api/studio/upload-queue/bulk', [ 'action' => 'delete', 'item_ids' => [$ownedItem->id, $foreignItem->id], 'confirm' => 'DELETE', ]) ->assertOk() ->assertJsonPath('success', 1); $ownedItem->refresh(); $foreignItem->refresh(); expect($ownedItem->status)->toBe('deleted') ->and(Artwork::withTrashed()->find($ownedArtwork->id)?->trashed())->toBeTrue() ->and($foreignItem->status)->not->toBe('deleted') ->and(Artwork::find($foreignArtwork->id))->not->toBeNull(); }); test('upload queue retry rejects drafts without processed media', function () { $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Retry batch', 'status' => 'completed_with_errors', 'total_items' => 1, ]); $artwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'file_path' => '', 'hash' => '', ]); $item = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => 'retry.webp', 'status' => 'failed', ]); $this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry') ->assertStatus(422) ->assertJsonValidationErrors(['item']); }); test('upload queue item failure does not break the rest of the batch', function () { $response = $this->postJson('/api/studio/upload-queue/batches', [ 'name' => 'Mixed batch', 'files' => [ ['name' => 'good.webp'], ['name' => 'bad.webp'], ], ]); $batchId = (int) $response->json('batch.id'); $items = UploadBatchItem::query()->where('upload_batch_id', $batchId)->orderBy('id')->get(); expect($items)->toHaveCount(2); $this->postJson('/api/studio/upload-queue/items/' . $items[1]->id . '/fail', [ 'error_code' => 'invalid_file', 'error_message' => 'Invalid image payload.', ])->assertOk(); $payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batchId]); $queueItems = collect($payload['items'])->keyBy('id'); expect($queueItems)->toHaveCount(2) ->and($queueItems[$items[0]->id]['status'])->not->toBe('failed') ->and($queueItems[$items[1]->id]['status'])->toBe('failed') ->and($queueItems[$items[1]->id]['error_message'])->toBe('Invalid image payload.'); }); test('upload queue processing states update correctly per item', function () { $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Processing batch', 'status' => 'uploading', 'total_items' => 1, ]); $artwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Processing artwork', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, ]); $item = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => 'processing.webp', 'status' => 'uploaded', 'processing_stage' => 'queued', ]); $queue = app(UploadQueueService::class); $queued = $queue->markItemProcessingQueued($item->id); expect($queued->status)->toBe('processing') ->and($queued->processing_stage)->toBe('thumbnails'); $artwork->forceFill([ 'file_name' => 'processing.webp', 'file_path' => 'artworks/test/processing.webp', 'hash' => str_repeat('c', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', ])->saveQuietly(); $processed = $queue->markItemMediaProcessed($item->id); expect($processed->status)->toBe('processing') ->and($processed->processing_stage)->toBe('maturity_check'); }); test('upload queue publish readiness respects metadata and maturity review rules', function () { $category = uploadQueueCategory(); $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Readiness batch', 'status' => 'processing', 'total_items' => 4, ]); $readyArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Ready artwork', 'file_name' => 'ready.webp', 'file_path' => 'artworks/test/ready.webp', 'hash' => str_repeat('d', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, ]); $readyArtwork->categories()->sync([$category->id]); $missingMetadataArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => '', 'file_name' => 'metadata.webp', 'file_path' => 'artworks/test/metadata.webp', 'hash' => str_repeat('e', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, ]); $missingMetadataArtwork->categories()->sync([$category->id]); $reviewArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Review artwork', 'file_name' => 'review.webp', 'file_path' => 'artworks/test/review.webp', 'hash' => str_repeat('f', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_SUSPECTED, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, ]); $reviewArtwork->categories()->sync([$category->id]); $processingArtwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Processing artwork', 'file_name' => 'pending', 'file_path' => '', 'hash' => '', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, ]); $processingArtwork->categories()->sync([$category->id]); $items = collect([ [$readyArtwork, 'ready.webp'], [$missingMetadataArtwork, 'metadata.webp'], [$reviewArtwork, 'review.webp'], [$processingArtwork, 'processing.webp'], ])->map(function (array $entry) use ($batch) { [$artwork, $filename] = $entry; return UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => $filename, 'status' => 'processing', 'processing_stage' => 'maturity_check', ]); }); $payload = app(UploadQueueService::class)->listPayload($this->user, ['batch_id' => $batch->id]); $byFilename = collect($payload['items'])->keyBy('original_filename'); expect($byFilename['ready.webp']['status'])->toBe('ready') ->and($byFilename['ready.webp']['is_ready_to_publish'])->toBeTrue() ->and($byFilename['metadata.webp']['status'])->toBe('needs_metadata') ->and($byFilename['metadata.webp']['is_ready_to_publish'])->toBeFalse() ->and($byFilename['review.webp']['status'])->toBe('needs_review') ->and($byFilename['review.webp']['is_ready_to_publish'])->toBeFalse() ->and($byFilename['processing.webp']['status'])->toBe('processing') ->and($byFilename['processing.webp']['is_ready_to_publish'])->toBeFalse(); }); test('upload queue retry works for safe failure cases', function () { Queue::fake(); $category = uploadQueueCategory(); $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'Retry safe batch', 'status' => 'completed_with_errors', 'total_items' => 1, ]); $artwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Retry safe artwork', 'file_name' => 'retry-safe.webp', 'file_path' => 'artworks/test/retry-safe.webp', 'hash' => str_repeat('g', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_PENDING, ]); $artwork->categories()->sync([$category->id]); $item = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => 'retry-safe.webp', 'status' => 'failed', 'processing_stage' => 'finalized', 'error_code' => 'vision_timeout', 'error_message' => 'Vision analysis timed out.', ]); $this->postJson('/api/studio/upload-queue/items/' . $item->id . '/retry') ->assertOk() ->assertJsonPath('ok', true); $item->refresh(); expect($item->status)->toBe('processing') ->and($item->processing_stage)->toBe('maturity_check') ->and($item->error_code)->toBeNull() ->and($item->error_message)->toBeNull(); Queue::assertPushed(AutoTagArtworkJob::class); Queue::assertPushed(DetectArtworkMaturityJob::class); Queue::assertPushed(GenerateArtworkEmbeddingJob::class); Queue::assertPushed(AnalyzeArtworkAiAssistJob::class); }); test('upload queue AI generation does not overwrite manual metadata silently', function () { Queue::fake(); $category = uploadQueueCategory(); $batch = UploadBatch::query()->create([ 'user_id' => $this->user->id, 'name' => 'AI batch', 'status' => 'completed_with_errors', 'total_items' => 1, ]); $artwork = uploadQueueArtwork([ 'user_id' => $this->user->id, 'title' => 'Manual title', 'description' => 'Manual description', 'file_name' => 'manual.webp', 'file_path' => 'artworks/test/manual.webp', 'hash' => str_repeat('h', 64), 'thumb_ext' => 'webp', 'file_ext' => 'webp', 'artwork_status' => 'draft', 'is_public' => false, 'published_at' => null, 'maturity_status' => ArtworkMaturityService::STATUS_CLEAR, 'maturity_ai_status' => ArtworkMaturityService::AI_STATUS_SUCCEEDED, ]); $artwork->categories()->sync([$category->id]); app(TagService::class)->syncStudioTags($artwork, ['manual-tag']); $item = UploadBatchItem::query()->create([ 'upload_batch_id' => $batch->id, 'user_id' => $this->user->id, 'artwork_id' => $artwork->id, 'original_filename' => 'manual.webp', 'status' => 'failed', 'processing_stage' => 'finalized', 'error_code' => 'metadata_failed', 'error_message' => 'AI metadata generation failed.', ]); $this->postJson('/api/studio/upload-queue/bulk', [ 'action' => 'generate_ai', 'item_ids' => [$item->id], ]) ->assertOk() ->assertJsonPath('success', 1) ->assertJsonPath('failed', 0); $artwork->refresh(); expect($artwork->title)->toBe('Manual title') ->and($artwork->description)->toBe('Manual description') ->and($artwork->categories()->pluck('categories.id')->all())->toBe([$category->id]) ->and($artwork->tags()->pluck('tags.slug')->all())->toBe(['manual-tag']); Queue::assertPushed(AnalyzeArtworkAiAssistJob::class); });