$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 $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); }); });