diff --git a/tests/Unit/Lib/PathGenerators/DateHashPathGeneratorTest.php b/tests/Unit/Lib/PathGenerators/DateHashPathGeneratorTest.php new file mode 100644 index 0000000..1d240d5 --- /dev/null +++ b/tests/Unit/Lib/PathGenerators/DateHashPathGeneratorTest.php @@ -0,0 +1,199 @@ +generate($disk, $sanitizedFile); + + // THEN + expect($result)->toBeInstanceOf(StorageFilePath::class); + + $pattern = sprintf( + '#^%s/%s/%s/%s/document-[a-f0-9]{8}\.pdf$#', + $expectedYear, + $expectedMonth, + $expectedDay, + $expectedHash + ); + expect($result->value)->toMatch($pattern); + }); + + it('generates a path for a file without extension', function (): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'LICENSE', + sanitizedName: 'LICENSE', + contents: FileStream::fromString('MIT License') + ); + + $expectedHash = substr(hash('sha256', 'LICENSE'), 0, 2); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($result)->toBeInstanceOf(StorageFilePath::class); + + $pattern = sprintf( + '#^%s/%s/%s/%s/license-[a-f0-9]{8}$#', + gmdate('Y'), + gmdate('m'), + gmdate('d'), + $expectedHash + ); + expect($result->value)->toMatch($pattern); + }); + + it('generates unique paths for identical files generated consecutively', function (): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'photo.jpg', + sanitizedName: 'photo.jpg', + contents: FileStream::fromString('image bytes') + ); + + // WHEN + $path1 = $generator->generate($disk, $sanitizedFile); + $path2 = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($path1->value)->not->toBe($path2->value); + }); +}); + +describe('DateHashPathGenerator generation with directory', function (): void { + it('prepends the given directory to the path', function (): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'avatar.png', + sanitizedName: 'avatar.png', + contents: FileStream::fromString('image data') + ); + $directory = 'avatars/users'; + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + $expectedHash = substr(hash('sha256', 'avatar.png'), 0, 2); + $pattern = sprintf( + '#^avatars/users/%s/%s/%s/%s/avatar-[a-f0-9]{8}\.png$#', + gmdate('Y'), + gmdate('m'), + gmdate('d'), + $expectedHash + ); + expect($result->value)->toMatch($pattern); + }); + + it('trims leading and trailing slashes from the directory', function (string $directory): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'file.txt', + sanitizedName: 'file.txt', + contents: FileStream::fromString('txt') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + expect($result->value)->toStartWith('uploads/archive/'); + })->with([ + 'leading slash' => '/uploads/archive', + 'trailing slash' => 'uploads/archive/', + 'both slashes' => '/uploads/archive/', + 'multiple outer slashes' => '///uploads/archive///', + ]); + + it('ignores empty or slash-only directory', function (string $directory): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'report.pdf', + sanitizedName: 'report.pdf', + contents: FileStream::fromString('pdf') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + expect($result->value)->toStartWith(gmdate('Y') . '/'); + })->with([ + 'empty string' => '', + 'single slash' => '/', + 'multi slashes' => '///', + ]); +}); + +describe('DateHashPathGenerator filename slugging', function (): void { + it('slugs filename containing underscores or mixed case', function (): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'My_Monthly_Report_2026.csv', + sanitizedName: 'My_Monthly_Report_2026.csv', + contents: FileStream::fromString('a,b,c') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($result->value)->toMatch('#/my-monthly-report-2026-[a-f0-9]{8}\.csv$#'); + }); + + it('falls back to "file" when filename produces an empty slug', function (): void { + // GIVEN + $generator = new DateHashPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: '.env.backup', + sanitizedName: '.backup', + contents: FileStream::fromString('KEY=VAL') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($result->value)->toMatch('#/file-[a-f0-9]{8}\.backup$#'); + }); +}); diff --git a/tests/Unit/Lib/PathGenerators/UuidPathGeneratorTest.php b/tests/Unit/Lib/PathGenerators/UuidPathGeneratorTest.php new file mode 100644 index 0000000..ac57c0e --- /dev/null +++ b/tests/Unit/Lib/PathGenerators/UuidPathGeneratorTest.php @@ -0,0 +1,140 @@ +generate($disk, $sanitizedFile); + + // THEN + expect($result)->toBeInstanceOf(StorageFilePath::class); + + $filename = $result->value; + expect($filename)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$#'); + + $uuidOnly = pathinfo($filename, PATHINFO_FILENAME); + expect(Str::isUuid($uuidOnly))->toBeTrue(); + }); + + it('generates a storage file path without extension when file has no extension', function (): void { + // GIVEN + $generator = new UuidPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'DOCKERFILE', + sanitizedName: 'DOCKERFILE', + contents: FileStream::fromString('FROM php:8.5') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($result)->toBeInstanceOf(StorageFilePath::class) + ->and(Str::isUuid($result->value))->toBeTrue(); + }); + + it('generates unique paths on successive calls for the same file', function (): void { + // GIVEN + $generator = new UuidPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'data.json', + sanitizedName: 'data.json', + contents: FileStream::fromString('{}') + ); + + // WHEN + $path1 = $generator->generate($disk, $sanitizedFile); + $path2 = $generator->generate($disk, $sanitizedFile); + + // THEN + expect($path1->value)->not->toBe($path2->value); + }); +}); + +describe('UuidPathGenerator generation with directory', function (): void { + it('prepends the given directory to the generated UUID filename', function (): void { + // GIVEN + $generator = new UuidPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'invoice.pdf', + sanitizedName: 'invoice.pdf', + contents: FileStream::fromString('PDF DATA') + ); + $directory = 'billing/invoices'; + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + expect($result)->toBeInstanceOf(StorageFilePath::class) + ->and($result->value)->toStartWith('billing/invoices/') + ->and($result->value)->toMatch('#^billing/invoices/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$#'); + }); + + it('trims leading and trailing slashes from the directory', function (string $directory): void { + // GIVEN + $generator = new UuidPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'file.txt', + sanitizedName: 'file.txt', + contents: FileStream::fromString('text') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + expect($result->value)->toStartWith('custom/path/'); + })->with([ + 'leading slash' => '/custom/path', + 'trailing slash' => 'custom/path/', + 'both slashes' => '/custom/path/', + 'multiple outer slashes' => '///custom/path///', + ]); + + it('ignores empty or slash-only directory', function (string $directory): void { + // GIVEN + $generator = new UuidPathGenerator(); + $disk = new DiskName('local', 'local'); + $sanitizedFile = new SanitizedFile( + originalName: 'file.txt', + sanitizedName: 'file.txt', + contents: FileStream::fromString('text') + ); + + // WHEN + $result = $generator->generate($disk, $sanitizedFile, $directory); + + // THEN + expect($result->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.txt$#'); + })->with([ + 'empty string' => '', + 'single slash' => '/', + 'multi slashes' => '///', + ]); +});