Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion system/HTTP/Files/UploadedFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? '');
Expand All @@ -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
Expand Down
28 changes: 27 additions & 1 deletion system/Test/FeatureTestTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
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;
use CodeIgniter\HTTP\ResponseInterface;
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;
Expand All @@ -43,6 +45,11 @@
*/
trait FeatureTestTrait
{
/**
* @var array<string, array<array-key, UploadedFile>|UploadedFile>
*/
protected array $uploadedFiles = [];

/**
* Sets a RouteCollection that will override
* the application's route collection.
Expand Down Expand Up @@ -154,6 +161,20 @@ public function withBody($body)
return $this;
}

/**
* Sets uploaded files for the next request.
*
* @param array<string, array<array-key, UploadedFile>|UploadedFile> $files
*
* @return $this
*/
public function withFiles(array $files)
{
$this->uploadedFiles = $files;

return $this;
}

/**
* Don't run any events while running this test.
*
Expand Down Expand Up @@ -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');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve multipart behavior after request-body formatting

A feature test that first calls withBodyFormat('json')->post(...), then withFiles(['document' => $file])->post(...), sends the second request with Content-Type: application/json even though getFile('document') is populated. I reproduced this in a route-level test: expected multipart/form-data:present, actual application/json:present. bodyFormat persists, and the later setRequestBody() call overwrites this header and JSON-encodes the form fields. Controllers or filters expecting multipart therefore reject an otherwise valid upload test.

Make attached files select multipart consistently during body setup, and add a regression for JSON request -> upload request -> ordinary request. The upload's files and multipart override should apply only to that upload request, without leaving upload state on the following request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e10a3a7. Body formatting now skips requests with uploaded files, preserving multipart for that request while the following request still uses the persistent JSON format. The new route-level regression covers JSON → upload → ordinary requests.

$this->uploadedFiles = [];
}
$name = strtolower($method);
$request = $this->populateGlobals($name, $request, $params);
$request = $this->setRequestBody($request, $params);
Expand Down Expand Up @@ -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';
Expand Down
28 changes: 28 additions & 0 deletions system/Test/Mock/MockFileCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string, array<array-key, UploadedFile>|UploadedFile> $files
*/
public function __construct(array $files)
{
$this->files = $files;
}
}
43 changes: 43 additions & 0 deletions system/Test/Mock/MockUploadedFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give omitted mock MIME metadata a string value

The new constructor permits new MockUploadedFile($path, 'document.txt'), but this leaves originalMimeType as null. Calling the inherited getClientMimeType(): string then throws TypeError: Return value must be of type string, null returned; I reproduced this with an existing local fixture that passes isValid(). A normal PHP upload supplies a string for this metadata, so tests using the documented optional constructor arguments cannot exercise otherwise valid code that reads the client MIME type.

Normalize an omitted MIME type to a string in the mock, while preserving explicitly supplied client MIME values, and cover construction without the third argument. The result should be usable through getClientMimeType() without an exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e10a3a7. The mock normalizes omitted client MIME metadata to an empty string and preserves an explicit MIME value. The regression covers both cases and calls getClientMimeType().

?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);
}
}
125 changes: 125 additions & 0 deletions tests/system/Test/FeatureTestTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
========
Expand Down
17 changes: 17 additions & 0 deletions user_guide_src/source/testing/feature.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------

Expand Down
Loading