toBeInstanceOf(StorageFilePath::class) ->and($vo->value)->toBe('document.pdf'); }); it('can instantiate with a valid nested path', function (string $validPath): void { // GIVEN $path = $validPath; // WHEN $vo = new StorageFilePath($path); // THEN expect($vo)->toBeInstanceOf(StorageFilePath::class) ->and($vo->value)->toBe($validPath); })->with([ 'simple directory' => 'uploads/file.png', 'multi-level nested' => '2026/11/03/invoice_123.pdf', 'with hyphens dots' => 'user-data/avatar.min.jpg', ]); }); describe('StorageFilePath validation', function (): void { it('throws exception if path is empty or only whitespace', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path cannot be empty.' ); })->with([ 'empty string' => '', 'spaces only' => ' ', 'tabs and newlines' => "\t \n \r", ]); it('throws exception if path starts with a slash', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path must not start with a "/".' ); })->with([ 'single slash root' => '/file.txt', 'leading nested path' => '/uploads/document.pdf', ]); it('throws exception if path ends with a slash', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path must not end with a "/".' ); })->with([ 'trailing slash on file' => 'file.txt/', 'trailing directory' => 'uploads/2026/', ]); it('throws exception if path contains double slashes', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path must not contain "//".' ); })->with([ 'double slash in middle' => 'uploads//file.txt', 'triple slash' => 'folder///nested/file.txt', ]); it('throws exception if path contains backslashes', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path must not contain "\\".' ); })->with([ 'windows separator' => 'uploads\\file.txt', 'multiple backslash' => 'folder\\sub\\file.txt', ]); it('throws exception if path contains directory traversal', function (string $invalidPath): void { // GIVEN $path = $invalidPath; // WHEN $action = fn () => new StorageFilePath($path); // THEN expect($action)->toThrow( UnexpectedStorageException::class, 'Path must not contain "..".' ); })->with([ 'relative parent traversal' => 'uploads/../secret.txt', 'leading traversal' => '../file.txt', 'trailing traversal' => 'folder/..', ]); }); describe('StorageFilePath json serialization', function (): void { it('returns the path when json serialized', function (): void { // GIVEN $path = 'documents/archive/2026.zip'; $vo = new StorageFilePath($path); // WHEN $jsonSerialized = $vo->jsonSerialize(); $jsonEncoded = json_encode($vo); // THEN expect($jsonSerialized)->toBe($path) ->and($jsonEncoded)->toBe('"documents\/archive\/2026.zip"'); }); }); describe('StorageFilePath string representation', function (): void { it('returns the path when cast to string or via __toString', function (): void { // GIVEN $path = 'documents/archive/2026.zip'; $vo = new StorageFilePath($path); // WHEN $asString = (string) $vo; $fromToStringMethod = $vo->__toString(); // THEN expect($asString)->toBe($path) ->and($fromToStringMethod)->toBe($path); }); });