diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a45e7..6f86eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,26 @@ All notable changes to this project will be documented in this file. - This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), - Commits respect [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/). +## [1.0.3] - 2026-11-04 + +### 🚀 Features + +- *(StorageServiceConfig)* Support scoped driver with boot-time underlying driver resolution + +### 🎨 Styling + +- *(tests)* Fix spacing in docblock for allowedExtensions + ## [1.0.2] - 2026-11-04 ### 🚀 Features - *(StorageServiceConfig)* Add prefix handling and validation +### ⚙️ Miscellaneous Tasks + +- *(cliff.toml)* Add spacing before release headings in changelog + ## [1.0.1] - 2026-11-04 ### 🚀 Features diff --git a/composer.json b/composer.json index 070c79e..88867cc 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "uwil/storage-laravel", "description": "Handles file storage", "license": "MIT", - "version": "1.0.2", + "version": "1.0.3", "readme": "https://lab.s-web.fr/ldc-web/projets/storage-laravel/-/blob/main/README.md", "support": { "email": "digital@ldc.fr", diff --git a/src/Config/StorageServiceConfig.php b/src/Config/StorageServiceConfig.php index 82d2191..4ff0afc 100644 --- a/src/Config/StorageServiceConfig.php +++ b/src/Config/StorageServiceConfig.php @@ -19,6 +19,9 @@ final class StorageServiceConfig private ?string $prefix = null; + /** @var array Laravel disk name => physical driver (scoped disks resolved to their parent) */ + private array $realDrivers = []; + /** * @param string[] $eligibleDisks * @param object{'disk':string,'driver':string}[] $laravelExistingDisks @@ -140,6 +143,15 @@ final class StorageServiceConfig $disk->disk, $disk->driver ), $intersectWithDrivers); + + $this->resolveRealDrivers(); + } + + private function resolveRealDrivers(): void + { + foreach ([$this->default, ...$this->eligibles] as $disk) { + $this->realDrivers[$disk->value] = Utils::resolveDriver($disk->value, $disk->driver); + } } public function getPrefix(): ?string @@ -191,4 +203,15 @@ final class StorageServiceConfig { return $this->allowedExtensions; } + + public function getRealDriver(DiskName $disk): string + { + return $this->realDrivers[$disk->value] + ?? throw new UnexpectedStorageException("Disk {$disk->value} is neither the default nor an eligible disk"); + } + + public function isS3Driver(DiskName $disk): bool + { + return $this->getRealDriver($disk) === 's3'; + } } diff --git a/src/Lib/StorageGatewayImpl.php b/src/Lib/StorageGatewayImpl.php index dbcc6d5..dfdfcfb 100644 --- a/src/Lib/StorageGatewayImpl.php +++ b/src/Lib/StorageGatewayImpl.php @@ -7,6 +7,7 @@ namespace Uwil\Storage\Lib; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Support\Facades\Storage; use Psr\Log\LoggerInterface; +use Uwil\Storage\Config\StorageServiceConfig; use Uwil\Storage\Contracts\PathGenerator; use Uwil\Storage\Contracts\StorageGateway; use Uwil\Storage\Exceptions\StorageException; @@ -21,6 +22,7 @@ use Uwil\Storage\Lib\ValueObjects\StorageFilePath; readonly class StorageGatewayImpl implements StorageGateway { public function __construct( + private StorageServiceConfig $config, private LoggerInterface $logger, private DateHashPathGenerator $datePathGenerator, private UuidPathGenerator $uuidPathGenerator @@ -123,12 +125,12 @@ readonly class StorageGatewayImpl implements StorageGateway private function pathGeneratorFor(DiskName $disk): PathGenerator { - return match ($disk->driver) { - 'local' => $this->datePathGenerator, - 'ftp' => $this->datePathGenerator, - 'sftp' => $this->datePathGenerator, - 's3' => $this->uuidPathGenerator, - default => throw new UnexpectedStorageException("Unsupported disk driver: {$disk->driver} on disk {$disk->value}"), + $realDriver = $this->config->getRealDriver($disk); + + return match ($realDriver) { + 'local', 'ftp', 'sftp' => $this->datePathGenerator, + 's3' => $this->uuidPathGenerator, + default => throw new UnexpectedStorageException("Unsupported disk driver: {$realDriver} on disk {$disk->value}"), }; } diff --git a/src/Lib/Utils.php b/src/Lib/Utils.php index be6193c..3e3c08b 100644 --- a/src/Lib/Utils.php +++ b/src/Lib/Utils.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Uwil\Storage\Lib; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; use Uwil\Storage\Config\StorageServiceConfig; +use Uwil\Storage\Exceptions\UnexpectedStorageException; class Utils { @@ -14,6 +16,45 @@ class Utils // Abstract class. } + public static function resolveDriver(string $disk, ?string $driver = null): string + { + $currentDisk = $disk; + + if ($driver === null) { + /** @var mixed $raw */ + $raw = Config::get("filesystems.disks.{$currentDisk}.driver"); + $driver = is_string($raw) ? $raw : ''; + } + + $visited = [$currentDisk]; + + while ($driver === 'scoped') { + /** @var mixed $parentDisk */ + $parentDisk = Config::get("filesystems.disks.{$currentDisk}.disk"); + + if (! is_string($parentDisk) || $parentDisk === '') { + throw new UnexpectedStorageException("Scoped disk '{$currentDisk}' is missing a valid parent 'disk' configuration."); + } + + if (in_array($parentDisk, $visited, true)) { + throw new UnexpectedStorageException("Circular reference detected in scoped disk '{$currentDisk}'."); + } + + /** @var mixed $parentDriver */ + $parentDriver = Config::get("filesystems.disks.{$parentDisk}.driver"); + + if (! is_string($parentDriver) || $parentDriver === '') { + throw new UnexpectedStorageException("Parent disk '{$parentDisk}' of scoped disk '{$currentDisk}' has no valid driver configured."); + } + + $visited[] = $parentDisk; + $driver = $parentDriver; + $currentDisk = $parentDisk; + } + + return $driver; + } + public static function diskExists(string | \UnitEnum $disk): bool { try { diff --git a/src/Lib/ValueObjects/DiskName.php b/src/Lib/ValueObjects/DiskName.php index 03f5cb0..3efff0c 100644 --- a/src/Lib/ValueObjects/DiskName.php +++ b/src/Lib/ValueObjects/DiskName.php @@ -9,7 +9,7 @@ use Uwil\Storage\Lib\Utils; final readonly class DiskName implements \Stringable { - public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3']; + public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3', 'scoped']; public string $value; diff --git a/src/StorageServiceProvider.php b/src/StorageServiceProvider.php index 7052475..79ab7c1 100644 --- a/src/StorageServiceProvider.php +++ b/src/StorageServiceProvider.php @@ -92,6 +92,7 @@ class StorageServiceProvider extends ServiceProvider $this->app->bind( StorageGateway::class, fn (Application $app) => new StorageGatewayImpl( + $app->make(StorageServiceConfig::class), $app->make(LoggerInterface::class), $app->make(DateHashPathGenerator::class), $app->make(UuidPathGenerator::class), diff --git a/tests/Unit/Config/StorageServiceConfigTest.php b/tests/Unit/Config/StorageServiceConfigTest.php index cfef058..da11901 100644 --- a/tests/Unit/Config/StorageServiceConfigTest.php +++ b/tests/Unit/Config/StorageServiceConfigTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Uwil\Storage\Tests\Unit\Config; +use Illuminate\Support\Facades\Storage; use Uwil\Storage\Config\StorageServiceConfig; use Uwil\Storage\Exceptions\UnexpectedStorageException; use Uwil\Storage\Lib\ValueObjects\DiskName; @@ -415,3 +416,97 @@ describe('StorageServiceConfig prefix validation and retrieval', function (): vo 'double slash' => 'media//sub', ]); }); + +describe('StorageServiceConfig real driver resolution', function (): void { + it('resolves direct drivers of default and eligible disks', function (): void { + // GIVEN + Storage::fake('s3'); + + // WHEN + $config = new StorageServiceConfig( + defaultDisk: 'local', + eligibleDisks: ['local', 's3'], + laravelExistingDisks: [ + (object) ['disk' => 'local', 'driver' => 'local'], + (object) ['disk' => 's3', 'driver' => 's3'], + ], + allowedExtensions: ['pdf'] + ); + $localDisk = new DiskName('local', 'local'); + $s3Disk = new DiskName('s3', 's3'); + + // THEN + expect($config->getRealDriver($localDisk))->toBe('local') + ->and($config->isS3Driver($localDisk))->toBeFalse() + ->and($config->getRealDriver($s3Disk))->toBe('s3') + ->and($config->isS3Driver($s3Disk))->toBeTrue(); + }); + + it('resolves the underlying driver of a scoped disk at boot', function (): void { + // GIVEN + config()->set('filesystems.disks.s3_parent', ['driver' => 's3']); + config()->set('filesystems.disks.media', [ + 'driver' => 'scoped', + 'disk' => 's3_parent', + 'prefix' => 'media', + ]); + Storage::fake('media'); + + // WHEN + $config = new StorageServiceConfig( + defaultDisk: 'media', + eligibleDisks: ['media'], + laravelExistingDisks: [(object) ['disk' => 'media', 'driver' => 'scoped']], + allowedExtensions: ['pdf'] + ); + $media = new DiskName('media', 'scoped'); + + // THEN + expect($config->getRealDriver($media))->toBe('s3') + ->and($config->isS3Driver($media))->toBeTrue(); + }); + + it('fails at boot when a scoped disk is misconfigured', function (): void { + // GIVEN + config()->set('filesystems.disks.broken', ['driver' => 'scoped']); + Storage::fake('broken'); + + // WHEN + $action = fn () => new StorageServiceConfig( + defaultDisk: 'local', + eligibleDisks: ['local', 'broken'], + laravelExistingDisks: [ + (object) ['disk' => 'local', 'driver' => 'local'], + (object) ['disk' => 'broken', 'driver' => 'scoped'], + ], + allowedExtensions: ['pdf'] + ); + + // THEN + expect($action)->toThrow( + UnexpectedStorageException::class, + "Scoped disk 'broken' is missing a valid parent 'disk' configuration." + ); + }); + + it('throws when asking the driver of a disk that is neither default nor eligible', function (): void { + // GIVEN + $config = new StorageServiceConfig( + defaultDisk: 'local', + eligibleDisks: ['local'], + laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']], + allowedExtensions: ['pdf'] + ); + Storage::fake('other'); + $other = new DiskName('other', 'local'); + + // WHEN + $action = fn () => $config->getRealDriver($other); + + // THEN + expect($action)->toThrow( + UnexpectedStorageException::class, + 'Disk other is neither the default nor an eligible disk' + ); + }); +}); diff --git a/tests/Unit/Lib/FileSanitizerImplTest.php b/tests/Unit/Lib/FileSanitizerImplTest.php index 5df915b..fec054d 100644 --- a/tests/Unit/Lib/FileSanitizerImplTest.php +++ b/tests/Unit/Lib/FileSanitizerImplTest.php @@ -14,7 +14,7 @@ use Uwil\Storage\Lib\ValueObjects\SanitizedFile; covers(FileSanitizerImpl::class); /** - * @param array $allowedExtensions + * @param array $allowedExtensions */ function createConfig(array $allowedExtensions = ['jpg', 'png', 'pdf', 'txt']): StorageServiceConfig { diff --git a/tests/Unit/Lib/StorageGatewayImplTest.php b/tests/Unit/Lib/StorageGatewayImplTest.php index 0af150e..f643da5 100644 --- a/tests/Unit/Lib/StorageGatewayImplTest.php +++ b/tests/Unit/Lib/StorageGatewayImplTest.php @@ -4,8 +4,11 @@ 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; @@ -14,14 +17,36 @@ use Uwil\Storage\Lib\ValueObjects\DiskName; use Uwil\Storage\Lib\ValueObjects\FileStream; use Uwil\Storage\Lib\ValueObjects\SanitizedFile; use Uwil\Storage\Lib\ValueObjects\StorageFilePath; -use Illuminate\Contracts\Filesystem\Filesystem; -use Psr\Log\LoggerInterface; covers(StorageGatewayImpl::class); -function createGateway(): StorageGatewayImpl +/** + * @param array $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() @@ -52,7 +77,7 @@ describe('StorageGatewayImpl storeFile', function (): void { it('stores a file onto an s3 disk using UuidPathGenerator', function (): void { // GIVEN Storage::fake('s3'); - $gateway = createGateway(); + $gateway = createGateway(['s3' => 's3']); $disk = new DiskName('s3', 's3'); $sanitizedFile = new SanitizedFile( originalName: 'picture.png', @@ -74,9 +99,9 @@ describe('StorageGatewayImpl storeFile', function (): void { $mockStorage = \Mockery::mock(Filesystem::class); $mockStorage->shouldReceive('put')->andReturn(false); + $gateway = createGateway(); Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage); - $gateway = createGateway(); $disk = new DiskName('local', 'local'); $sanitizedFile = new SanitizedFile( originalName: 'test.txt', @@ -96,9 +121,9 @@ describe('StorageGatewayImpl storeFile', function (): void { $mockStorage = \Mockery::mock(Filesystem::class); $mockStorage->shouldReceive('put')->andThrow(new \RuntimeException('Network drop')); + $gateway = createGateway(); Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage); - $gateway = createGateway(); $disk = new DiskName('local', 'local'); $sanitizedFile = new SanitizedFile( originalName: 'test.txt', @@ -119,9 +144,9 @@ describe('StorageGatewayImpl storeFile', function (): void { $mockStorage = \Mockery::mock(Filesystem::class); $mockStorage->shouldReceive('put')->andThrow($directException); + $gateway = createGateway(); Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage); - $gateway = createGateway(); $disk = new DiskName('local', 'local'); $sanitizedFile = new SanitizedFile( originalName: 'test.txt', @@ -143,9 +168,9 @@ describe('StorageGatewayImpl storeFile', function (): void { 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); - $gateway = createGateway(); $disk = new DiskName($driver, $driver); $sanitizedFile = new SanitizedFile( originalName: 'test.txt', @@ -163,6 +188,83 @@ describe('StorageGatewayImpl storeFile', function (): void { '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 { @@ -320,6 +422,7 @@ describe('StorageGatewayImpl logging', function (): void { $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() @@ -346,6 +449,7 @@ describe('StorageGatewayImpl logging', function (): void { $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() diff --git a/tests/Unit/Lib/UtilsTest.php b/tests/Unit/Lib/UtilsTest.php index c412aa3..d479199 100644 --- a/tests/Unit/Lib/UtilsTest.php +++ b/tests/Unit/Lib/UtilsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Uwil\Storage\Tests\Unit\Lib; use Uwil\Storage\Config\StorageServiceConfig; +use Uwil\Storage\Exceptions\UnexpectedStorageException; use Uwil\Storage\Lib\Utils; covers(Utils::class); @@ -99,3 +100,108 @@ describe('Utils isExtensionAllowed', function (): void { expect($fallback)->toBeTrue(); }); }); + +describe('Utils resolveDriver', function (): void { + it('returns direct driver for non-scoped disk', function (): void { + // GIVEN + config()->set('filesystems.disks.my_s3', ['driver' => 's3']); + + // WHEN + $driver = Utils::resolveDriver('my_s3'); + + // THEN + expect($driver)->toBe('s3'); + }); + + it('resolves parent driver for scoped disk from string', function (): void { + // GIVEN + config()->set('filesystems.disks.parent_local', ['driver' => 'local']); + config()->set('filesystems.disks.scoped_disk', [ + 'driver' => 'scoped', + 'disk' => 'parent_local', + ]); + + // WHEN + $driver = Utils::resolveDriver('scoped_disk'); + + // THEN + expect($driver)->toBe('local'); + }); + + it('resolves parent driver for scoped disk when driver is given explicitly', function (): void { + // GIVEN + config()->set('filesystems.disks.parent_s3', ['driver' => 's3']); + config()->set('filesystems.disks.scoped_s3', [ + 'driver' => 'scoped', + 'disk' => 'parent_s3', + ]); + + // WHEN + $driver = Utils::resolveDriver('scoped_s3', 'scoped'); + + // THEN + expect($driver)->toBe('s3'); + }); + + it('returns explicit non-scoped driver without reading config', function (): void { + // WHEN + $driver = Utils::resolveDriver('not_configured', 'sftp'); + + // THEN + expect($driver)->toBe('sftp'); + }); + + it('throws exception when parent disk has no driver configured', function (): void { + // GIVEN + config()->set('filesystems.disks.scoped_orphan', [ + 'driver' => 'scoped', + 'disk' => 'missing_parent', + ]); + + // WHEN + $action = fn () => Utils::resolveDriver('scoped_orphan'); + + // THEN + expect($action)->toThrow( + UnexpectedStorageException::class, + "Parent disk 'missing_parent' of scoped disk 'scoped_orphan' has no valid driver configured." + ); + }); + + it('throws exception when scoped disk has missing parent disk', function (): void { + // GIVEN + config()->set('filesystems.disks.scoped_invalid', [ + 'driver' => 'scoped', + ]); + + // WHEN + $action = fn () => Utils::resolveDriver('scoped_invalid'); + + // THEN + expect($action)->toThrow( + UnexpectedStorageException::class, + "Scoped disk 'scoped_invalid' is missing a valid parent 'disk' configuration." + ); + }); + + it('throws exception when scoped disk has circular reference', function (): void { + // GIVEN + config()->set('filesystems.disks.scoped_x', [ + 'driver' => 'scoped', + 'disk' => 'scoped_y', + ]); + config()->set('filesystems.disks.scoped_y', [ + 'driver' => 'scoped', + 'disk' => 'scoped_x', + ]); + + // WHEN + $action = fn () => Utils::resolveDriver('scoped_x'); + + // THEN + expect($action)->toThrow( + UnexpectedStorageException::class, + "Circular reference detected in scoped disk 'scoped_y'." + ); + }); +}); diff --git a/tests/Unit/Lib/ValueObjects/DiskNameTest.php b/tests/Unit/Lib/ValueObjects/DiskNameTest.php index be63e6e..f80f006 100644 --- a/tests/Unit/Lib/ValueObjects/DiskNameTest.php +++ b/tests/Unit/Lib/ValueObjects/DiskNameTest.php @@ -22,10 +22,11 @@ describe('DiskName instantiation', function (): void { ->and($vo->value)->toBe('local') ->and($vo->driver)->toBe($driver); })->with([ - 'local driver' => 'local', - 'ftp driver' => 'ftp', - 'sftp driver' => 'sftp', - 's3 driver' => 's3', + 'local driver' => 'local', + 'ftp driver' => 'ftp', + 'sftp driver' => 'sftp', + 's3 driver' => 's3', + 'scoped driver' => 'scoped', ]); it('can instantiate with a BackedEnum value', function (): void {