492 lines
18 KiB
PHP
492 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Uwil\Storage\Tests\Unit\Lib;
|
|
|
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\NullLogger;
|
|
use Uwil\Storage\Config\StorageServiceConfig;
|
|
use Uwil\Storage\Exceptions\UnexpectedStorageException;
|
|
use Uwil\Storage\Lib\PathGenerators\DateHashPathGenerator;
|
|
use Uwil\Storage\Lib\PathGenerators\UuidPathGenerator;
|
|
use Uwil\Storage\Lib\StorageGatewayImpl;
|
|
use Uwil\Storage\Lib\ValueObjects\DiskName;
|
|
use Uwil\Storage\Lib\ValueObjects\FileStream;
|
|
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
|
|
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
|
|
|
|
covers(StorageGatewayImpl::class);
|
|
|
|
/**
|
|
* @param array<string, string> $extraDisks eligible disks to declare in addition to `local` (name => driver)
|
|
*/
|
|
function createGatewayConfig(array $extraDisks = []): StorageServiceConfig
|
|
{
|
|
$existing = [(object) ['disk' => 'local', 'driver' => 'local']];
|
|
|
|
foreach ($extraDisks as $name => $driver) {
|
|
Storage::fake($name);
|
|
$existing[] = (object) ['disk' => $name, 'driver' => $driver];
|
|
}
|
|
|
|
return new StorageServiceConfig(
|
|
defaultDisk: 'local',
|
|
eligibleDisks: ['local', ...array_keys($extraDisks)],
|
|
laravelExistingDisks: $existing,
|
|
allowedExtensions: ['txt']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $extraDisks
|
|
*/
|
|
function createGateway(array $extraDisks = []): StorageGatewayImpl
|
|
{
|
|
return new StorageGatewayImpl(
|
|
config: createGatewayConfig($extraDisks),
|
|
logger: new NullLogger(),
|
|
datePathGenerator: new DateHashPathGenerator(),
|
|
uuidPathGenerator: new UuidPathGenerator()
|
|
);
|
|
}
|
|
|
|
describe('StorageGatewayImpl storeFile', function (): void {
|
|
it('stores a file onto a local disk using DateHashPathGenerator', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'test.txt',
|
|
sanitizedName: 'test.txt',
|
|
contents: FileStream::fromString('file content to store')
|
|
);
|
|
|
|
// WHEN
|
|
$storedPath = $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($storedPath)->toBeInstanceOf(StorageFilePath::class)
|
|
->and(Storage::disk('local')->exists($storedPath->value))->toBeTrue()
|
|
->and(Storage::disk('local')->get($storedPath->value))->toBe('file content to store');
|
|
});
|
|
|
|
it('stores a file onto an s3 disk using UuidPathGenerator', function (): void {
|
|
// GIVEN
|
|
Storage::fake('s3');
|
|
$gateway = createGateway(['s3' => 's3']);
|
|
$disk = new DiskName('s3', 's3');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'picture.png',
|
|
sanitizedName: 'picture.png',
|
|
contents: FileStream::fromString('png data')
|
|
);
|
|
|
|
// WHEN
|
|
$storedPath = $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($storedPath)->toBeInstanceOf(StorageFilePath::class)
|
|
->and(Storage::disk('s3')->exists($storedPath->value))->toBeTrue()
|
|
->and($storedPath->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$#');
|
|
});
|
|
|
|
it('throws UnexpectedStorageException if storage write fails', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andReturn(false);
|
|
|
|
$gateway = createGateway();
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName('local', 'local');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'test.txt',
|
|
sanitizedName: 'test.txt',
|
|
contents: FileStream::fromString('fail data')
|
|
);
|
|
|
|
// WHEN
|
|
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($action)->toThrow(UnexpectedStorageException::class, 'Failed to write file stream to disk');
|
|
});
|
|
|
|
it('wraps generic exceptions into UnexpectedStorageException', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andThrow(new \RuntimeException('Network drop'));
|
|
|
|
$gateway = createGateway();
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName('local', 'local');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'test.txt',
|
|
sanitizedName: 'test.txt',
|
|
contents: FileStream::fromString('fail data')
|
|
);
|
|
|
|
// WHEN
|
|
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($action)->toThrow(UnexpectedStorageException::class, 'Failed to store file: Network drop');
|
|
});
|
|
|
|
it('rethrows StorageException instances directly without wrapping them', function (): void {
|
|
// GIVEN
|
|
$directException = new UnexpectedStorageException('Direct storage failure');
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andThrow($directException);
|
|
|
|
$gateway = createGateway();
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName('local', 'local');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'test.txt',
|
|
sanitizedName: 'test.txt',
|
|
contents: FileStream::fromString('fail data')
|
|
);
|
|
|
|
// WHEN
|
|
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($action)->toThrow(function (UnexpectedStorageException $e) use ($directException) {
|
|
expect($e)->toBe($directException);
|
|
});
|
|
});
|
|
|
|
it('stores a file onto ftp and sftp disks using DateHashPathGenerator', function (string $driver): void {
|
|
// GIVEN
|
|
config()->set("filesystems.disks.{$driver}", ['driver' => $driver]);
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andReturn(true);
|
|
$gateway = createGateway([$driver => $driver]);
|
|
Storage::shouldReceive('disk')->with($driver)->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName($driver, $driver);
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'test.txt',
|
|
sanitizedName: 'test.txt',
|
|
contents: FileStream::fromString('ftp data')
|
|
);
|
|
|
|
// WHEN
|
|
$path = $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($path)->toBeInstanceOf(StorageFilePath::class)
|
|
->and($path->value)->toStartWith(gmdate('Y') . '/');
|
|
})->with([
|
|
'ftp driver' => 'ftp',
|
|
'sftp driver' => 'sftp',
|
|
]);
|
|
|
|
it('stores a file onto a scoped disk resolving underlying s3 driver', function (): void {
|
|
// GIVEN
|
|
config()->set('filesystems.disks.s3', ['driver' => 's3']);
|
|
config()->set('filesystems.disks.scoped_s3', [
|
|
'driver' => 'scoped',
|
|
'disk' => 's3',
|
|
'prefix' => 'media',
|
|
]);
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andReturn(true);
|
|
$gateway = createGateway(['scoped_s3' => 'scoped']);
|
|
Storage::shouldReceive('disk')->with('scoped_s3')->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName('scoped_s3', 'scoped');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'photo.jpg',
|
|
sanitizedName: 'photo.jpg',
|
|
contents: FileStream::fromString('image bytes')
|
|
);
|
|
|
|
// WHEN
|
|
$path = $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($path)->toBeInstanceOf(StorageFilePath::class)
|
|
->and($path->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$#');
|
|
});
|
|
|
|
it('stores a file onto a scoped disk resolving underlying local driver', function (): void {
|
|
// GIVEN
|
|
config()->set('filesystems.disks.local_parent', ['driver' => 'local']);
|
|
config()->set('filesystems.disks.scoped_local', [
|
|
'driver' => 'scoped',
|
|
'disk' => 'local_parent',
|
|
'prefix' => 'uploads',
|
|
]);
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('put')->andReturn(true);
|
|
$gateway = createGateway(['scoped_local' => 'scoped']);
|
|
Storage::shouldReceive('disk')->with('scoped_local')->andReturn($mockStorage);
|
|
|
|
$disk = new DiskName('scoped_local', 'scoped');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'doc.pdf',
|
|
sanitizedName: 'doc.pdf',
|
|
contents: FileStream::fromString('pdf data')
|
|
);
|
|
|
|
// WHEN
|
|
$path = $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($path)->toBeInstanceOf(StorageFilePath::class)
|
|
->and($path->value)->toStartWith(gmdate('Y') . '/');
|
|
});
|
|
|
|
it('rejects a disk that is neither default nor eligible', function (): void {
|
|
// GIVEN
|
|
$gateway = createGateway();
|
|
Storage::fake('unknown');
|
|
$disk = new DiskName('unknown', 'local');
|
|
$sanitizedFile = new SanitizedFile(
|
|
originalName: 'doc.pdf',
|
|
sanitizedName: 'doc.pdf',
|
|
contents: FileStream::fromString('data')
|
|
);
|
|
|
|
// WHEN
|
|
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
|
|
|
|
// THEN
|
|
expect($action)->toThrow(
|
|
UnexpectedStorageException::class,
|
|
'Disk unknown is neither the default nor an eligible disk'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('StorageGatewayImpl getFile', function (): void {
|
|
it('returns null if file does not exist on disk', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('missing/file.txt');
|
|
|
|
// WHEN
|
|
$result = $gateway->getFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($result)->toBeNull();
|
|
});
|
|
|
|
it('returns null if readStream returns false or null', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('exists')->andReturn(true);
|
|
$mockStorage->shouldReceive('readStream')->andReturn(false);
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('corrupt.txt');
|
|
|
|
// WHEN
|
|
$result = $gateway->getFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($result)->toBeNull();
|
|
});
|
|
|
|
it('returns FileStream if file exists on disk', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('existing/file.txt', 'retrieved content');
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('existing/file.txt');
|
|
|
|
// WHEN
|
|
$stream = $gateway->getFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($stream)->toBeInstanceOf(FileStream::class)
|
|
/** @phpstan-ignore method.nonObject */
|
|
->and($stream->getContents())->toBe('retrieved content');
|
|
});
|
|
|
|
it('returns null if exception occurs during file retrieval', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('exists')->andThrow(new \RuntimeException('Connection failed'));
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('any/file.txt');
|
|
|
|
// WHEN
|
|
$result = $gateway->getFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($result)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('StorageGatewayImpl fileExists', function (): void {
|
|
it('returns true if file exists and false otherwise', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('present.txt', 'hello');
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$presentPath = new StorageFilePath('present.txt');
|
|
$absentPath = new StorageFilePath('absent.txt');
|
|
|
|
// WHEN
|
|
$existsPresent = $gateway->fileExists($disk, $presentPath);
|
|
$existsAbsent = $gateway->fileExists($disk, $absentPath);
|
|
|
|
// THEN
|
|
expect($existsPresent)->toBeTrue()
|
|
->and($existsAbsent)->toBeFalse();
|
|
});
|
|
|
|
it('returns false if exception occurs during existence check', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('exists')->andThrow(new \RuntimeException('Disk error'));
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('error.txt');
|
|
|
|
// WHEN
|
|
$exists = $gateway->fileExists($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($exists)->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('StorageGatewayImpl deleteFile', function (): void {
|
|
it('deletes an existing file from disk and returns true', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('to_delete.txt', 'bye');
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('to_delete.txt');
|
|
|
|
// WHEN
|
|
$result = $gateway->deleteFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($result)->toBeTrue()
|
|
->and(Storage::disk('local')->exists('to_delete.txt'))->toBeFalse();
|
|
});
|
|
|
|
it('returns false if exception occurs during file deletion', function (): void {
|
|
// GIVEN
|
|
$mockStorage = \Mockery::mock(Filesystem::class);
|
|
$mockStorage->shouldReceive('delete')->andThrow(new \RuntimeException('Delete failure'));
|
|
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
|
|
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$filePath = new StorageFilePath('locked.txt');
|
|
|
|
// WHEN
|
|
$result = $gateway->deleteFile($disk, $filePath);
|
|
|
|
// THEN
|
|
expect($result)->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('StorageGatewayImpl logging', function (): void {
|
|
it('logs informative messages on storeFile and deleteFile', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
$logger = \Mockery::mock(LoggerInterface::class);
|
|
|
|
$logger->shouldReceive('info')->with('Storing file to disk: local')->once();
|
|
$logger->shouldReceive('info')->with(\Mockery::pattern('/^File stored at path:/'))->once();
|
|
$logger->shouldReceive('info')->with(\Mockery::pattern('/^Deleting file from disk: local/'))->once();
|
|
$logger->shouldReceive('info')->with(\Mockery::pattern('/^File deletion result for disk:.*: success$/'))->once();
|
|
|
|
$gateway = new StorageGatewayImpl(
|
|
config: createGatewayConfig(),
|
|
logger: $logger,
|
|
datePathGenerator: new DateHashPathGenerator(),
|
|
uuidPathGenerator: new UuidPathGenerator()
|
|
);
|
|
|
|
$disk = new DiskName('local', 'local');
|
|
$sanitizedFile = new SanitizedFile('doc.txt', 'doc.txt', FileStream::fromString('logged content'));
|
|
|
|
// WHEN
|
|
$storedPath = $gateway->storeFile($disk, $sanitizedFile);
|
|
$deleted = $gateway->deleteFile($disk, $storedPath);
|
|
|
|
// THEN
|
|
expect($deleted)->toBeTrue();
|
|
});
|
|
|
|
it('logs debug messages on getFile when file is found or missing', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
Storage::disk('local')->put('found.txt', 'exists');
|
|
|
|
$logger = \Mockery::mock(LoggerInterface::class);
|
|
$logger->shouldReceive('debug')->with('File found on disk: local, path: found.txt')->once();
|
|
$logger->shouldReceive('debug')->with('File not found on disk: local, path: not_found.txt')->once();
|
|
|
|
$gateway = new StorageGatewayImpl(
|
|
config: createGatewayConfig(),
|
|
logger: $logger,
|
|
datePathGenerator: new DateHashPathGenerator(),
|
|
uuidPathGenerator: new UuidPathGenerator()
|
|
);
|
|
|
|
$disk = new DiskName('local', 'local');
|
|
$foundPath = new StorageFilePath('found.txt');
|
|
$missingPath = new StorageFilePath('not_found.txt');
|
|
|
|
// WHEN
|
|
$stream = $gateway->getFile($disk, $foundPath);
|
|
$missing = $gateway->getFile($disk, $missingPath);
|
|
|
|
// THEN
|
|
expect($stream)->not->toBeNull()
|
|
->and($missing)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('StorageGatewayImpl binary data roundtrip', function (): void {
|
|
it('preserves exact binary payload without corruption', function (): void {
|
|
// GIVEN
|
|
Storage::fake('local');
|
|
$gateway = createGateway();
|
|
$disk = new DiskName('local', 'local');
|
|
$binaryPayload = random_bytes(64 * 1024); // 64 KB of random binary data
|
|
$sanitizedFile = new SanitizedFile('binary.dat', 'binary.dat', FileStream::fromString($binaryPayload));
|
|
|
|
// WHEN
|
|
$storedPath = $gateway->storeFile($disk, $sanitizedFile);
|
|
$retrievedStream = $gateway->getFile($disk, $storedPath);
|
|
|
|
// THEN
|
|
expect($retrievedStream)->not->toBeNull()
|
|
/** @phpstan-ignore method.nonObject */
|
|
->and($retrievedStream->getSize())->toBe(64 * 1024)
|
|
/** @phpstan-ignore method.nonObject */
|
|
->and($retrievedStream->getContents())->toBe($binaryPayload);
|
|
});
|
|
});
|