Files
SkinbaseNova/tests/Feature/ArtworkDownloadTest.php
klevze 04b8160d9e 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.
2026-09-20 14:49:19 +02:00

243 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
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');
config([
'uploads.storage_root' => $root,
'uploads.local_originals_root' => $root,
]);
if (File::exists($root)) {
File::deleteDirectory($root);
}
File::makeDirectory($root, 0755, true);
});
afterEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
if (File::exists($root)) {
File::deleteDirectory($root);
}
});
function makeOriginalFile(string $hash, string $ext, string $content = 'test-image-content'): string
{
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$dir = $root . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
File::makeDirectory($dir, 0755, true, true);
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
File::put($path, $content);
return $path;
}
it('downloads an existing artwork file', function () {
$hash = 'a9f3e6c1b8a9f3e6c1b8a9f3e6c1b8a9f3e6c1b8';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'Sky Sunset',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$response->assertDownload('Sky Sunset (skinbase.org).png');
});
it('forces the download filename using file_name and extension', function () {
$hash = 'b7c4d1e2f3b7c4d1e2f3b7c4d1e2f3b7c4d1e2f3';
$ext = 'jpg';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'My Original Name',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$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();
});
it('returns 404 when the original file is missing', function () {
$artwork = Artwork::factory()->create([
'hash' => 'c1d2e3f4a5',
'file_ext' => 'webp',
]);
$this->get("/download/artwork/{$artwork->id}")->assertNotFound();
});
it('logs download metadata with user and request context', function () {
$hash = 'd4e5f6a7b8d4e5f6a7b8d4e5f6a7b8d4e5f6a7b8';
$ext = 'gif';
makeOriginalFile($hash, $ext);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->actingAs($user)
->withHeaders([
'User-Agent' => 'SkinbaseTestAgent/1.0',
'Referer' => 'https://example.test/art/' . $artwork->id,
])
->get("/download/artwork/{$artwork->id}")
->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'SkinbaseTestAgent/1.0',
'referer' => 'https://example.test/art/' . $artwork->id,
]);
});
it('logs guest download with null user_id', function () {
$hash = 'e1f2a3b4c5e1f2a3b4c5e1f2a3b4c5e1f2a3b4c5';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->get("/download/artwork/{$artwork->id}")->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => null,
]);
});
it('increments artwork_stats downloads on the real download route', function () {
$hash = 'f1e2d3c4b5f1e2d3c4b5f1e2d3c4b5f1e2d3c4b5';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artwork->id,
'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
$this->get("/download/artwork/{$artwork->id}")->assertOk();
$this->get("/download/artwork/{$artwork->id}")->assertOk();
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads'))->toBe(2);
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads_24h'))->toBe(2);
expect(DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads_7d'))->toBe(2);
});