263 lines
12 KiB
PHP
263 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\User;
|
|
use App\Repositories\Uploads\UploadSessionRepository;
|
|
use App\Services\Uploads\UploadPipelineService;
|
|
use App\Services\Uploads\UploadSessionStatus;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('s3');
|
|
|
|
$suffix = Str::lower(Str::random(10));
|
|
$this->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}");
|
|
}
|
|
}); |