cult3

How to test uploading and returning files in Laravel

Dec 21, 2015

Table of contents:

  1. Configuring Laravel
  2. Defining the Routes
  3. Creating the Controller
  4. Testing Uploading Files
  5. Testing Viewing and Downloading Files
  6. Conclusion

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.

Configuring Laravel

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.

Defining the Routes

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.

Creating the Controller

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);
}

Testing Uploading Files

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.

Testing Viewing and Downloading Files

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.

Conclusion

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.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.