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 c28e59f..cbdb74c 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": "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/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..c42e913 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
@@ -25,31 +26,53 @@ class UploadComponent extends Component
];
/**
- * Allowed stoage types
- *
- * @var string[]
+ * @var \FileUpload\Storage\StorageManagerInterface|null
*/
- protected array $_allowedStorageTypes = [
- 'local', 's3',
- ];
+ 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;
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..f922e93
--- /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');
+ }
+}