Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Testing, Updating Roles & Refreshing Data

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

This test is failing... which is great! It proves that our end goal - only returning the phoneNumber field to users authenticated as an admin - is totally not working yet. Before we get it working, let's finish the test: let's make the user an admin and assert that the phoneNumber field is returned.

First, "promote" this user to be an admin: call $user->setRoles() and pass this an array with ROLE_ADMIN inside.

Services are Recreated between Requests

Easy! But... we need to be careful with what we do next.

Let me explain: in the "real" world - when you're not inside a test - each request is handled by a separate PHP process where all the service objects - like the entity manager - are instantiated fresh. That's just... how PHP works: objects are created during the request and then trashed at the end of the request.

But in our test environment, that's not the case: we're really making many, sort of, "fake" requests into our app all within the same PHP process. This means that, in theory, if we made a request that, for some reason, changed a property on a service object, when we make the next request... that property would still be changed. In the test environment, one request can affect the next requests. That's not what we want because it's not how the real world works.

Not to worry: Symfony handles this automatically for us. Before each request with the client, the client "resets" the container and recreates it from scratch. That gives each request an "isolated" environment because each request will create new service objects.

But it also affects the service objects that we use inside our test... and it can be confusing. Stick with me through the explanation, and then I'll give you some rules to live by at the end.

Check out the entity manager here on top. Internally, the entity manager keeps track of all of the objects that it's fetched or persisted. This is called the identity map. Then, when we call flush(), it loops over all of those objects, finds the ones that have changed and runs any queries it needs.

Up here, this entity manager does have this User object in its identity map, because the createUserAndLogIn() method just used that entity manager to persist it. But when we make a request, two things happen. First, the old container - the one we've been working with inside the test class so far, is "reset". That means that the "state" of a lot of services is reset back to its initial state, back when the container was originally created. And second, a totally new container is created for the request itself.

This has two side effects. First, the "identity map" on this entity manager was just "reset" back to empty. It means that it has no idea that we ever persisted this User object. And second, if you called $this->getEnityManager() now, it would give you the EntityManager that was just used for the last request, which would be a different object than the $em variable. That detail is less important.

On a high level, you basically want to think of everything above $client->request() as code that runs on one page-load and everything after $client->request() as code that runs on a totally different page load. If the two parts of this method really were being executed by two different requests, I wouldn't expect the entity manager down here to be aware that I persisted this User object on some previous request.

Ok, I get it, I lost you. What's going on behind the scenes is technical - it confuses me too sometimes. But, here's what you need to know. After calling $user->setRoles(), if we just said $em->flush(), nothing would save. The $em variable was "reset" and so it doesn't know it's supposed to be "managing" this User object.

Here's the rule to live by: after each request, if you need to work with an entity - whether to read data or update data - do not re-use an entity object you were working with from before that request. Nope, query for a new one: $user = $em->getRepository(User::class)->find($user->getId()).

We would need to do the same thing if we wanted to read data. If we made a PUT request up here to edit the user and wanted to assert that a field was updated in the database, we should query for a new User object down here. If we used the old $user variable, it would hold the old data, even though the database was successfully updated.

Logging in as Admin

So I'll put a comment about this: we're refreshing the user and elevating them to an admin. Saying ->flush() is enough for this to save because we've just queried for this object.

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 44
public function testGetUser()
{
... lines 47 - 61
// refresh the user & elevate
$user = $em->getRepository(User::class)->find($user->getId());
$user->setRoles(['ROLE_ADMIN']);
$em->flush();
... lines 66 - 71
}
}

Below, say $this->logIn() and pass this $client and... the same two arguments as before: the email & password.

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 65
$this->logIn($client, 'cheeseplease@example.com', 'foo');
... lines 67 - 71
}

Wait... why do we need to log in again? Weren't we already logged in... and didn't we just change this user's roles in the database? Yep! Unrelated to the test environment, in order for Symfony's security system to "notice" that a user's roles were updated in the database, that user needs to log back in. It's a quirk of the security system and hopefully one we'll fix soon. Heck, I personally have a two year old pull request open to do this! I gotta finish that!

Anyways, that's why we're logging back in: so that the security system sees the updated roles.

Finally, down here, we can do the same $client->request() as before. In fact, let's copy it from above, including the assertJsonContains() part. But this time, assert that there should be a phoneNumber field set to 555.123.4567.

... lines 1 - 44
public function testGetUser()
{
... lines 47 - 67
$client->request('GET', '/api/users/'.$user->getId());
$this->assertJsonContains([
'phoneNumber' => '555.123.4567'
]);
}

Phew! Ok, we already know this will fail: when we make a GET request for a User, it is currently returning the phoneNumber field: the test is failing on assertArrayNotHasKey().

Dynamic Fields

So... now that we have this big, fancy test... how are we going to handle this? How can we make some fields conditionally available?

Via... dynamic normalization groups.

When you make a request for an operation, the normalization groups are determined on an operation-by-operation basis. In the case of a User, API Platform is using user:read for normalization and user:write for denormalization. In CheeseListing, we're customizing it even deeper: when you get a single CheeseListing, it uses the cheese_listing:read and cheese_listing:item:get groups.

That's great. But all of these groups are still static: we can't change them on a user-by-user basis... or via any dynamic information. You can't say:

Oh! This is an admin user! So when we normalize this resource, I want to include an extra normalization group.

But... doing this is possible. On User, above $phoneNumber, we're going to leave the user:write group so it's writable by anyone with access to a write operation. But instead of user:read, change this to admin:read.

... lines 1 - 39
class User implements UserInterface
{
... lines 42 - 89
/**
... line 91
* @Groups({"admin:read", "user:write"})
*/
private $phoneNumber;
... lines 95 - 239
}

That's a new group name that I... just invented. Nothing uses this group, so if we try the test now:

php bin/phpunit --filter=testGetUser

It fails... but gets further! It fails on UserResourceTest line 68... it's failing down here. We successfully made phoneNumber not return when we fetch a user.

Next, we're going to create something called a "context builder", which will allow us to dynamically add this admin:read group when an "admin" is trying to normalize a User resource.

Leave a comment!

3
Login or Register to join the conversation
Simon L. Avatar
Simon L. Avatar Simon L. | posted 2 years ago | edited

Hi there!

I wrote this test:


    public function testCanUserModifyHisFirstName()
    {
        $client = self::createClient();

        $em = $this->getEntityManager();

        $user = $this->createUser('username', '4564513');
        $apiToken = $this->createApiToken($user);

        $user->setFirstName('Georges');
        $em->persist($user);
        $em->flush();

        $client->request('PUT', 'https://api.alpha.hallee.io/user_objects/' . $user->getId(), [
            'headers' => [
                'Authorization' => 'Bearer ' . $apiToken->getToken()
            ],
            'json' => [
                'firstName' => 'Alice'
            ]
        ]);
        $this->assertResponseStatusCodeSame(200);

        // After a request, $user needs to be refreshed otherwise we get the old data (before the request)
        /** @var UserObject $user */
        // $user = $em->getRepository(UserObject::class)->find($user->getId());

        $this->assertSame(
            $user->getFirstName(),
            'Alice'
        );
    }

At the beginning, $user = $em->getRepository(UserObject::class)->find($user->getId()); was uncommented. But then I just wanted to check what would be the error wihout "reloading the user from the database"... and surprise it works fine...

I am confused because I understand why we need to refresh the user from the database after a request, but in this test it seems that it is not needed... Am I missing something?

Reply

Hey Simon L.!

To be honest, this stuff is really tricky :/. I believe I can answer your question, but you'll see how tricky it is when I do it ;). Here is the flow:

1) When you call $client = self::createClient();, behind the scenes, this boots a Symfony kernel/container.
2) When you call $em = $this->getEntityManager();, that causes the entity manager to be instantiated from that container. That same entity manager object is used, obviously, to create the user and token.
3) When you call $client->request(), because you have NOT made a request yet through this client, it makes a "fake" request into Symfony but re-uses the same kernel/container that we've been working with.This means that when your API Platform code runs, it uses the same EntityManager as your test. And so, when your API code, for example, queries for the User object, the EntityManager is smart enough to return the same User object (in memory) as the one you have in your test. So when your API code updates that User object, it is updating the same User object in memory as your test.

So... THAT is why you don't need to refresh: the User object in your test === the User object that's used inside the request.

Of course, the next question is: then why do we ever need to refresh objects? If you proceed to make a second $client->request(), THIS time, the client object says "oh, I have already made a request. So I should shutdown my kernel and create a new Container" - you can see that in this method: https://github.com/symfony/symfony/blob/5.x/src/Symfony/Bundle/FrameworkBundle/KernelBrowser.php#L144-L157

This means that the second request will use a different container than your test. And this means that, inside your API code in that request, a NEW EntityManager object will be created. When your API code queries for the User object this time, that EntityManager will not have already queried for that User object (like in the first request), and so it will make a fresh query and (most importantly) will create & use a fresh User object. Then, that NEW User object will be modified by the rest of your API code. In this case, the User object in your test !== the User object being used by your API request.

So... make sense? 🙃 This is just a tricky area: you need a container in your test so you can access services. And Symfony needs a container for each request... but to be realistic (since in real life, 2 requests are totally isolated), it deletes and re-creates a new container for each request. The accidental end result is that the container in your test === the container in your request for the FIRST request only 😛

Cheers!

1 Reply
Simon L. Avatar
Simon L. Avatar Simon L. | weaverryan | posted 2 years ago | edited

Thank you weaverryan :)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}
userVoice