tempUploadRoot = storage_path('framework/testing/uploads-pipeline-' . $suffix); $this->localOriginalsRoot = storage_path('framework/testing/originals-artworks-' . $suffix); File::deleteDirectory($this->tempUploadRoot); File::deleteDirectory($this->localOriginalsRoot); config()->set('uploads.storage_root', $this->tempUploadRoot); config()->set('uploads.local_originals_root', $this->localOriginalsRoot); config()->set('uploads.object_storage.disk', 's3'); config()->set('uploads.object_storage.prefix', 'artworks'); config()->set('uploads.derivatives', [ 'xs' => ['max' => 320], 'sm' => ['max' => 680], 'md' => ['max' => 1024], 'lg' => ['max' => 1920], 'xl' => ['max' => 2560], 'sq' => ['size' => 512], ]); config()->set('uploads.square_thumbnails', [ 'width' => 512, 'height' => 512, 'quality' => 82, 'smart_crop' => true, 'padding_ratio' => 0.18, 'allow_upscale' => false, 'fallback_strategy' => 'center', 'log' => false, 'preview_size' => 320, 'subject_detector' => [ 'preferred_labels' => ['person', 'portrait', 'animal', 'face'], ], 'saliency' => [ 'sample_max_dimension' => 96, 'min_total_energy' => 2400, 'window_ratios' => [0.55, 0.7, 0.82, 1.0], ], ]); }); afterEach(function () { File::deleteDirectory($this->tempUploadRoot); File::deleteDirectory($this->localOriginalsRoot); }); function pipelineTestCreateTempImage(string $root, string $name = 'preview.jpg'): string { $tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp'; if (! File::exists($tmpDir)) { File::makeDirectory($tmpDir, 0755, true); } $upload = UploadedFile::fake()->image($name, 1800, 1200); $path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.jpg'; if (! File::copy($upload->getPathname(), $path)) { throw new RuntimeException('Failed to copy fake image into upload temp path.'); } return $path; } function pipelineTestCreateTempArchive(string $root, string $name = 'pack.zip'): string { $tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp'; if (! File::exists($tmpDir)) { File::makeDirectory($tmpDir, 0755, true); } $path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.zip'; File::put($path, 'fake-archive-binary-' . Str::random(24)); return $path; } it('stores image originals locally and in object storage with all derivatives', function () { $user = User::factory()->create(); $artwork = Artwork::factory()->for($user)->unpublished()->create(); $tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'sunset.jpg'); $hash = hash_file('sha256', $tempImage); $sessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1'); $result = app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, $artwork->id, 'sunset.jpg'); $localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg'; expect(File::exists($localOriginal))->toBeTrue(); Storage::disk('s3')->assertExists("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg"); foreach (['xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) { Storage::disk('s3')->assertExists("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp"); } $sqBytes = Storage::disk('s3')->get("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp"); $sqSize = getimagesizefromstring($sqBytes); expect($sqSize[0] ?? null)->toBe(512) ->and($sqSize[1] ?? null)->toBe(512); $mdBytes = Storage::disk('s3')->get("artworks/md/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp"); $mdSize = getimagesizefromstring($mdBytes); expect($mdSize[0] ?? 0)->toBeGreaterThan(($mdSize[1] ?? 0)); $storedVariants = DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->pluck('path', 'variant') ->all(); expect($storedVariants['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg") ->and($storedVariants['orig_image'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg") ->and($storedVariants['sq'])->toBe("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp"); $artwork->refresh(); expect($artwork->hash)->toBe($hash) ->and($artwork->thumb_ext)->toBe('webp') ->and($artwork->file_ext)->toBe('jpg') ->and($artwork->file_path)->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg") ->and($result['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg"); }); it('stores preview image and archive originals separately when an archive session is provided', function () { $user = User::factory()->create(); $artwork = Artwork::factory()->for($user)->unpublished()->create(); $tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg'); $imageHash = hash_file('sha256', $tempImage); $imageSessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1'); $tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip'); $archiveHash = hash_file('sha256', $tempArchive); $archiveSessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1'); $result = app(UploadPipelineService::class)->processAndPublish( $imageSessionId, $imageHash, $artwork->id, 'cover.jpg', $archiveSessionId, $archiveHash, 'skin-pack.zip' ); Storage::disk('s3')->assertExists("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg"); Storage::disk('s3')->assertExists("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip"); $variants = DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->pluck('path', 'variant') ->all(); expect($variants['orig'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip") ->and($variants['orig_image'])->toBe("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg") ->and($variants['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip"); $artwork->refresh(); expect($artwork->hash)->toBe($imageHash) ->and($artwork->file_ext)->toBe('zip') ->and($artwork->file_path)->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip") ->and($result['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip"); }); it('stores additional archive screenshots as dedicated artwork file variants', function () { $user = User::factory()->create(); $artwork = Artwork::factory()->for($user)->unpublished()->create(); $tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg'); $imageHash = hash_file('sha256', $tempImage); $imageSessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1'); $tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip'); $archiveHash = hash_file('sha256', $tempArchive); $archiveSessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1'); $tempScreenshot = pipelineTestCreateTempImage($this->tempUploadRoot, 'screen-2.jpg'); $screenshotHash = hash_file('sha256', $tempScreenshot); $screenshotSessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($screenshotSessionId, $user->id, $tempScreenshot, UploadSessionStatus::TMP, '127.0.0.1'); $result = app(UploadPipelineService::class)->processAndPublish( $imageSessionId, $imageHash, $artwork->id, 'cover.jpg', $archiveSessionId, $archiveHash, 'skin-pack.zip', [ [ 'session_id' => $screenshotSessionId, 'hash' => $screenshotHash, 'file_name' => 'screen-2.jpg', ], ] ); Storage::disk('s3')->assertExists("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg"); $variants = DB::table('artwork_files') ->where('artwork_id', $artwork->id) ->pluck('path', 'variant') ->all(); expect($variants['shot01'])->toBe("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg") ->and($result['screenshots'])->toBe([ [ 'variant' => 'shot01', 'path' => "artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg", ], ]); }); it('cleans up local and object storage when publish persistence fails', function () { $user = User::factory()->create(); $tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'broken.jpg'); $hash = hash_file('sha256', $tempImage); $sessionId = (string) Str::uuid(); app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1'); try { app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, 999999, 'broken.jpg'); $this->fail('Expected upload pipeline publish to fail for a missing artwork.'); } catch (\Throwable) { // Expected: DB persistence fails and the pipeline must clean up written files. } $localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg'; expect(File::exists($localOriginal))->toBeFalse(); foreach (['original', 'xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) { $filename = $variant === 'original' ? "{$hash}.jpg" : "{$hash}.webp"; Storage::disk('s3')->assertMissing("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$filename}"); } });