Track original artwork file availability more accurately.

Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
This commit is contained in:
2026-09-20 14:49:19 +02:00
parent 0f52803b05
commit 04b8160d9e
7 changed files with 599 additions and 38 deletions
+79 -5
View File
@@ -6,6 +6,7 @@ use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
@@ -44,7 +45,7 @@ function makeOriginalFile(string $hash, string $ext, string $content = 'test-ima
}
it('downloads an existing artwork file', function () {
$hash = 'a9f3e6c1b8';
$hash = 'a9f3e6c1b8a9f3e6c1b8a9f3e6c1b8a9f3e6c1b8';
$ext = 'png';
makeOriginalFile($hash, $ext);
@@ -61,7 +62,7 @@ it('downloads an existing artwork file', function () {
});
it('forces the download filename using file_name and extension', function () {
$hash = 'b7c4d1e2f3';
$hash = 'b7c4d1e2f3b7c4d1e2f3b7c4d1e2f3b7c4d1e2f3';
$ext = 'jpg';
makeOriginalFile($hash, $ext);
@@ -77,6 +78,79 @@ it('forces the download filename using file_name and extension', function () {
$response->assertDownload('My Original Name (skinbase.org).jpg');
});
it('downloads an object-only original without restoring it locally', function () {
config(['uploads.object_storage.disk' => 's3']);
$hash = '1234567890abcdef1234567890abcdef12345678';
$key = "artworks/original/12/34/{$hash}.jpg";
$disk = Mockery::mock();
$disk->shouldReceive('exists')->once()->with($key)->andReturnTrue();
$disk->shouldReceive('size')->once()->with($key)->andReturn(15);
$disk->shouldReceive('temporaryUrl')
->once()
->withArgs(fn (string $path, \DateTimeInterface $expires, array $options): bool =>
$path === $key
&& $expires > now()
&& str_contains((string) ($options['ResponseContentDisposition'] ?? ''), 'Object Original')
)
->andReturn('https://object.example.test/signed-object');
$disk->shouldNotReceive('readStream');
$disk->shouldNotReceive('get');
Storage::shouldReceive('disk')->with('s3')->andReturn($disk);
$artwork = Artwork::factory()->create([
'file_name' => 'Object Original',
'file_path' => '',
'hash' => $hash,
'file_ext' => 'jpg',
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertRedirect('https://object.example.test/signed-object');
expect($artwork->fresh()->download_status)->toBe('available')
->and($artwork->fresh()->download_source)->toBe('object')
->and(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(1);
});
it('does not count an object download when signing fails', function () {
config(['uploads.object_storage.disk' => 's3']);
$hash = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd';
$key = "artworks/original/ab/cd/{$hash}.jpg";
$disk = Mockery::mock();
$disk->shouldReceive('exists')->once()->with($key)->andReturnTrue();
$disk->shouldReceive('size')->once()->with($key)->andReturn(15);
$disk->shouldReceive('temporaryUrl')->once()->andThrow(new RuntimeException('signing unavailable'));
Storage::shouldReceive('disk')->with('s3')->andReturn($disk);
$artwork = Artwork::factory()->create([
'file_name' => 'Signing Failure',
'file_path' => '',
'hash' => $hash,
'file_ext' => 'jpg',
]);
$this->mock(\App\Services\ArtworkStatsService::class, function ($mock): void {
$mock->shouldReceive('incrementDownloads')->never();
});
$this->get("/download/artwork/{$artwork->id}")->assertNotFound();
});
it('classifies incomplete original metadata as pending', function () {
$artwork = Artwork::factory()->create([
'file_name' => 'pending',
'file_path' => '',
'hash' => null,
'file_ext' => null,
]);
expect(app(\App\Services\ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'pending',
'source' => null,
'exists' => false,
]);
});
it('returns 404 for a missing artwork', function () {
$this->get('/download/artwork/999999')->assertNotFound();
});
@@ -91,7 +165,7 @@ it('returns 404 when the original file is missing', function () {
});
it('logs download metadata with user and request context', function () {
$hash = 'd4e5f6a7b8';
$hash = 'd4e5f6a7b8d4e5f6a7b8d4e5f6a7b8d4e5f6a7b8';
$ext = 'gif';
makeOriginalFile($hash, $ext);
@@ -119,7 +193,7 @@ it('logs download metadata with user and request context', function () {
});
it('logs guest download with null user_id', function () {
$hash = 'e1f2a3b4c5';
$hash = 'e1f2a3b4c5e1f2a3b4c5e1f2a3b4c5e1f2a3b4c5';
$ext = 'png';
makeOriginalFile($hash, $ext);
@@ -137,7 +211,7 @@ it('logs guest download with null user_id', function () {
});
it('increments artwork_stats downloads on the real download route', function () {
$hash = 'f1e2d3c4b5';
$hash = 'f1e2d3c4b5f1e2d3c4b5f1e2d3c4b5f1e2d3c4b5';
$ext = 'png';
makeOriginalFile($hash, $ext);
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\ArtworkOriginalFileLocator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
$root = storage_path('framework/testing/artwork-originals');
$legacyRoot = storage_path('framework/testing/artwork-legacy');
config([
'uploads.local_originals_root' => $root,
'uploads.readonly_backup_originals_root' => storage_path('framework/testing/artwork-readonly-backup'),
'uploads.legacy_originals_root' => $legacyRoot,
'uploads.object_storage.disk' => 's3',
]);
File::deleteDirectory($root);
File::deleteDirectory($legacyRoot);
File::deleteDirectory(storage_path('framework/testing/artwork-readonly-backup'));
File::makeDirectory($root, 0755, true);
File::makeDirectory($legacyRoot, 0755, true);
Storage::fake('s3');
});
afterEach(function (): void {
File::deleteDirectory(storage_path('framework/testing/artwork-originals'));
File::deleteDirectory(storage_path('framework/testing/artwork-legacy'));
File::deleteDirectory(storage_path('framework/testing/artwork-readonly-backup'));
});
function locatorLocalPath(string $hash, string $extension): string
{
return storage_path("framework/testing/artwork-originals/{$hash[0]}{$hash[1]}/{$hash[2]}{$hash[3]}/{$hash}.{$extension}");
}
it('infers a missing extension and resolves a hashed local original', function (): void {
$hash = str_repeat('a', 40);
File::ensureDirectoryExists(dirname(locatorLocalPath($hash, 'jpg')));
File::put(locatorLocalPath($hash, 'jpg'), 'local');
$artwork = Artwork::factory()->create(['hash' => $hash, 'file_ext' => null, 'file_name' => 'Something.JPG', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'available', 'source' => 'local', 'file_ext' => 'jpg',
]);
});
it('infers a missing extension and resolves a hashed object original', function (): void {
$hash = str_repeat('b', 40);
Storage::disk('s3')->put("artworks/original/bb/bb/{$hash}.png", 'object');
$artwork = Artwork::factory()->create(['hash' => $hash, 'file_ext' => null, 'file_name' => 'Image.PNG', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'available', 'source' => 'object', 'file_ext' => 'png',
]);
});
it('infers a missing extension and resolves a hashed readonly backup original', function (): void {
$hash = str_repeat('d', 40);
$root = (string) config('uploads.readonly_backup_originals_root');
$path = $root . DIRECTORY_SEPARATOR . 'dd' . DIRECTORY_SEPARATOR . 'dd' . DIRECTORY_SEPARATOR . $hash . '.jpeg';
File::ensureDirectoryExists(dirname($path));
File::put($path, 'backup');
$artwork = Artwork::factory()->create(['hash' => $hash, 'file_ext' => null, 'file_name' => 'backup.JPEG', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'available', 'source' => 'legacy', 'file_ext' => 'jpeg',
]);
});
it('resolves a hashless legacy upload from the authoritative legacy filesystem root', function (): void {
$relative = 'legacy/uploads/old-image.jpg';
$path = storage_path("framework/testing/artwork-legacy/{$relative}");
File::ensureDirectoryExists(dirname($path));
File::put($path, 'legacy');
$artwork = Artwork::factory()->create(['hash' => null, 'file_ext' => null, 'file_name' => 'old-image.jpg', 'file_path' => $relative]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'available', 'source' => 'legacy', 'file_ext' => 'jpg',
]);
});
it('resolves a hashless legacy upload from the authoritative object disk', function (): void {
$relative = 'legacy/uploads/old-image.jpg';
Storage::disk('s3')->put($relative, 'legacy');
$artwork = Artwork::factory()->create(['hash' => null, 'file_ext' => null, 'file_name' => 'old-image.jpg', 'file_path' => $relative]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'available', 'source' => 'object', 'object_key' => $relative, 'file_ext' => 'jpg',
]);
});
it('does not use the canonical readonly backup root as a legacy filename root', function (): void {
config(['uploads.legacy_originals_root' => '']);
$relative = 'legacy/uploads/not-migrated.jpg';
$path = storage_path("framework/testing/artwork-readonly-backup/{$relative}");
File::ensureDirectoryExists(dirname($path));
File::put($path, 'not authoritative');
$artwork = Artwork::factory()->create(['hash' => null, 'file_ext' => null, 'file_name' => 'not-migrated.jpg', 'file_path' => $relative]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork)['status'])->toBe('unknown');
});
it('keeps a genuine pending placeholder pending', function (): void {
$artwork = Artwork::factory()->create(['file_name' => 'pending', 'file_path' => '', 'hash' => null, 'file_ext' => null]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork)['status'])->toBe('pending');
});
it('classifies unresolved incomplete legacy metadata as unknown', function (): void {
$artwork = Artwork::factory()->create(['file_name' => 'old-image.jpg', 'file_path' => 'legacy/uploads/old-image.jpg', 'hash' => null, 'file_ext' => null]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork)['status'])->toBe('unknown');
});
it('does not infer unsupported or executable extensions', function (): void {
$artwork = Artwork::factory()->create(['file_name' => 'CIH.php', 'file_path' => 'legacy/uploads/CIH.php', 'hash' => null, 'file_ext' => null]);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork)['status'])->toBe('unknown');
});
it('keeps complete metadata missing everywhere as missing', function (): void {
$hash = str_repeat('c', 40);
$artwork = Artwork::factory()->create(['hash' => $hash, 'file_ext' => 'jpg', 'file_name' => 'missing.jpg', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork)['status'])->toBe('missing');
});
it('keeps a valid SHA-1 BMP original missing when all sources are absent', function (): void {
$hash = str_repeat('e', 40);
$artwork = Artwork::factory()->create(['hash' => $hash, 'file_ext' => null, 'file_name' => 'preview.bmp', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'missing', 'file_ext' => 'bmp',
]);
});
it('classifies a short hash as unknown and does not construct a canonical path', function (): void {
$artwork = Artwork::factory()->create(['hash' => 'ddeeff112233', 'file_ext' => null, 'file_name' => 'rose-closeup.jpg', 'file_path' => 'uploads/artworks/image.jpg']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'unknown', 'path' => '', 'object_key' => '',
]);
});
it('classifies a non-hex 40-character hash as unknown', function (): void {
$artwork = Artwork::factory()->create(['hash' => str_repeat('g', 40), 'file_ext' => 'jpg', 'file_name' => 'invalid.jpg', 'file_path' => '']);
expect(app(ArtworkOriginalFileLocator::class)->resolve($artwork))->toMatchArray([
'status' => 'unknown', 'path' => '', 'object_key' => '',
]);
});
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
it('processes an explicit artwork id exactly once with a chunk of one', function (string $status, ?string $source, string $expected): void {
Storage::fake('s3');
config(['uploads.object_storage.disk' => 's3']);
$hash = $status === 'pending'
? null
: str_repeat($source === 'local' ? 'a' : ($source === 'object' ? 'b' : 'c'), 40);
$ext = $status === 'pending' ? null : 'jpg';
if ($source === 'local') {
$root = (string) config('uploads.local_originals_root');
$path = $root . DIRECTORY_SEPARATOR . 'aa' . DIRECTORY_SEPARATOR . 'aa' . DIRECTORY_SEPARATOR . $hash . '.jpg';
File::ensureDirectoryExists(dirname($path));
File::put($path, 'local');
} elseif ($source === 'object') {
Storage::disk('s3')->put("artworks/original/bb/bb/{$hash}.jpg", 'object');
}
$artwork = Artwork::factory()->create([
'file_name' => $status === 'pending' ? 'pending' : 'audit-' . $status,
'hash' => $hash,
'file_ext' => $ext,
'file_path' => '',
]);
$exit = Artisan::call('artworks:audit-download-files', [
'--id' => $artwork->id,
'--chunk' => 1,
]);
$output = Artisan::output();
expect($exit)->toBe(Command::SUCCESS)
->and($output)->toContain("classification={$expected} count=1")
->and($output)->toContain('processed=1');
})->with([
['available', 'local', 'LOCAL_OK'],
['available', 'object', 'OBJECT_OK'],
['pending', null, 'PENDING'],
['missing', null, 'MISSING_EVERYWHERE'],
]);
it('marks an explicit id once when requested', function (): void {
Storage::fake('s3');
config(['uploads.object_storage.disk' => 's3']);
$hash = str_repeat('cd', 20);
Storage::disk('s3')->put("artworks/original/cd/cd/{$hash}.jpg", 'object');
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => 'jpg',
'file_path' => '',
]);
expect(Artisan::call('artworks:audit-download-files', [
'--id' => $artwork->id,
'--chunk' => 1,
'--mark-status' => true,
]))->toBe(Command::SUCCESS);
$artwork->refresh();
expect($artwork->download_status)->toBe('available')
->and($artwork->download_source)->toBe('object')
->and(Artisan::output())->toContain('processed=1');
});