From fcd1f33bfbacbf1bf233deba1d72fc31517d3c73 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Tue, 27 Oct 2026 13:36:53 +0600 Subject: [PATCH 1/4] Add a Contract for Storage Services --- src/Phaseolies/Launchers/FacadeLauncher.php | 4 +- src/Phaseolies/Support/Facades/Storage.php | 4 +- .../Storage/Contracts/StorageInterface.php | 24 ++++++++++ .../Support/Storage/IFileSystem.php | 46 ++++++++++++++++++- .../Support/Storage/LocalFileSystem.php | 2 +- .../Support/Storage/PublicFileSystem.php | 2 +- .../Support/Storage/StorageFileService.php | 19 ++------ tests/Storage/FileUploadTest.php | 15 ++++++ tests/Storage/StorageServiceTest.php | 34 ++++++++++++++ 9 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 src/Phaseolies/Support/Storage/Contracts/StorageInterface.php create mode 100644 tests/Storage/StorageServiceTest.php diff --git a/src/Phaseolies/Launchers/FacadeLauncher.php b/src/Phaseolies/Launchers/FacadeLauncher.php index 5dbb30c9..4d04336b 100644 --- a/src/Phaseolies/Launchers/FacadeLauncher.php +++ b/src/Phaseolies/Launchers/FacadeLauncher.php @@ -7,6 +7,7 @@ use Phaseolies\Support\ViteManager; use Phaseolies\Support\StringService; use Phaseolies\Support\Storage\StorageFileService; +use Phaseolies\Support\Storage\Contracts\StorageInterface; use Phaseolies\Signals\SignalBus; use Phaseolies\Support\Session; use Phaseolies\Support\Mail\MailService; @@ -79,7 +80,8 @@ public function register() // Bind the 'storage' service to a singleton instance of the Storage class. // This handles file uploads. - $this->app->singleton('storage', StorageFileService::class); + $this->app->singleton(StorageInterface::class, StorageFileService::class); + $this->app->alias(StorageInterface::class, 'storage'); // Bind the 'vite' service to a singleton instance of the Vite manager. // This resolves dev-server and built frontend assets. diff --git a/src/Phaseolies/Support/Facades/Storage.php b/src/Phaseolies/Support/Facades/Storage.php index 05ab8050..0b532ba0 100644 --- a/src/Phaseolies/Support/Facades/Storage.php +++ b/src/Phaseolies/Support/Facades/Storage.php @@ -5,10 +5,10 @@ use Phaseolies\Facade\BaseFacade; /** - * @method static mixed disk(?string $name = null) + * @method static \Phaseolies\Support\Storage\IFileSystem disk(string $name) * @method static ?string getDiskPath(string $disk) * - * @see \Phaseolies\Support\Storage\StorageFileService + * @see \Phaseolies\Support\Storage\Contracts\StorageInterface */ class Storage extends BaseFacade { diff --git a/src/Phaseolies/Support/Storage/Contracts/StorageInterface.php b/src/Phaseolies/Support/Storage/Contracts/StorageInterface.php new file mode 100644 index 00000000..cf493193 --- /dev/null +++ b/src/Phaseolies/Support/Storage/Contracts/StorageInterface.php @@ -0,0 +1,24 @@ +resolvePath($path); diff --git a/src/Phaseolies/Support/Storage/PublicFileSystem.php b/src/Phaseolies/Support/Storage/PublicFileSystem.php index 6a6cc40b..c056204a 100644 --- a/src/Phaseolies/Support/Storage/PublicFileSystem.php +++ b/src/Phaseolies/Support/Storage/PublicFileSystem.php @@ -56,7 +56,7 @@ public function get(string $path): ?string * * @throws \Phaseolies\Support\Storage\FileNotFoundException */ - public function content($path) + public function content(string $path): string { $fullPath = $this->resolvePath($path); diff --git a/src/Phaseolies/Support/Storage/StorageFileService.php b/src/Phaseolies/Support/Storage/StorageFileService.php index cf6b27d6..36588919 100644 --- a/src/Phaseolies/Support/Storage/StorageFileService.php +++ b/src/Phaseolies/Support/Storage/StorageFileService.php @@ -2,18 +2,9 @@ namespace Phaseolies\Support\Storage; -use Phaseolies\Support\Storage\LocalFileSystem; -use Phaseolies\Support\Storage\DiskNotFoundException; +use Phaseolies\Support\Storage\Contracts\StorageInterface; -/** - * @method static store(string $path, File $file, ?string $fileName = null): bool - * @method static storeageBasePath(): string - * @method static get(string $path): ?string - * @method static content($path) - * @method static delete(string|array $path): bool - * @see \Phaseolies\Support\Storage\StorageFileService - */ -class StorageFileService +class StorageFileService implements StorageInterface { /** * The array of resolved filesystem drivers. @@ -25,10 +16,10 @@ class StorageFileService /** * Get a filesystem instance. * - * @param string|null $name - * @return \Phaseolies\Support\Storage\IFileSystem + * @param string $name + * @return IFileSystem */ - public function disk($name = null) + public function disk(string $name): IFileSystem { $path = $this->getDiskPath($name); diff --git a/tests/Storage/FileUploadTest.php b/tests/Storage/FileUploadTest.php index b7e4c9f9..6b9bcec0 100644 --- a/tests/Storage/FileUploadTest.php +++ b/tests/Storage/FileUploadTest.php @@ -128,6 +128,8 @@ public static function isUploadedFile(string $filename): bool use Phaseolies\Support\Storage\LocalFileSystem; use Phaseolies\Support\Storage\PublicFileSystem; use Phaseolies\Support\Storage\StorageFileService; + use Phaseolies\Support\Storage\Contracts\StorageInterface; + use Phaseolies\Support\Storage\IFileSystem; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Unit\Storage\Support\UploadTestConfig; @@ -190,6 +192,19 @@ public function testStorageFacadeResolvesLocalDiskAndStoresUpload(): void $this->assertSame('local disk content', (string) file_get_contents($this->localRoot . '/documents/stored.txt')); } + public function testStorageImplementationConformsToTheStorageContract(): void + { + $storage = new StorageFileService(); + + $this->assertInstanceOf(StorageInterface::class, $storage); + $this->assertSame($this->localRoot, $storage->getDiskPath('local')); + + $disk = $storage->disk('local'); + + $this->assertInstanceOf(IFileSystem::class, $disk); + $this->assertInstanceOf(LocalFileSystem::class, $disk); + } + public function testStorageFacadeResolvesPublicDiskAndStoresUpload(): void { $disk = Storage::disk('public'); diff --git a/tests/Storage/StorageServiceTest.php b/tests/Storage/StorageServiceTest.php new file mode 100644 index 00000000..691c2531 --- /dev/null +++ b/tests/Storage/StorageServiceTest.php @@ -0,0 +1,34 @@ +getMockBuilder(Application::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $containerConstructor = (new ReflectionClass(Container::class))->getConstructor(); + $containerConstructor->invoke($app); + + (new FacadeLauncher($app))->register(); + + $contractStorage = $app->get(StorageInterface::class); + $legacyStorage = $app->get('storage'); + + $this->assertInstanceOf(StorageInterface::class, $contractStorage); + $this->assertInstanceOf(StorageFileService::class, $contractStorage); + $this->assertSame($contractStorage, $legacyStorage); + } +} From 8450e4d231acbe47dac4e5493792ecdfd63c6dcf Mon Sep 17 00:00:00 2001 From: techmahedy Date: Fri, 30 Oct 2026 10:27:44 +0600 Subject: [PATCH 2/4] fix(storage): rename storeageBasePath to storageBasePath in IFileSystem --- src/Phaseolies/Launchers/FacadeLauncher.php | 5 +- src/Phaseolies/Support/Storage/FileSystem.php | 15 ++- .../Support/Storage/IFileSystem.php | 2 +- .../Support/Storage/LocalFileSystem.php | 10 -- .../Support/Storage/PublicFileSystem.php | 10 -- tests/Storage/FileSystemTest.php | 30 ++++- tests/Storage/FileUploadTest.php | 43 ++++++++ tests/Storage/LocalFileSystemTest.php | 15 ++- tests/Storage/PublicFileSystemTest.php | 16 +++ tests/Storage/StorageServiceTest.php | 103 ++++++++++++++++-- 10 files changed, 213 insertions(+), 36 deletions(-) diff --git a/src/Phaseolies/Launchers/FacadeLauncher.php b/src/Phaseolies/Launchers/FacadeLauncher.php index 4cfb5c55..2b5bbc66 100644 --- a/src/Phaseolies/Launchers/FacadeLauncher.php +++ b/src/Phaseolies/Launchers/FacadeLauncher.php @@ -89,8 +89,9 @@ public function register() return new UrlGenerator(env('APP_URL') ?? config('app.url')); }); - // Bind the 'storage' service to a singleton instance of the Storage class. - // This handles file uploads. + // Bind the StorageInterface contract to a singleton StorageFileService and + // keep the legacy 'storage' key as an alias of it, so both resolve to the + // same instance. This handles file uploads. $this->app->singleton(StorageInterface::class, StorageFileService::class); $this->app->alias(StorageInterface::class, 'storage'); diff --git a/src/Phaseolies/Support/Storage/FileSystem.php b/src/Phaseolies/Support/Storage/FileSystem.php index 6d8a1617..0f19fff5 100644 --- a/src/Phaseolies/Support/Storage/FileSystem.php +++ b/src/Phaseolies/Support/Storage/FileSystem.php @@ -189,15 +189,26 @@ public function destinationFile(string $path, $fileName): string } /** - * return project base path in os + * Get the filesystem base path. * * @return string */ - public function storeageBasePath(): string + public function storageBasePath(): string { return $this->filePath; } + /** + * Get the filesystem base path. + * + * @deprecated Misspelled name kept for backward compatibility, use storageBasePath() instead. + * @return string + */ + public function storeageBasePath(): string + { + return $this->storageBasePath(); + } + /** * Determine if the given path is a file. * diff --git a/src/Phaseolies/Support/Storage/IFileSystem.php b/src/Phaseolies/Support/Storage/IFileSystem.php index be5cf536..bf828ea2 100644 --- a/src/Phaseolies/Support/Storage/IFileSystem.php +++ b/src/Phaseolies/Support/Storage/IFileSystem.php @@ -21,7 +21,7 @@ public function store(string $path, File $file, ?string $fileName = null): bool; * * @return string */ - public function storeageBasePath(): string; + public function storageBasePath(): string; /** * Get the absolute path for a stored file. diff --git a/src/Phaseolies/Support/Storage/LocalFileSystem.php b/src/Phaseolies/Support/Storage/LocalFileSystem.php index 857541ba..17085119 100644 --- a/src/Phaseolies/Support/Storage/LocalFileSystem.php +++ b/src/Phaseolies/Support/Storage/LocalFileSystem.php @@ -19,16 +19,6 @@ public function store(string $path, File $file, ?string $fileName = null): bool return $this->put($this->isDirectoryExists($path), $file, $fileName); } - /** - * return project base path in os - * - * @return string - */ - public function storeageBasePath(): string - { - return $this->filePath; - } - /** * Get the contents of a file. * diff --git a/src/Phaseolies/Support/Storage/PublicFileSystem.php b/src/Phaseolies/Support/Storage/PublicFileSystem.php index c056204a..4a2d285a 100644 --- a/src/Phaseolies/Support/Storage/PublicFileSystem.php +++ b/src/Phaseolies/Support/Storage/PublicFileSystem.php @@ -21,16 +21,6 @@ public function store(string $path, File $file, ?string $fileName = null): bool return $this->put($this->isDirectoryExists($path), $file, $fileName); } - /** - * return project base path in os - * - * @return string - */ - public function storeageBasePath(): string - { - return $this->filePath; - } - /** * Get the contents of a file. * diff --git a/tests/Storage/FileSystemTest.php b/tests/Storage/FileSystemTest.php index cde83abf..667503b5 100644 --- a/tests/Storage/FileSystemTest.php +++ b/tests/Storage/FileSystemTest.php @@ -101,9 +101,35 @@ public function testDestinationFileBuildsCorrectPath() $this->assertEquals($this->tmpDir . '/uploads/image.jpg', $result); } - public function testStoreageBasePathReturnsFilePath() + public function testStorageBasePathReturnsFilePath() { - $this->assertEquals($this->tmpDir, $this->fs->storeageBasePath()); + $this->assertSame($this->tmpDir, $this->fs->storageBasePath()); + } + + public function testDeprecatedStoreageBasePathAliasReturnsSameValue() + { + $this->assertSame($this->fs->storageBasePath(), $this->fs->storeageBasePath()); + } + + public function testDeprecatedAliasDelegatesToStorageBasePath() + { + $fs = new class('/original') extends FileSystem { + public function storageBasePath(): string + { + return '/overridden'; + } + }; + + $this->assertSame('/overridden', $fs->storeageBasePath()); + } + + public function testDeprecatedAliasIsMarkedDeprecated() + { + $doc = (new \ReflectionMethod(FileSystem::class, 'storeageBasePath'))->getDocComment(); + + $this->assertIsString($doc); + $this->assertStringContainsString('@deprecated', $doc); + $this->assertStringContainsString('storageBasePath()', $doc); } public function testIsDirectoryExistsCreatesDirectoryIfMissing() diff --git a/tests/Storage/FileUploadTest.php b/tests/Storage/FileUploadTest.php index 6b9bcec0..79a4cc85 100644 --- a/tests/Storage/FileUploadTest.php +++ b/tests/Storage/FileUploadTest.php @@ -205,6 +205,49 @@ public function testStorageImplementationConformsToTheStorageContract(): void $this->assertInstanceOf(LocalFileSystem::class, $disk); } + public function testDiskResolvesPublicDiskAsFileSystemContract(): void + { + $disk = (new StorageFileService())->disk('public'); + + $this->assertInstanceOf(IFileSystem::class, $disk); + $this->assertInstanceOf(PublicFileSystem::class, $disk); + } + + public function testDiskBasePathMatchesConfiguredRootThroughNewAndDeprecatedNames(): void + { + $storage = new StorageFileService(); + + $this->assertSame($this->localRoot, $storage->disk('local')->storageBasePath()); + $this->assertSame($this->localRoot, $storage->disk('local')->storeageBasePath()); + $this->assertSame($this->publicRoot, $storage->disk('public')->storageBasePath()); + $this->assertSame($this->publicRoot, $storage->disk('public')->storeageBasePath()); + } + + public function testDiskThrowsForUnsupportedDiskName(): void + { + $this->expectException(\Phaseolies\Support\Storage\DiskNotFoundException::class); + $this->expectExceptionMessage('Unsupported s3'); + + (new StorageFileService())->disk('s3'); + } + + public function testGetDiskPathReturnsNullForUnconfiguredDisk(): void + { + $this->assertNull((new StorageFileService())->getDiskPath('missing-disk')); + } + + public function testDiskContractSupportsStoreGetContentAndDeleteRoundTrip(): void + { + $disk = (new StorageFileService())->disk('local'); + $file = $this->makeUploadedStyleFile('round.txt', 'round trip'); + + $this->assertTrue($disk->store('docs', $file, 'round.txt')); + $this->assertSame($this->localRoot . '/docs/round.txt', $disk->get('docs/round.txt')); + $this->assertSame('round trip', $disk->content('docs/round.txt')); + $this->assertTrue($disk->delete('docs/round.txt')); + $this->assertNull($disk->get('docs/round.txt')); + } + public function testStorageFacadeResolvesPublicDiskAndStoresUpload(): void { $disk = Storage::disk('public'); diff --git a/tests/Storage/LocalFileSystemTest.php b/tests/Storage/LocalFileSystemTest.php index 65dcab8f..21892120 100644 --- a/tests/Storage/LocalFileSystemTest.php +++ b/tests/Storage/LocalFileSystemTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Storage; +use Phaseolies\Support\Storage\IFileSystem; use Phaseolies\Support\Storage\LocalFileSystem; use Phaseolies\Support\Storage\FileNotFoundException; use Phaseolies\Support\File; @@ -116,9 +117,19 @@ public function testDeleteMultipleFilesReturnsFalseWhenOneIsMissing() $this->assertFalse($result); } - public function testStoreageBasePathReturnsConfiguredPath() + public function testStorageBasePathReturnsConfiguredPath() { - $this->assertEquals($this->tmpDir, $this->fs->storeageBasePath()); + $this->assertSame($this->tmpDir, $this->fs->storageBasePath()); + } + + public function testDeprecatedStoreageBasePathAliasStillWorks() + { + $this->assertSame($this->tmpDir, $this->fs->storeageBasePath()); + } + + public function testImplementsFileSystemContract() + { + $this->assertInstanceOf(IFileSystem::class, $this->fs); } public function testStoreMovesFileToCorrectPath() diff --git a/tests/Storage/PublicFileSystemTest.php b/tests/Storage/PublicFileSystemTest.php index 4f768df6..d364564a 100644 --- a/tests/Storage/PublicFileSystemTest.php +++ b/tests/Storage/PublicFileSystemTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Storage; +use Phaseolies\Support\Storage\IFileSystem; use Phaseolies\Support\Storage\PublicFileSystem; use PHPUnit\Framework\TestCase; @@ -105,4 +106,19 @@ public function testDeleteRejectsPathTraversal(): void $this->fs->delete('../../etc/passwd'); } + public function testStorageBasePathReturnsConfiguredPath() + { + $this->assertSame($this->tmpDir, $this->fs->storageBasePath()); + } + + public function testDeprecatedStoreageBasePathAliasStillWorks() + { + $this->assertSame($this->tmpDir, $this->fs->storeageBasePath()); + } + + public function testImplementsFileSystemContract() + { + $this->assertInstanceOf(IFileSystem::class, $this->fs); + } + } diff --git a/tests/Storage/StorageServiceTest.php b/tests/Storage/StorageServiceTest.php index 691c2531..04a7faf6 100644 --- a/tests/Storage/StorageServiceTest.php +++ b/tests/Storage/StorageServiceTest.php @@ -6,24 +6,33 @@ use Phaseolies\DI\Container; use Phaseolies\Launchers\FacadeLauncher; use Phaseolies\Support\Storage\Contracts\StorageInterface; +use Phaseolies\Support\Storage\FileSystem; +use Phaseolies\Support\Storage\IFileSystem; +use Phaseolies\Support\Storage\LocalFileSystem; +use Phaseolies\Support\Storage\PublicFileSystem; use Phaseolies\Support\Storage\StorageFileService; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionClass; +use ReflectionMethod; class StorageServiceTest extends TestCase { - public function testApplicationBindsTheStorageContractAndLegacyServiceNameToTheSameSingleton(): void + private function bootedApplication(): Application { - $app = $this->getMockBuilder(Application::class) - ->disableOriginalConstructor() - ->onlyMethods([]) - ->getMock(); + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); - $containerConstructor = (new ReflectionClass(Container::class))->getConstructor(); - $containerConstructor->invoke($app); + (new ReflectionClass(Container::class))->getConstructor()->invoke($app); (new FacadeLauncher($app))->register(); + return $app; + } + + public function testApplicationBindsTheStorageContractAndLegacyServiceNameToTheSameSingleton(): void + { + $app = $this->bootedApplication(); + $contractStorage = $app->get(StorageInterface::class); $legacyStorage = $app->get('storage'); @@ -31,4 +40,84 @@ public function testApplicationBindsTheStorageContractAndLegacyServiceNameToTheS $this->assertInstanceOf(StorageFileService::class, $contractStorage); $this->assertSame($contractStorage, $legacyStorage); } + + public function testStorageSingletonIsStableAcrossRepeatedResolution(): void + { + $app = $this->bootedApplication(); + + $first = $app->get('storage'); + $second = $app->get(StorageInterface::class); + $third = $app->get('storage'); + + $this->assertSame($first, $second); + $this->assertSame($second, $third); + } + + public function testStorageContractDeclaresStrictlyTypedDiskAndDiskPath(): void + { + $disk = new ReflectionMethod(StorageInterface::class, 'disk'); + $param = $disk->getParameters()[0]; + + $this->assertSame('string', $param->getType()->getName()); + $this->assertFalse($param->getType()->allowsNull()); + $this->assertFalse($param->isOptional()); + $this->assertSame(IFileSystem::class, $disk->getReturnType()->getName()); + + $path = new ReflectionMethod(StorageInterface::class, 'getDiskPath'); + + $this->assertSame('string', $path->getParameters()[0]->getType()->getName()); + $this->assertSame('string', $path->getReturnType()->getName()); + $this->assertTrue($path->getReturnType()->allowsNull()); + } + + public function testDiskRejectsNullBecauseTheNameIsRequired(): void + { + $this->expectException(\TypeError::class); + + (new StorageFileService())->disk(null); + } + + public function testFileSystemContractDeclaresTheSharedDiskApi(): void + { + $methods = array_map( + fn(ReflectionMethod $m) => $m->getName(), + (new ReflectionClass(IFileSystem::class))->getMethods() + ); + sort($methods); + + $this->assertSame(['content', 'delete', 'get', 'storageBasePath', 'store'], $methods); + } + + public function testFileSystemContractDoesNotExposeTheMisspelledMethod(): void + { + $this->assertFalse((new ReflectionClass(IFileSystem::class))->hasMethod('storeageBasePath')); + } + + public function testFileSystemContractTypesContentAndBasePath(): void + { + $content = new ReflectionMethod(IFileSystem::class, 'content'); + + $this->assertSame('string', $content->getParameters()[0]->getType()->getName()); + $this->assertSame('string', $content->getReturnType()->getName()); + $this->assertSame('string', (new ReflectionMethod(IFileSystem::class, 'storageBasePath'))->getReturnType()->getName()); + } + + #[DataProvider('diskImplementations')] + public function testBuiltInDisksImplementTheContractAndInheritTheBasePathApi(string $class): void + { + $this->assertTrue(is_subclass_of($class, IFileSystem::class)); + $this->assertTrue(is_subclass_of($class, FileSystem::class)); + + // The base path lives once on FileSystem, so the disks cannot drift apart. + $this->assertSame(FileSystem::class, (new ReflectionMethod($class, 'storageBasePath'))->getDeclaringClass()->getName()); + $this->assertSame(FileSystem::class, (new ReflectionMethod($class, 'storeageBasePath'))->getDeclaringClass()->getName()); + } + + public static function diskImplementations(): array + { + return [ + 'local' => [LocalFileSystem::class], + 'public' => [PublicFileSystem::class], + ]; + } } From a196e5b5d447bbc904777f07f49d0890074694d1 Mon Sep 17 00:00:00 2001 From: techmahedy Date: Fri, 30 Oct 2026 18:22:03 +0600 Subject: [PATCH 3/4] Missing disk root --- phpstan-baseline.neon | 24 ------------------- .../Support/Storage/StorageFileService.php | 4 ++++ tests/Storage/FileSystemTest.php | 9 ------- tests/Storage/FileUploadTest.php | 21 ++++++++++++++++ 4 files changed, 25 insertions(+), 33 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 78037316..ee2494b0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2406,30 +2406,6 @@ parameters: count: 1 path: src/Phaseolies/Support/Storage/FileSystem.php - - - message: '#^PHPDoc tag @method for method Phaseolies\\Support\\Storage\\StorageFileService\:\:store\(\) parameter \#2 \$file contains unknown class Phaseolies\\Support\\Storage\\File\.$#' - identifier: class.notFound - count: 1 - path: src/Phaseolies/Support/Storage/StorageFileService.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(\)\: string\)\: Unexpected token "\\n \* ", expected type at offset 130 on line 3$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Storage/StorageFileService.php - - - - message: '#^PHPDoc tag @method has invalid value \(\(string\|array \$path\)\: bool\)\: Unexpected token "\\n \* ", expected type at offset 259 on line 6$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Storage/StorageFileService.php - - - - message: '#^PHPDoc tag @method has invalid value \(static get\(string \$path\)\: \?string\)\: Unexpected token "\\n \* ", expected type at offset 175 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Phaseolies/Support/Storage/StorageFileService.php - - message: '#^Instanceof between Iterator and Traversable will always evaluate to true\.$#' identifier: instanceof.alwaysTrue diff --git a/src/Phaseolies/Support/Storage/StorageFileService.php b/src/Phaseolies/Support/Storage/StorageFileService.php index 36588919..343fc0bf 100644 --- a/src/Phaseolies/Support/Storage/StorageFileService.php +++ b/src/Phaseolies/Support/Storage/StorageFileService.php @@ -23,6 +23,10 @@ public function disk(string $name): IFileSystem { $path = $this->getDiskPath($name); + if ($path === null && in_array($name, ['local', 'public'], true)) { + throw new DiskNotFoundException("Disk [{$name}] has no root. Set filesystem.disks.{$name}.root."); + } + return match ($name) { 'local' => app(LocalFileSystem::class, [$path]), 'public' => app(PublicFileSystem::class, [$path]), diff --git a/tests/Storage/FileSystemTest.php b/tests/Storage/FileSystemTest.php index 667503b5..2aa6748e 100644 --- a/tests/Storage/FileSystemTest.php +++ b/tests/Storage/FileSystemTest.php @@ -123,15 +123,6 @@ public function storageBasePath(): string $this->assertSame('/overridden', $fs->storeageBasePath()); } - public function testDeprecatedAliasIsMarkedDeprecated() - { - $doc = (new \ReflectionMethod(FileSystem::class, 'storeageBasePath'))->getDocComment(); - - $this->assertIsString($doc); - $this->assertStringContainsString('@deprecated', $doc); - $this->assertStringContainsString('storageBasePath()', $doc); - } - public function testIsDirectoryExistsCreatesDirectoryIfMissing() { $subPath = 'new_sub_' . uniqid(); diff --git a/tests/Storage/FileUploadTest.php b/tests/Storage/FileUploadTest.php index 79a4cc85..76af658c 100644 --- a/tests/Storage/FileUploadTest.php +++ b/tests/Storage/FileUploadTest.php @@ -231,6 +231,27 @@ public function testDiskThrowsForUnsupportedDiskName(): void (new StorageFileService())->disk('s3'); } + public function testDiskThrowsDiskNotFoundWhenALocalDiskHasNoConfiguredRoot(): void + { + UploadTestConfig::reset(); + + $this->expectException(\Phaseolies\Support\Storage\DiskNotFoundException::class); + $this->expectExceptionMessage('Disk [local] has no root'); + + (new StorageFileService())->disk('local'); + } + + public function testDiskThrowsDiskNotFoundWhenAPublicDiskHasNoConfiguredRoot(): void + { + UploadTestConfig::reset(); + UploadTestConfig::set('filesystem.disks.local.root', $this->localRoot); + + $this->expectException(\Phaseolies\Support\Storage\DiskNotFoundException::class); + $this->expectExceptionMessage('Disk [public] has no root'); + + (new StorageFileService())->disk('public'); + } + public function testGetDiskPathReturnsNullForUnconfiguredDisk(): void { $this->assertNull((new StorageFileService())->getDiskPath('missing-disk')); From 572ad2442bd0a4baee8e6341cabdc00e5c29abb0 Mon Sep 17 00:00:00 2001 From: techmahedy Date: Fri, 30 Oct 2026 19:02:06 +0600 Subject: [PATCH 4/4] added proper error message for file does not exists --- src/Phaseolies/Support/Storage/FileSystem.php | 9 ++++- tests/Storage/FileSystemTest.php | 33 ++++++++++++------- tests/Storage/FileUploadTest.php | 23 +++++++++++++ tests/Storage/LocalFileSystemTest.php | 18 +++------- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/Phaseolies/Support/Storage/FileSystem.php b/src/Phaseolies/Support/Storage/FileSystem.php index 0f19fff5..838d68b6 100644 --- a/src/Phaseolies/Support/Storage/FileSystem.php +++ b/src/Phaseolies/Support/Storage/FileSystem.php @@ -73,11 +73,18 @@ protected function resolvePath(string $relativePath): string * @param File $file * @param string|null $fileName * @return bool + * @throws FileNotFoundException When the uploaded temporary file is missing */ public function put(string $path, File $file, ?string $fileName = null): bool { $fileName = $fileName ?? $this->getFileName($file); - $stream = fopen($file->getClientOriginalPath(), 'rb'); + $source = $file->getClientOriginalPath(); + + if (!is_file($source)) { + throw new FileNotFoundException("Uploaded file does not exist at path {$source}. It may have been moved already."); + } + + $stream = fopen($source, 'rb'); if (is_resource($stream)) { $this->writeStream($path, $fileName, $stream); diff --git a/tests/Storage/FileSystemTest.php b/tests/Storage/FileSystemTest.php index 2aa6748e..b4f7fb72 100644 --- a/tests/Storage/FileSystemTest.php +++ b/tests/Storage/FileSystemTest.php @@ -188,7 +188,7 @@ public function testGetFileNameUsesConfiguredBaseNameAndDetectedExtension() $this->assertSame('profile_image.png', $customFs->getFileName($file)); } - public function testPutReturnsFalseWhenSourceStreamCannotBeOpened() + public function testPutThrowsAClearErrorWhenTheTemporaryFileIsGone() { $file = new \Phaseolies\Support\File([ 'name' => 'missing.txt', @@ -198,20 +198,31 @@ public function testPutReturnsFalseWhenSourceStreamCannotBeOpened() 'size' => 0, ]); - $warning = null; - set_error_handler(static function (int $severity, string $message) use (&$warning): bool { - $warning = $message; - return true; - }, E_WARNING); + $this->expectException(\Phaseolies\Support\Storage\FileNotFoundException::class); + $this->expectExceptionMessage('may have been moved already'); + + $this->fs->put('uploads', $file); + } + + public function testPutNamesTheMissingTemporaryPathInTheError() + { + $source = $this->tmpDir . '/gone-' . uniqid() . '.txt'; + $file = new \Phaseolies\Support\File([ + 'name' => 'photo.png', + 'type' => 'image/png', + 'tmp_name' => $source, + 'error' => UPLOAD_ERR_OK, + 'size' => 0, + ]); try { - $this->assertFalse($this->fs->put('uploads', $file)); - } finally { - restore_error_handler(); + $this->fs->put('uploads', $file); + $this->fail('Expected a FileNotFoundException.'); + } catch (\Phaseolies\Support\Storage\FileNotFoundException $e) { + $this->assertSame("Uploaded file does not exist at path {$source}. It may have been moved already.", $e->getMessage()); } - $this->assertNotNull($warning); - $this->assertStringContainsString('Failed to open stream', $warning); + $this->assertFileDoesNotExist($this->tmpDir . '/uploads/photo.png'); } public function testPutWritesUsingConfiguredCustomFileName() diff --git a/tests/Storage/FileUploadTest.php b/tests/Storage/FileUploadTest.php index 76af658c..48acb842 100644 --- a/tests/Storage/FileUploadTest.php +++ b/tests/Storage/FileUploadTest.php @@ -252,6 +252,29 @@ public function testDiskThrowsDiskNotFoundWhenAPublicDiskHasNoConfiguredRoot(): (new StorageFileService())->disk('public'); } + public function testStoringAnUploadAfterStoreAsExplainsThatTheTemporaryFileWasMoved(): void + { + $file = $this->makeUploadedStyleFile('photo.jpg', 'payload', 'image/jpeg'); + + $this->assertSame('first/photo.jpg', $file->storeAs('first', 'photo.jpg', 'public')); + + $this->expectException(\Phaseolies\Support\Storage\FileNotFoundException::class); + $this->expectExceptionMessage('may have been moved already'); + + Storage::disk('public')->store('second', $file, 'photo.jpg'); + } + + public function testStoringAnUploadOnTheDiskBeforeStoreAsKeepsBothCopies(): void + { + $file = $this->makeUploadedStyleFile('photo.jpg', 'payload', 'image/jpeg'); + + $this->assertTrue(Storage::disk('public')->store('second', $file, 'photo.jpg')); + $this->assertSame('first/photo.jpg', $file->storeAs('first', 'photo.jpg', 'public')); + + $this->assertSame('payload', (string) file_get_contents($this->publicRoot . '/second/photo.jpg')); + $this->assertSame('payload', (string) file_get_contents($this->publicRoot . '/first/photo.jpg')); + } + public function testGetDiskPathReturnsNullForUnconfiguredDisk(): void { $this->assertNull((new StorageFileService())->getDiskPath('missing-disk')); diff --git a/tests/Storage/LocalFileSystemTest.php b/tests/Storage/LocalFileSystemTest.php index 21892120..0ed9840f 100644 --- a/tests/Storage/LocalFileSystemTest.php +++ b/tests/Storage/LocalFileSystemTest.php @@ -164,7 +164,7 @@ public function testContentReadsMultilineFile() $this->assertStringContainsString('line three', $result); } - public function testStoreReturnsFalseWhenSourceUploadPathDoesNotExist() + public function testStoreThrowsWhenSourceUploadPathDoesNotExist() { $file = new File([ 'name' => 'ghost.txt', @@ -174,20 +174,10 @@ public function testStoreReturnsFalseWhenSourceUploadPathDoesNotExist() 'size' => 0, ]); - $warning = null; - set_error_handler(static function (int $severity, string $message) use (&$warning): bool { - $warning = $message; - return true; - }, E_WARNING); - - try { - $this->assertFalse($this->fs->store('uploads', $file)); - } finally { - restore_error_handler(); - } + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage('may have been moved already'); - $this->assertNotNull($warning); - $this->assertStringContainsString('Failed to open stream', $warning); + $this->fs->store('uploads', $file); } public function testDeleteReturnsTrueForEmptyArray()