Dec 21, 2015
Table of contents:
Last week we looked at creating temporary urls to allow API clients to get uploaded files without exposing those files to the public internet (Returning secure files from an API with temporary URLs).
Uploading and returning files is a very common requirement of web applications and so it’s probably very likely that you will need to implement it at some point in your career.
However, testing this functionality isn’t very straight forward. Uploading and returning a file is quite different to just sending a POST
request with a body and asserting the correct JSON payload was returned.
In today’s tutorial we will be looking at writing integration tests for uploading files and returning responses.
We pretty much have everything we need out of the box in Laravel to make this easy, but there are a couple of things I like to set up to make things easier.
First I will set the default storage system to be an environment variable under filesystems.php
:
'default' => env('FILESYSTEM_DEFAULT', 'local')
Locally I’m just going to use the local filesystem, but in development and production I will want to use S3.
Laravel’s filesystem abstraction means we don’t need to mess about with crazy mocks, so this will make things a lot easier.
Secondly I like to create a new directory under storage
called testing
. In this directory I will keep a copy of a few different file types that will likely be uploaded to the application, for example, a couple of different image formats, pdfs, Word documents, Excel spreadsheets etc.
Next I’m going to define the endpoints we will need to upload and return files. In this tutorial I’m only going to deal with storing the file, and then returning it.
use Illuminate\Contracts\Routing\Registrar;
class UploadsRoutes
{
/**
* Define the routes
*
* @param Registrar $router
* @return void
*/
public function map(Registrar $router)
{
$router->post("uploads", [
"as" => "uploads.store",
"uses" => "UploadsController@store",
]);
$router->get("uploads/{upload_id}/actions/view/{filename}", [
"as" => "uploads.actions.view",
"uses" => "UploadsController@view",
]);
$router->get("uploads/{upload_id}/actions/download/{filename}", [
"as" => "uploads.actions.download",
"uses" => "UploadsController@download",
]);
}
}
Normally you would also have endpoints to return details of all of the uploads and for a specific upload by it’s id, but that’s pretty straightforward so I won’t be including it in this tutorial.
Next we need to define the Controller methods for storing a new upload and for returning the file as a view or as a download response.
Here is a simplified store
method:
/**
* Create a new Upload
*
* @param Request $request
* @return JsonResponse
*/
public function store(Request $request)
{
$file = $request->file('upload');
$name = $file->getClientOriginalName();
$path = md5(time().$name).$name;
$mime = $file->getClientMimeType();
$size = $file->getClientSize();
$upload = Upload::create(compact('name', 'path', 'mime', 'size'));
Storage::put($upload->path, File::get($file->getrealpath()));
// Transform $upload using something like Fractal
return response()->json($upload);
}
As you can see, we grab some details from the Request
, we then create a new Upload
model object, store the file using Laravel’s Storage
service and then return the $upload
as JSON.
As we saw in last week’s tutorial, during the transformation phase of taking the model and returning the JSON representation, I would also be generating the view and download URLs.
Next we have the view
method:
/**
* View an upload
*
* @param string $upload_id
* @param string $filename
* @return Response
*/
public function view($upload_id, $filename)
{
$filename = base64_decode($filename);
$this->checkForValidToken($filename);
$upload = Context::get('Upload')->model();
$file = $this->getFileFromStorage($upload->path, $filename);
return response($file)->header('Content-Type', $upload->mime);
}
First I decode the $filename
from the URL, and then I check to make sure the token is valid (see Returning secure files from an API with temporary URLs).
Next I get the Upload
from the Context (see Managing Context in a Laravel application and Setting the Context in a Laravel Application).
Next I get the file from the storage:
/**
* Get the file from storage
*
* @param string $path
* @param string $filename
* @return string
*/
private function getFileFromStorage($path, $filename)
{
if (Storage::exists($path)) {
return Storage::get($path);
}
throw new UploadNotFound('upload_not_found', [$filename]);
}
This method simply checks for the file in the storage and returns it, or otherwise throws an Exception that will bubble up to the surface and return the correct HTTP response and error, (see Dealing with Exceptions in a Laravel API application).
Finally I can return the file as an HTTP response with the correct mime type.
The download
method is basically the same except we need to save the file locally in order to return the correct download response:
/**
* Download an upload
*
* @param string $upload_id
* @param string $filename
* @return Response
*/
public function download($upload_id, $filename)
{
$filename = base64_decode($filename);
$this->checkForValidToken($filename);
$upload = Context::get('Upload')->model();
$file = $this->getFileFromStorage($upload->path, $filename);
Storage::disk('local')->put($upload->path, $file);
$path = storage_path(sprintf('app/%s', $upload->path));
return response()->download($path, $upload->name);
}
Now that we’ve got the routes and controller methods setup, we can actually get to the testing bit.
First I will test uploading files to the API. I’m only going to cover the actual file upload bit otherwise we’re going to get lost in the weeds.
To send the file to the API, we need to make a POST
request that contains an instance of UploadedFile
:
/** @test */
public function should_store_upload()
{
$path = storage_path('testing/pikachu.png');
$original_name = 'pikachu.png';
$mime_type = 'image/png';
$size = 2476;
$error = null;
$test = true;
$file = new UploadedFile($path, $original_name, $mime_type, $size, $error, $test);
$this->call('POST', 'me/avatars', [], [], ['upload' => $file], []);
$this->assertResponseOk();
}
In the text above I’m grabbing a file from the testing
directory we created earlier and then creating a new instance of UploadedFile
.
We can then send a POST
request and then assert that the response was ok.
Next we can look at testing viewing and downloading files. This requires us to make sure the file is in place before we attempt to return it.
If you use Laravel’s factories this is already possible thanks to Faker.
First, define a factory model like this:
$factory->define(Upload::class, function (Faker\Generator $faker) {
return [
"path" => ($path = $faker->file(
storage_path("testing"),
storage_path("app"),
false
)),
"name" => $path,
"mime" => $faker->mimeType,
"size" => $faker->randomNumber,
];
});
Notice how I’m using faker to copy one of the files from the testing directory into the main local storage directory. This will make the file available when we attempt to get it from storage.
Next we can write the test:
/** @test */
public function should_return_upload()
{
$user = factory(User::class)->create();
$upload = factory(Upload::class)->create();
$expires = time() + 300;
$token = Token::generate($user->uuid, $upload->filename(), $upload->extension(), $expires);
$this->get(
sprintf('uploads/%s/actions/view/%s?user_id=%s&expires=%s&token=%s', $upload->uuid,
base64_encode($upload->name, $user->uuid, $expires, $token);
$this->assertResponseOk();
}
First I will create new User
and Upload
objects from the factory.
Next I will generate a Token
.
Finally I will make the GET
request and then assert the response is ok.
Of course this is only showing the happy path without any type of authentication. You should also have tests for everything that could go wrong to assert that the correct descriptive response is returned under each circumstance, but I’ll leave that up to you.
Testing uploading and returning files can seem a bit daunting because you need to set up the right situation and interact with the filesystem.
But Laravel makes this really easy!
Firstly we can use Laravel’s filesystem abstraction to run the tests without mocking anything.
Secondly we can use faker to get the files in place.
This allows you to write end-to-end tests that cover all of the functionality of uploading and returning files from your API without any of the mess of mocks or stubs.