andreas.loew's avatar

Cached user model lets my test cases fail

Scenario:

A user has to own a project to work with my web app. I am trying using a middleware to enforce the existing of a project:

class VerifyUserHasProject
{
    public function handle($request, Closure $next)
    {
        if ($request->user() && ! $request->user()->hasProject()) {
            return redirect('new-project');
        }

        return $next($request);
    }
}

Creating a project is some kind of wizard. So after submitting to /new-project the user should be taken to a second page with more project details: /new-project/select-type.

This all works well manually.

I've also added a test:

public function user_can_create_new_project()
{
    $user = factory(App\User::class)->create();

    $this
        ->actingAs($user)
        ->visit("/new-project")
        ->type("project1", "name")
        ->press('Create')
        ->seeInDatabase("projects", ["name" => "project1", "owner_id" => $user->id])
        ->seeStatusCode(200)
        ->seePageIs("/new-project/select-type")  // this line fails!
    ;
}

This test fails because the resulting page is /new-project and not /new-project/select-type. But: Everything else is ok, the project is created and available in the database.

The problem is that the user model is not reloaded in the middleware and still thinks that the user has no projects.

Making the following change to the middleware solved the issue:

if ($request->user() && ! $request->user()->hasProject()) {

to

if ($request->user() && ! $request->user()->fresh()->hasProject()) {

But this is not what I want: I don't want to add the reload of the user model in the middleware just to make a test pass.

Is there a way to disable caching the user model in the test?

0 likes
5 replies
davielee's avatar

Hey @andreas.loew, can you post the code for hasProject() and the code that runs when the form on /new-project is run? Also, just curious, what is the CACHE_DRIVER in your phpunit.xml file set to?

andreas.loew's avatar

Sure. This is /new-project:

public function createProject(Request $request)
{
    $this->validate($request, [
        'name' => 'required|max:40',
    ]);

    $m = new Project;
    $m->name = $request->name;
    $m->owner_id = Auth::id();
    $m->save();

    return redirect('new-project/select-type');
}

This is in the User controller:

public function hasProject()
{
    return count($this->projects) > 0;
}

public function projects()
{
    return $this->hasMany(Project::class, "owner_id");
}

The cache-Driver is Array

<env name="CACHE_DRIVER" value="array"/>
davielee's avatar

Hmm...that's very weird. I just recreated your setup and it worked properly with the exact same test. Let me go through all the code I wrote and let's see if anything is different for you.

App\User

class User extends Authenticatable
{
    public function projects()
    {
        return $this->hasMany(Project::class, 'owner_id');
    }

    public function hasProject()
    {
        // Sidenote: This can be implemented like this as well since
        // projects() will always return at least an empty collection
        // return $this->projects->count() > 0;

        return count ($this->projects) > 0;
    }
}

App\Project

class Project extends Model
{
    public function owner()
    {
        return $this->belongsTo(User::class);
    }
}

CreateProjectsTable

public function up()
{
    Schema::create('projects', function (Blueprint $table) {
        $table->increments('id');
        $table->unsignedInteger('owner_id');
        $table->string('name');
        $table->timestamps();
    });
}

App\Http\Middleware\VerifyUserHasProject

class VerifyUserHasProject
{
    public function handle($request, \Closure $next)
    {
        if ($request->user() && !$request->user()->hasProject()) {
            return redirect('new-project');
        }

        return $next($request);
    }
}

App\Http\Kernel

protected $routeMiddleware = [
    'has-project' => \App\Http\Middleware\VerifyUserHasProject::class,
];

web.php

Route::get('/new-project', 'NewProjectController@create');

Route::post('/new-project', 'NewProjectController@store');

Route::get('/new-project/select-type', 'SelectTypeController@create')->middleware('has-project');

Besides that, the test was exactly the same and the view with the form for new-project just contained an input and a button. Thinking through it, perhaps where you are applying the middleware has something to do with...but I'm not entirely sure. To be sure, here is my entire phpunit.xml contents.

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="bootstrap/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory suffix="Test.php">./tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">./app</directory>
        </whitelist>
    </filter>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

All of this was done in Homestead and the tests were run inside the VM as well, so perhaps that might be where yours is failing if that is different for you. Let me know if you see any differences.

andreas.loew's avatar

First of all: Thanks a lot for taking your time and reproducing this! This is really awesome! I really had not expected to find somebody to do this.

Seems almost identical. I am using Spark as a base for this project.

Everything is running in a VM - but I am not using Homestead.

My routes file looks like this:

$router->group(['middleware' => 'auth'], function ($router)
{
    Route::get('new-project', 'NewProjectController@newProject');
    Route::post('new-project', 'NewProjectController@createProject');

    $router->group(['middleware' => 'hasProject'], function ($router)
    {
        Route::get('new-project/select-type', 'NewProjectController@selectType');
    });
});

The migration for the Projects-Table also contains setup for a foreign key

        $table->foreign('owner_id')
              ->references('id')->on('users')
              ->onDelete('cascade');

and I am using MariaDB instead of sqlite. Switched to sqlite - but this did as expected - not solve the issue.

andreas.loew's avatar

Ok. Found why it works like this... it's simply a problem with Eloquent.

Relationships never get updated once they are loaded in a model. And setting the owner on the Project does not update the User.

The middleware uses the User instance which I set on the test case using $this->actingAs($user).

The state is frozen as soon as $user->projects is called - the first time the middleware is called.

In the real application, it does not matter since there's a complete reload of the application after the redirect. The state of the user model is reset and reloaded on request - with the correct values from the database.

In the test, there's no reloading. The User set with actingAs is still alive - without any Project. And the middleware is served from the cached relationship data: No project. This is why it's redirected again...

The basic issue here is the design of Eloquent. It always creates new instances of models instead of re-using the same instance. It also does not update related models. E.g. adding a Project does not inform the User about its new data.

Propel (the ORM I used until now) does this. It only keeps a single instance of each database entry - and it also updates the connected models....

Please or to participate in this conversation.