Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,168 @@
<?php
namespace Tests\Unit\Uploads;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\InspectionResult;
use Tests\TestCase;
use ZipArchive;
class ArchiveInspectorServiceTest extends TestCase
{
/** @var array<int, string> */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $file) {
if (is_file($file)) {
@unlink($file);
}
}
parent::tearDown();
}
public function test_rejects_zip_slip_path(): void
{
$archive = $this->makeZip([
'../evil.txt' => 'x',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('path traversal', (string) $result->reason);
}
public function test_rejects_symlink_entries(): void
{
$archive = $this->makeZipWithCallback([
'safe/file.txt' => 'ok',
'safe/link' => 'target',
], function (ZipArchive $zip, string $entryName): void {
if ($entryName === 'safe/link') {
$zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
}
});
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('symlink', strtolower((string) $result->reason));
}
public function test_rejects_deep_nesting(): void
{
$archive = $this->makeZip([
'a/b/c/d/e/f/file.txt' => 'too deep',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('depth', strtolower((string) $result->reason));
}
public function test_rejects_too_many_files(): void
{
$entries = [];
for ($index = 0; $index < 5001; $index++) {
$entries['f' . $index . '.txt'] = 'x';
}
$archive = $this->makeZip($entries);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('5000', (string) $result->reason);
}
public function test_rejects_executable_extensions(): void
{
$archive = $this->makeZip([
'skins/readme.txt' => 'ok',
'skins/run.exe' => 'MZ',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('blocked', strtolower((string) $result->reason));
}
public function test_rejects_zip_bomb_ratio(): void
{
$archive = $this->makeZip([
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('ratio', strtolower((string) $result->reason));
}
public function test_valid_archive_passes(): void
{
$archive = $this->makeZip([
'skins/theme/readme.txt' => 'safe',
'skins/theme/colors.ini' => 'accent=blue',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertInstanceOf(InspectionResult::class, $result);
$this->assertTrue($result->valid);
$this->assertNull($result->reason);
$this->assertIsArray($result->stats);
$this->assertArrayHasKey('files', $result->stats);
$this->assertArrayHasKey('depth', $result->stats);
$this->assertArrayHasKey('size', $result->stats);
$this->assertArrayHasKey('ratio', $result->stats);
}
/**
* @param array<string, string> $entries
*/
private function makeZip(array $entries): string
{
return $this->makeZipWithCallback($entries, null);
}
/**
* @param array<string, string> $entries
* @param (callable(ZipArchive,string):void)|null $entryCallback
*/
private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
{
if (! class_exists(ZipArchive::class)) {
$this->markTestSkipped('ZipArchive extension is required.');
}
$path = tempnam(sys_get_temp_dir(), 'sb_zip_');
if ($path === false) {
throw new \RuntimeException('Unable to create temporary zip path.');
}
$this->tempFiles[] = $path;
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Unable to open temporary zip for writing.');
}
foreach ($entries as $name => $content) {
$zip->addFromString($name, $content);
if ($entryCallback !== null) {
$entryCallback($zip, $name);
}
}
$zip->close();
return $path;
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Tests\Unit\Uploads;
use App\Models\User;
use App\Uploads\Services\CleanupService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class CleanupServiceTest extends TestCase
{
use RefreshDatabase;
private function insertUploadRow(array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => User::factory()->create()->id,
'type' => 'image',
'status' => 'draft',
'title' => null,
'slug' => null,
'category_id' => null,
'description' => null,
'tags' => null,
'license' => null,
'nsfw' => false,
'is_scanned' => false,
'has_tags' => false,
'preview_path' => null,
'published_at' => null,
'final_path' => null,
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
public function test_deletes_expired_draft_uploads_and_returns_count(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'expires_at' => now()->subMinute(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(1, $deleted);
$this->assertFalse(DB::table('uploads')->where('id', $uploadId)->exists());
}
public function test_keeps_active_drafts_untouched(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'expires_at' => now()->addDay(),
'updated_at' => now()->subHours(2),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(0, $deleted);
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->exists());
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
}
public function test_removes_temp_folder_when_deleting_stale_drafts(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'updated_at' => now()->subHours(25),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.bin", 'x');
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(1, $deleted);
$this->assertFalse(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
}
public function test_enforces_hard_cleanup_limit_of_100_per_run(): void
{
Storage::fake('local');
for ($index = 0; $index < 120; $index++) {
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'updated_at' => now()->subHours(30),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
}
$deleted = app(CleanupService::class)->cleanupStaleDrafts(999);
$this->assertSame(100, $deleted);
$this->assertSame(20, DB::table('uploads')->count());
}
public function test_never_deletes_published_uploads(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'published',
'updated_at' => now()->subDays(5),
'published_at' => now()->subDays(4),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(0, $deleted);
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->where('status', 'published')->exists());
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
}
}

View File

@@ -0,0 +1,102 @@
<?php
use App\Models\User;
use App\Uploads\Services\PublishService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createCategoryForPublishTests(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Skins',
'slug' => 'skins-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Winamp',
'slug' => 'winamp-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
it('rejects publish when user is not owner', function () {
Storage::fake('local');
$owner = User::factory()->create();
$other = User::factory()->create();
$categoryId = createCategoryForPublishTests();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $owner->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'City Lights',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
$service = app(PublishService::class);
expect(fn () => $service->publish($uploadId, $other))
->toThrow(RuntimeException::class, 'You do not own this upload.');
});
it('rejects archive publish without screenshots', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishTests();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $owner->id,
'type' => 'archive',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'Skin Pack',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/pack.zip",
'type' => 'main',
'hash' => 'aabbccddeeff0011',
'size' => 1024,
'mime' => 'application/zip',
'created_at' => now(),
]);
$service = app(PublishService::class);
expect(fn () => $service->publish($uploadId, $owner))
->toThrow(RuntimeException::class, 'Archive uploads require at least one screenshot.');
});

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Unit\Uploads;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
use App\Services\Upload\UploadDraftService;
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
use Illuminate\Filesystem\FilesystemManager;
use Carbon\Carbon;
use App\Models\User;
class UploadDraftServiceTest extends TestCase
{
use RefreshDatabase;
protected UploadDraftService $service;
protected User $user;
protected function setUp(): void
{
parent::setUp();
// Use fake storage so we don't touch the real filesystem
Storage::fake('local');
$this->user = User::factory()->create();
// Provide a dummy clamav scanner binding so any scanning calls are mocked
$this->app->instance('clamav', new class {
public function scan(string $path): bool
{
return true;
}
});
$filesystem = $this->app->make(FilesystemManager::class);
$this->service = new UploadDraftService($filesystem, 'local');
}
public function test_createDraft_creates_directory_and_writes_meta()
{
$result = $this->service->createDraft(['title' => 'Test Draft', 'user_id' => $this->user->id, 'type' => 'image']);
$this->assertArrayHasKey('id', $result);
$id = $result['id'];
Storage::disk('local')->assertExists("tmp/drafts/{$id}");
Storage::disk('local')->assertExists("tmp/drafts/{$id}/meta.json");
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertSame('Test Draft', $meta['title']);
$this->assertSame($id, $meta['id']);
}
public function test_storeMainFile_saves_file_and_updates_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$file = UploadedFile::fake()->create('song.mp3', 1500, 'audio/mpeg');
$info = $this->service->storeMainFile($id, $file);
$this->assertArrayHasKey('path', $info);
Storage::disk('local')->assertExists($info['path']);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('main_file', $meta);
$this->assertSame($info['hash'], $meta['main_file']['hash']);
}
public function test_storeScreenshot_saves_file_and_appends_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$img = UploadedFile::fake()->image('thumb.jpg', 640, 480);
$info = $this->service->storeScreenshot($id, $img);
$this->assertArrayHasKey('path', $info);
Storage::disk('local')->assertExists($info['path']);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('screenshots', $meta);
$this->assertCount(1, $meta['screenshots']);
$this->assertSame($info['hash'], $meta['screenshots'][0]['hash']);
}
public function test_calculateHash_for_local_file_and_storage_path()
{
$file = UploadedFile::fake()->create('doc.pdf', 10);
$realPath = $file->getRealPath();
$expected = hash_file('sha256', $realPath);
$this->assertSame($expected, $this->service->calculateHash($realPath));
// Store into drafts and calculate by storage path
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$info = $this->service->storeMainFile($id, $file);
$storageHash = $this->service->calculateHash($info['path']);
$storedContents = Storage::disk('local')->get($info['path']);
$this->assertSame(hash('sha256', $storedContents), $storageHash);
}
public function test_setExpiration_writes_expires_at_in_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$when = Carbon::now()->addDays(3);
$ok = $this->service->setExpiration($id, $when);
$this->assertTrue($ok);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('expires_at', $meta);
$this->assertSame($when->toISOString(), $meta['expires_at']);
}
public function test_calculateHash_throws_for_missing_file()
{
$this->expectException(\RuntimeException::class);
$this->service->calculateHash('this/path/does/not/exist');
}
}

View File

@@ -0,0 +1,263 @@
<?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}");
}
});