Files
SkinbaseNova/tests/Feature/StudioUploadQueueTest.php
2026-04-25 08:36:03 +02:00

657 lines
22 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\UploadBatch;
use App\Models\UploadBatchItem;
use App\Models\User;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Maturity\ArtworkMaturityService;
use App\Services\TagService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function uploadQueueArtwork(array $attributes = []): Artwork
{
return Artwork::withoutEvents(fn () => 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);
});