Skip to content

Commit 05d6cae

Browse files
committed
feat: support file uploads in feature tests (#7987)
1 parent 0bd3c52 commit 05d6cae

7 files changed

Lines changed: 192 additions & 1 deletion

File tree

‎system/HTTP/Files/UploadedFile.php‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite =
145145
$destination = $overwrite ? $targetPath . $name : $this->getDestination($targetPath . $name);
146146

147147
try {
148-
$this->hasMoved = move_uploaded_file($this->path, $destination);
148+
$this->hasMoved = $this->moveFile($destination);
149149
} catch (Exception) {
150150
$error = error_get_last();
151151
$message = strip_tags($error['message'] ?? '');
@@ -170,6 +170,11 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite =
170170
return $this;
171171
}
172172

173+
protected function moveFile(string $destination): bool
174+
{
175+
return move_uploaded_file($this->path, $destination);
176+
}
177+
173178
/**
174179
* create file target path if
175180
* the set path does not exist

‎system/Test/FeatureTestTrait.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
use CodeIgniter\Events\Events;
1818
use CodeIgniter\Exceptions\RuntimeException;
1919
use CodeIgniter\HTTP\Exceptions\RedirectException;
20+
use CodeIgniter\HTTP\Files\UploadedFile;
2021
use CodeIgniter\HTTP\IncomingRequest;
2122
use CodeIgniter\HTTP\Method;
2223
use CodeIgniter\HTTP\Request;
2324
use CodeIgniter\HTTP\ResponseInterface;
2425
use CodeIgniter\HTTP\SiteURI;
2526
use CodeIgniter\HTTP\URI;
2627
use CodeIgniter\Router\RouteCollection;
28+
use CodeIgniter\Test\Mock\MockFileCollection;
2729
use Config\App;
2830
use Config\Services;
2931
use Exception;
@@ -43,6 +45,11 @@
4345
*/
4446
trait FeatureTestTrait
4547
{
48+
/**
49+
* @var array<string, array<array-key, UploadedFile>|UploadedFile>
50+
*/
51+
protected array $uploadedFiles = [];
52+
4653
/**
4754
* Sets a RouteCollection that will override
4855
* the application's route collection.
@@ -154,6 +161,20 @@ public function withBody($body)
154161
return $this;
155162
}
156163

164+
/**
165+
* Sets uploaded files for the next request.
166+
*
167+
* @param array<string, array<array-key, UploadedFile>|UploadedFile> $files
168+
*
169+
* @return $this
170+
*/
171+
public function withFiles(array $files)
172+
{
173+
$this->uploadedFiles = $files;
174+
175+
return $this;
176+
}
177+
157178
/**
158179
* Don't run any events while running this test.
159180
*
@@ -183,6 +204,11 @@ public function call(string $method, string $path, ?array $params = null)
183204

184205
$request = $this->setupRequest($method, $path);
185206
$request = $this->setupHeaders($request);
207+
if ($this->uploadedFiles !== []) {
208+
$this->setPrivateProperty($request, 'files', new MockFileCollection($this->uploadedFiles));
209+
$request->setHeader('Content-Type', 'multipart/form-data');
210+
$this->uploadedFiles = [];
211+
}
186212
$name = strtolower($method);
187213
$request = $this->populateGlobals($name, $request, $params);
188214
$request = $this->setRequestBody($request, $params);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Test\Mock;
15+
16+
use CodeIgniter\HTTP\Files\FileCollection;
17+
use CodeIgniter\HTTP\Files\UploadedFile;
18+
19+
class MockFileCollection extends FileCollection
20+
{
21+
/**
22+
* @param array<string, array<array-key, UploadedFile>|UploadedFile> $files
23+
*/
24+
public function __construct(array $files)
25+
{
26+
$this->files = $files;
27+
}
28+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Test\Mock;
15+
16+
use CodeIgniter\HTTP\Files\UploadedFile;
17+
18+
/**
19+
* An uploaded file for feature tests, using a regular local file as its source.
20+
*/
21+
class MockUploadedFile extends UploadedFile
22+
{
23+
public function __construct(
24+
string $path,
25+
string $originalName,
26+
?string $mimeType = null,
27+
?int $size = null,
28+
?int $error = UPLOAD_ERR_OK,
29+
?string $clientPath = null,
30+
) {
31+
parent::__construct($path, $originalName, $mimeType, $size, $error, $clientPath);
32+
}
33+
34+
public function isValid(): bool
35+
{
36+
return is_file($this->path) && $this->error === UPLOAD_ERR_OK;
37+
}
38+
39+
protected function moveFile(string $destination): bool
40+
{
41+
return rename($this->path, $destination);
42+
}
43+
}

‎tests/system/Test/FeatureTestTraitTest.php‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use CodeIgniter\HTTP\Method;
2121
use CodeIgniter\HTTP\Response;
2222
use CodeIgniter\Test\Mock\MockCodeIgniter;
23+
use CodeIgniter\Test\Mock\MockUploadedFile;
2324
use Config\App;
2425
use Config\Feature;
2526
use Config\Routing;
@@ -149,6 +150,76 @@ public function testCallPostWithBody(): void
149150
$response->assertSee('Hello Mars!');
150151
}
151152

153+
public function testPostWithUploadedFile(): void
154+
{
155+
$source = tempnam(sys_get_temp_dir(), 'ci4-upload-');
156+
$destination = basename($source) . '.txt';
157+
file_put_contents($source, 'file contents');
158+
159+
try {
160+
$this->withRoutes([
161+
[
162+
'POST',
163+
'upload',
164+
static function () use ($destination): string {
165+
$request = service('request');
166+
$file = $request->getFile('document');
167+
168+
if ($file === null || ! $file->isValid()) {
169+
return 'invalid upload';
170+
}
171+
172+
$validation = service('validation');
173+
$validation->setRule('document', 'document', 'uploaded[document]');
174+
if (! $validation->run([])) {
175+
return 'invalid validation';
176+
}
177+
178+
$file->move(sys_get_temp_dir(), $destination);
179+
180+
return $request->getPost('title') . ':'
181+
. $request->getHeaderLine('Content-Type') . ':'
182+
. ($file->hasMoved() ? 'moved' : 'not moved');
183+
},
184+
],
185+
]);
186+
187+
$response = $this->withFiles([
188+
'document' => new MockUploadedFile($source, 'document.txt', 'text/plain'),
189+
])->post('upload', ['title' => 'Report']);
190+
191+
$this->assertSame('Report:multipart/form-data:moved', $response->response()->getBody());
192+
$this->assertSame('file contents', file_get_contents(sys_get_temp_dir() . '/' . $destination));
193+
} finally {
194+
@unlink($source);
195+
@unlink(sys_get_temp_dir() . '/' . $destination);
196+
}
197+
}
198+
199+
public function testUploadedFilesAreClearedAfterRequest(): void
200+
{
201+
$source = tempnam(sys_get_temp_dir(), 'ci4-upload-');
202+
203+
try {
204+
$this->withRoutes([
205+
[
206+
'POST',
207+
'upload',
208+
static fn (): string => service('request')->getFile('document') === null ? 'absent' : 'present',
209+
],
210+
]);
211+
212+
$this->assertSame(
213+
'present',
214+
$this->withFiles(['document' => new MockUploadedFile($source, 'document.txt', 'text/plain')])
215+
->post('upload')->response()->getBody(),
216+
);
217+
$this->assertSame('absent', $this->post('upload')->response()->getBody());
218+
} finally {
219+
@unlink($source);
220+
}
221+
}
222+
152223
public function testCallValidationTwice(): void
153224
{
154225
$this->withRoutes([

‎user_guide_src/source/changelogs/v4.8.0.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Testing
265265
=======
266266

267267
- Added ``assertSameSql()`` to ``CIUnitTestCase`` to compare generated SQL while ignoring newlines in the actual SQL.
268+
- Added ``FeatureTestTrait::withFiles()`` and ``MockUploadedFile`` for testing routes that validate and move uploaded files.
268269

269270
Database
270271
========

‎user_guide_src/source/testing/feature.rst‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ passed as a header into the call:
8484
.. literalinclude:: feature/006.php
8585
:lines: 2-
8686

87+
Testing File Uploads
88+
--------------------
89+
90+
Use ``withFiles()`` to attach uploaded files to the next feature request. Create each file with
91+
``CodeIgniter\Test\Mock\MockUploadedFile``, passing an existing local file path and the name that the client would send:
92+
93+
.. code-block:: php
94+
95+
use CodeIgniter\Test\Mock\MockUploadedFile;
96+
97+
$file = new MockUploadedFile($path, 'photo.jpg', 'image/jpeg');
98+
$result = $this->withFiles(['photo' => $file])->post('photos', ['title' => 'Portrait']);
99+
100+
The route can access the file through ``$request->getFile('photo')`` and use upload validation, ``move()``, or ``store()``.
101+
``withFiles()`` sets the request's ``Content-Type`` to ``multipart/form-data`` and clears the attached files after the request.
102+
Moving or storing the file moves the local source file, so create a new file object for each upload request.
103+
87104
Bypassing Events
88105
----------------
89106

0 commit comments

Comments
 (0)