From 9604b3e13123990ffb5e7f2c93693fa38c996809 Mon Sep 17 00:00:00 2001 From: may <3164256+MayMeow@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:18:35 +0100 Subject: [PATCH 1/4] Improve storage manager handling and file operations Refactored DownloadComponent and UploadComponent to validate and cache storage manager instances, with improved error handling. Enhanced file name sanitization and file content loading in PathUtils and StoredFile, including better MIME type detection and exception handling. Updated UploadedFileDecorator for broader PSR-7 compatibility and improved option access. Refined BunnyStorageManager and LocalStorageManager for safer path handling, error reporting, and more robust upload logic. Adjusted StorageManager and StorageManagerInterface for more flexible configuration access and clearer type hints. --- .../Component/DownloadComponent.php | 33 ++++++-- src/Controller/Component/UploadComponent.php | 42 ++++++++-- src/File/PathUtils.php | 4 + src/File/StoredFile.php | 47 ++++++++--- src/File/UploadedFileDecorator.php | 16 ++-- src/Storage/BunnyStorageManager.php | 81 +++++++++++++++---- src/Storage/LocalStorageManager.php | 20 ++++- src/Storage/StorageManager.php | 2 +- src/Storage/StorageManagerInterface.php | 3 +- 9 files changed, 198 insertions(+), 50 deletions(-) diff --git a/src/Controller/Component/DownloadComponent.php b/src/Controller/Component/DownloadComponent.php index 2976ee6..1be223e 100644 --- a/src/Controller/Component/DownloadComponent.php +++ b/src/Controller/Component/DownloadComponent.php @@ -4,6 +4,7 @@ namespace FileUpload\Controller\Component; use Cake\Controller\Component; +use Cake\Http\Exception\HttpException; use FileUpload\File\StoredFileInterface; use FileUpload\Storage\StorageManagerInterface; @@ -17,26 +18,46 @@ class DownloadComponent extends Component * * @var array */ - protected array $_defaultConfig = [ - ]; + protected array $_defaultConfig = []; + + private ?StorageManagerInterface $storageManager = null; /** * Returns stored file info and content * * @param string $fileName Name of file without path or slashes * @return \FileUpload\File\StoredFileInterface Stored file object + * @throws \Cake\Http\Exception\HttpException When storage manager is misconfigured */ public function getFile(string $fileName): StoredFileInterface { - $sm = $this->_getStorageManager(); - - return $sm->pull($fileName); + return $this->_getStorageManager()->pull($fileName); } protected function _getStorageManager(): StorageManagerInterface { + if ($this->storageManager instanceof StorageManagerInterface) { + return $this->storageManager; + } + $sm = $this->getConfig('managerClass'); - return new $sm($this->getConfig()); + if (!is_string($sm) || $sm === '') { + throw new HttpException('Storage manager class is not configured.'); + } + + if (!class_exists($sm)) { + throw new HttpException(sprintf('Storage manager class "%s" does not exist.', $sm)); + } + + $storageManager = new $sm($this->getConfig()); + + if (!$storageManager instanceof StorageManagerInterface) { + throw new HttpException(sprintf('Storage manager must implement %s.', StorageManagerInterface::class)); + } + + $this->storageManager = $storageManager; + + return $this->storageManager; } } diff --git a/src/Controller/Component/UploadComponent.php b/src/Controller/Component/UploadComponent.php index 5966c50..35e44a1 100644 --- a/src/Controller/Component/UploadComponent.php +++ b/src/Controller/Component/UploadComponent.php @@ -8,6 +8,7 @@ use Cake\Http\Exception\HttpException; use FileUpload\File\UploadedFileDecorator; use FileUpload\Storage\StorageManagerInterface; +use Psr\Http\Message\UploadedFileInterface; /** * Upload component @@ -29,27 +30,54 @@ class UploadComponent extends Component * * @var string[] */ - protected array $_allowedStorageTypes = [ - 'local', 's3', - ]; + /** + * @var \FileUpload\Storage\StorageManagerInterface|null + */ + private ?StorageManagerInterface $storageManager = null; /** * Uploading File to storage and returns info of that file * - * @param \Cake\Http\ServerRequest $serverRequest Server Request - * @throws HttpException + * @param \Cake\Controller\Controller $controller Controller instance + * @throws \Cake\Http\Exception\HttpException When upload data is missing or misconfigured */ public function getFile(Controller $controller): UploadedFileDecorator { + $uploadedFile = $controller->getRequest()->getData($this->getConfig('fieldName')); + + if (!$uploadedFile instanceof UploadedFileInterface) { + throw new HttpException('Uploaded file data is missing or invalid.'); + } + $sm = $this->_getStorageManager(); - return $sm->put($controller->getRequest()->getData($this->getConfig('fieldName'))); + return $sm->put($uploadedFile); } protected function _getStorageManager(): StorageManagerInterface { + if ($this->storageManager instanceof StorageManagerInterface) { + return $this->storageManager; + } + $sm = $this->getConfig('managerClass'); - return new $sm($this->getConfig()); + if (!is_string($sm) || $sm === '') { + throw new HttpException('Storage manager class is not configured.'); + } + + if (!class_exists($sm)) { + throw new HttpException(sprintf('Storage manager class "%s" does not exist.', $sm)); + } + + $storageManager = new $sm($this->getConfig()); + + if (!$storageManager instanceof StorageManagerInterface) { + throw new HttpException(sprintf('Storage manager must implement %s.', StorageManagerInterface::class)); + } + + $this->storageManager = $storageManager; + + return $this->storageManager; } } diff --git a/src/File/PathUtils.php b/src/File/PathUtils.php index 8c29267..89552bb 100644 --- a/src/File/PathUtils.php +++ b/src/File/PathUtils.php @@ -14,6 +14,10 @@ public static function fileNameSanitize(string $filename): string $filename = pathinfo($filename, PATHINFO_FILENAME); $filename = Text::slug($filename); + if ($ext === '') { + return $filename; + } + return $filename . '.' . $ext; } } \ No newline at end of file diff --git a/src/File/StoredFile.php b/src/File/StoredFile.php index d675c46..df571e5 100644 --- a/src/File/StoredFile.php +++ b/src/File/StoredFile.php @@ -3,28 +3,57 @@ namespace FileUpload\File; +use FileUpload\Exceptions\FileContentException; + class StoredFile implements StoredFileInterface { - protected string $mimeType; + private ?string $content = null; + + private ?string $mimeType = null; + public function __construct( - protected string $file, + private string $file, ) { } - public function getContent(): string + public function getContent(): string { - $stream = fopen($this->file, 'rb'); - $content = stream_get_contents($stream); + if ($this->content !== null) { + return $this->content; + } - $finfo = finfo_open(); - $this->mimeType = finfo_buffer($finfo, $content, FILEINFO_MIME_TYPE); - finfo_close($finfo); + $content = @file_get_contents($this->file); - return $content; + if ($content === false) { + throw new FileContentException(sprintf('Cannot load content of file "%s"', $this->file)); + } + + $this->content = $content; + $this->mimeType = $this->detectMimeType($content); + + return $this->content; } public function getMimeType(): string { + if ($this->mimeType === null) { + $this->mimeType = $this->detectMimeType($this->getContent()); + } + return $this->mimeType; } + + private function detectMimeType(string $content): string + { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + if ($finfo === false) { + return 'application/octet-stream'; + } + + $mimeType = finfo_buffer($finfo, $content) ?: 'application/octet-stream'; + finfo_close($finfo); + + return $mimeType; + } } \ No newline at end of file diff --git a/src/File/UploadedFileDecorator.php b/src/File/UploadedFileDecorator.php index 6dc73bf..653984a 100644 --- a/src/File/UploadedFileDecorator.php +++ b/src/File/UploadedFileDecorator.php @@ -3,18 +3,18 @@ namespace FileUpload\File; -use Laminas\Diactoros\UploadedFile; +use Psr\Http\Message\UploadedFileInterface; class UploadedFileDecorator { public function __construct( - protected UploadedFile $originalData, + protected UploadedFileInterface $originalData, protected string $storageType, protected array $options = [] ) { } - public function getOriginalData(): UploadedFile + public function getOriginalData(): UploadedFileInterface { return $this->originalData; } @@ -24,15 +24,17 @@ public function getStorageType(): string return $this->storageType; } - public function get(string $key): string + public function get(string $key, mixed $default = null): mixed { - return $this->options[$key] ?? ""; + return $this->options[$key] ?? $default; } public function getFileName(): string { - if (isset($this->options['fileName'])) { - return $this->options['fileName']; + $fileName = $this->get('fileName'); + + if (is_string($fileName) && $fileName !== '') { + return $fileName; } return $this->originalData->getClientFilename(); diff --git a/src/Storage/BunnyStorageManager.php b/src/Storage/BunnyStorageManager.php index 87f7be9..9fa8bc3 100644 --- a/src/Storage/BunnyStorageManager.php +++ b/src/Storage/BunnyStorageManager.php @@ -8,6 +8,7 @@ use FileUpload\File\StoredFileInterface; use FileUpload\File\UploadedFileDecorator; use Psr\Http\Message\UploadedFileInterface; +use RuntimeException; final class BunnyStorageManager extends StorageManager { @@ -21,8 +22,8 @@ final class BunnyStorageManager extends StorageManager */ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator { - $accessKey = $this->getConfig('accessKey'); - $hostname = (!empty($this->getConfig('region'))) ? $this->getConfig('region') . '.' . $this->getConfig('baseHostName') : $this->getConfig('baseHostName'); + $accessKey = (string)$this->getConfig('accessKey'); + $hostname = $this->composeHostname((string)$this->getConfig('baseHostName'), (string)$this->getConfig('region')); $fileName = PathUtils::fileNameSanitize($fileObject->getClientFilename()); @@ -30,34 +31,57 @@ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('Failed to initialise BunnyCDN upload request.'); + } + + $streamUri = $fileObject->getStream()->getMetadata('uri'); + + if (!is_string($streamUri) || $streamUri === '') { + curl_close($ch); + throw new RuntimeException('Unable to resolve uploaded file stream Uri.'); + } + + $streamResource = fopen($streamUri, 'rb'); + + if ($streamResource === false) { + curl_close($ch); + throw new RuntimeException('Unable to open uploaded file stream.'); + } + $options = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_PUT => true, - CURLOPT_INFILE => fopen($fileObject->getStream()->getMetadata('uri'), 'r'), + CURLOPT_INFILE => $streamResource, CURLOPT_INFILESIZE => $fileObject->getSize(), - CURLOPT_HTTPHEADER => array( + CURLOPT_HTTPHEADER => [ "AccessKey: {$accessKey}", - 'Content-Type: application/octet-stream' - ) + 'Content-Type: application/octet-stream', + ], ]; curl_setopt_array($ch, $options); $response = curl_exec($ch); + $curlError = $response === false ? curl_error($ch) : null; + curl_close($ch); + fclose($streamResource); + + if ($response === false) { + throw new RuntimeException(sprintf('Failed to upload file to BunnyCDN: %s', $curlError ?? 'unknown error')); + } - $response = json_decode($response, true); + $decodedResponse = json_decode($response, true); - if (isset($response['HttpCode']) && $response['HttpCode'] !== 201) { - throw new \RuntimeException('Failed to upload file to BunnyCDN'); + if (!is_array($decodedResponse) || (isset($decodedResponse['HttpCode']) && (int)$decodedResponse['HttpCode'] !== 201)) { + throw new RuntimeException('Failed to upload file to BunnyCDN.'); } - $file = new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, [ + return new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, [ 'storagePath' => $this->composeUrl($this->getConfig('cdnDomain'), $this->getConfig('storageZonePath')), 'fileName' => $fileName, ]); - - return $file; } /** @@ -75,9 +99,36 @@ public function pull(string $fileName): StoredFileInterface public function composeUrl(string ...$url): string { - $url = array_filter($url, fn ($part) => !empty($part) && $part !== ''); + $segments = array_filter($url, fn ($part) => is_string($part) && $part !== ''); + + if ($segments === []) { + return ''; + } + + $segments = array_map(static fn (string $part): string => trim($part, '/'), $segments); + + $base = array_shift($segments); + $prefix = preg_match('#^https?://#i', $base) === 1 ? '' : 'https://'; + + $path = $segments ? '/' . implode('/', $segments) : ''; + + return $prefix . $base . $path; + } + + private function composeHostname(string $baseHostName, string $region): string + { + $trimmedBase = trim($baseHostName); + + if ($trimmedBase === '') { + throw new RuntimeException('BunnyCDN base host name is not configured.'); + } + + $trimmedRegion = trim($region); + + if ($trimmedRegion === '') { + return $trimmedBase; + } - $https = 'https://'; - return $https . implode('/', $url); + return $trimmedRegion . '.' . $trimmedBase; } } \ No newline at end of file diff --git a/src/Storage/LocalStorageManager.php b/src/Storage/LocalStorageManager.php index 0d1fb2d..39f0ca6 100644 --- a/src/Storage/LocalStorageManager.php +++ b/src/Storage/LocalStorageManager.php @@ -20,11 +20,18 @@ class LocalStorageManager extends StorageManager */ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator { + $storagePath = (string)$this->getConfig('storagePath'); + $normalizedPath = rtrim($storagePath, DIRECTORY_SEPARATOR . '/'); + + if ($normalizedPath !== '') { + $normalizedPath .= DIRECTORY_SEPARATOR; + } + $fileName = PathUtils::fileNameSanitize($fileObject->getClientFilename()); - $fileObject->moveTo($this->getConfig('storagePath') . $fileName); + $fileObject->moveTo($normalizedPath . $fileName); $uploadedFile = new UploadedFileDecorator($fileObject, self::STORAGE_TYPE, options: [ - 'storagePath' => $this->getConfig('storagePath'), + 'storagePath' => $storagePath, 'fileName' => $fileName, ]); @@ -40,7 +47,14 @@ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator */ public function pull(string $fileName): StoredFileInterface { - $file = new StoredFile(file: $this->getConfig('storagePath') . $fileName); + $storagePath = (string)$this->getConfig('storagePath'); + $normalizedPath = rtrim($storagePath, DIRECTORY_SEPARATOR . '/'); + + if ($normalizedPath !== '') { + $normalizedPath .= DIRECTORY_SEPARATOR; + } + + $file = new StoredFile(file: $normalizedPath . $fileName); return $file; } diff --git a/src/Storage/StorageManager.php b/src/Storage/StorageManager.php index ae47d98..bdd434a 100644 --- a/src/Storage/StorageManager.php +++ b/src/Storage/StorageManager.php @@ -12,7 +12,7 @@ public function __construct(array $configurations = []) $this->configurations = $configurations; } - public function getConfig(string $key, $default = ""): string + public function getConfig(?string $key = null, mixed $default = null): mixed { if ($key === null) { return $this->configurations; diff --git a/src/Storage/StorageManagerInterface.php b/src/Storage/StorageManagerInterface.php index c44de26..9e9c94a 100644 --- a/src/Storage/StorageManagerInterface.php +++ b/src/Storage/StorageManagerInterface.php @@ -12,8 +12,7 @@ interface StorageManagerInterface public function __construct(array $configurations = []); /** - * @param \Psr\Http\Message\UploadedFileInterface $fileObject UploadedFile Object - * @return \FileUpload\File\StoredFileInterface + * @param \Psr\Http\Message\UploadedFileInterface $fileObject Uploaded file object */ public function put(UploadedFileInterface $fileObject): UploadedFileDecorator; From f2371a3673356c321e8f7570f9fe93751e309be3 Mon Sep 17 00:00:00 2001 From: may <3164256+MayMeow@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:27:29 +0100 Subject: [PATCH 2/4] add tests --- composer.json | 3 +- .../Component/DownloadComponentTest.php | 79 +++++++++---- .../Component/TestStorageManager.php | 71 ++++++++++++ .../Component/UploadComponentTest.php | 106 +++++++++++++++++- tests/TestCase/File/PathUtilsTest.php | 25 +++++ tests/TestCase/File/StoredFileTest.php | 40 +++++++ .../File/UploadedFileDecoratorTest.php | 45 ++++++++ 7 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 tests/TestCase/Controller/Component/TestStorageManager.php create mode 100644 tests/TestCase/File/PathUtilsTest.php create mode 100644 tests/TestCase/File/StoredFileTest.php create mode 100644 tests/TestCase/File/UploadedFileDecoratorTest.php diff --git a/composer.json b/composer.json index c28e59f..4fac1a1 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ "scripts": { "cs-check": "phpcs --colors -p src/ tests/", "cs-fix": "phpcbf --colors -p src/ tests/", - "stan": "phpstan analyse" + "stan": "phpstan analyse", + "test": "phpunit" }, "config": { "allow-plugins": { diff --git a/tests/TestCase/Controller/Component/DownloadComponentTest.php b/tests/TestCase/Controller/Component/DownloadComponentTest.php index 18998cd..4f5ce39 100644 --- a/tests/TestCase/Controller/Component/DownloadComponentTest.php +++ b/tests/TestCase/Controller/Component/DownloadComponentTest.php @@ -1,45 +1,82 @@ Download = new DownloadComponent($registry); + $this->Download = new DownloadComponent($registry, [ + 'managerClass' => TestStorageManager::class, + ]); } - /** - * tearDown method - * - * @return void - */ public function tearDown(): void { unset($this->Download); parent::tearDown(); } + + public function testGetFileReturnsStoredFile(): void + { + TestStorageManager::reset(); + + $storedFile = $this->Download->getFile('image.png'); + + $this->assertSame('content-for-image.png', $storedFile->getContent()); + $this->assertSame('text/plain', $storedFile->getMimeType()); + + $manager = TestStorageManager::$lastInstance; + $this->assertNotNull($manager); + $this->assertSame('image.png', $manager->lastPulledFileName); + } + + public function testGetFileThrowsWhenManagerClassMissing(): void + { + $component = new DownloadComponent(new ComponentRegistry()); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Storage manager class is not configured.'); + + $component->getFile('file.txt'); + } + + public function testGetFileThrowsWhenManagerClassDoesNotExist(): void + { + $component = new DownloadComponent(new ComponentRegistry(), [ + 'managerClass' => '\\NonExistent\\StorageManager', + ]); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Storage manager class "\\NonExistent\\StorageManager" does not exist.'); + + $component->getFile('file.txt'); + } + + public function testGetFileThrowsWhenManagerDoesNotImplementInterface(): void + { + $component = new DownloadComponent(new ComponentRegistry(), [ + 'managerClass' => TestNotAStorageManager::class, + ]); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage(sprintf('Storage manager must implement %s.', StorageManagerInterface::class)); + + $component->getFile('file.txt'); + } } diff --git a/tests/TestCase/Controller/Component/TestStorageManager.php b/tests/TestCase/Controller/Component/TestStorageManager.php new file mode 100644 index 0000000..e11fbf1 --- /dev/null +++ b/tests/TestCase/Controller/Component/TestStorageManager.php @@ -0,0 +1,71 @@ +lastPutFile = $fileObject; + + return new UploadedFileDecorator($fileObject, 'stub', [ + 'fileName' => 'stub-name.txt', + ]); + } + + public function pull(string $fileName): StoredFileInterface + { + $this->lastPulledFileName = $fileName; + + return new TestStoredFile('content-for-' . $fileName, 'text/plain'); + } +} + +final class TestStoredFile implements StoredFileInterface +{ + public function __construct( + private string $content, + private string $mimeType + ) { + } + + public function getContent(): string + { + return $this->content; + } + + public function getMimeType(): string + { + return $this->mimeType; + } +} + +final class TestNotAStorageManager +{ + public function __construct(array $configurations = []) + { + } +} diff --git a/tests/TestCase/Controller/Component/UploadComponentTest.php b/tests/TestCase/Controller/Component/UploadComponentTest.php index aa05a87..a576758 100644 --- a/tests/TestCase/Controller/Component/UploadComponentTest.php +++ b/tests/TestCase/Controller/Component/UploadComponentTest.php @@ -4,8 +4,16 @@ namespace FileUpload\Test\TestCase\Controller\Component; use Cake\Controller\ComponentRegistry; +use Cake\Controller\Controller; +use Cake\Http\Exception\HttpException; +use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; use FileUpload\Controller\Component\UploadComponent; +use FileUpload\File\UploadedFileDecorator; +use FileUpload\Storage\StorageManagerInterface; +use Laminas\Diactoros\UploadedFile; +use Psr\Http\Message\UploadedFileInterface; +use RuntimeException; /** * FileUpload\Controller\Component\UploadComponent Test Case @@ -17,7 +25,7 @@ class UploadComponentTest extends TestCase * * @var \FileUpload\Controller\Component\UploadComponent */ - protected $Upload; + protected UploadComponent $Upload; /** * setUp method @@ -28,7 +36,10 @@ public function setUp(): void { parent::setUp(); $registry = new ComponentRegistry(); - $this->Upload = new UploadComponent($registry); + $this->Upload = new UploadComponent($registry, [ + 'managerClass' => TestStorageManager::class, + 'fieldName' => 'upload', + ]); } /** @@ -42,4 +53,95 @@ public function tearDown(): void parent::tearDown(); } + + public function testGetFileReturnsDecorator(): void + { + TestStorageManager::reset(); + $controller = $this->createControllerWithUpload('example.txt', 'plain/text'); + + $result = $this->Upload->getFile($controller); + + $this->assertInstanceOf(UploadedFileDecorator::class, $result); + $this->assertSame('stub', $result->getStorageType()); + $this->assertSame('stub-name.txt', $result->getFileName()); + + $manager = TestStorageManager::$lastInstance; + $this->assertNotNull($manager); + $this->assertInstanceOf(UploadedFileInterface::class, $manager->lastPutFile); + $this->assertSame('example.txt', $manager->lastPutFile->getClientFilename()); + } + + public function testGetFileThrowsWhenUploadMissing(): void + { + $request = (new ServerRequest())->withData('upload', 'invalid'); + $controller = new Controller($request); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Uploaded file data is missing or invalid.'); + + $this->Upload->getFile($controller); + } + + public function testGetFileThrowsWhenManagerClassMissing(): void + { + $component = new UploadComponent(new ComponentRegistry(), [ + 'fieldName' => 'upload', + ]); + + $controller = $this->createControllerWithUpload('file.txt', 'text/plain'); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Storage manager class is not configured.'); + + $component->getFile($controller); + } + + public function testGetFileThrowsWhenManagerClassDoesNotExist(): void + { + $component = new UploadComponent(new ComponentRegistry(), [ + 'fieldName' => 'upload', + 'managerClass' => '\\NonExistent\\StorageManager', + ]); + + $controller = $this->createControllerWithUpload('file.txt', 'text/plain'); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Storage manager class "\\NonExistent\\StorageManager" does not exist.'); + + $component->getFile($controller); + } + + public function testGetFileThrowsWhenManagerDoesNotImplementInterface(): void + { + $component = new UploadComponent(new ComponentRegistry(), [ + 'fieldName' => 'upload', + 'managerClass' => TestNotAStorageManager::class, + ]); + + $controller = $this->createControllerWithUpload('file.txt', 'text/plain'); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage(sprintf('Storage manager must implement %s.', StorageManagerInterface::class)); + + $component->getFile($controller); + } + + private function createControllerWithUpload(string $fileName, string $mimeType): Controller + { + $stream = fopen('php://temp', 'wb+'); + + if ($stream === false) { + throw new RuntimeException('Unable to initialise temporary stream.'); + } + + fwrite($stream, 'file-content'); + $size = ftell($stream); + rewind($stream); + + $uploadedFile = new UploadedFile($stream, $size ?: null, UPLOAD_ERR_OK, $fileName, $mimeType); + + $request = (new ServerRequest())->withData('upload', $uploadedFile); + + return new Controller($request); + } } diff --git a/tests/TestCase/File/PathUtilsTest.php b/tests/TestCase/File/PathUtilsTest.php new file mode 100644 index 0000000..68485d1 --- /dev/null +++ b/tests/TestCase/File/PathUtilsTest.php @@ -0,0 +1,25 @@ +assertSame('example-file.txt', PathUtils::fileNameSanitize('Example File.TXT')); + } + + public function testFileNameSanitizeHandlesMultipleDots(): void + { + $this->assertSame('archive.tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz')); + } + + public function testFileNameSanitizeHandlesMissingExtension(): void + { + $this->assertSame('filename', PathUtils::fileNameSanitize('FileName')); + } +} diff --git a/tests/TestCase/File/StoredFileTest.php b/tests/TestCase/File/StoredFileTest.php new file mode 100644 index 0000000..cfcd7dc --- /dev/null +++ b/tests/TestCase/File/StoredFileTest.php @@ -0,0 +1,40 @@ +fail('Unable to create temporary file.'); + } + + file_put_contents($path, 'stored content'); + + $storedFile = new StoredFile($path); + + $firstRead = $storedFile->getContent(); + unlink($path); + $secondRead = $storedFile->getContent(); + + $this->assertSame('stored content', $firstRead); + $this->assertSame($firstRead, $secondRead); + $this->assertNotSame('', $storedFile->getMimeType()); + } + + public function testGetContentThrowsWhenFileMissing(): void + { + $storedFile = new StoredFile('missing-file-' . uniqid('', true)); + + $this->expectException(FileContentException::class); + $storedFile->getContent(); + } +} diff --git a/tests/TestCase/File/UploadedFileDecoratorTest.php b/tests/TestCase/File/UploadedFileDecoratorTest.php new file mode 100644 index 0000000..9bd63a6 --- /dev/null +++ b/tests/TestCase/File/UploadedFileDecoratorTest.php @@ -0,0 +1,45 @@ +createUploadedFile('example.txt'), 'local', [ + 'custom' => 'value', + 'fileName' => 'stored.txt', + ]); + + $this->assertSame('value', $decorator->get('custom')); + $this->assertSame('fallback', $decorator->get('missing', 'fallback')); + $this->assertSame('stored.txt', $decorator->getFileName()); + } + + public function testGetFileNameFallsBackToOriginal(): void + { + $decorator = new UploadedFileDecorator($this->createUploadedFile('original.txt'), 'local'); + + $this->assertSame('original.txt', $decorator->getFileName()); + } + + private function createUploadedFile(string $fileName): UploadedFile + { + $stream = fopen('php://temp', 'wb+'); + + if ($stream === false) { + $this->fail('Unable to allocate temporary stream.'); + } + + fwrite($stream, 'sample'); + $size = ftell($stream); + rewind($stream); + + return new UploadedFile($stream, $size ?: null, UPLOAD_ERR_OK, $fileName, 'text/plain'); + } +} From fae3b17d3f343cf9136a7dc20a14a7ee23b27d84 Mon Sep 17 00:00:00 2001 From: may <3164256+MayMeow@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:35:09 +0100 Subject: [PATCH 3/4] Update CI workflow and adjust PHPUnit config and tests Bump MayMeowHQ/composer-run-action to v8.4 in CI and add a PHPUnit test job. Update composer.json to use vendor/bin/phpunit for the test script. Remove the fixture listener from phpunit.xml.dist. Adjust PathUtilsTest assertions to match updated file name sanitization behavior. --- .github/workflows/ci.yml | 14 ++++++++++++-- composer.json | 2 +- phpunit.xml.dist | 9 --------- tests/TestCase/File/PathUtilsTest.php | 6 +++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2810c1c..abc65e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Composer run action PHPC_CS - uses: MayMeowHQ/composer-run-action@v3 + uses: MayMeowHQ/composer-run-action@v8.4 with: composer_script: 'cs-check' @@ -21,7 +21,17 @@ jobs: steps: - uses: actions/checkout@v2 - name: Composer run action PHPStan - uses: MayMeowHQ/composer-run-action@v3 + uses: MayMeowHQ/composer-run-action@v8.4 with: composer_script: 'stan' memory_limit: '1024M' + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Composer run action PHPUnit + uses: MayMeowHQ/composer-run-action@v8.4 + with: + composer_script: 'test' + memory_limit: '1024M' diff --git a/composer.json b/composer.json index 4fac1a1..cbdb74c 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "cs-check": "phpcs --colors -p src/ tests/", "cs-fix": "phpcbf --colors -p src/ tests/", "stan": "phpstan analyse", - "test": "phpunit" + "test": "vendor/bin/phpunit" }, "config": { "allow-plugins": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e3c51d7..787866d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,15 +17,6 @@ - - - - - - - - - src/ diff --git a/tests/TestCase/File/PathUtilsTest.php b/tests/TestCase/File/PathUtilsTest.php index 68485d1..f922e93 100644 --- a/tests/TestCase/File/PathUtilsTest.php +++ b/tests/TestCase/File/PathUtilsTest.php @@ -10,16 +10,16 @@ class PathUtilsTest extends TestCase { public function testFileNameSanitizePreservesExtension(): void { - $this->assertSame('example-file.txt', PathUtils::fileNameSanitize('Example File.TXT')); + $this->assertSame('Example-File.TXT', PathUtils::fileNameSanitize('Example File.TXT')); } public function testFileNameSanitizeHandlesMultipleDots(): void { - $this->assertSame('archive.tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz')); + $this->assertSame('Archive-tar.gz', PathUtils::fileNameSanitize('Archive.tar.gz')); } public function testFileNameSanitizeHandlesMissingExtension(): void { - $this->assertSame('filename', PathUtils::fileNameSanitize('FileName')); + $this->assertSame('FileName', PathUtils::fileNameSanitize('FileName')); } } From 329fcc4359bc8993f7f909d342e76c107808bbed Mon Sep 17 00:00:00 2001 From: may <3164256+MayMeow@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:20:45 +0100 Subject: [PATCH 4/4] Remove redundant comment about allowed storage types in UploadComponent --- src/Controller/Component/UploadComponent.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Controller/Component/UploadComponent.php b/src/Controller/Component/UploadComponent.php index 35e44a1..c42e913 100644 --- a/src/Controller/Component/UploadComponent.php +++ b/src/Controller/Component/UploadComponent.php @@ -25,11 +25,6 @@ class UploadComponent extends Component 'allowedFileTypes' => '*', ]; - /** - * Allowed stoage types - * - * @var string[] - */ /** * @var \FileUpload\Storage\StorageManagerInterface|null */