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
26 changes: 25 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,18 @@ public function withBody($body)
return $this;
}

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

return $this;
}

/**
* Don't run any events while running this test.
*
Expand Down Expand Up @@ -183,6 +202,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');
Comment thread
paulbalandan marked this conversation as resolved.
$this->uploadedFiles = [];
}
$name = strtolower($method);
$request = $this->populateGlobals($name, $request, $params);
$request = $this->setRequestBody($request, $params);
Expand Down Expand Up @@ -434,7 +458,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,
Comment thread
paulbalandan marked this conversation as resolved.
?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