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..d7a7f3e3 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; @@ -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. * @@ -793,6 +792,8 @@ public function setPsr7Request(ServerRequestInterface $request): void $this->adapter = new ServerRequestAdapter( $request->withHeader('statelessAppStartTime', (string) microtime(true)), ); + + UploadedFile::setPsr7Adapter($this->adapter); } /** @@ -851,6 +852,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/StatelessApplication.php b/src/http/StatelessApplication.php index d07ee958..5a90b348 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; @@ -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); @@ -441,7 +444,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 +460,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 new file mode 100644 index 00000000..66ed0366 --- /dev/null +++ b/src/http/UploadedFile.php @@ -0,0 +1,476 @@ + + */ + public static $_files = []; + + /** + * PSR-7 ServerRequestAdapter for bridging PSR-7 UploadedFileInterface. + */ + private static ServerRequestAdapter|null $psr7Adapter = null; + + /** + * 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): self|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): self|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 (str_ends_with($name, '[]')) { + $name = substr($name, 0, -2); + } + + 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, PSR-7 adapter state, and closes any open tempResource handles. + * + * 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 + * UploadedFile::reset(); + * ``` + */ + public static function reset(): void + { + self::closeResources(); + + self::$_files = []; + self::$psr7Adapter = null; + } + + /** + * 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::$psr7Adapter = $adapter; + } + + /** + * Closes any open tempResource handles stored in the internal cache. + */ + private static function closeResources(): void + { + foreach (self::$_files as $entry) { + if (is_resource($entry['tempResource'])) { + @fclose($entry['tempResource']); + } + } + } + + /** + * 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 + { + $error = $psr7File->getError(); + + if ($error !== UPLOAD_ERR_OK) { + return [ + 'name' => $psr7File->getClientFilename() ?? '', + 'tempName' => '', + 'tempResource' => null, + 'type' => $psr7File->getClientMediaType() ?? '', + 'size' => (int) $psr7File->getSize(), + 'error' => $error, + 'fullPath' => null, + ]; + } + + $stream = $psr7File->getStream(); + $uri = $stream->getMetadata('uri'); + + return [ + 'name' => $psr7File->getClientFilename() ?? '', + 'tempName' => is_string($uri) ? $uri : '', + 'tempResource' => $stream->detach(), + 'type' => $psr7File->getClientMediaType() ?? '', + 'size' => (int) $psr7File->getSize(), + 'error' => $error, + '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::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 mixed[]|UploadedFileInterface $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/UploadedFileTest.php b/tests/http/UploadedFileTest.php new file mode 100644 index 00000000..81d99f73 --- /dev/null +++ b/tests/http/UploadedFileTest.php @@ -0,0 +1,1711 @@ +createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles( + [ + 'error-file' => FactoryHelper::createUploadedFile( + 'error-file.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 100, + ), + ], + ), + ), + ); + + $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 testConvertPsr7FileWithNullSizeShouldDefaultToZero(): void + { + $tmpFile = $this->createTmpFile(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles( + [ + 'null-size-file' => FactoryHelper::createUploadedFileFactory()->createUploadedFile( + FactoryHelper::createStream($tmpPath), + null, + UPLOAD_ERR_CANT_WRITE, + 'null-size-file.txt', + 'text/plain', + ), + ], + ), + ), + ); + + $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 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 = [ + '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 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 = [ + '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']; + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => FactoryHelper::createUploadedFile( + 'error-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_CANT_WRITE, + 50, + ), + ], + ), + ), + ); + + $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::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/upload') + ->withUploadedFiles( + [ + 'UploadedFileModel[file]' => FactoryHelper::createUploadedFile( + 'psr7-model-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 24, + ), + ], + ), + ), + ); + + $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 = [ + 'upload' => [ + '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 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(); + + $tmpPath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpPath, 'Test content for reset method test'); + + UploadedFile::setPsr7Adapter( + new ServerRequestAdapter( + FactoryHelper::createRequest('POST', 'site/post') + ->withUploadedFiles( + [ + 'reset-test' => FactoryHelper::createUploadedFile( + 'reset-test.txt', + 'text/plain', + FactoryHelper::createStream($tmpPath), + UPLOAD_ERR_OK, + 32, + ), + ], + ), + ), + ); + + $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::assertSame( + 0, + $stillOpenAfterReset, + "All resources should be closed after 'reset()' method.", + ); + } + + 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(); + + $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); + + $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( + 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 new file mode 100644 index 00000000..9c656cfe --- /dev/null +++ b/tests/http/stateless/StatelessApplicationUploadedTest.php @@ -0,0 +1,229 @@ +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(); + + $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.', + ); + + $tmpFile2 = $this->createTmpFile(); + + $tmpPath2 = stream_get_meta_data($tmpFile2)['uri']; + $size2 = filesize($tmpPath2); + + self::assertIsInt( + $size2, + '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', + $tmpPath2, + size: $size2, + ), + ], + ), + ); + + 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', + ); + } +} diff --git a/tests/support/FactoryHelper.php b/tests/support/FactoryHelper.php index 78e6decd..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 $tmpName Temporary file name. + * @param StreamInterface|string $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 { diff --git a/tests/support/stub/ComplexUploadedFileModel.php b/tests/support/stub/ComplexUploadedFileModel.php new file mode 100644 index 00000000..0ed00287 --- /dev/null +++ b/tests/support/stub/ComplexUploadedFileModel.php @@ -0,0 +1,21 @@ +