From 874f7eb30b49a742d5d337be7a5ea257a9ac1a93 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 12:05:38 -0400 Subject: [PATCH 01/25] Introduce `UploadedFile` class bridge implementation with tests. --- src/adapter/ServerRequestAdapter.php | 2 +- src/http/Request.php | 5 +- src/http/UploadedFile.php | 454 ++++++++++++++++++ .../StatelessApplicationUploadedTest.php | 141 ++++++ 4 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 src/http/UploadedFile.php create mode 100644 tests/http/stateless/StatelessApplicationUploadedTest.php diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index c8bce294..4264b8d3 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -313,7 +313,7 @@ public function getServerParams(): array * * @return array Uploaded files from the PSR-7 ServerRequestInterface. * - * @phpstan-return array + * @phpstan-return array * * Usage example: * ```php diff --git a/src/http/Request.php b/src/http/Request.php index 681415da..c13234cf 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -7,7 +7,7 @@ use Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface}; use Yii; use yii\base\InvalidConfigException; -use yii\web\{CookieCollection, HeaderCollection, NotFoundHttpException, UploadedFile}; +use yii\web\{CookieCollection, HeaderCollection, NotFoundHttpException}; use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; use yii2\extensions\psrbridge\exception\Message; @@ -793,6 +793,8 @@ public function setPsr7Request(ServerRequestInterface $request): void $this->adapter = new ServerRequestAdapter( $request->withHeader('statelessAppStartTime', (string) microtime(true)), ); + + UploadedFile::setPsr7Adapter($this->adapter); } /** @@ -851,6 +853,7 @@ private function createUploadedFile(UploadedFileInterface $psrFile): UploadedFil 'size' => $psrFile->getSize(), 'tempName' => $psrFile->getStream()->getMetadata('uri') ?? '', 'type' => $psrFile->getClientMediaType() ?? '', + 'tempResource' => $psrFile->getStream()->detach(), ], ); } diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php new file mode 100644 index 00000000..e781b7d5 --- /dev/null +++ b/src/http/UploadedFile.php @@ -0,0 +1,454 @@ + + * + * @phpstan-ignore property.phpDocType + */ + public static $_files = []; + + /** + * PSR-7 ServerRequestAdapter for bridging PSR-7 UploadedFileInterface. + */ + private static ServerRequestAdapter|null $psr7Adapter = null; + + /** + * Flag indicating whether PSR-7 files have been loaded into the internal cache. + */ + private static bool $psr7FilesLoaded = false; + + /** + * Returns the instance of the uploaded file associated with the specified model attribute. + * + * Retrieves the uploaded file instance for the given model and attribute, supporting array-indexed attribute names + * such as '[1]file' for tabular uploads or 'file[1]' for file arrays. The method resolves the input name using + * {@see Html::getInputName()} and delegates to {@see getInstanceByName()} for file lookup. + * + * @param Model $model Data model. + * @param string $attribute Attribute name, which may contain array indexes (for example, '[1]file', 'file[1]'). + * + * @return UploadedFile|null Instance of the uploaded file, or `null` if no file was uploaded for the attribute. + * + * Usage example: + * ```php + * $file = UploadedFile::getInstance($model, 'file'); + * + * if ($file !== null) { + * // process the uploaded file + * } + * ``` + */ + public static function getInstance($model, $attribute): UploadedFile|null + { + $name = Html::getInputName($model, $attribute); + + return self::getInstanceByName($name); + } + + /** + * Returns the instance of the uploaded file for the specified input name. + * + * @param string $name Name of the file input field. + * + * @return UploadedFile|null Instance of the uploaded file, or `null` if no file was uploaded for the name. + * + * Usage example: + * ```php + * $file = UploadedFile::getInstanceByName('file'); + * + * if ($file !== null) { + * // process the uploaded file + * } + * ``` + */ + public static function getInstanceByName($name): UploadedFile|null + { + $files = self::loadFiles(); + + return isset($files[$name]) ? new self($files[$name]) : null; + } + + /** + * Returns an array of uploaded file instances associated with the specified model attribute. + * + * Resolves the input name for the given model and attribute using {@see Html::getInputName()} and delegates to + * {@see getInstancesByName()} for file lookup. Supports array-indexed attribute names for tabular file uploading, + * such as '[1]file'. + * + * @param Model $model Data model. + * @param string $attribute Attribute name, which may contain array indexes (for example, '[1]file'). + * + * @return array Array of UploadedFile objects for the specified attribute. Returns an empty array if no + * files were uploaded. + * + * @phpstan-return UploadedFile[] + * + * Usage example: + * ```php + * $files = UploadedFile::getInstances($model, 'file'); + * + * foreach ($files as $file) { + * // process each uploaded file + * } + * ``` + */ + public static function getInstances($model, $attribute): array + { + $name = Html::getInputName($model, $attribute); + + return self::getInstancesByName($name); + } + + /** + * Returns an array of uploaded file instances for the specified input name. + * + * Iterates over the internal file cache and collects all uploaded files whose keys match the given input name or + * are prefixed with the input name followed by an opening bracket (for array-style file inputs). + * + * @param string $name Name of the file input field or array of files. + * + * @return array Array of UploadedFile objects for the specified input name. Returns an empty array if no files were + * uploaded. + * + * @phpstan-return UploadedFile[] + * + * Usage example: + * ```php + * $files = UploadedFile::getInstancesByName('file'); + * foreach ($files as $file) { + * // process each uploaded file + * } + * ``` + */ + public static function getInstancesByName($name): array + { + $files = self::loadFiles(); + + if (isset($files[$name])) { + return [new self($files[$name])]; + } + + $results = []; + + foreach ($files as $key => $file) { + if (str_starts_with($key, "{$name}[")) { + $results[] = new self($file); + } + } + + return $results; + } + + /** + * Resets the internal uploaded files cache and PSR-7 adapter state. + * + * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file + * handling operations. This method should be called to fully reset the file handling environment, including both + * legacy and PSR-7 file sources. + * + * Usage example: + * ```php + * UploadedFile::reset(); + * ``` + */ + public static function reset(): void + { + self::$_files = []; + self::$psr7Adapter = null; + self::$psr7FilesLoaded = false; + } + + /** + * Sets the PSR-7 ServerRequestAdapter for file handling. + * + * Configures the bridge to use PSR-7 UploadedFileInterface data instead of reading from $_FILES global. + * + * This method should be called when initializing PSR-7 request processing to enable clean file handling without + * global state modification. + * + * The adapter will be used by {@see loadFiles()} to populate the internal file cache directly from PSR-7 data, + * ensuring compatibility with both worker environments and traditional SAPI setups. + * + * @param ServerRequestAdapter $adapter PSR-7 adapter containing UploadedFileInterface instances. + * + * Usage example: + * ```php + * UploadedFile::setPsr7Adapter($serverRequestAdapter); + * ``` + */ + public static function setPsr7Adapter(ServerRequestAdapter $adapter): void + { + self::$_files = []; + self::$psr7Adapter = $adapter; + self::$psr7FilesLoaded = false; + } + + /** + * Converts a PSR-7 UploadedFileInterface to Yii2 UploadedFile format. + * + * Maps PSR-7 file properties to the array structure expected by Yii2 UploadedFile constructor, ensuring proper + * handling of file metadata, error codes, and stream resources. + * + * Handles edge cases such as missing metadata, stream resource extraction, and proper error code mapping to + * maintain full compatibility with Yii2 file validation and processing workflows. + * + * @param UploadedFileInterface $psr7File PSR-7 UploadedFileInterface to convert. + * + * @return array Yii2 compatible file data array. + * + * @phpstan-return array{ + * name: string, + * tempName: string, + * tempResource: resource|null, + * type: string, + * size: int, + * error: int, + * fullPath: string|null + * } + */ + private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr7File): array + { + $stream = $psr7File->getStream(); + $uri = $stream->getMetadata('uri'); + + return [ + 'name' => $psr7File->getClientFilename() ?? '', + 'tempName' => is_string($uri) ? $uri : '', + 'tempResource' => $stream->detach(), + 'type' => $psr7File->getClientMediaType() ?? '', + 'size' => $psr7File->getSize() ?? 0, + 'error' => $psr7File->getError(), + 'fullPath' => null, + ]; + } + + /** + * Loads and returns the internal uploaded files cache. + * + * Populates the internal file cache from either the PSR-7 adapter or the legacy $_FILES global, depending on the + * current configuration and state. + * + * This method ensures that the file cache is initialized only once per request lifecycle, and subsequent calls + * return the cached result. + * + * @return array Internal uploaded files cache. + * + * @phpstan-return array< + * string, + * array{ + * name: string, + * tempName: string, + * tempResource: resource|null, + * type: string, + * size: int, + * error: int, + * fullPath: string|null, + * } + * > + */ + private static function loadFiles(): array + { + if (self::$_files === []) { + if (self::$psr7Adapter !== null && self::$psr7FilesLoaded === false) { + self::$psr7FilesLoaded = true; + + self::loadPsr7Files(); + } elseif (self::$psr7Adapter === null) { + self::loadLegacyFiles(); + } + } + + return self::$_files; + } + + /** + * Recursive reformats data of uploaded file(s) for legacy compatibility. + * + * This is a copy of the parent class private method to maintain compatibility with legacy $_FILES processing. + * + * The method handles both single files and array structures, preserving the original Yii2 logic. + * + * @param string $key Key for identifying uploaded file (sub-array index). + * @param string[]|string $names File name(s) provided by PHP. + * @param string[]|string $tempNames Temporary file name(s) provided by PHP. + * @param string[]|string $types File type(s) provided by PHP. + * @param int[]|int $sizes File size(s) provided by PHP. + * @param int[]|int $errors Uploading issue(s) provided by PHP. + * @param mixed[]|string|null $fullPaths Full path(s) as submitted by the browser/PHP. + * @param mixed[]|mixed $tempResources Resource(s) of temporary file(s) provided by PHP. + */ + private static function loadFilesRecursiveInternal( + string $key, + array|string $names, + array|string $tempNames, + array|string $types, + array|int $sizes, + array|int $errors, + array|string|null $fullPaths, + mixed $tempResources, + ): void { + if (is_array($names)) { + foreach ($names as $i => $name) { + self::loadFilesRecursiveInternal( + $key . '[' . $i . ']', + $name, + $tempNames[$i] ?? '', + $types[$i] ?? '', + $sizes[$i] ?? 0, + $errors[$i] ?? UPLOAD_ERR_NO_FILE, + isset($fullPaths[$i]) && is_string($fullPaths[$i]) ? $fullPaths[$i] : null, + (is_array($tempResources) && isset($tempResources[$i])) ? $tempResources[$i] : null, + ); + } + } elseif ($errors !== UPLOAD_ERR_NO_FILE) { + self::$_files[$key] = [ + 'name' => $names, + 'tempName' => is_array($tempNames) ? '' : $tempNames, + 'tempResource' => is_resource($tempResources) ? $tempResources : null, + 'type' => is_array($types) ? '' : $types, + 'size' => is_array($sizes) ? 0 : $sizes, + 'error' => is_array($errors) ? UPLOAD_ERR_NO_FILE : $errors, + 'fullPath' => is_string($fullPaths) ? $fullPaths : null, + ]; + } + } + + /** + * Loads uploaded files from legacy $_FILES global array. + * + * Provides fallback functionality for traditional SAPI environments where PSR-7 is not available. + * + * This method is called automatically when no PSR-7 adapter is set, ensuring seamless operation in legacy + * environments without any configuration changes. + */ + private static function loadLegacyFiles(): void + { + /** + * @phpstan-var array< + * string, + * array{ + * name: string|string[], + * tmp_name: string|string[], + * type: string|string[], + * size: int|int[], + * error: int|int[], + * full_path?: string|string[]|null, + * tmp_resource?: resource|null|array, + * } + * > $_FILES + */ + foreach ($_FILES as $key => $info) { + self::loadFilesRecursiveInternal( + $key, + $info['name'], + $info['tmp_name'], + $info['type'], + $info['size'], + $info['error'], + $info['full_path'] ?? null, + $info['tmp_resource'] ?? null, + ); + } + } + + /** + * Loads uploaded files from PSR-7 UploadedFileInterface instances. + * + * Converts PSR-7 UploadedFileInterface data to Yii2 compatible format and populates the internal file cache + * directly, avoiding global $_FILES manipulation while maintaining full compatibility with Yii2 file handling + * expectations. + * + * Handles both simple file uploads and complex nested file arrays, ensuring proper structure preservation for + * form-based file uploads with array notation. + * + * This method enables clean separation of PSR-7 file handling from global state, improving testability and worker + * environment compatibility. + */ + private static function loadPsr7Files(): void + { + /** @phpstan-var array */ + $uploadedFiles = self::$psr7Adapter?->getUploadedFiles() ?? []; + + foreach ($uploadedFiles as $name => $file) { + self::processPsr7File($name, $file); + } + } + + /** + * Processes a PSR-7 UploadedFileInterface and converts it to Yii2 format. + * + * Handles both single files and arrays of files, recursively processing nested structures to maintain compatibility + * with complex form-based file uploads using array notation. + * + * Converts PSR-7 UploadedFileInterface properties to the format expected by Yii2 UploadedFile, including proper + * error code mapping and stream resource handling. + * + * @param string $name Field name for the uploaded file(s). + * @param UploadedFileInterface|mixed[] $file PSR-7 UploadedFileInterface or array of files. + */ + private static function processPsr7File(string $name, UploadedFileInterface|array $file): void + { + if ($file instanceof UploadedFileInterface) { + self::$_files[$name] = self::convertPsr7FileToLegacyFormat($file); + } elseif (is_array($file)) { + foreach ($file as $key => $nestedFile) { + $nestedName = $name . '[' . $key . ']'; + + if ($nestedFile instanceof UploadedFileInterface || is_array($nestedFile)) { + self::processPsr7File($nestedName, $nestedFile); + } + } + } + } +} diff --git a/tests/http/stateless/StatelessApplicationUploadedTest.php b/tests/http/stateless/StatelessApplicationUploadedTest.php new file mode 100644 index 00000000..d0c6a60f --- /dev/null +++ b/tests/http/stateless/StatelessApplicationUploadedTest.php @@ -0,0 +1,141 @@ +createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + $size1 = filesize($tmpPath1); + + self::assertIsInt( + $size1, + 'Temporary file should have a valid size greater than zero.', + ); + + $app = $this->statelessApplication(); + + $response = $app->handle( + FactoryHelper::createRequest('POST', '/site/post', parsedBody: ['action' => 'upload']) + ->withUploadedFiles( + [ + 'file1' => FactoryHelper::createUploadedFile( + 'test1.txt', + 'text/plain', + $tmpPath1, + size: $size1, + ), + ], + ), + ); + + self::assertSame( + 200, + $response->getStatusCode(), + "Expected HTTP '200' for route '/site/post'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'application/json; charset=UTF-8' for route '/site/post'.", + ); + self::assertJsonStringEqualsJsonString( + <<getBody()->getContents(), + "Expected PSR-7 Response body '{\"action\":\"upload\"}'.", + ); + self::assertNotEmpty( + UploadedFile::getInstancesByName('file1'), + 'Expected PSR-7 Request should have uploaded files.', + ); + + $response = $app->handle( + FactoryHelper::createRequest('GET', '/site/post') + ->withQueryParams(['action' => 'check']), + ); + + self::assertSame( + 200, + $response->getStatusCode(), + "Expected HTTP '200' for route '/site/post'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'application/json; charset=UTF-8' for route '/site/post'.", + ); + self::assertSame( + '[]', + $response->getBody()->getContents(), + 'Expected PSR-7 Response body to be empty for POST request with no uploaded files.', + ); + + self::assertEmpty( + UploadedFile::getInstancesByName('file1'), + 'PSR-7 Request should NOT have uploaded files from previous request.', + ); + + $tmpFile3 = $this->createTmpFile(); + + $tmpPath3 = stream_get_meta_data($tmpFile3)['uri']; + $size3 = filesize($tmpPath3); + + self::assertIsInt( + $size3, + 'Temporary file should have a valid size greater than zero.', + ); + + $response = $app->handle( + FactoryHelper::createRequest('POST', '/site/post', parsedBody: ['action' => 'upload']) + ->withUploadedFiles( + [ + 'file2' => FactoryHelper::createUploadedFile( + 'test3.txt', + 'text/plain', + $tmpPath3, + size: $size3, + ), + ], + ), + ); + + self::assertSame( + 200, + $response->getStatusCode(), + "Expected HTTP '200' for route '/site/post'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'application/json; charset=UTF-8' for route '/site/post'.", + ); + self::assertJsonStringEqualsJsonString( + <<getBody()->getContents(), + "Expected PSR-7 Response body '{\"action\":\"upload\"}'.", + ); + self::assertNotEmpty( + UploadedFile::getInstancesByName('file2'), + 'Expected PSR-7 Request should have uploaded files.', + ); + self::assertEmpty( + UploadedFile::getInstancesByName('file1'), + 'Files from first request should still not be present after third request', + ); + } +} From 348810ad964b55e3f1d4fcdb9636dcb16ce4e6f1 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 16:06:11 +0000 Subject: [PATCH 02/25] Apply fixes from StyleCI --- src/http/UploadedFile.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index e781b7d5..0339d054 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -89,7 +89,7 @@ final class UploadedFile extends \yii\web\UploadedFile * } * ``` */ - public static function getInstance($model, $attribute): UploadedFile|null + public static function getInstance($model, $attribute): self|null { $name = Html::getInputName($model, $attribute); @@ -112,7 +112,7 @@ public static function getInstance($model, $attribute): UploadedFile|null * } * ``` */ - public static function getInstanceByName($name): UploadedFile|null + public static function getInstanceByName($name): self|null { $files = self::loadFiles(); @@ -320,13 +320,13 @@ private static function loadFiles(): array * The method handles both single files and array structures, preserving the original Yii2 logic. * * @param string $key Key for identifying uploaded file (sub-array index). - * @param string[]|string $names File name(s) provided by PHP. - * @param string[]|string $tempNames Temporary file name(s) provided by PHP. - * @param string[]|string $types File type(s) provided by PHP. - * @param int[]|int $sizes File size(s) provided by PHP. - * @param int[]|int $errors Uploading issue(s) provided by PHP. + * @param string|string[] $names File name(s) provided by PHP. + * @param string|string[] $tempNames Temporary file name(s) provided by PHP. + * @param string|string[] $types File type(s) provided by PHP. + * @param int|int[] $sizes File size(s) provided by PHP. + * @param int|int[] $errors Uploading issue(s) provided by PHP. * @param mixed[]|string|null $fullPaths Full path(s) as submitted by the browser/PHP. - * @param mixed[]|mixed $tempResources Resource(s) of temporary file(s) provided by PHP. + * @param mixed|mixed[] $tempResources Resource(s) of temporary file(s) provided by PHP. */ private static function loadFilesRecursiveInternal( string $key, @@ -435,7 +435,7 @@ private static function loadPsr7Files(): void * error code mapping and stream resource handling. * * @param string $name Field name for the uploaded file(s). - * @param UploadedFileInterface|mixed[] $file PSR-7 UploadedFileInterface or array of files. + * @param mixed[]|UploadedFileInterface $file PSR-7 UploadedFileInterface or array of files. */ private static function processPsr7File(string $name, UploadedFileInterface|array $file): void { From bee44e9c713ec08dac232f374f7f643ef8aa1d21 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 12:10:01 -0400 Subject: [PATCH 03/25] fix(http): Remove unnecessary PHPStan ignore comment from UploadedFile class. --- src/http/UploadedFile.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 0339d054..479cdea2 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -53,8 +53,6 @@ final class UploadedFile extends \yii\web\UploadedFile * fullPath: string|null * } * > - * - * @phpstan-ignore property.phpDocType */ public static $_files = []; From dbd6bcdb92bc52db72a64040b8d18be589e40626 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 12:29:23 -0400 Subject: [PATCH 04/25] refactor(http): Remove unused PSR-7 adapter reference in UploadedFile reset method. --- src/http/StatelessApplication.php | 2 +- src/http/UploadedFile.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index d07ee958..6ac52fb9 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -9,7 +9,7 @@ use Throwable; use yii\base\{Event, InvalidConfigException}; use yii\di\{Container, NotInstantiableException}; -use yii\web\{Application, UploadedFile}; +use yii\web\Application; use function array_merge; use function array_reverse; diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 479cdea2..f81f27f4 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -203,7 +203,6 @@ public static function getInstancesByName($name): array public static function reset(): void { self::$_files = []; - self::$psr7Adapter = null; self::$psr7FilesLoaded = false; } From 196da6ca14b9669d69eb77e23efaa7e9766b3357 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 12:38:44 -0400 Subject: [PATCH 05/25] refactor(http): Remove UploadedFile reset method and update lifecycle finalization documentation. --- src/http/StatelessApplication.php | 4 +--- src/http/UploadedFile.php | 18 ------------------ 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 6ac52fb9..184b3b6d 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -441,7 +441,7 @@ protected function reset(ServerRequestInterface $request): void /** * Finalizes the application lifecycle and converts the Yii2 Response to a PSR-7 ResponseInterface. * - * Cleans up registered events, resets uploaded files, flushes the logger. + * Cleans up registered events, and flushes the logger. * * This method ensures that all application resources are released and the response is converted to a PSR-7 * ResponseInterface for interoperability with PSR-7 compatible HTTP stacks. @@ -457,8 +457,6 @@ protected function terminate(Response $response): ResponseInterface { $this->cleanupEvents(); - UploadedFile::reset(); - if ($this->flushLogger) { $this->getLog()->getLogger()->flush(true); } diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index f81f27f4..3ee4c601 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -188,24 +188,6 @@ public static function getInstancesByName($name): array return $results; } - /** - * Resets the internal uploaded files cache and PSR-7 adapter state. - * - * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file - * handling operations. This method should be called to fully reset the file handling environment, including both - * legacy and PSR-7 file sources. - * - * Usage example: - * ```php - * UploadedFile::reset(); - * ``` - */ - public static function reset(): void - { - self::$_files = []; - self::$psr7FilesLoaded = false; - } - /** * Sets the PSR-7 ServerRequestAdapter for file handling. * From 3ad793e245fd3e71d723843462b8809c4dbfd0cb Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 16:42:21 -0400 Subject: [PATCH 06/25] test(http): Add tests for UploadedFile handling in stateless application and legacy file loading. --- tests/http/UploadedFileTest.php | 134 ++++++++++++++++++ .../StatelessApplicationUploadedTest.php | 104 ++++++++++++-- tests/support/FactoryHelper.php | 4 +- 3 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 tests/http/UploadedFileTest.php diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php new file mode 100644 index 00000000..547770ac --- /dev/null +++ b/tests/http/UploadedFileTest.php @@ -0,0 +1,134 @@ + [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phptest', + 'error' => UPLOAD_ERR_OK, + 'size' => 100, + ], + ]; + + $uploadFile = UploadedFile::getInstanceByName('upload'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadFile, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'test.txt', + $uploadFile->name, + 'Should preserve \'name\' from $_FILES.', + ); + self::assertSame( + 'text/plain', + $uploadFile->type, + 'Should preserve \'type\' from $_FILES.', + ); + self::assertSame( + '/tmp/phptest', + $uploadFile->tempName, + 'Should preserve \'tmp_name\' from $_FILES.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadFile->error, + 'Should preserve \'error\' from $_FILES.', + ); + self::assertSame( + 100, + $uploadFile->size, + 'Should preserve \'size\' from $_FILES.', + ); + } + + public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr7(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPathFile1 = stream_get_meta_data($tmpFile1)['uri']; + file_put_contents($tmpPathFile1, 'content1'); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPathFile2 = stream_get_meta_data($tmpFile2)['uri']; + file_put_contents($tmpPathFile2, 'content2'); + + $adapter = new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'http://example.com') + ->withUploadedFiles( + [ + 'files' => [ + FactoryHelper::createUploadedFile( + 'file1.txt', + 'text/plain', + FactoryHelper::createStream($tmpPathFile1), + UPLOAD_ERR_OK, + 8, + ), + FactoryHelper::createUploadedFile( + 'file2.txt', + 'text/plain', + FactoryHelper::createStream($tmpPathFile2), + UPLOAD_ERR_OK, + 8, + ), + ], + ], + ), + ); + + UploadedFile::setPsr7Adapter($adapter); + + $uploadFile1 = UploadedFile::getInstanceByName('files[0]'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadFile1, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'file1.txt', + $uploadFile1->name, + "Should preserve 'name' from PSR-7 UploadedFile.", + ); + + $uploadFile2 = UploadedFile::getInstanceByName('files[1]'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadFile2, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'file2.txt', + $uploadFile2->name, + "Should preserve 'name' from PSR-7 UploadedFile.", + ); + self::assertNotSame( + $uploadFile1, + $uploadFile2, + 'Should return different instances for different files.', + ); + } +} diff --git a/tests/http/stateless/StatelessApplicationUploadedTest.php b/tests/http/stateless/StatelessApplicationUploadedTest.php index d0c6a60f..9c656cfe 100644 --- a/tests/http/stateless/StatelessApplicationUploadedTest.php +++ b/tests/http/stateless/StatelessApplicationUploadedTest.php @@ -2,16 +2,105 @@ declare(strict_types=1); -namespace yii2\extensions\psrbridge\tests\http; +namespace yii2\extensions\psrbridge\tests\http\stateless; use PHPUnit\Framework\Attributes\Group; +use yii\base\InvalidConfigException; +use yii2\extensions\psrbridge\creator\ServerRequestCreator; use yii2\extensions\psrbridge\http\UploadedFile; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; +use function filesize; +use function stream_get_meta_data; + #[Group('http')] final class StatelessApplicationUploadedTest extends TestCase { + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testCreateUploadedFileFromSuperglobalWhenMultipartFormDataPosted(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $_FILES['avatar'] = [ + 'name' => 'profile.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmpPath, + 'error' => UPLOAD_ERR_OK, + 'size' => 1024, + ]; + $_POST = ['action' => 'upload']; + $_SERVER = [ + 'CONTENT_TYPE' => 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_HOST' => 'example.com', + 'HTTP_USER_AGENT' => 'PHPUnit Test', + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/site/post', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + ]; + + $creator = new ServerRequestCreator( + FactoryHelper::createServerRequestFactory(), + FactoryHelper::createStreamFactory(), + FactoryHelper::createUploadedFileFactory(), + ); + + $app = $this->statelessApplication(); + + $response = $app->handle($creator->createFromGlobals()); + + self::assertSame( + 200, + $response->getStatusCode(), + "Expected HTTP '200' for route '/site/post'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'application/json; charset=UTF-8' for route '/site/post'.", + ); + self::assertJsonStringEqualsJsonString( + <<getBody()->getContents(), + "Expected PSR-7 Response body '{\"action\":\"upload\"}'.", + ); + + $uploadedFiles = UploadedFile::getInstancesByName('avatar'); + + foreach ($uploadedFiles as $uploadedFile) { + self::assertSame( + 'profile.jpg', + $uploadedFile->name, + 'Should preserve \'name\' from $_FILES.', + ); + self::assertSame( + 'image/jpeg', + $uploadedFile->type, + 'Should preserve \'type\' from $_FILES.', + ); + self::assertSame( + 1024, + $uploadedFile->size, + 'Should preserve \'size\' from $_FILES.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadedFile->error, + 'Should preserve \'error\' from $_FILES.', + ); + } + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ public function testUploadedFilesAreResetBetweenRequests(): void { $tmpFile1 = $this->createTmpFile(); @@ -82,19 +171,18 @@ public function testUploadedFilesAreResetBetweenRequests(): void $response->getBody()->getContents(), 'Expected PSR-7 Response body to be empty for POST request with no uploaded files.', ); - self::assertEmpty( UploadedFile::getInstancesByName('file1'), 'PSR-7 Request should NOT have uploaded files from previous request.', ); - $tmpFile3 = $this->createTmpFile(); + $tmpFile2 = $this->createTmpFile(); - $tmpPath3 = stream_get_meta_data($tmpFile3)['uri']; - $size3 = filesize($tmpPath3); + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + $size2 = filesize($tmpPath2); self::assertIsInt( - $size3, + $size2, 'Temporary file should have a valid size greater than zero.', ); @@ -105,8 +193,8 @@ public function testUploadedFilesAreResetBetweenRequests(): void 'file2' => FactoryHelper::createUploadedFile( 'test3.txt', 'text/plain', - $tmpPath3, - size: $size3, + $tmpPath2, + size: $size2, ), ], ), diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index 78e6decd..f589c826 100644 --- a/tests/support/FactoryHelper.php +++ b/tests/support/FactoryHelper.php @@ -213,7 +213,7 @@ public static function createStreamFactory(): StreamFactoryInterface * * @param string $name Client filename. * @param string $type Client media type. - * @param string $tmpName Temporary file name. + * @param string|StreamInterface $tmpName Temporary file name or stream. * @param int $error Upload error code. * @param int $size File size. * @@ -227,7 +227,7 @@ public static function createStreamFactory(): StreamFactoryInterface public static function createUploadedFile( string $name = '', string $type = '', - string $tmpName = '', + string|StreamInterface $tmpName = '', int $error = 0, int $size = 0, ): UploadedFileInterface { From d73bbab22d54f4940375b75fc69d18ffd18a6b05 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 20:42:57 +0000 Subject: [PATCH 07/25] Apply fixes from StyleCI --- tests/support/FactoryHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index f589c826..31286e30 100644 --- a/tests/support/FactoryHelper.php +++ b/tests/support/FactoryHelper.php @@ -213,7 +213,7 @@ public static function createStreamFactory(): StreamFactoryInterface * * @param string $name Client filename. * @param string $type Client media type. - * @param string|StreamInterface $tmpName Temporary file name or stream. + * @param StreamInterface|string $tmpName Temporary file name or stream. * @param int $error Upload error code. * @param int $size File size. * From 38fc95c24bb4adab43246b9c211050a807595f71 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 16:48:32 -0400 Subject: [PATCH 08/25] feat(http): Add reset method to `UploadedFile` for clearing internal state. --- src/http/UploadedFile.php | 19 +++++++++++++++++++ tests/http/UploadedFileTest.php | 2 ++ 2 files changed, 21 insertions(+) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 3ee4c601..479cdea2 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -188,6 +188,25 @@ public static function getInstancesByName($name): array return $results; } + /** + * Resets the internal uploaded files cache and PSR-7 adapter state. + * + * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file + * handling operations. This method should be called to fully reset the file handling environment, including both + * legacy and PSR-7 file sources. + * + * Usage example: + * ```php + * UploadedFile::reset(); + * ``` + */ + public static function reset(): void + { + self::$_files = []; + self::$psr7Adapter = null; + self::$psr7FilesLoaded = false; + } + /** * Sets the PSR-7 ServerRequestAdapter for file handling. * diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 547770ac..fa059086 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -28,6 +28,8 @@ public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void ], ]; + UploadedFile::reset(); + $uploadFile = UploadedFile::getInstanceByName('upload'); self::assertInstanceOf( From a916a1e6db5db8c03f6c4bcf06464dbb6d1e9532 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 17:10:37 -0400 Subject: [PATCH 09/25] feat(http): Enhance UploadedFile handling for multiple file uploads and trailing brackets. --- src/http/UploadedFile.php | 14 ++++++------- tests/http/UploadedFileTest.php | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 479cdea2..a575831f 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -12,7 +12,9 @@ use function is_array; use function is_resource; use function is_string; +use function str_ends_with; use function str_starts_with; +use function substr; /** * Uploaded file handler with PSR-7 bridge support. @@ -173,6 +175,10 @@ public static function getInstancesByName($name): array { $files = self::loadFiles(); + if (str_ends_with($name, '[]')) { + $name = substr($name, 0, -2); + } + if (isset($files[$name])) { return [new self($files[$name])]; } @@ -189,11 +195,7 @@ public static function getInstancesByName($name): array } /** - * Resets the internal uploaded files cache and PSR-7 adapter state. - * - * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file - * handling operations. This method should be called to fully reset the file handling environment, including both - * legacy and PSR-7 file sources. + * Resets the internal uploaded files cache. * * Usage example: * ```php @@ -203,8 +205,6 @@ public static function getInstancesByName($name): array public static function reset(): void { self::$_files = []; - self::$psr7Adapter = null; - self::$psr7FilesLoaded = false; } /** diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index fa059086..b3661f9c 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -102,6 +102,42 @@ public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr UploadedFile::setPsr7Adapter($adapter); + $allFiles = UploadedFile::getInstancesByName('files'); + + self::assertCount( + 2, + $allFiles, + 'Should return both files when querying by base name.', + ); + self::assertInstanceOf( + UploadedFile::class, + $allFiles[0] ?? null, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'file1.txt', + $allFiles[0]->name, + "Should preserve 'name' from PSR-7 UploadedFile.", + ); + self::assertInstanceOf( + UploadedFile::class, + $allFiles[1] ?? null, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'file2.txt', + $allFiles[1]->name, + "Should preserve 'name' from PSR-7 UploadedFile.", + ); + + $filesWithBrackets = UploadedFile::getInstancesByName('files[]'); + + self::assertCount( + 2, + $filesWithBrackets, + "Should handle trailing '[]' notation.", + ); + $uploadFile1 = UploadedFile::getInstanceByName('files[0]'); self::assertInstanceOf( From 08c8146a5a65d0824b31c37535dc6dc9ba1196c2 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 17:15:17 -0400 Subject: [PATCH 10/25] feat(http): Enhance reset method to clear PSR-7 adapter state and uploaded files cache. --- src/http/UploadedFile.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index a575831f..c4558800 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -195,7 +195,11 @@ public static function getInstancesByName($name): array } /** - * Resets the internal uploaded files cache. + * Resets the internal uploaded files cache and PSR-7 adapter state. + * + * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file + * handling operations. This method should be called to fully reset the file handling environment, including both + * legacy and PSR-7 file sources. * * Usage example: * ```php @@ -205,6 +209,7 @@ public static function getInstancesByName($name): array public static function reset(): void { self::$_files = []; + self::$psr7Adapter = null; } /** From a174e4c94f989b0f460b5b456992edf4af03361b Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 17:40:02 -0400 Subject: [PATCH 11/25] feat(http): Update UploadedFile and Request classes to improve resource management and enhance reset method functionality. --- src/http/Request.php | 3 +- src/http/UploadedFile.php | 30 +++++++++++++---- tests/http/UploadedFileTest.php | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/http/Request.php b/src/http/Request.php index c13234cf..d7a7f3e3 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -777,8 +777,7 @@ public function resolve(): array * This method injects a stateless application start time header (`statelessAppStartTime`) into the request for * tracing and debugging purposes. * - * Once set, all request operations will use the PSR-7 ServerRequestInterface via the adapter until {@see reset()} - * is called. + * Once set, all request operations will use the PSR-7 ServerRequestInterface via the adapter until is called. * * @param ServerRequestInterface $request PSR-7 ServerRequestInterface instance to bridge. * diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index c4558800..e8239dbb 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -9,6 +9,7 @@ use yii\helpers\Html; use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; +use function fclose; use function is_array; use function is_resource; use function is_string; @@ -29,7 +30,6 @@ * Key features. * - Compatible with both legacy and modern PHP runtimes. * - Conversion utilities for PSR-7 UploadedFileInterface to Yii2 format. - * - Immutable, type-safe access to uploaded file metadata and streams. * - Internal cache for efficient file lookup and repeated access. * - PSR-7 ServerRequestAdapter integration for file handling without global state. * @@ -195,11 +195,11 @@ public static function getInstancesByName($name): array } /** - * Resets the internal uploaded files cache and PSR-7 adapter state. + * Resets the internal uploaded files cache, PSR-7 adapter state, and closes any open tempResource handles. * - * Clears all cached uploaded file data and PSR-7 adapter references, ensuring a clean state for subsequent file - * handling operations. This method should be called to fully reset the file handling environment, including both - * legacy and PSR-7 file sources. + * Clears all cached uploaded file data, PSR-7 adapter references and closes any open tempResource handles, ensuring + * a clean state for subsequent file handling operations. This method should be called to fully reset the file + * handling environment, including both legacy and PSR-7 file sources. * * Usage example: * ```php @@ -210,6 +210,8 @@ public static function reset(): void { self::$_files = []; self::$psr7Adapter = null; + + self::closeResources(); } /** @@ -232,11 +234,25 @@ public static function reset(): void */ public static function setPsr7Adapter(ServerRequestAdapter $adapter): void { + self::closeResources(); + self::$_files = []; self::$psr7Adapter = $adapter; self::$psr7FilesLoaded = false; } + /** + * Closes any open tempResource handles stored in the internal cache. + */ + private static function closeResources(): void + { + foreach (self::$_files as $entry) { + if (isset($entry['tempResource']) && is_resource($entry['tempResource'])) { + @fclose($entry['tempResource']); + } + } + } + /** * Converts a PSR-7 UploadedFileInterface to Yii2 UploadedFile format. * @@ -304,9 +320,9 @@ private static function loadFiles(): array { if (self::$_files === []) { if (self::$psr7Adapter !== null && self::$psr7FilesLoaded === false) { - self::$psr7FilesLoaded = true; - self::loadPsr7Files(); + + self::$psr7FilesLoaded = true; } elseif (self::$psr7Adapter === null) { self::loadLegacyFiles(); } diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index b3661f9c..ab8e5b88 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -169,4 +169,63 @@ public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr 'Should return different instances for different files.', ); } + + public function testResetMethodShouldCloseDetachedResources(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpPath, 'Test content for reset method test'); + + $uploadedFile = FactoryHelper::createUploadedFile( + 'reset-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 32 + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles(['reset-test' => $uploadedFile]) + ), + ); + + $uploadedFile = UploadedFile::getInstanceByName('reset-test'); + + self::assertNotNull( + $uploadedFile, + 'Should retrieve uploaded file for reset test.', + ); + + $resourcesBeforeReset = []; + + foreach (UploadedFile::$_files as $fileData) { + if (isset($fileData['tempResource']) && is_resource($fileData['tempResource'])) { + $resourcesBeforeReset[] = $fileData['tempResource']; + } + } + + self::assertNotEmpty( + $resourcesBeforeReset, + 'Should have detached resources before reset.', + ); + + UploadedFile::reset(); + + $stillOpenAfterReset = 0; + + foreach ($resourcesBeforeReset as $resource) { + if (is_resource($resource)) { + $stillOpenAfterReset++; + } + } + + self::assertGreaterThan( + 0, + $stillOpenAfterReset, + "Resources should still be open after current 'reset()' implementation, showing the need for improvement.", + ); + } } From fe97d0cc562e09552e65c3137dfa714e29b1d444 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 17:41:39 -0400 Subject: [PATCH 12/25] test(http): Add test for reset method to verify closure of detached resources. --- tests/http/UploadedFileTest.php | 118 ++++++++++++++++---------------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index ab8e5b88..4937610b 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -64,6 +64,65 @@ public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void ); } + public function testResetMethodShouldCloseDetachedResources(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpPath, 'Test content for reset method test'); + + $uploadedFile = FactoryHelper::createUploadedFile( + 'reset-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 32, + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles(['reset-test' => $uploadedFile]), + ), + ); + + $uploadedFile = UploadedFile::getInstanceByName('reset-test'); + + self::assertNotNull( + $uploadedFile, + 'Should retrieve uploaded file for reset test.', + ); + + $resourcesBeforeReset = []; + + foreach (UploadedFile::$_files as $fileData) { + if (isset($fileData['tempResource']) && is_resource($fileData['tempResource'])) { + $resourcesBeforeReset[] = $fileData['tempResource']; + } + } + + self::assertNotEmpty( + $resourcesBeforeReset, + 'Should have detached resources before reset.', + ); + + UploadedFile::reset(); + + $stillOpenAfterReset = 0; + + foreach ($resourcesBeforeReset as $resource) { + if (is_resource($resource)) { + $stillOpenAfterReset++; + } + } + + self::assertGreaterThan( + 0, + $stillOpenAfterReset, + "Resources should still be open after current 'reset()' implementation, showing the need for improvement.", + ); + } + public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr7(): void { $tmpFile1 = $this->createTmpFile(); @@ -169,63 +228,4 @@ public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr 'Should return different instances for different files.', ); } - - public function testResetMethodShouldCloseDetachedResources(): void - { - $tmpFile = $this->createTmpFile(); - - $tmpPath = stream_get_meta_data($tmpFile)['uri']; - file_put_contents($tmpPath, 'Test content for reset method test'); - - $uploadedFile = FactoryHelper::createUploadedFile( - 'reset-test.txt', - 'text/plain', - FactoryHelper::createStream($tmpPath), - UPLOAD_ERR_OK, - 32 - ); - - UploadedFile::setPsr7Adapter( - new ServerRequestAdapter( - FactoryHelper::createRequest('POST', 'site/post') - ->withUploadedFiles(['reset-test' => $uploadedFile]) - ), - ); - - $uploadedFile = UploadedFile::getInstanceByName('reset-test'); - - self::assertNotNull( - $uploadedFile, - 'Should retrieve uploaded file for reset test.', - ); - - $resourcesBeforeReset = []; - - foreach (UploadedFile::$_files as $fileData) { - if (isset($fileData['tempResource']) && is_resource($fileData['tempResource'])) { - $resourcesBeforeReset[] = $fileData['tempResource']; - } - } - - self::assertNotEmpty( - $resourcesBeforeReset, - 'Should have detached resources before reset.', - ); - - UploadedFile::reset(); - - $stillOpenAfterReset = 0; - - foreach ($resourcesBeforeReset as $resource) { - if (is_resource($resource)) { - $stillOpenAfterReset++; - } - } - - self::assertGreaterThan( - 0, - $stillOpenAfterReset, - "Resources should still be open after current 'reset()' implementation, showing the need for improvement.", - ); - } } From d7e7f9858e06d4140801af6ea05246e041375e2e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 17:59:16 -0400 Subject: [PATCH 13/25] feat(http): Enhance `UploadedFile` conversion to handle upload errors gracefully. --- src/http/UploadedFile.php | 16 +++++++++- tests/http/UploadedFileTest.php | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index e8239dbb..868c6acb 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -278,6 +278,20 @@ private static function closeResources(): void */ private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr7File): array { + $error = $psr7File->getError(); + + if ($error !== UPLOAD_ERR_OK) { + return [ + 'name' => $psr7File->getClientFilename() ?? '', + 'tempName' => '', + 'tempResource' => null, + 'type' => $psr7File->getClientMediaType() ?? '', + 'size' => $psr7File->getSize() ?? 0, + 'error' => $error, + 'fullPath' => null, + ]; + } + $stream = $psr7File->getStream(); $uri = $stream->getMetadata('uri'); @@ -287,7 +301,7 @@ private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr 'tempResource' => $stream->detach(), 'type' => $psr7File->getClientMediaType() ?? '', 'size' => $psr7File->getSize() ?? 0, - 'error' => $psr7File->getError(), + 'error' => $error, 'fullPath' => null, ]; } diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 4937610b..d49c7b50 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -16,6 +16,61 @@ final class UploadedFileTest extends TestCase { + public function testConvertPsr7FileWithErrorShouldNotThrowException(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $uploadedFileWithError = FactoryHelper::createUploadedFile( + 'error-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 100, + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles(['error-file' => $uploadedFileWithError]), + ), + ); + + $uploadedFile = UploadedFile::getInstanceByName('error-file'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile even when there is an upload error.', + ); + self::assertSame( + 'error-file.txt', + $uploadedFile->name, + "Should preserve the original file 'name' even when there is an upload error.", + ); + self::assertSame( + 'text/plain', + $uploadedFile->type, + "Should preserve the original file 'type' even when there is an upload error.", + ); + self::assertSame( + '', + $uploadedFile->tempName, + 'Should have an empty tempName when there is an upload error.', + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $uploadedFile->error, + 'Should preserve the upload error code from PSR-7 UploadedFile.', + ); + self::assertSame( + 100, + $uploadedFile->size, + "Should preserve the original file 'size' even when there is an upload error.", + ); + } + public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void { $_FILES = [ From 6f50895fb89a41b433d0c56a75327045f3a3d4d7 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 18:13:49 -0400 Subject: [PATCH 14/25] test(http): Ensure UploadedFile `reset()` method is called in `setUp` method to maintain test isolation. --- tests/http/UploadedFileTest.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index d49c7b50..bbec1482 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -16,6 +16,13 @@ final class UploadedFileTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + UploadedFile::reset(); + } + public function testConvertPsr7FileWithErrorShouldNotThrowException(): void { $tmpFile = $this->createTmpFile(); @@ -83,8 +90,6 @@ public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void ], ]; - UploadedFile::reset(); - $uploadFile = UploadedFile::getInstanceByName('upload'); self::assertInstanceOf( From 6c97da9e90ea033cff8e6f2b02ed6a414099142a Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 18:25:39 -0400 Subject: [PATCH 15/25] fix(http): Simplify resource closure logic in closeResources method. --- src/http/UploadedFile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 868c6acb..cfc15840 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -247,7 +247,7 @@ public static function setPsr7Adapter(ServerRequestAdapter $adapter): void private static function closeResources(): void { foreach (self::$_files as $entry) { - if (isset($entry['tempResource']) && is_resource($entry['tempResource'])) { + if (is_resource($entry['tempResource'])) { @fclose($entry['tempResource']); } } From da822e2cabdd2878d8e82e17edb66a2ff4985840 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 18:32:55 -0400 Subject: [PATCH 16/25] fix(http): Ensure resources are closed before resetting UploadedFile state. --- src/http/UploadedFile.php | 4 ++-- tests/http/UploadedFileTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index cfc15840..ead64215 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -208,10 +208,10 @@ public static function getInstancesByName($name): array */ public static function reset(): void { + self::closeResources(); + self::$_files = []; self::$psr7Adapter = null; - - self::closeResources(); } /** diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index bbec1482..8990ebea 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -176,10 +176,10 @@ public function testResetMethodShouldCloseDetachedResources(): void } } - self::assertGreaterThan( + self::assertSame( 0, $stillOpenAfterReset, - "Resources should still be open after current 'reset()' implementation, showing the need for improvement.", + "All resources should be closed after `reset()` method.", ); } From 82326d722682150e2f5a1720bf301cd7c33842e4 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 22:33:18 +0000 Subject: [PATCH 17/25] Apply fixes from StyleCI --- tests/http/UploadedFileTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 8990ebea..146fff43 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -179,7 +179,7 @@ public function testResetMethodShouldCloseDetachedResources(): void self::assertSame( 0, $stillOpenAfterReset, - "All resources should be closed after `reset()` method.", + 'All resources should be closed after `reset()` method.', ); } From f94a1d17496970925d5dbeee5ae9fe4f0eae8314 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 18:40:27 -0400 Subject: [PATCH 18/25] refactor(http): Remove unused `psr7FilesLoaded` flag and update related logic. --- src/http/UploadedFile.php | 10 +--------- tests/http/UploadedFileTest.php | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index ead64215..880a1a71 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -63,11 +63,6 @@ final class UploadedFile extends \yii\web\UploadedFile */ private static ServerRequestAdapter|null $psr7Adapter = null; - /** - * Flag indicating whether PSR-7 files have been loaded into the internal cache. - */ - private static bool $psr7FilesLoaded = false; - /** * Returns the instance of the uploaded file associated with the specified model attribute. * @@ -238,7 +233,6 @@ public static function setPsr7Adapter(ServerRequestAdapter $adapter): void self::$_files = []; self::$psr7Adapter = $adapter; - self::$psr7FilesLoaded = false; } /** @@ -333,10 +327,8 @@ private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr private static function loadFiles(): array { if (self::$_files === []) { - if (self::$psr7Adapter !== null && self::$psr7FilesLoaded === false) { + if (self::$psr7Adapter !== null) { self::loadPsr7Files(); - - self::$psr7FilesLoaded = true; } elseif (self::$psr7Adapter === null) { self::loadLegacyFiles(); } diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 146fff43..c708cacf 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -179,7 +179,7 @@ public function testResetMethodShouldCloseDetachedResources(): void self::assertSame( 0, $stillOpenAfterReset, - 'All resources should be closed after `reset()` method.', + "All resources should be closed after 'reset()' method.", ); } From 37f5837aa94001749d6cbc9c97c95732946e451e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 19:29:11 -0400 Subject: [PATCH 19/25] test(http): Add test for UploadedFile handling `null` size to default to zero. --- tests/http/UploadedFileTest.php | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index c708cacf..9c9c6f15 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -78,6 +78,41 @@ public function testConvertPsr7FileWithErrorShouldNotThrowException(): void ); } + public function testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $uploadedFileWithNullSize = FactoryHelper::createUploadedFileFactory()->createUploadedFile( + FactoryHelper::createStream($tmpPath), + null, + UPLOAD_ERR_CANT_WRITE, + 'null-size-file.txt', + 'text/plain', + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles(['null-size-file' => $uploadedFileWithNullSize]), + ), + ); + + $uploadedFile = UploadedFile::getInstanceByName('null-size-file'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + "Should return an instance of UploadedFile with 'null' size.", + ); + self::assertSame( + 0, + $uploadedFile->size, + "Should default to exactly '0' when PSR-7 'getSize()' method returns 'null' in error condition.", + ); + } + public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void { $_FILES = [ From e7378124fc64a01b8dfbbbd9157a7f6e53c11308 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 19:40:16 -0400 Subject: [PATCH 20/25] fix(http): Cast UploadedFile size to integer for consistency. --- src/http/UploadedFile.php | 4 ++-- tests/http/UploadedFileTest.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index 880a1a71..aed4b58f 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -280,7 +280,7 @@ private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr 'tempName' => '', 'tempResource' => null, 'type' => $psr7File->getClientMediaType() ?? '', - 'size' => $psr7File->getSize() ?? 0, + 'size' => (int) $psr7File->getSize(), 'error' => $error, 'fullPath' => null, ]; @@ -294,7 +294,7 @@ private static function convertPsr7FileToLegacyFormat(UploadedFileInterface $psr 'tempName' => is_string($uri) ? $uri : '', 'tempResource' => $stream->detach(), 'type' => $psr7File->getClientMediaType() ?? '', - 'size' => $psr7File->getSize() ?? 0, + 'size' => (int) $psr7File->getSize(), 'error' => $error, 'fullPath' => null, ]; diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 9c9c6f15..f92c9944 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -12,6 +12,7 @@ use function file_put_contents; use function stream_get_meta_data; +use const UPLOAD_ERR_CANT_WRITE; use const UPLOAD_ERR_OK; final class UploadedFileTest extends TestCase From 4c57293766b5168b4a1c2308628c358a9f040e10 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Thu, 28 Aug 2025 20:03:11 -0400 Subject: [PATCH 21/25] test(http): Add test for handling legacy file size as an array in UploadedFile. --- tests/http/UploadedFileTest.php | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index f92c9944..eb42b00c 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -219,6 +219,57 @@ public function testResetMethodShouldCloseDetachedResources(): void ); } + public function testReturnUploadedFileInstanceWhenLegacyFilesSizeIsArray(): void + { + $_FILES = [ + 'file' => [ + 'name' => 'file1.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phptest1', + 'error' => UPLOAD_ERR_OK, + 'size' => [0], + ], + ]; + + $uploadFiles = UploadedFile::getInstancesByName('file'); + + self::assertCount( + 1, + $uploadFiles, + 'Should process the malformed array structure.', + ); + self::assertInstanceOf( + UploadedFile::class, + $uploadFiles[0] ?? null, + 'Should return an instance of UploadedFile when a single file is uploaded.', + ); + self::assertSame( + 'file1.txt', + $uploadFiles[0]->name, + 'Should preserve \'name\' from $_FILES.', + ); + self::assertSame( + 'text/plain', + $uploadFiles[0]->type, + 'Should preserve \'type\' from $_FILES.', + ); + self::assertSame( + '/tmp/phptest1', + $uploadFiles[0]->tempName, + 'Should preserve \'tmp_name\' from $_FILES.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadFiles[0]->error, + 'Should preserve \'error\' from $_FILES.', + ); + self::assertSame( + 0, + $uploadFiles[0]->size, + "Should default to exactly '0' when legacy file 'size' is an array.", + ); + } + public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr7(): void { $tmpFile1 = $this->createTmpFile(); From 2cac0c7c7fc0c9b213d2b19f6140cc0cce9036d9 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 29 Aug 2025 05:09:15 -0400 Subject: [PATCH 22/25] fix(http): Reset UploadedFile state in `StatelessApplication` to prevent cross-request contamination. --- src/http/StatelessApplication.php | 5 ++++- src/http/UploadedFile.php | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/http/StatelessApplication.php b/src/http/StatelessApplication.php index 184b3b6d..5a90b348 100644 --- a/src/http/StatelessApplication.php +++ b/src/http/StatelessApplication.php @@ -387,7 +387,7 @@ protected static function parseMemoryLimit(string $limit): int * Resets the StatelessApplication state and prepares the Yii2 environment for handling a PSR-7 request. * * Performs a full reinitialization of the application state, including event tracking, error handler cleanup, - * request adapter reset, session management, and PSR-7 request injection. + * request adapter reset, session management, uploaded file state reset, and PSR-7 request injection. * * This method ensures that the application is ready to process a new stateless request in worker or SAPI * environments, maintaining strict type safety and compatibility with Yii2 core components. @@ -402,6 +402,9 @@ protected function reset(ServerRequestInterface $request): void { $this->startEventTracking(); + // reset UploadedFile static state to avoid cross-request contamination + UploadedFile::reset(); + // parent constructor is called because StatelessApplication uses a custom initialization pattern // @phpstan-ignore-next-line parent::__construct($this->config); diff --git a/src/http/UploadedFile.php b/src/http/UploadedFile.php index aed4b58f..66ed0366 100644 --- a/src/http/UploadedFile.php +++ b/src/http/UploadedFile.php @@ -229,9 +229,6 @@ public static function reset(): void */ public static function setPsr7Adapter(ServerRequestAdapter $adapter): void { - self::closeResources(); - - self::$_files = []; self::$psr7Adapter = $adapter; } From ed5b095f77762b924babaadb27cee8787c5e0831 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 29 Aug 2025 05:47:44 -0400 Subject: [PATCH 23/25] test(http): Add tests for `UploadedFile` handling with complex model names and array attributes. --- tests/http/UploadedFileTest.php | 400 ++++++++++++++++++ .../support/stub/ComplexUploadedFileModel.php | 17 + tests/support/stub/UploadedFileModel.php | 17 + 3 files changed, 434 insertions(+) create mode 100644 tests/support/stub/ComplexUploadedFileModel.php create mode 100644 tests/support/stub/UploadedFileModel.php diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index eb42b00c..969bf0ff 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -7,6 +7,7 @@ use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; use yii2\extensions\psrbridge\http\UploadedFile; use yii2\extensions\psrbridge\tests\support\FactoryHelper; +use yii2\extensions\psrbridge\tests\support\stub\{ComplexUploadedFileModel, UploadedFileModel}; use yii2\extensions\psrbridge\tests\TestCase; use function file_put_contents; @@ -114,6 +115,263 @@ public function testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void ); } + public function testGetInstanceWithModelAndArrayAttributeReturnsUploadedFile(): void + { + $_FILES = [ + 'UploadedFileModel[files][0]' => [ + 'name' => 'array-test.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phparray', + 'error' => UPLOAD_ERR_OK, + 'size' => 150, + ], + ]; + + $uploadedFile = UploadedFile::getInstance(new UploadedFileModel(), 'files[0]'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile when using array-indexed attribute.', + ); + self::assertSame( + 'array-test.txt', + $uploadedFile->name, + 'Should preserve name from $_FILES when using array-indexed attribute.', + ); + self::assertSame( + 150, + $uploadedFile->size, + 'Should preserve size from $_FILES when using array-indexed attribute.', + ); + } + + public function testGetInstanceWithModelAndAttributeHandlesComplexModelName(): void + { + $_FILES = [ + 'Complex_Model-Name[file_attribute]' => [ + 'name' => 'complex-name-test.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpcomplex', + 'error' => UPLOAD_ERR_OK, + 'size' => 250, + ], + ]; + + $uploadedFile = UploadedFile::getInstance(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile when using complex model name.', + ); + self::assertSame( + 'complex-name-test.txt', + $uploadedFile->name, + 'Should preserve name from $_FILES when using complex model name.', + ); + self::assertSame( + 250, + $uploadedFile->size, + 'Should preserve size from $_FILES when using complex model name.', + ); + } + + public function testGetInstanceWithModelAndAttributeHandlesErrorFiles(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + $uploadedFileWithError = FactoryHelper::createUploadedFile( + 'error-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 50, + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles(['UploadedFileModel[file]' => $uploadedFileWithError]), + ), + ); + + $uploadedFile = UploadedFile::getInstance(new UploadedFileModel(), 'file'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile even when there is an upload error using model and attribute.', + ); + self::assertSame( + 'error-model-test.txt', + $uploadedFile->name, + 'Should preserve the original file name even when there is an upload error using model and attribute.', + ); + self::assertSame( + '', + $uploadedFile->tempName, + 'Should have an empty tempName when there is an upload error using model and attribute.', + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $uploadedFile->error, + 'Should preserve the upload error code from PSR-7 UploadedFile when using model and attribute.', + ); + self::assertSame( + 50, + $uploadedFile->size, + 'Should preserve the original file size even when there is an upload error using model and attribute.', + ); + } + + public function testGetInstanceWithModelAndAttributeReturnsNullWhenNoFileUploaded(): void + { + $_FILES = []; + + $uploadedFile = UploadedFile::getInstance(new UploadedFileModel(), 'file'); + + self::assertNull( + $uploadedFile, + 'Should return null when no file was uploaded for the specified model attribute.', + ); + } + + public function testGetInstanceWithModelAndAttributeReturnsUploadedFileForLegacyFiles(): void + { + $_FILES = [ + 'UploadedFileModel[file]' => [ + 'name' => 'model-test.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpmodel', + 'error' => UPLOAD_ERR_OK, + 'size' => 200, + ], + ]; + + $uploadedFile = UploadedFile::getInstance(new UploadedFileModel(), 'file'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile when using model and attribute.', + ); + self::assertSame( + 'model-test.txt', + $uploadedFile->name, + 'Should preserve name from $_FILES when using model and attribute.', + ); + self::assertSame( + 'text/plain', + $uploadedFile->type, + 'Should preserve type from $_FILES when using model and attribute.', + ); + self::assertSame( + '/tmp/phpmodel', + $uploadedFile->tempName, + 'Should preserve tmp_name from $_FILES when using model and attribute.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadedFile->error, + 'Should preserve error from $_FILES when using model and attribute.', + ); + self::assertSame( + 200, + $uploadedFile->size, + 'Should preserve size from $_FILES when using model and attribute.', + ); + } + + public function testGetInstanceWithModelAndAttributeReturnsUploadedFileForPsr7Files(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpPath, 'PSR-7 model test content'); + + $uploadedFile = FactoryHelper::createUploadedFile( + 'psr7-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 24, + ); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles(['UploadedFileModel[file]' => $uploadedFile]), + ), + ); + + $retrievedFile = UploadedFile::getInstance(new UploadedFileModel(), 'file'); + + self::assertInstanceOf( + UploadedFile::class, + $retrievedFile, + 'Should return an instance of UploadedFile when using PSR-7 with model and attribute.', + ); + self::assertSame( + 'psr7-model-test.txt', + $retrievedFile->name, + 'Should preserve name from PSR-7 UploadedFile when using model and attribute.', + ); + self::assertSame( + 'text/plain', + $retrievedFile->type, + 'Should preserve type from PSR-7 UploadedFile when using model and attribute.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $retrievedFile->error, + 'Should preserve error from PSR-7 UploadedFile when using model and attribute.', + ); + self::assertSame( + 24, + $retrievedFile->size, + 'Should preserve size from PSR-7 UploadedFile when using model and attribute.', + ); + } + + public function testGetInstanceWithModelAndTabularAttributeReturnsUploadedFile(): void + { + $_FILES = [ + 'UploadedFileModel[1][file]' => [ + 'name' => 'tabular-test.txt', + 'type' => 'application/json', + 'tmp_name' => '/tmp/phptabular', + 'error' => UPLOAD_ERR_OK, + 'size' => 300, + ], + ]; + + $uploadedFile = UploadedFile::getInstance(new UploadedFileModel(), '[1]file'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile when using tabular-style attribute.', + ); + self::assertSame( + 'tabular-test.txt', + $uploadedFile->name, + 'Should preserve name from $_FILES when using tabular-style attribute.', + ); + self::assertSame( + 'application/json', + $uploadedFile->type, + 'Should preserve type from $_FILES when using tabular-style attribute.', + ); + self::assertSame( + 300, + $uploadedFile->size, + 'Should preserve size from $_FILES when using tabular-style attribute.', + ); + } + public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void { $_FILES = [ @@ -160,6 +418,148 @@ public function testLegacyFilesLoadingWhenNotPsr7Adapter(): void ); } + public function testLoadFilesRecursiveInternalWithArrayNamesAndMissingIndices(): void + { + $_FILES = [ + 'documents' => [ + 'name' => ['doc1.pdf', 'doc2.docx', 'doc3.txt'], + 'type' => ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'tmp_name' => ['/tmp/php1', '/tmp/php2', '/tmp/php3'], + 'size' => [1024, 2048], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK, UPLOAD_ERR_CANT_WRITE], + ], + ]; + + $uploadFiles = UploadedFile::getInstancesByName('documents'); + + self::assertCount( + 3, + $uploadFiles, + 'Should process all files in the array structure.', + ); + self::assertInstanceOf( + UploadedFile::class, + $uploadFiles[0] ?? null, + 'Should return an instance of UploadedFile for first file.', + ); + self::assertSame( + 'doc1.pdf', + $uploadFiles[0]->name, + "Should preserve 'name' from array at index '0'.", + ); + self::assertSame( + 'application/pdf', + $uploadFiles[0]->type, + "Should preserve 'type' from array at index '0'.", + ); + self::assertSame( + '/tmp/php1', + $uploadFiles[0]->tempName, + "Should preserve 'tmp_name' from array at index '0'.", + ); + self::assertSame( + 1024, + $uploadFiles[0]->size, + "Should preserve 'size' from array at index '0'.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadFiles[0]->error, + "Should preserve 'error' from array at index '0'.", + ); + self::assertInstanceOf( + UploadedFile::class, + $uploadFiles[1] ?? null, + 'Should return an instance of UploadedFile for second file.', + ); + self::assertSame( + 'doc2.docx', + $uploadFiles[1]->name, + "Should preserve 'name' from array at index '1'.", + ); + self::assertSame( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + $uploadFiles[1]->type, + "Should preserve 'type' from array at index '1'.", + ); + self::assertSame( + '/tmp/php2', + $uploadFiles[1]->tempName, + "Should preserve 'tmp_name' from array at index '1'.", + ); + self::assertSame( + 2048, + $uploadFiles[1]->size, + "Should preserve 'size' from array at index '1'.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadFiles[1]->error, + "Should preserve 'error' from array at index '1'.", + ); + self::assertInstanceOf( + UploadedFile::class, + $uploadFiles[2] ?? null, + 'Should return an instance of UploadedFile for third file.', + ); + self::assertSame( + 'doc3.txt', + $uploadFiles[2]->name, + "Should preserve 'name' from array at index '2'.", + ); + self::assertSame( + '', + $uploadFiles[2]->type, + "Should default to empty string when 'types[2]' is missing.", + ); + self::assertSame( + '/tmp/php3', + $uploadFiles[2]->tempName, + "Should preserve 'tmp_name' from array at index '2'.", + ); + self::assertSame( + 0, + $uploadFiles[2]->size, + "Should default to 0 when 'sizes[2]' is missing.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $uploadFiles[2]->error, + "Should preserve 'error' from array at index '2'.", + ); + + $document1 = UploadedFile::getInstanceByName('documents[0]'); + + self::assertInstanceOf( + UploadedFile::class, + $document1, + 'Should return instance when accessing by array index notation.', + ); + self::assertSame( + 'doc1.pdf', + $document1->name, + "Should preserve 'name' when accessing via 'documents[0]'.", + ); + + $document3 = UploadedFile::getInstanceByName('documents[2]'); + + self::assertInstanceOf( + UploadedFile::class, + $document3, + 'Should return instance for file with missing indices.', + ); + self::assertSame( + '', + $document3->type, + 'Should have default empty type when accessing file with missing type index.', + ); + self::assertSame( + 0, + $document3->size, + "Should have default size of '0' when accessing file with missing size index.", + ); + } + public function testResetMethodShouldCloseDetachedResources(): void { $tmpFile = $this->createTmpFile(); diff --git a/tests/support/stub/ComplexUploadedFileModel.php b/tests/support/stub/ComplexUploadedFileModel.php new file mode 100644 index 00000000..e634e50b --- /dev/null +++ b/tests/support/stub/ComplexUploadedFileModel.php @@ -0,0 +1,17 @@ + Date: Fri, 29 Aug 2025 06:31:02 -0400 Subject: [PATCH 24/25] fix(http): Update `UploadedFileModel` and `ComplexUploadedFileModel` to use `UploadedFile` type hinting for file attributes. --- tests/http/UploadedFileTest.php | 791 ++++++++++++++++-- .../support/stub/ComplexUploadedFileModel.php | 6 +- tests/support/stub/UploadedFileModel.php | 6 +- 3 files changed, 715 insertions(+), 88 deletions(-) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 969bf0ff..05937e17 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -31,18 +31,20 @@ public function testConvertPsr7FileWithErrorShouldNotThrowException(): void $tmpPath = stream_get_meta_data($tmpFile)['uri']; - $uploadedFileWithError = FactoryHelper::createUploadedFile( - 'error-file.txt', - 'text/plain', - FactoryHelper::createStream($tmpPath), - UPLOAD_ERR_CANT_WRITE, - 100, - ); - UploadedFile::setPsr7Adapter( new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'site/post') - ->withUploadedFiles(['error-file' => $uploadedFileWithError]), + ->withUploadedFiles( + [ + 'error-file' => FactoryHelper::createUploadedFile( + 'error-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 100, + ), + ], + ), ), ); @@ -66,12 +68,12 @@ public function testConvertPsr7FileWithErrorShouldNotThrowException(): void self::assertSame( '', $uploadedFile->tempName, - 'Should have an empty tempName when there is an upload error.', + "Should have an empty 'tempName' when there is an upload error.", ); self::assertSame( UPLOAD_ERR_CANT_WRITE, $uploadedFile->error, - 'Should preserve the upload error code from PSR-7 UploadedFile.', + "Should preserve the upload 'error' code from PSR-7 UploadedFile.", ); self::assertSame( 100, @@ -86,18 +88,20 @@ public function testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void $tmpPath = stream_get_meta_data($tmpFile)['uri']; - $uploadedFileWithNullSize = FactoryHelper::createUploadedFileFactory()->createUploadedFile( - FactoryHelper::createStream($tmpPath), - null, - UPLOAD_ERR_CANT_WRITE, - 'null-size-file.txt', - 'text/plain', - ); - UploadedFile::setPsr7Adapter( new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'site/post') - ->withUploadedFiles(['null-size-file' => $uploadedFileWithNullSize]), + ->withUploadedFiles( + [ + 'null-size-file' => FactoryHelper::createUploadedFileFactory()->createUploadedFile( + FactoryHelper::createStream($tmpPath), + null, + UPLOAD_ERR_CANT_WRITE, + 'null-size-file.txt', + 'text/plain', + ), + ], + ), ), ); @@ -115,6 +119,615 @@ public function testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void ); } + public function testGetInstancesWithModelAndArrayAttributeReturnsArrayForArrayIndexedUpload(): void + { + $_FILES = [ + 'UploadedFileModel[files][0]' => [ + 'name' => 'array-indexed-1.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phparray1', + 'error' => UPLOAD_ERR_OK, + 'size' => 180, + ], + 'UploadedFileModel[files][1]' => [ + 'name' => 'array-indexed-2.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phparray2', + 'error' => UPLOAD_ERR_OK, + 'size' => 220, + ], + ]; + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'files'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files for array-indexed attribute upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first file in array-indexed upload.', + ); + self::assertSame( + 'array-indexed-1.txt', + $files[0]->name, + 'Should preserve \'name\' from $_FILES for first file in array-indexed upload.', + ); + self::assertSame( + 180, + $files[0]->size, + 'Should preserve \'size\' from $_FILES for first file in array-indexed upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second file in array-indexed upload.', + ); + self::assertSame( + 'array-indexed-2.txt', + $files[1]->name, + 'Should preserve \'name\' from $_FILES for second file in array-indexed upload.', + ); + self::assertSame( + 220, + $files[1]->size, + 'Should preserve \'size\' from $_FILES for second file in array-indexed upload.', + ); + } + + public function testGetInstancesWithModelAndAttributeHandlesComplexModelNameArray(): void + { + $_FILES = [ + 'Complex_Model-Name[file_attribute][0]' => [ + 'name' => 'complex-array-1.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpcomplex1', + 'error' => UPLOAD_ERR_OK, + 'size' => 300, + ], + 'Complex_Model-Name[file_attribute][1]' => [ + 'name' => 'complex-array-2.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpcomplex2', + 'error' => UPLOAD_ERR_OK, + 'size' => 350, + ], + ]; + + $files = UploadedFile::getInstances(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files for complex model name with array upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first file in complex model array upload.', + ); + self::assertSame( + 'complex-array-1.txt', + $files[0]->name, + 'Should preserve \'name\' from $_FILES for first file in complex model array upload.', + ); + self::assertSame( + 300, + $files[0]->size, + 'Should preserve \'size\' from $_FILES for first file in complex model array upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second file in complex model array upload.', + ); + self::assertSame( + 'complex-array-2.txt', + $files[1]->name, + 'Should preserve \'name\' from $_FILES for second file in complex model array upload.', + ); + self::assertSame( + 350, + $files[1]->size, + 'Should preserve \'size\' from $_FILES for second file in complex model array upload.', + ); + } + + public function testGetInstancesWithModelAndAttributeHandlesErrorFilesArray(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => [ + FactoryHelper::createUploadedFile( + 'error-array-1.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath1), + UPLOAD_ERR_CANT_WRITE, + 75, + ), + FactoryHelper::createUploadedFile( + 'error-array-2.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath2), + UPLOAD_ERR_CANT_WRITE, + 85, + ), + ], + ], + ), + ), + ); + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files even when there are upload errors.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first file even when there is an upload error.', + ); + self::assertSame( + 'error-array-1.txt', + $files[0]->name, + "Should preserve the original file 'name' for first file even when there is an upload error.", + ); + self::assertSame( + '', + $files[0]->tempName, + "Should have an empty 'tempName' for first file when there is an upload error.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $files[0]->error, + "Should preserve the upload 'error' code for first file from PSR-7 UploadedFile.", + ); + self::assertSame( + 75, + $files[0]->size, + "Should preserve the original file 'size' for first file even when there is an upload error.", + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second file even when there is an upload error.', + ); + self::assertSame( + 'error-array-2.txt', + $files[1]->name, + "Should preserve the original file 'name' for second file even when there is an upload error.", + ); + self::assertSame( + '', + $files[1]->tempName, + "Should have an empty 'tempName' for second file when there is an upload error.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $files[1]->error, + "Should preserve the upload 'error' code for second file from PSR-7 UploadedFile.", + ); + self::assertSame( + 85, + $files[1]->size, + "Should preserve the original file 'size' for second file even when there is an upload error.", + ); + } + + public function testGetInstancesWithModelAndAttributeHandlesMixedSuccessAndErrorFiles(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + file_put_contents($tmpPath1, 'Success file content'); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => [ + FactoryHelper::createUploadedFile( + 'success-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath1), + UPLOAD_ERR_OK, + 20, + ), + FactoryHelper::createUploadedFile( + 'error-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath2), + UPLOAD_ERR_CANT_WRITE, + 100, + ), + ], + ], + ), + ), + ); + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files when mixing successful and error files.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for successful file in mixed scenario.', + ); + self::assertSame( + 'success-file.txt', + $files[0]->name, + "Should preserve 'name' for successful file in mixed scenario.", + ); + self::assertNotEmpty( + $files[0]->tempName, + "Should have a 'tempName' for successful file in mixed scenario.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[0]->error, + "Should have no 'error' for successful file in mixed scenario.", + ); + self::assertSame( + 20, + $files[0]->size, + "Should preserve 'size' for successful file in mixed scenario.", + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for error file in mixed scenario.', + ); + self::assertSame( + 'error-file.txt', + $files[1]->name, + "Should preserve 'name' for error file in mixed scenario.", + ); + self::assertSame( + '', + $files[1]->tempName, + "Should have empty 'tempName' for error file in mixed scenario.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $files[1]->error, + "Should preserve 'error' code for error file in mixed scenario.", + ); + self::assertSame( + 100, + $files[1]->size, + "Should preserve 'size' for error file in mixed scenario.", + ); + } + + public function testGetInstancesWithModelAndAttributeReturnsArrayForMultipleFilesUpload(): void + { + $_FILES = [ + 'UploadedFileModel[file][0]' => [ + 'name' => 'multi-file-1.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpmulti1', + 'error' => UPLOAD_ERR_OK, + 'size' => 256, + ], + 'UploadedFileModel[file][1]' => [ + 'name' => 'multi-file-2.pdf', + 'type' => 'application/pdf', + 'tmp_name' => '/tmp/phpmulti2', + 'error' => UPLOAD_ERR_OK, + 'size' => 512, + ], + 'UploadedFileModel[file][2]' => [ + 'name' => 'multi-file-3.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => '/tmp/phpmulti3', + 'error' => UPLOAD_ERR_OK, + 'size' => 1024, + ], + ]; + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertCount( + 3, + $files, + 'Should return an array with three files for multiple files upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first file in multiple upload.', + ); + self::assertSame( + 'multi-file-1.txt', + $files[0]->name, + 'Should preserve \'name\' from $_FILES for first file in multiple upload.', + ); + self::assertSame( + 'text/plain', + $files[0]->type, + 'Should preserve \'type\' from $_FILES for first file in multiple upload.', + ); + self::assertSame( + 256, + $files[0]->size, + 'Should preserve \'size\' from $_FILES for first file in multiple upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second file in multiple upload.', + ); + self::assertSame( + 'multi-file-2.pdf', + $files[1]->name, + 'Should preserve \'name\' from $_FILES for second file in multiple upload.', + ); + self::assertSame( + 'application/pdf', + $files[1]->type, + 'Should preserve \'type\' from $_FILES for second file in multiple upload.', + ); + self::assertSame( + 512, + $files[1]->size, + 'Should preserve \'size\' from $_FILES for second file in multiple upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[2] ?? null, + 'Should return an instance of UploadedFile for third file in multiple upload.', + ); + self::assertSame( + 'multi-file-3.jpg', + $files[2]->name, + 'Should preserve \'name\' from $_FILES for third file in multiple upload.', + ); + self::assertSame( + 'image/jpeg', + $files[2]->type, + 'Should preserve \'type\' from $_FILES for third file in multiple upload.', + ); + self::assertSame( + 1024, + $files[2]->size, + 'Should preserve \'size\' from $_FILES for third file in multiple upload.', + ); + } + + public function testGetInstancesWithModelAndAttributeReturnsArrayForPsr7MultipleFiles(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + file_put_contents($tmpPath1, 'PSR-7 array test content 1'); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + file_put_contents($tmpPath2, 'PSR-7 array test content 2'); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => [ + FactoryHelper::createUploadedFile( + 'psr7-array-1.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath1), + UPLOAD_ERR_OK, + 28, + ), + FactoryHelper::createUploadedFile( + 'psr7-array-2.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath2), + UPLOAD_ERR_OK, + 28, + ), + ], + ], + ), + ), + ); + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files when using PSR-7 with multiple files and model attribute.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first PSR-7 file with model and attribute.', + ); + self::assertSame( + 'psr7-array-1.txt', + $files[0]->name, + "Should preserve 'name' from first PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + 'text/plain', + $files[0]->type, + "Should preserve 'type' from first PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[0]->error, + "Should preserve 'error' from first PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + 28, + $files[0]->size, + "Should preserve 'size' from first PSR-7 UploadedFile when using model and attribute.", + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second PSR-7 file with model and attribute.', + ); + self::assertSame( + 'psr7-array-2.txt', + $files[1]->name, + "Should preserve 'name' from second PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + 'text/plain', + $files[1]->type, + "Should preserve 'type' from second PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[1]->error, + "Should preserve 'error' from second PSR-7 UploadedFile when using model and attribute.", + ); + self::assertSame( + 28, + $files[1]->size, + "Should preserve 'size' from second PSR-7 UploadedFile when using model and attribute.", + ); + } + + public function testGetInstancesWithModelAndAttributeReturnsArrayForSingleFileUpload(): void + { + $_FILES = [ + 'UploadedFileModel[file]' => [ + 'name' => 'single-file.txt', + 'type' => 'text/plain', + 'tmp_name' => '/tmp/phpsingle', + 'error' => UPLOAD_ERR_OK, + 'size' => 128, + ], + ]; + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertCount( + 1, + $files, + 'Should return an array with one file for single file upload.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for single file upload.', + ); + self::assertSame( + 'single-file.txt', + $files[0]->name, + 'Should preserve \'name\' from $_FILES for single file upload.', + ); + self::assertSame( + 'text/plain', + $files[0]->type, + 'Should preserve \'type\' from $_FILES for single file upload.', + ); + self::assertSame( + '/tmp/phpsingle', + $files[0]->tempName, + 'Should preserve \'tmp_name\' from $_FILES for single file upload.', + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[0]->error, + 'Should preserve \'error\' from $_FILES for single file upload.', + ); + self::assertSame( + 128, + $files[0]->size, + 'Should preserve \'size\' from $_FILES for single file upload.', + ); + } + + public function testGetInstancesWithModelAndAttributeReturnsEmptyArrayWhenNoFilesUploaded(): void + { + $_FILES = []; + + $files = UploadedFile::getInstances(new UploadedFileModel(), 'file'); + + self::assertEmpty( + $files, + 'Should return an empty array when no files are uploaded for the specified model attribute.', + ); + } + + public function testGetInstancesWithModelAndTabularAttributeReturnsArrayForTabularUpload(): void + { + $_FILES = [ + 'UploadedFileModel[1][file]' => [ + 'name' => 'tabular-array-1.txt', + 'type' => 'application/json', + 'tmp_name' => '/tmp/phptabular1', + 'error' => UPLOAD_ERR_OK, + 'size' => 400, + ], + 'UploadedFileModel[2][file]' => [ + 'name' => 'tabular-array-2.txt', + 'type' => 'application/json', + 'tmp_name' => '/tmp/phptabular2', + 'error' => UPLOAD_ERR_OK, + 'size' => 450, + ], + ]; + + $files = UploadedFile::getInstances(new UploadedFileModel(), '[1]file'); + + self::assertCount( + 1, + $files, + 'Should return an array with one file for specific tabular index.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for tabular-style attribute upload.', + ); + self::assertSame( + 'tabular-array-1.txt', + $files[0]->name, + 'Should preserve \'name\' from $_FILES for tabular-style attribute upload.', + ); + self::assertSame( + 'application/json', + $files[0]->type, + 'Should preserve \'type\' from $_FILES for tabular-style attribute upload.', + ); + self::assertSame( + 400, + $files[0]->size, + 'Should preserve \'size\' from $_FILES for tabular-style attribute upload.', + ); + } + public function testGetInstanceWithModelAndArrayAttributeReturnsUploadedFile(): void { $_FILES = [ @@ -137,12 +750,12 @@ public function testGetInstanceWithModelAndArrayAttributeReturnsUploadedFile(): self::assertSame( 'array-test.txt', $uploadedFile->name, - 'Should preserve name from $_FILES when using array-indexed attribute.', + 'Should preserve \'name\' from $_FILES when using array-indexed attribute.', ); self::assertSame( 150, $uploadedFile->size, - 'Should preserve size from $_FILES when using array-indexed attribute.', + 'Should preserve \'size\' from $_FILES when using array-indexed attribute.', ); } @@ -168,12 +781,12 @@ public function testGetInstanceWithModelAndAttributeHandlesComplexModelName(): v self::assertSame( 'complex-name-test.txt', $uploadedFile->name, - 'Should preserve name from $_FILES when using complex model name.', + 'Should preserve \'name\' from $_FILES when using complex model name.', ); self::assertSame( 250, $uploadedFile->size, - 'Should preserve size from $_FILES when using complex model name.', + 'Should preserve \'size\' from $_FILES when using complex model name.', ); } @@ -183,18 +796,20 @@ public function testGetInstanceWithModelAndAttributeHandlesErrorFiles(): void $tmpPath = stream_get_meta_data($tmpFile)['uri']; - $uploadedFileWithError = FactoryHelper::createUploadedFile( - 'error-model-test.txt', - 'text/plain', - FactoryHelper::createStream($tmpPath), - UPLOAD_ERR_CANT_WRITE, - 50, - ); - UploadedFile::setPsr7Adapter( new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'site/upload') - ->withUploadedFiles(['UploadedFileModel[file]' => $uploadedFileWithError]), + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => FactoryHelper::createUploadedFile( + 'error-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 50, + ), + ], + ), ), ); @@ -208,22 +823,22 @@ public function testGetInstanceWithModelAndAttributeHandlesErrorFiles(): void self::assertSame( 'error-model-test.txt', $uploadedFile->name, - 'Should preserve the original file name even when there is an upload error using model and attribute.', + "Should preserve the original file 'name' even when there is an upload error using model and attribute.", ); self::assertSame( '', $uploadedFile->tempName, - 'Should have an empty tempName when there is an upload error using model and attribute.', + "Should have an empty 'tempName' when there is an upload error using model and attribute.", ); self::assertSame( UPLOAD_ERR_CANT_WRITE, $uploadedFile->error, - 'Should preserve the upload error code from PSR-7 UploadedFile when using model and attribute.', + "Should preserve the upload 'error' code from PSR-7 UploadedFile when using model and attribute.", ); self::assertSame( 50, $uploadedFile->size, - 'Should preserve the original file size even when there is an upload error using model and attribute.', + "Should preserve the original file 'size' even when there is an upload error using model and attribute.", ); } @@ -261,27 +876,27 @@ public function testGetInstanceWithModelAndAttributeReturnsUploadedFileForLegacy self::assertSame( 'model-test.txt', $uploadedFile->name, - 'Should preserve name from $_FILES when using model and attribute.', + 'Should preserve \'name\' from $_FILES when using model and attribute.', ); self::assertSame( 'text/plain', $uploadedFile->type, - 'Should preserve type from $_FILES when using model and attribute.', + 'Should preserve \'type\' from $_FILES when using model and attribute.', ); self::assertSame( '/tmp/phpmodel', $uploadedFile->tempName, - 'Should preserve tmp_name from $_FILES when using model and attribute.', + 'Should preserve \'tmp_name\' from $_FILES when using model and attribute.', ); self::assertSame( UPLOAD_ERR_OK, $uploadedFile->error, - 'Should preserve error from $_FILES when using model and attribute.', + 'Should preserve \'error\' from $_FILES when using model and attribute.', ); self::assertSame( 200, $uploadedFile->size, - 'Should preserve size from $_FILES when using model and attribute.', + 'Should preserve \'size\' from $_FILES when using model and attribute.', ); } @@ -292,18 +907,20 @@ public function testGetInstanceWithModelAndAttributeReturnsUploadedFileForPsr7Fi $tmpPath = stream_get_meta_data($tmpFile)['uri']; file_put_contents($tmpPath, 'PSR-7 model test content'); - $uploadedFile = FactoryHelper::createUploadedFile( - 'psr7-model-test.txt', - 'text/plain', - FactoryHelper::createStream($tmpPath), - UPLOAD_ERR_OK, - 24, - ); - UploadedFile::setPsr7Adapter( new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'site/upload') - ->withUploadedFiles(['UploadedFileModel[file]' => $uploadedFile]), + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => FactoryHelper::createUploadedFile( + 'psr7-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 24, + ), + ], + ), ), ); @@ -317,22 +934,22 @@ public function testGetInstanceWithModelAndAttributeReturnsUploadedFileForPsr7Fi self::assertSame( 'psr7-model-test.txt', $retrievedFile->name, - 'Should preserve name from PSR-7 UploadedFile when using model and attribute.', + "Should preserve 'name' from PSR-7 UploadedFile when using model and attribute.", ); self::assertSame( 'text/plain', $retrievedFile->type, - 'Should preserve type from PSR-7 UploadedFile when using model and attribute.', + "Should preserve 'type' from PSR-7 UploadedFile when using model and attribute.", ); self::assertSame( UPLOAD_ERR_OK, $retrievedFile->error, - 'Should preserve error from PSR-7 UploadedFile when using model and attribute.', + "Should preserve 'error' from PSR-7 UploadedFile when using model and attribute.", ); self::assertSame( 24, $retrievedFile->size, - 'Should preserve size from PSR-7 UploadedFile when using model and attribute.', + "Should preserve 'size' from PSR-7 UploadedFile when using model and attribute.", ); } @@ -358,17 +975,17 @@ public function testGetInstanceWithModelAndTabularAttributeReturnsUploadedFile() self::assertSame( 'tabular-test.txt', $uploadedFile->name, - 'Should preserve name from $_FILES when using tabular-style attribute.', + 'Should preserve \'name\' from $_FILES when using tabular-style attribute.', ); self::assertSame( 'application/json', $uploadedFile->type, - 'Should preserve type from $_FILES when using tabular-style attribute.', + 'Should preserve \'type\' from $_FILES when using tabular-style attribute.', ); self::assertSame( 300, $uploadedFile->size, - 'Should preserve size from $_FILES when using tabular-style attribute.', + 'Should preserve \'size\' from $_FILES when using tabular-style attribute.', ); } @@ -567,18 +1184,20 @@ public function testResetMethodShouldCloseDetachedResources(): void $tmpPath = stream_get_meta_data($tmpFile)['uri']; file_put_contents($tmpPath, 'Test content for reset method test'); - $uploadedFile = FactoryHelper::createUploadedFile( - 'reset-test.txt', - 'text/plain', - FactoryHelper::createStream($tmpPath), - UPLOAD_ERR_OK, - 32, - ); - UploadedFile::setPsr7Adapter( new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'site/post') - ->withUploadedFiles(['reset-test' => $uploadedFile]), + ->withUploadedFiles( + [ + 'reset-test' => FactoryHelper::createUploadedFile( + 'reset-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 32, + ), + ], + ), ), ); @@ -684,26 +1303,26 @@ public function testReturnUploadedFileInstanceWhenMultipleFilesAreUploadedViaPsr $adapter = new ServerRequestAdapter( FactoryHelper::createRequest('POST', 'http://example.com') - ->withUploadedFiles( - [ - 'files' => [ - FactoryHelper::createUploadedFile( - 'file1.txt', - 'text/plain', - FactoryHelper::createStream($tmpPathFile1), - UPLOAD_ERR_OK, - 8, - ), - FactoryHelper::createUploadedFile( - 'file2.txt', - 'text/plain', - FactoryHelper::createStream($tmpPathFile2), - UPLOAD_ERR_OK, - 8, - ), - ], + ->withUploadedFiles( + [ + 'files' => [ + FactoryHelper::createUploadedFile( + 'file1.txt', + 'text/plain', + FactoryHelper::createStream($tmpPathFile1), + UPLOAD_ERR_OK, + 8, + ), + FactoryHelper::createUploadedFile( + 'file2.txt', + 'text/plain', + FactoryHelper::createStream($tmpPathFile2), + UPLOAD_ERR_OK, + 8, + ), ], - ), + ], + ), ); UploadedFile::setPsr7Adapter($adapter); diff --git a/tests/support/stub/ComplexUploadedFileModel.php b/tests/support/stub/ComplexUploadedFileModel.php index e634e50b..0ed00287 100644 --- a/tests/support/stub/ComplexUploadedFileModel.php +++ b/tests/support/stub/ComplexUploadedFileModel.php @@ -5,10 +5,14 @@ namespace yii2\extensions\psrbridge\tests\support\stub; use yii\base\Model; +use yii2\extensions\psrbridge\http\UploadedFile; final class ComplexUploadedFileModel extends Model { - public string $file_attribute = ''; + /** + * @phpstan-var UploadedFile|UploadedFile[]|null + */ + public UploadedFile|array|null $file_attribute = null; public function formName(): string { diff --git a/tests/support/stub/UploadedFileModel.php b/tests/support/stub/UploadedFileModel.php index 15f2ca4a..f88393ac 100644 --- a/tests/support/stub/UploadedFileModel.php +++ b/tests/support/stub/UploadedFileModel.php @@ -5,10 +5,14 @@ namespace yii2\extensions\psrbridge\tests\support\stub; use yii\base\Model; +use yii2\extensions\psrbridge\http\UploadedFile; final class UploadedFileModel extends Model { - public string $file = ''; + /** + * @phpstan-var UploadedFile|UploadedFile[]|null + */ + public UploadedFile|array|null $file = null; public function formName(): string { From 344152cc7460a7267ee5986c7c6c9129831c2a71 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Fri, 29 Aug 2025 06:56:56 -0400 Subject: [PATCH 25/25] test(http): Add tests for handling multiple files and error scenarios with `ComplexUploadedFileModel` using PSR-7 adapter. --- tests/http/UploadedFileTest.php | 314 ++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) diff --git a/tests/http/UploadedFileTest.php b/tests/http/UploadedFileTest.php index 05937e17..81d99f73 100644 --- a/tests/http/UploadedFileTest.php +++ b/tests/http/UploadedFileTest.php @@ -119,6 +119,206 @@ public function testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void ); } + public function testGetInstancesWithComplexUploadedFileModelAndPsr7AdapterForMultipleFiles(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + file_put_contents($tmpPath1, 'PSR-7 complex model array test content 1'); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + file_put_contents($tmpPath2, 'PSR-7 complex model array test content 2'); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'Complex_Model-Name[file_attribute]' => [ + FactoryHelper::createUploadedFile( + 'psr7-complex-array-1.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath1), + UPLOAD_ERR_OK, + 38, + ), + FactoryHelper::createUploadedFile( + 'psr7-complex-array-2.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath2), + UPLOAD_ERR_OK, + 38, + ), + ], + ], + ), + ), + ); + + $files = UploadedFile::getInstances(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files when using PSR-7 with ComplexUploadedFileModel for multiple files.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for first PSR-7 file with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-array-1.txt', + $files[0]->name, + "Should preserve 'name' from first PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 'text/plain', + $files[0]->type, + "Should preserve 'type' from first PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[0]->error, + "Should preserve 'error' from first PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 38, + $files[0]->size, + "Should preserve 'size' from first PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertNotEmpty( + $files[0]->tempName, + "Should have a valid 'tempName' from first PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for second PSR-7 file with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-array-2.txt', + $files[1]->name, + "Should preserve 'name' from second PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 'text/plain', + $files[1]->type, + "Should preserve 'type' from second PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[1]->error, + "Should preserve 'error' from second PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 38, + $files[1]->size, + "Should preserve 'size' from second PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertNotEmpty( + $files[1]->tempName, + "Should have a valid 'tempName' from second PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + } + + public function testGetInstancesWithComplexUploadedFileModelAndPsr7AdapterWithMixedErrorFiles(): void + { + $tmpFile1 = $this->createTmpFile(); + + $tmpPath1 = stream_get_meta_data($tmpFile1)['uri']; + file_put_contents($tmpPath1, 'PSR-7 complex model success file content'); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'Complex_Model-Name[file_attribute]' => [ + FactoryHelper::createUploadedFile( + 'psr7-complex-success-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath1), + UPLOAD_ERR_OK, + 36, + ), + FactoryHelper::createUploadedFile( + 'psr7-complex-error-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath2), + UPLOAD_ERR_CANT_WRITE, + 120, + ), + ], + ], + ), + ), + ); + + $files = UploadedFile::getInstances(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertCount( + 2, + $files, + 'Should return an array with two files when mixing successful and error files using PSR-7 with ComplexUploadedFileModel.', + ); + self::assertInstanceOf( + UploadedFile::class, + $files[0] ?? null, + 'Should return an instance of UploadedFile for successful file in mixed scenario using PSR-7 with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-success-file.txt', + $files[0]->name, + "Should preserve 'name' for successful file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertNotEmpty( + $files[0]->tempName, + "Should have a 'tempName' for successful file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $files[0]->error, + "Should have no 'error' for successful file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + 36, + $files[0]->size, + "Should preserve 'size' for successful file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertInstanceOf( + UploadedFile::class, + $files[1] ?? null, + 'Should return an instance of UploadedFile for error file in mixed scenario using PSR-7 with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-error-file.txt', + $files[1]->name, + "Should preserve 'name' for error file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + '', + $files[1]->tempName, + "Should have empty 'tempName' for error file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $files[1]->error, + "Should preserve 'error' code for error file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + 120, + $files[1]->size, + "Should preserve 'size' for error file in mixed scenario using PSR-7 with ComplexUploadedFileModel.", + ); + } + public function testGetInstancesWithModelAndArrayAttributeReturnsArrayForArrayIndexedUpload(): void { $_FILES = [ @@ -728,6 +928,120 @@ public function testGetInstancesWithModelAndTabularAttributeReturnsArrayForTabul ); } + public function testGetInstanceWithComplexUploadedFileModelAndPsr7Adapter(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpPath, 'PSR-7 complex model test content'); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'Complex_Model-Name[file_attribute]' => FactoryHelper::createUploadedFile( + 'psr7-complex-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 30, + ), + ], + ), + ), + ); + + $uploadedFile = UploadedFile::getInstance(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile when using PSR-7 with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-model-test.txt', + $uploadedFile->name, + "Should preserve 'name' from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 'text/plain', + $uploadedFile->type, + "Should preserve 'type' from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_OK, + $uploadedFile->error, + "Should preserve 'error' from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 30, + $uploadedFile->size, + "Should preserve 'size' from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertNotEmpty( + $uploadedFile->tempName, + "Should have a valid 'tempName' from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + } + + public function testGetInstanceWithComplexUploadedFileModelAndPsr7AdapterWithError(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'Complex_Model-Name[file_attribute]' => FactoryHelper::createUploadedFile( + 'psr7-complex-error-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 75, + ), + ], + ), + ), + ); + + $uploadedFile = UploadedFile::getInstance(new ComplexUploadedFileModel(), 'file_attribute'); + + self::assertInstanceOf( + UploadedFile::class, + $uploadedFile, + 'Should return an instance of UploadedFile even when there is an upload error using PSR-7 with ComplexUploadedFileModel.', + ); + self::assertSame( + 'psr7-complex-error-test.txt', + $uploadedFile->name, + "Should preserve the original file 'name' even when there is an upload error using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + 'text/plain', + $uploadedFile->type, + "Should preserve the original file 'type' even when there is an upload error using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + '', + $uploadedFile->tempName, + "Should have an empty 'tempName' when there is an upload error using PSR-7 with ComplexUploadedFileModel.", + ); + self::assertSame( + UPLOAD_ERR_CANT_WRITE, + $uploadedFile->error, + "Should preserve the upload 'error' code from PSR-7 UploadedFile when using ComplexUploadedFileModel.", + ); + self::assertSame( + 75, + $uploadedFile->size, + "Should preserve the original file 'size' even when there is an upload error using PSR-7 with ComplexUploadedFileModel.", + ); + } + public function testGetInstanceWithModelAndArrayAttributeReturnsUploadedFile(): void { $_FILES = [