localOriginalsRoot = storage_path('framework/testing/square-command-originals-' . $suffix); File::deleteDirectory($this->localOriginalsRoot); File::ensureDirectoryExists($this->localOriginalsRoot); config()->set('uploads.local_originals_root', $this->localOriginalsRoot); config()->set('uploads.object_storage.disk', 's3'); config()->set('uploads.object_storage.prefix', 'artworks'); }); afterEach(function () { File::deleteDirectory($this->localOriginalsRoot); }); function generateSqCommandImage(string $root, string $filename = 'source.jpg'): string { $path = $root . DIRECTORY_SEPARATOR . $filename; $image = imagecreatetruecolor(1200, 800); $background = imagecolorallocate($image, 18, 22, 29); $subject = imagecolorallocate($image, 245, 180, 110); imagefilledrectangle($image, 0, 0, 1200, 800, $background); imagefilledellipse($image, 280, 340, 360, 380, $subject); imagejpeg($image, $path, 90); imagedestroy($image); return $path; } function generateSqCommandWebp(string $root, string $filename = 'source.webp'): string { $path = $root . DIRECTORY_SEPARATOR . $filename; $image = imagecreatetruecolor(1400, 900); $background = imagecolorallocate($image, 16, 21, 28); $subject = imagecolorallocate($image, 242, 194, 94); imagefilledrectangle($image, 0, 0, 1400, 900, $background); imagefilledellipse($image, 360, 420, 420, 360, $subject); imagewebp($image, $path, 90); imagedestroy($image); return $path; } function seedArtworkWithOriginal(string $localOriginalsRoot): Artwork { $user = User::factory()->create(); $artwork = Artwork::factory()->for($user)->unpublished()->create(); $source = generateSqCommandImage($localOriginalsRoot, 'seed.jpg'); $hash = hash_file('sha256', $source); $storage = app(UploadStorageService::class); $localTarget = $storage->localOriginalPath($hash, $hash . '.jpg'); File::ensureDirectoryExists(dirname($localTarget)); File::copy($source, $localTarget); $artwork->update([ 'hash' => $hash, 'file_ext' => 'jpg', 'thumb_ext' => 'webp', 'width' => 1200, 'height' => 800, ]); DB::table('artwork_files')->insert([ 'artwork_id' => $artwork->id, 'variant' => 'orig_image', 'path' => "artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg", 'mime' => 'image/jpeg', 'size' => (int) filesize($localTarget), ]); return $artwork->fresh(); } it('generates missing square thumbnails from the best available source', function () { $artwork = seedArtworkWithOriginal($this->localOriginalsRoot); $code = Artisan::call('artworks:generate-missing-sq-thumbs'); expect($code)->toBe(0); expect(Artisan::output())->toContain('generated=1'); $sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp"; Storage::disk('s3')->assertExists($sqPath); expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue(); $sqSize = getimagesizefromstring(Storage::disk('s3')->get($sqPath)); expect($sqSize[0] ?? null)->toBe(512) ->and($sqSize[1] ?? null)->toBe(512); }); it('supports dry run without writing sq thumbnails', function () { $artwork = seedArtworkWithOriginal($this->localOriginalsRoot); $code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--dry-run' => true]); expect($code)->toBe(0); expect(Artisan::output())->toContain('planned=1'); $sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp"; Storage::disk('s3')->assertMissing($sqPath); expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeFalse(); }); it('forces regeneration when an sq row already exists', function () { $artwork = seedArtworkWithOriginal($this->localOriginalsRoot); DB::table('artwork_files')->insert([ 'artwork_id' => $artwork->id, 'variant' => 'sq', 'path' => "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp", 'mime' => 'image/webp', 'size' => 0, ]); $code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--force' => true]); expect($code)->toBe(0); expect(Artisan::output())->toContain('generated=1'); $sqPath = "artworks/sq/" . substr($artwork->hash, 0, 2) . "/" . substr($artwork->hash, 2, 2) . "/{$artwork->hash}.webp"; Storage::disk('s3')->assertExists($sqPath); }); it('purges the canonical sq url after regeneration when Cloudflare is configured', function () { Http::fake([ 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200), ]); config()->set('cdn.files_url', 'https://cdn.skinbase.org'); config()->set('cdn.cloudflare.zone_id', 'test-zone'); config()->set('cdn.cloudflare.api_token', 'test-token'); $artwork = seedArtworkWithOriginal($this->localOriginalsRoot); $code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]); expect($code)->toBe(0); Http::assertSent(function ($request) use ($artwork): bool { return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' && $request['files'] === [ 'https://cdn.skinbase.org/artworks/sq/' . substr($artwork->hash, 0, 2) . '/' . substr($artwork->hash, 2, 2) . '/' . $artwork->hash . '.webp', ]; }); }); it('falls back to canonical CDN derivatives for legacy artworks without artwork_files rows', function () { $cdnRoot = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . 'fake-cdn'; File::ensureDirectoryExists($cdnRoot); config()->set('cdn.files_url', 'file:///' . str_replace(DIRECTORY_SEPARATOR, '/', $cdnRoot)); $user = User::factory()->create(); $hash = '6183c98975512ee6bff4657043067953a33769c7'; $artwork = Artwork::factory()->for($user)->unpublished()->create([ 'hash' => $hash, 'file_ext' => 'jpg', 'thumb_ext' => 'webp', 'file_path' => 'legacy/uploads/IMG_20210727_090534.jpg', 'width' => 4624, 'height' => 2080, ]); $xlUrl = ThumbnailService::fromHash($hash, 'webp', 'xl'); $webpSource = generateSqCommandWebp($this->localOriginalsRoot, 'legacy-xl.webp'); $xlPath = $cdnRoot . DIRECTORY_SEPARATOR . 'artworks' . DIRECTORY_SEPARATOR . 'xl' . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.webp'; File::ensureDirectoryExists(dirname($xlPath)); File::copy($webpSource, $xlPath); expect($xlUrl)->toContain('/artworks/xl/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.webp'); $code = Artisan::call('artworks:generate-missing-sq-thumbs', ['--id' => $artwork->id]); expect($code)->toBe(0); expect(Artisan::output())->toContain('generated=1'); $sqPath = "artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp"; Storage::disk('s3')->assertExists($sqPath); expect(DB::table('artwork_files')->where('artwork_id', $artwork->id)->where('variant', 'sq')->exists())->toBeTrue(); });