Auth Test and Route0:00
All right, can we get back to TDD now? Okay, so let's jump into our tests directory and we have this LikeController that we need to support. Slightly different this time because the LikeController needs to support liking multiple different types of models, Post or Comment in our case. So we'll have to give that some thought, but let's just make a start. So directory, we'll create a new LikeController directory and then inside here we can have our storeTest.php file. Obviously we have our classic requires authentication check. So let's go and grab a copy of that, paste it in here and we want likes.store instead.
Obviously we have our classic requires authentication check. So let's go and grab a copy of that, paste it in here and we want likes.store instead of post.store and of course we can still assert that there's a redirect to the login route. Let's run this test. It's going to fail because we've not yet defined the route. So we'll jump into web.php and change that. We want it to be inside our auth group here. So down at the bottom we could say Route::post to /likes and that's going to link to this LikeController that we created along with our Like model and of course the store method and we'll give it a name of likes.store.
Golden Path Like Test1:07
link to this LikeController that we created along with our Like model and of course the store method and we'll give it a name of likes.store. Let's rerun the test and now it passes. For our second test, let's tackle the golden path. How do we create a like on a Post or a Comment? So we'll come into our test file again and let's say it allows liking and I'm not going to say a Post or a Comment specifically, I'm just going to call it a likeable and what I'll do is I'll accept inside the test closure an instance of a model, a likeable. So it could be a Post or a Comment and we can use pest data sets in order to provide any number of those models that we like.
So it could be a Post or a Comment and we can use pest data sets in order to provide any number of those models that we like. So perhaps first of all we'll provide a Post, PostFactory::create and it's important when you're doing this that you prefix with a closure. The reason being is that in order to build a Post or a Comment and put it in the database, we need the Laravel container, the application to be booted. By default inside a data set in pest, the Laravel container isn't booted. So wrapping it in a closure allows pest to wait until the moment that Laravel is available to actually create the model for us. If you want more on that, you can go and check out my pest from scratch series where I explain
to actually create the model for us. If you want more on that, you can go and check out my pest from scratch series where I explain that in a little more depth. So first of all, we'll create ourselves a User, userFactory create, and we'll want to log in as that User using the actingAs helper and make a post request to the correct route, which we've already said is likes.store. Now so far, all of the routes we've defined have accepted just one parameter, the ID of whatever we're interacting with. But if you think about it, we can't do that in the case of the LikeController because it's not enough information to go on.
But if you think about it, we can't do that in the case of the LikeController because it's not enough information to go on. How would the LikeController know in this instance whether we want to like a Post of one or a Comment of ID one? It wouldn't. So we actually need to pass in two pieces of information, the type, which could be maybe post, and then the ID, which could be one. Now rather than hard coding post or comment, why don't we just go ahead and use the morphMap that we created in previous episodes? So if we want to look up a Post, we would use this key here.
map that we created in previous episodes? So if we want to look up a Post, we would use this key here. If we want to look up a Comment, we would use this key here. So in order to support that, we could do something like likeable::getMorphClass() that would return the correct key. And then of course, for the ID, we can say likeable->id like so, just as we're used to doing. For now, let's assert a simple redirect, and then we'll perform a few checks. First of all, let's say $this->assertDatabaseHas(), we'll check the likes table. And of course, we're looking for a user_id of the $user at the top.
First of all, let's say this assert database has, we'll check the likes table. And of course, we're looking for a user_id of the User at the top. So that would be user_id, a likeable_id of, well, the likeable class. And of course, a likeable_type of whatever the morph class is. But remember that we also want to increment that likes_count on the likeable itself. So why don't we expect that if we go and grab a fresh instance of the likeable from the database, and that's important to make sure that the column is loaded correctly. If we check the likes_count column, it should be one. Previously, of course, we would expect that to have been zero. Now it should be set to one, it should have been incremented.
Previously, of course, we would expect that to have been zero. Now it should be set to one, it should have been incremented. Let's run the test. Of course, it fails because it received a 200. So for now, we'll jump into the controller. And I'm gonna do something very simplistic inside our store method. I'm just going to return back. We probably will stick with that. I think when you like something, it should just return to wherever you were previously. But we need to test that more in depth.
Implement Store Logic5:08
I think when you like something, it should just return to wherever you were previously. But we need to test that more in depth. We'll come to that in just a moment. Let's rerun the test again. Now it's saying, well, yep, it worked, but nothing was inserted into the database. So why don't we accept inside the store method, both the type and the ID. So we'll have string type, and we'll have int ID. And of course, we'll need to update web.php to reflect that. So you go /likes, /type as a parameter, /ID as a parameter.
So you go likes, type as a parameter, ID as a parameter. Okay, so with that type and ID in place, we should be able to use those two parameters to grab the correct model. Let's say likable equals, and we can use the Relation class for this. Relation::getMorphedModel, we're interested in the type. So that's going to look in our app/Providers/AppServiceProvider.php at this morphMap that we've created. And it's going to say, okay, you've passed the word post. The word post links to the Post fully qualified class name, so that's what I'll return. And then we could call findOrFail, passing in the ID.
The word Post links to the Post fully qualified class name, so that's what I'll return. And then we could call findOrFail, passing in the ID. findOrFail is going to look in the database for our Post or our Comment. If it can't find it, it will throw a ModelNotFoundException, which Laravel automatically transforms into a 404, which is exactly what we'd expect from this behavior. Once we have a likable instance, we know that there's a likes method that we can call. So we'll grab the likes relation, and we want to call create on that likes relation. We'll want to make sure to assign this like to the correct User. So let's say $userId equals request()->user()->id.
We'll want to make sure to assign this like to the correct User. So let's say $userId equals $request->user()->id. And that should be everything we need for the first part of this test to pass. Let's rerun, and yeah, it changes the error. The error is now that zero is not identical to one. So it's this check here. We're not manually incrementing the likes count. Let's fix that. So back in our LikeController, after we've created the like, we can say $likable->increment('likes_count').
So back in our LikeController, after we've created the like, we can say likable increment, and we want to increment the likes_count column. Rerun the test, and now it's passing. So that's the golden path. But remember, not only do we want to test against a Post, we also want to test against a Comment, which you are also able to like. That's actually incredibly simple because of how we wrote the test. In our data set here, I'll add another line, and I'll return a CommentFactory instance. So we create a comment, and now this test will be executed twice,
and I'll return a CommentFactory instance. So we create a Comment, and now this test will be executed twice, once with a Post, and then a second time with a Comment. We'll run the test, and both times it passes. Now, as I mentioned earlier, one thing I want to test further is this back method. I want to ensure that that's actually what's going on. And there's a nifty way to do this inside Laravel. What we need to do is stipulate which route we've actually come from. Where were we previously to return to?
Test Redirect Back8:11
What we need to do is stipulate which route we've actually come from. Where were we previously to return to? And between acting as and post, we can call a method called fromRoute, and you can pass this any of your routes. In this case, we'll pass dashboard. In other words, we're saying, look, treat the previous request as being the dashboard. That's where we were before. And now in redirect, we can just say that we expect that we're returning to the dashboard route.
And now in redirect, we can just say that we expect that we're returning to the dashboard route. If we run this, of course, you can see it still passes. So that's a pretty cool way of being able to check for redirects to previous routes when you're using the back function or something similar. So we can successfully create a like on a post or a comment, but we need to make sure we haven't got edge case bugs. So let's write some more tests. For example, what happens if you try to like something you already liked? It prevents liking something you already liked.
Prevent Duplicate Likes9:03
For example, what happens if you try to like something you already liked? It prevents liking something you already liked. Now, how do we actually go about testing this? Well, let's start by creating a like. So we can say $like equals like factory create(). And of course, we can grab the likable from that created like. Then we can log in as whoever created that like, $likeUser. And we can make a POST request to the like.store endpoint, passing the same likable that we already liked before. Now, how do we want to handle the error here?
passing the same likable that we already liked before. Now, how do we want to handle the error here? We could handle the error as a validation issue. So it throws a validation exception, and then we show those validation messages on the front end. But whenever the parameters are part of the route, which in this case is exactly what's going on, right? We're passing the likable morph class and the likable ID to the route, not data that we pass as the payload. Whenever that's the case, I prefer to reach for authorization.
not data that we pass as the payload. Whenever that's the case, I prefer to reach for authorization. So instead, I'm going to say assertForbidden, and that's what I actually want to check for. Let's run the test. The test is going to fail because we actually returned a 500 error. Now, if we go ahead and say this without exception handling, and then we rerun this, note that this is actually the SQL error because we have that unique constraint on liking something twice in our database. But of course, we don't want it to ever get that far.
because we have that unique constraint on liking something twice in our database. But of course, we don't want it to ever get that far. We should catch this well before that happens. So instead of waiting for that SQL exception to be thrown, we'll authorize this using a policy. Let's go to our LikeController, and after we've retrieved the like from the database, why don't we say this->authorize to create a Like, and we'll pass the like fully qualified class name. One note, by the way, if you're using Laravel 11 from the get-go in this series,
and we'll pass the like fully qualified class name. One note, by the way, if you're using Laravel 11 from the get-go in this series, it's likely that the authorize method isn't supported inside your controller, and that's because from Laravel 11 onwards, you no longer extend anything in controllers. So you can just use the Gate facade instead. Gate, authorize, create, pass the like class in. Just as before, everything will work the same. It's just a slightly different syntax. But seeing as it's what I've been using throughout the rest of the series,
It's just a slightly different syntax. But seeing as it's what I've been using throughout the rest of the series, I'll stick with this for now. Let's jump into the create method on our LikePolicy. And as I said, we want to return true if there is no like on the given likable for the given user. But currently, we have nowhere near enough information to perform that check. We need the likable. In order to provide that likable, as the second parameter to authorize, we can return an array.
In order to provide that likable, as the second parameter to authorize, we can return an array. We still pass the likable fully qualified class name as the first item in the array, but then as a second item, I'm going to pass the likable instance. And we can accept that as a second parameter to our create method. That'll be a model which we can call likable. So now we can say something like return likable likes, and let's use the whereBelongsTo method to check for the given user. And I want to say only if there isn't anything that exists for that user. So let's just break this down.
And I want to say only if there isn't anything that exists for that User. So let's just break this down. Imagine the likable is a Post. So return this post's likes that belong to the User that don't exist. And this will return a Boolean. If there are no likes for the given User on this likable, you are allowed to create a like. But if it already has a like, you're not allowed to do so, sorry. So let's go ahead and rerun our test, and you can see that it passes. Very good.
Restrict Likeable Types12:53
So let's go ahead and rerun our test, and you can see that it passes. Very good. Okay, what's next? We should probably check that you can only like posts and comments. You can't allow liking users, for example. So if we create a test for this, it only allows liking supported models. And then we could copy some of the code from a previous test, maybe this one here. Let's copy it and paste it in. And we can get rid of from root.
Let's copy it and paste it in. And we can get rid of from root. When we actually call post, let's go ahead and pass a User in. So User get morphClass and userId. And then we can use assertForbidden, because we absolutely should not be able to do that. Let's run the test. It currently fails because no morphMap has been defined for the User model. So if we jump into the AppServiceProvider, we need to add User as an item to this array here.
So if we jump into the AppServiceProvider, we need to add User as an item to this array here. Let's say user, and that links to the User fully qualified class name. Run the test again. And now we return a 500 error. Let's see a little bit more detail on that. So this, without exception handling, run the test again. Field likable type doesn't have a default value. Okay, so this is happening for a very interesting reason. Basically, the User has a likes method.
Okay, so this is happening for a very interesting reason. Basically, the User has a likes method. Here we are. Likes has many. So inside our controller, when we say likable likes create, what it's actually doing is returning this relationship type here. So likes does exist, but of course, it's the wrong relationship type, so it fails. And now, of course, we wouldn't expect to ever get to this line here if we tried to like a User. It should fail well before that inside our policy.
if we tried to like a User. It should fail well before that inside our policy. So let's jump inside the create method on our LikePolicy. And at the top, why don't we say, if not in array, we'll use the Likable class, so Likable::class, and we'll check for the Post::class fully qualified class name or the Comment::class fully qualified class name. If the likable isn't of those two types, we'll return false. In other words, no, this is not a supported type to like. And now if we rerun the test, well, we get an error still, but the error is that the action is unauthorized.
And now if we rerun the test, well, we get an error still, but the error is that the action is unauthorized. The reason that's being thrown is because we add this line here without exception handling. If we remove it, then the exception will bubble up. Laravel will take over, turn it into an unauthorized response, and now the test passes. One last thing I'd like to check, what if we pass like a random string to type? Let's say we pass the word foo into type here. Well, obviously, getMorphModel is going to return null.
Let's say we pass the word foo into type here. Well, obviously, getMorphModel is going to return null because there's nothing inside our morph map that suits. There is no key called foo. So then when we try to call find or fail, I'm pretty sure it would cause a runtime error, and we'd get an exception. I think it would be nicer to handle that gracefully and perhaps just throw a 404 instead. So let's write a test so that we do this using TDD. In our test file, let's create a new test.
So let's write a test so that we do this using TDD. In our test file, let's create a new test. It throws a 404 if the type is unsupported. So let's start by logging in as a User. Acting as user factory create, we'll make a POST request to the correct endpoint, which is likes.store. And let's pass a random string as the type, foo, for example. And then we'll pass 1 as the ID. And I would assert that that throws a 404, which we can check using notFound. Run the test.
And I would assert that that throws a 404, which we can check using notFound. Run the test. What do we have? We received a 500 error. Again, I'm going to just add this without exception handling so we can see the underlying error. Yeah, exactly as I'd expect. Class name must be a valid object or string. In other words, this is not something that can call find or fail. Let's split this up into two different parts.
In other words, this is not something that can call find or fail. Let's split this up into two different parts. So how about we say $modelName equals relation getMorphedModel of type. And once we've done that, we could check if it's equal to null. So if $modelName is equal to null, what we actually want to do is throw a new ModelNotFoundException. Otherwise, we know that it's not null, and we can say $modelName find or fail $id. Let's rerun the code. OK, yeah, it's throwing a ModelNotFoundException. And once again, it's not being handled because we've
OK, yeah, it's throwing a ModelNotFoundException. And once again, it's not being handled because we've said without exception handling. So if we remove that line and rerun the test, everything now passes. Let's rerun all the tests in this file. Our first test is now failing because we're not passing the parameters to likes.store. So why don't we say postOne, rerun the tests again, and now all six tests are passing. Let's just clean this up a little.
and now all six tests are passing. Let's just clean this up a little. I think we could probably refactor these lines here to a little helper method on the controller. Refactor, extract method. How about we call it findLikable? That would work nicely. So we grab the likable by calling findLikable, passing in the type and ID. We can get rid of this doc block here. It's redundant.
We can get rid of this doc block here. It's redundant. And you can choose whether you want private or protected. I'm going to go protected, but whatever suits your style of coding. And I'll return an instance of a model. Note, by the way, that we have this squiggly line in our IDE. I think we could fix that with a little doc block above model name. So we could say that this is equal to an instance of class string. And then I'm going to use a php stand syntax here to say that it's a class string of type model.
And then I'm going to use a php stand syntax here to say that it's a class string of type model. Or, of course, it could be null. So we'll pass null there as well. And now you can see that the squiggly line has disappeared. Not all IDEs will support this kind of syntax. So it depends how powerful your IntelliSense checks are. But yeah, in this case, PHPStorm supports it. So we get rid of the error that we were seeing previously. Let's go ahead and run these tests again just
So we get rid of the error that we were seeing previously. Let's go ahead and run these tests again just to make sure we've not broken anything. Everything's still passing. So I'm confident in saying that we can now successfully like a Post or a Comment. Of course, we need to handle the other side of things, which is unliking a Post or a Comment. So we'll write that logic in the next episode.
So we'll write that logic in the next episode.
