diff --git a/system/HTTP/Files/UploadedFile.php b/system/HTTP/Files/UploadedFile.php index a3c133cb121a..be7bce058d6e 100644 --- a/system/HTTP/Files/UploadedFile.php +++ b/system/HTTP/Files/UploadedFile.php @@ -145,7 +145,7 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = $destination = $overwrite ? $targetPath . $name : $this->getDestination($targetPath . $name); try { - $this->hasMoved = move_uploaded_file($this->path, $destination); + $this->hasMoved = $this->moveFile($destination); } catch (Exception) { $error = error_get_last(); $message = strip_tags($error['message'] ?? ''); @@ -170,6 +170,11 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = return $this; } + protected function moveFile(string $destination): bool + { + return move_uploaded_file($this->path, $destination); + } + /** * create file target path if * the set path does not exist diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index ba2b99bf8094..34b5efd1bcc3 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -17,6 +17,7 @@ use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\HTTP\Files\UploadedFile; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Request; @@ -24,6 +25,7 @@ use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\URI; use CodeIgniter\Router\RouteCollection; +use CodeIgniter\Test\Mock\MockFileCollection; use Config\App; use Config\Services; use Exception; @@ -43,6 +45,11 @@ */ trait FeatureTestTrait { + /** + * @var array|UploadedFile> + */ + protected array $uploadedFiles = []; + /** * Sets a RouteCollection that will override * the application's route collection. @@ -154,6 +161,20 @@ public function withBody($body) return $this; } + /** + * Sets uploaded files for the next request. + * + * @param array|UploadedFile> $files + * + * @return $this + */ + public function withFiles(array $files) + { + $this->uploadedFiles = $files; + + return $this; + } + /** * Don't run any events while running this test. * @@ -183,6 +204,11 @@ public function call(string $method, string $path, ?array $params = null) $request = $this->setupRequest($method, $path); $request = $this->setupHeaders($request); + if ($this->uploadedFiles !== []) { + $this->setPrivateProperty($request, 'files', new MockFileCollection($this->uploadedFiles)); + $request->setHeader('Content-Type', 'multipart/form-data'); + $this->uploadedFiles = []; + } $name = strtolower($method); $request = $this->populateGlobals($name, $request, $params); $request = $this->setRequestBody($request, $params); @@ -434,7 +460,7 @@ protected function setRequestBody(Request $request, ?array $params = null): Requ $request->setBody($this->requestBody); } - if ($this->bodyFormat !== '') { + if ($this->bodyFormat !== '' && $request->getFiles() === []) { $formatMime = ''; if ($this->bodyFormat === 'json') { $formatMime = 'application/json'; diff --git a/system/Test/Mock/MockFileCollection.php b/system/Test/Mock/MockFileCollection.php new file mode 100644 index 000000000000..f5e4451c4676 --- /dev/null +++ b/system/Test/Mock/MockFileCollection.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Mock; + +use CodeIgniter\HTTP\Files\FileCollection; +use CodeIgniter\HTTP\Files\UploadedFile; + +class MockFileCollection extends FileCollection +{ + /** + * @param array|UploadedFile> $files + */ + public function __construct(array $files) + { + $this->files = $files; + } +} diff --git a/system/Test/Mock/MockUploadedFile.php b/system/Test/Mock/MockUploadedFile.php new file mode 100644 index 000000000000..dfff9bcc97f9 --- /dev/null +++ b/system/Test/Mock/MockUploadedFile.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Mock; + +use CodeIgniter\HTTP\Files\UploadedFile; + +/** + * An uploaded file for feature tests, using a regular local file as its source. + */ +class MockUploadedFile extends UploadedFile +{ + public function __construct( + string $path, + string $originalName, + ?string $mimeType = null, + ?int $size = null, + ?int $error = UPLOAD_ERR_OK, + ?string $clientPath = null, + ) { + parent::__construct($path, $originalName, $mimeType ?? '', $size, $error, $clientPath); + } + + public function isValid(): bool + { + return is_file($this->path) && $this->error === UPLOAD_ERR_OK; + } + + protected function moveFile(string $destination): bool + { + return rename($this->path, $destination); + } +} diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index 2f9685cd5dac..03bd4c11e468 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -20,6 +20,7 @@ use CodeIgniter\HTTP\Method; use CodeIgniter\HTTP\Response; use CodeIgniter\Test\Mock\MockCodeIgniter; +use CodeIgniter\Test\Mock\MockUploadedFile; use Config\App; use Config\Feature; use Config\Routing; @@ -149,6 +150,130 @@ public function testCallPostWithBody(): void $response->assertSee('Hello Mars!'); } + public function testPostWithUploadedFile(): void + { + $source = tempnam(sys_get_temp_dir(), 'ci4-upload-'); + $destination = basename($source) . '.txt'; + file_put_contents($source, 'file contents'); + + try { + $this->withRoutes([ + [ + 'POST', + 'upload', + static function () use ($destination): string { + $request = service('request'); + $file = $request->getFile('document'); + + if ($file === null || ! $file->isValid()) { + return 'invalid upload'; + } + + $validation = service('validation'); + $validation->setRule('document', 'document', 'uploaded[document]'); + if (! $validation->run([])) { + return 'invalid validation'; + } + + $file->move(sys_get_temp_dir(), $destination); + + return $request->getPost('title') . ':' + . $request->getHeaderLine('Content-Type') . ':' + . ($file->hasMoved() ? 'moved' : 'not moved'); + }, + ], + ]); + + $response = $this->withFiles([ + 'document' => new MockUploadedFile($source, 'document.txt', 'text/plain'), + ])->post('upload', ['title' => 'Report']); + + $this->assertSame('Report:multipart/form-data:moved', $response->response()->getBody()); + $this->assertSame('file contents', file_get_contents(sys_get_temp_dir() . '/' . $destination)); + } finally { + @unlink($source); + @unlink(sys_get_temp_dir() . '/' . $destination); + } + } + + public function testUploadedFilesAreClearedAfterRequest(): void + { + $source = tempnam(sys_get_temp_dir(), 'ci4-upload-'); + + try { + $this->withRoutes([ + [ + 'POST', + 'upload', + static fn (): string => service('request')->getFile('document') === null ? 'absent' : 'present', + ], + ]); + + $this->assertSame( + 'present', + $this->withFiles(['document' => new MockUploadedFile($source, 'document.txt', 'text/plain')]) + ->post('upload')->response()->getBody(), + ); + $this->assertSame('absent', $this->post('upload')->response()->getBody()); + } finally { + @unlink($source); + } + } + + public function testUploadAfterJsonRequestUsesMultipartOnlyForUpload(): void + { + $source = tempnam(sys_get_temp_dir(), 'ci4-upload-'); + file_put_contents($source, 'file contents'); + + try { + $this->withRoutes([ + [ + 'POST', + 'upload', + static function (): string { + $request = service('request'); + + return $request->getHeaderLine('Content-Type') . ':' + . ($request->getFile('document') === null ? 'absent' : 'present') . ':' + . $request->getPost('title'); + }, + ], + ]); + + $this->assertSame( + 'application/json:absent:First', + $this->withBodyFormat('json')->post('upload', ['title' => 'First'])->response()->getBody(), + ); + $this->assertSame( + 'multipart/form-data:present:Second', + $this->withFiles(['document' => new MockUploadedFile($source, 'document.txt')]) + ->post('upload', ['title' => 'Second'])->response()->getBody(), + ); + $this->assertSame( + 'application/json:absent:Third', + $this->post('upload', ['title' => 'Third'])->response()->getBody(), + ); + } finally { + @unlink($source); + } + } + + public function testMockUploadedFileWithoutMimeTypeHasStringClientMimeType(): void + { + $source = tempnam(sys_get_temp_dir(), 'ci4-upload-'); + file_put_contents($source, 'file contents'); + + try { + $file = new MockUploadedFile($source, 'document.txt'); + + $this->assertTrue($file->isValid()); + $this->assertSame('', $file->getClientMimeType()); + $this->assertSame('text/plain', (new MockUploadedFile($source, 'document.txt', 'text/plain'))->getClientMimeType()); + } finally { + @unlink($source); + } + } + public function testCallValidationTwice(): void { $this->withRoutes([ diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a7dac4c93afe..bffbe06027cf 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -265,6 +265,7 @@ Testing ======= - Added ``assertSameSql()`` to ``CIUnitTestCase`` to compare generated SQL while ignoring newlines in the actual SQL. +- Added ``FeatureTestTrait::withFiles()`` and ``MockUploadedFile`` for testing routes that validate and move uploaded files. Database ======== diff --git a/user_guide_src/source/testing/feature.rst b/user_guide_src/source/testing/feature.rst index 84f8fc18effc..8748039a7dc1 100644 --- a/user_guide_src/source/testing/feature.rst +++ b/user_guide_src/source/testing/feature.rst @@ -84,6 +84,23 @@ passed as a header into the call: .. literalinclude:: feature/006.php :lines: 2- +Testing File Uploads +-------------------- + +Use ``withFiles()`` to attach uploaded files to the next feature request. Create each file with +``CodeIgniter\Test\Mock\MockUploadedFile``, passing an existing local file path and the name that the client would send: + +.. code-block:: php + + use CodeIgniter\Test\Mock\MockUploadedFile; + + $file = new MockUploadedFile($path, 'photo.jpg', 'image/jpeg'); + $result = $this->withFiles(['photo' => $file])->post('photos', ['title' => 'Portrait']); + +The route can access the file through ``$request->getFile('photo')`` and use upload validation, ``move()``, or ``store()``. +``withFiles()`` sets the request's ``Content-Type`` to ``multipart/form-data`` and clears the attached files after the request. +Moving or storing the file moves the local source file, so create a new file object for each upload request. + Bypassing Events ----------------