DEV Community

Cover image for Chirp Beyond (Bootcamp) part 3 Follow me
Silver343
Silver343

Posted on

Chirp Beyond (Bootcamp) part 3 Follow me

As Chirper exists at the moment, all users can see and are notified of each other's chirps, but we are now going to allow users to follow each other and filter chirps, so we only want to notify them of chirps created by the people they follow.

Followers and Follows

We will create a new database table and relationship in our application. We will create a many-to-many relationship between users and other users. Create a new migration file with the command php artisan make:migration create_follower_user_table

The new migration can be found at database/migrations/_create_follower_user_table.php

Update the 'up()' method with two new fields a 'user_id' and 'follower_id' both of which reference the users table.

/**
* Run the migrations.
*/
public function up(): void
{
    Schema::create('follower_user', function (Blueprint $table) {
        $table->id();
        $table->foreignId('follower_id')->references('id')->on('users')->cascadeOnDelete();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->timestamps();
    });
}
Enter fullscreen mode Exit fullscreen mode

We now need to define the relationships inside our user model. Add the following two methods.

public function followers(): BelongsToMany
{
    return $this->belongsToMany(User::class, 'follower_user', 'user_id', 'follower_id')->withTimestamps();
}

public function follows(): BelongsToMany
{
    return $this->belongsToMany(User::class, 'follower_user', 'follower_id', 'user_id')->withTimestamps();
}
Enter fullscreen mode Exit fullscreen mode

You can find more about defining relationships in the Laravel docs.

New Controller

We’ll create a new controller to handle the logic. Use the following command php artisan make:controller FollowController --resource to create a resource controller.

This will create a new controller under the controller directory with all the resource methods defined.

New Route

Create two new routes. One will accept a post request to create a new follower-user relationship; the other will accept a delete request to destroy the relationship.

Add the routes to web.php

+use App\Http\Controllers\FollowController;


+Route::post('/follow', [FollowController::class, 'store'])->middleware('verified')->name('follow.store');
+Route::delete('/unfollow/{user}', [FollowController::class, 'destroy'])->middleware('verified')->name('follow.destroy');
Enter fullscreen mode Exit fullscreen mode

In our new routes, we are using the verified middleware. This middleware checks to see if the user has verified their email address. If they have, the request will proceed; otherwise they will be redirect to a page notifying them to verify their email address.

namespace App\Models;
-// use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
-class User extends Authenticatable
+class User extends Authenticatable implements MustVerifyEmail
Enter fullscreen mode Exit fullscreen mode

Follow and unfollow

Update the two methods in the Follow Controller that correspond with the new routes.

Update the store method

+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;


 public function store(Request $request):  RedirectResponse
 {
- //
+     $validated = $request->validate([
+            'id' => [
+            'required',
+            'integer',
+            'numeric',
+            Rule::notIn([Auth()->id()]),
+            'exists:users,id'
+        ]
+    ]);
+    Auth()->user()->follows()->attach($validated['id']);
+    return back();
}
Enter fullscreen mode Exit fullscreen mode

We validate the id passed from the request to ensure it is a number, it doesn't match the user making the request, and that it exists in the database. Do not trust data returned from the front end.

Once the id is validated we create the many-to-many relationship using the attach() method.

Next we’ll update the destroy method.

 public function destroy(String $id): RedirectResponse
 {
- //
+    Auth()->user()->follows()->detach($id);
+    return back();
 }
Enter fullscreen mode Exit fullscreen mode

In this method, we destroy the many-to-many relationship using the detach() method.

Update the Front end

We’ve created the new routes and controller we now need to add the forms to our front end to send data to them.

Our forms will live in the new heading component we made for our public profile page.

Add the following to bottom of the Heading component

 <script setup>
 import dayjs from 'dayjs';
+import PrimaryButton from './PrimaryButton.vue';
+import SecondaryButton from './SecondaryButton.vue';
 import { Link } from '@inertiajs/vue3'
 const props = defineProps(['user']);
 </script>
 <template>
     <div class="bg-white">
         <div class="max-w-7xl mx-auto pb-1 px-4 sm:px-6 lg:px-8 p-4 sm:py-6 lg:py-8 sm:flex sm:items-center sm:justify-between sm:space-x-5">
             <div class="flex items-start space-x-5">
                 <div class="pt-1.5">
                     <h1 class="text-2xl font-bold text-gray-900 capitalize">{{ user.name }}</h1>
                     <p class="text-sm font-medium text-gray-500">
                    Joined
                        <time :datetime="dayjs(user.created_at).format('YYYY-MM')">
                        {{ dayjs(user.created_at).format('MMMM YYYY') }}
                        </time>
                     </p>
                  </div>
            </div>
            <div v-if="user.id === $page.props.auth.user.id" class="my-3 flex flex-col-reverse justify-stretch sm:mt-0 sm:pr-3">
                <Link :href="route('profile.edit')" class="inline-flex items-center justify-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">Edit Profile</Link>
            </div>
+           <div v-else class="my-3 flex flex-col-reverse justify-stretch space-y-4 space-y-reverse sm:justify-end sm:space-x-3 sm:space-y-0 sm:mt-0 sm:flex-row sm:pr-3">
+          <form class="flex flex-col" @submit.prevent="follow.post(route('follow.store'), {
+     preserveScroll: true,
+  })">
+             <PrimaryButton class="justify-center">Follow</Primarybutton>
+         </form>
+         <form class="flex flex-col" @submit.prevent="unfollow.delete(route('follow.destroy', user.id), {
+            preserveScroll: true,
+    })">
+           <SecondaryButton class="justify-center" type='submit'>Unfollow</Secondarybutton>
+        </form>
+        </div>
      </div>
    </div>
 </template>
Enter fullscreen mode Exit fullscreen mode

We are using the v-else directive to conditionally render the forms, as we only want to show them if the profile doesn't belong to the Authenticated user.

Add new methods the top of the file for the new forms.


<script setup>
 import dayjs from 'dayjs';
 import PrimaryButton from './PrimaryButton.vue';
 import SecondaryButton from './SecondaryButton.vue';
 import { Link } from '@inertiajs/vue3'
 const props = defineProps(['user']);
+const follow = useForm({
+    id: props.user.id,
+});
+const unfollow = useForm({});
 </script>
Enter fullscreen mode Exit fullscreen mode

Both of these methods use inertia’s form helper to simplify our code

We are currently rendering both buttons as we have no way of knowing if the authenticated user is following the owner of the profile, we can fix this by checking if the relationship exisits and passing that to the front end.

Update the profile controller's show method to include a ‘following’ property.

 public function show(User $user): Response
 {
     return Inertia::render('Profile/Show',[
         'user' => $user->only(['id', 'name', 'created_at']),
         'chirps' => $user->chirps()->latest()->get()->map(fn (Chirp $chirp) => $chirp->setRelation('user',$user)),
+       'following' => fn() => Auth()->user()->follows()->where('user_id', $user->id)->exists(),
     ]);
 }
Enter fullscreen mode Exit fullscreen mode

We are querying the many-to-many relationship to see if one exists between the authenticated user and profile owner.

To pass this data to our heading component, we must first pass it to our Profile/show page, and then pass it down to the Heading component.

 <script setup>
 import AuthenticatedLayout from  '@/Layouts/AuthenticatedLayout.vue';
 import Heading from '@/Components/Heading.vue';
 import Chirp from '@/Components/Chirp.vue';
 import { Head } from '@inertiajs/vue3';
-defineProps(['user','chirps']);
+defineProps(['user','chirps', 'following']);
 </script>
 <template>
     <Head :title="user.name" />
     <AuthenticatedLayout>
-        <Heading :user="user"/>
+        <Heading :user="user" :following="following"/>
         <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
             <div class="mt-6 bg-white shadow-sm rounded-lg divide-y">
Enter fullscreen mode Exit fullscreen mode

We can now add the property to our heading table and conditionally render the forms.

 <script setup>
 import dayjs from 'dayjs';
 import PrimaryButton from './PrimaryButton.vue';
 import SecondaryButton from './SecondaryButton.vue';
 import { Link } from '@inertiajs/vue3'
-const props = defineProps(['user']);
+const props = defineProps(['user', 'following']);
 </script>
 <template>
     <div class="bg-white">
         <div class="max-w-7xl mx-auto pb-1 px-4 sm:px-6 lg:px-8 p-4 sm:py-6 lg:py-8 sm:flex sm:items-center sm:justify-between sm:space-x-5">
             <div class="flex items-start space-x-5">
                 <div class="pt-1.5">
                     <h1 class="text-2xl font-bold text-gray-900 capitalize">{{ user.name }}</h1>
                     <p class="text-sm font-medium text-gray-500">
                    Joined
                        <time :datetime="dayjs(user.created_at).format('YYYY-MM')">
                        {{ dayjs(user.created_at).format('MMMM YYYY') }}
                        </time>
                     </p>
                  </div>
            </div>
            <div v-if="user.id === $page.props.auth.user.id" class="my-3 flex flex-col-reverse justify-stretch sm:mt-0 sm:pr-3">
                <Link :href="route('profile.edit')" class="inline-flex items-center justify-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">Edit Profile</Link>
            </div>
+          <form v-if="!following" class="flex flex-col" @submit.prevent="follow.post(route('follow.store'), {
-          <form class="flex flex-col" @submit.prevent="follow.post(route('follow.store'), {
          preserveScroll: true,
          only:['following']
           })">
               <PrimaryButton class="justify-center">Follow</Primarybutton>
          </form>
+         <form v-else class="flex flex-col" @submit.prevent="unfollow.delete(route('follow.destroy', user.id), {
-         <form class="flex flex-col" @submit.prevent="unfollow.delete(route('follow.destroy', user.id), {
             preserveScroll: true,
             only:['following']
     })">
               <SecondaryButton class="justify-center" type='submit'>Unfollow</Secondarybutton>
             </form>
          </div>
        </div>
    </div>
 </template>
Enter fullscreen mode Exit fullscreen mode

Now we have the 'following' property; this is all we need to update after the forms are submitted, this is set but adding only:[’following’] to the forms.

The profile page now looks like this if the authenticated user follows the profile user.

Profile page with a button to unfollow the user

The profile page now looks like this if the authenticated user does not follow the profile user.

Profile page with a button to follow the user

Filtering Chirps

Our users can now follow each other but it provides no benefit. The first benefit we will add is to allow users to filter chirps so they only see chirps from people they follow.

To define if the chirps returned by our index method should be filtered, we will add a new 'filter' parameter to our query string.

So the URL we look like; http://chirper.test/chirps?filter=false

This parameter will be a boolean, we’ll update existing links to set ‘filter’ to false in our navigation in Authenticated layout.

35<NavLink :href="route('chirps.index', { filter: 'false'})" :active="route().current('chirps.index')">

122<ResponsiveNavLink :href="route('chirps.index', { filter: 'false'})" :active="route().current('chirps.index')">
Enter fullscreen mode Exit fullscreen mode

To allow users to set the filter, we will create a new Tabs component.
Each tab will be a link to the same route but one will have the filter parameter set to false, and the other will have it set to true.

Create a new file at resources/js/components called FilterTabs.vue and add the following code. The design of this component comes from Tailwind UI.

<script setup>
import { Link } from '@inertiajs/vue3'

</script>

<template>
    <nav class="isolate flex divide-x mt-6 divide-gray-200 rounded-lg shadow overflow-hidden" aria-label="Tabs">
        <Link
            :href="route('chirps.index', { filter: 'false'})"
            :only="['chirps']"
            :class="[route().current('chirps.index', { filter: 'false'}) ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700','group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10']"
            :aria-current="route().current('chirps.index', { filter: 'false'}) ? 'page' : undefined">
            <span> All </span>
            <span aria-hidden="true" :class="[route().current('chirps.index', { filter: 'false'}) ? 'bg-indigo-500' : 'bg-transparent', 'absolute inset-x-0 bottom-0 h-0.5']" />
        </Link>
        <Link
            :href="route('chirps.index', { filter: 'true'})"
            :only="['chirps']"
            :class="[route().current('chirps.index', { filter: 'true'}) ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700', 'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10']"
            :aria-current="route().current('chirps.index', { filter: 'true'}) ? 'page' : undefined">
            <span>Following </span>
            <span aria-hidden="true" :class="[route().current('chirps.index', { filter: 'true'}) ? 'bg-indigo-500' : 'bg-transparent', 'absolute inset-x-0 bottom-0 h-0.5']" />
        </Link>
    </nav>
</template>
Enter fullscreen mode Exit fullscreen mode

We are using only on our links so only the Chirps are refreshed.

Add our new component above out chirps to the Chirps/index page.

 <script setup>
 import AuthenticatedLayout from  '@/Layouts/AuthenticatedLayout.vue';
 import Chirp from '@/Components/Chirp.vue';
 import InputError from '@/Components/InputError.vue';
 import PrimaryButton from '@/Components/PrimaryButton.vue';
+import FilterTabs from '@/Components/FilterTabs.vue';
import { useForm, Head } from '@inertiajs/vue3';
defineProps(['chirps']);
const form = useForm({
    message: '',
});
 </script>
 <template>
     <Head title="Chirps" />
     <AuthenticatedLayout>
         <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
             <form @submit.prevent="form.post(route('chirps.store'), { onSuccess: () => form.reset() })">
                 <textarea
                     v-model="form.message"
                     placeholder="What's on your mind?"
                     class="block w-full border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 rounded-md shadow-sm"
                 ></textarea>
                 <InputError :message="form.errors.message" class="mt-2" />
                 <PrimaryButton class="mt-4">Chirp</PrimaryButton>
             </form>
+            <FilterTabs/>
Enter fullscreen mode Exit fullscreen mode

We now have a link with the filter parameters set to true. In the ChirpController, we will conditionally filter the chirps when the filter is true. Update the index method.

-public function index(): Response
+public function index(Request $request): Response
 {
     return Inertia::render('Chirps/Index', [
-       'chirps' => Chirp::with('user:id,name')->latest()->get(),
+       'chirps' => Chirp::with('user:id,name')
+       ->when($request->input('filter') === 'true', fn($q) =>
+           $q->whereIn(
+               'user_id',
+                Auth()->user()->follows->pluck('id')
+                ->merge(Auth()->id())
+           )
+       )
+       ->latest()
+       ->get(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

When the filter is true, we use a subquery so we only fetch the chirps where the user_id matches one of those in the follows() relationship, we then merge the request users id to so their chirps are also fetched.

Events and listeners

During the bootcamp you created an event and corresponding listener to send a notification when a chirp was created. The listener sent a notification to all users, as advised in the bootcamp we will now update the listener to only send the notification to those who follow the chirps author.

Update the send chirp listener.

public function handle(ChirpCreated $event): void
{
-   foreach (User::whereNot('id', $event->chirp->user_id)->cursor() as $user) {
+   foreach (User::whereIn('id', $event->chirp->user->followers->pluck('id'))->cursor() as $user) {
        $user->notify(new NewChirp($event->chirp));
    }
}
Enter fullscreen mode Exit fullscreen mode

Rather than filtering out the author of the chirp, we are filtering out users if they do not follow the chirp's author.

Users may also wish to be notified if they receive a new follower. We will create a new event, listener and notification.

Create a new event with the command php artisan make:event UserFollowed

Create a new Listener with the command php artisan make:listener SendNewFollowerNotifications

We will update the FollowController's store method to dispatch our new event.

+use App\Events\UserFollowed;

 public function store(Request $request): RedirectResponse
 {
 $validated = $request->validate([
     'id' => [
          'required',
          'integer',
          'numeric',
          Rule::notIn([Auth()->id()]),
          'exists:users,id'
      ]
 ]);

 Auth()->user()->follows()->attach($validated['id']);

 $following = User::findOrFail($validated['id']);
+UserFollowed::dispatch($following, Auth()->user());

 return back();
}
Enter fullscreen mode Exit fullscreen mode

We also need to update the Event Service provider to register our new event and listener.

 namespace App\Providers;
 use App\Events\ChirpCreated;
+use App\Events\UserFollowed;
 use App\Listeners\SendChirpCreatedNotifications;
+use App\Listeners\SendNewFollowerNotifications;
 use Illuminate\Auth\Events\Registered;
 use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
 use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
 use Illuminate\Support\Facades\Event;
 class EventServiceProvider extends ServiceProvider
 {
     /**
      * The event to listener mappings for the application.
      *
      * @var array<class-string, array<int, class-string>>
      */
     protected $listen = [
         ChirpCreated::class => [
             SendChirpCreatedNotifications::class,
         ],
+        UserFollowed::class => [
+            SendNewFollowerNotifications::class,
+        ],

         Registered::class => [
             SendEmailVerificationNotification::class,
         ],
Enter fullscreen mode Exit fullscreen mode

The new userFollowed event accepts two variables, the user the authenticated user is now following, and the authenticated user.

Update the UserFollowed Event to accept the variables.

<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserFollowed
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct(public User $user, public User $follower)
    {
        //
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('channel-name'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Update the SendNewFollowerNotifications.

<?php

namespace App\Listeners;

use App\Events\UserFollowed;
use App\Notifications\NewFollower;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;

class SendNewFollowerNotifications implements ShouldQueue
{
    /**
     * Create the event listener.
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     */
    public function handle(UserFollowed $event): void
    {
        $event->user->notify(new NewFollower($event->follower));
    }
}
Enter fullscreen mode Exit fullscreen mode

In the handle method, we are passing the new follower (the request user) to a new notification and sending it to the newly followed user, as with the event listener created in the Bootcamp this listener implements ShouldQueue.

Create a new notification with the command php artisan make:notification NewFollower

This command will create a new file in the App/Notifications directory.

Update the toMail() method.

public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
                    ->subject('You have a new follower')
                    ->greeting("{$this->follower->name} started following you")
                    ->action('View their profile', route('profile.show', $this->follower->id))
                    ->line('Thank you for using our application!');
    }
Enter fullscreen mode Exit fullscreen mode

If you now follow an account, you should see an email in your test client. I am using Helo From BeyondCode.

Screenshot of email in Helo from Beyond code

Tests

If we run our full test suite with php artisan test you will find our test for the sending notifications to all users when a new chirp is created fails due to our code changes, so lets update this test.


-public function test_notification_is_sent_to_all_but_chirp_creator()
+public function test_notifications_are_only_sent_to_users_who_follow_author()
 {
    Notification::fake();
-   $users = User::factory(2)->create();
+   $user = User::factory()->create();
-   $chirp = Chirp::factory()->create();
+   $followers = User::factory(2)->create();
+   $nonFollower = User::factory()->create();
+   $user->followers()->attach($followers->pluck('id'));
+   Chirp::factory()
+        ->for($user)
+        ->create();

    Notification::assertSentTo(
-       [$users], NewChirp::class
+       [$followers], NewChirp::class
    );

    Notification::assertNotSentTo(
-       [$chirp->user], NewChirp::class
+       [$user, $nonFollower], NewChirp::class
    );
 }
Enter fullscreen mode Exit fullscreen mode

We are now attaching followers to the chirp author, and then testing the notification is sent to them.

Although the rest of our tests didn’t break we should update and expand them to correspond with our code changes.

Update Chirp Controller Test

We updated our chirp controller to optionally filter the chirps, let's create a test for that.

Add this test to your chirpcontrollertest.php file.

public function test_only_chirps_by_people_the_user_follows_are_returned_if_filter_is_true()
{
    $following = User::factory()
        ->has(Chirp::factory())
        ->create();

  $user = User::factory()
        ->hasAttached($following,[],'follows')
        ->create();

    $nonFollowedChirps = Chirp::factory(10)->create();

    $this->actingAs($user)
        ->get(route('chirps.index', ['filter' => 'true']))
        ->assertInertia(fn (Assert $page) => $page
            ->component('Chirps/Index')
            ->has('chirps', 1, fn (Assert $page) => $page
                ->where('message', $following->chirps->first()->message)
                ->etc()
                ->has('user', fn (Assert $page) => $page
                    ->where('id', $following->id)
                    ->where('name', $following->name)
                    ->missing('password')
                    ->missing('email')
                )
            )
    );
}
Enter fullscreen mode Exit fullscreen mode

We are testing that the only chirp returned to our page is the one that was created by an author our test user follows.

Update Profile Test

In our profile test file, we have two tests that assert against the properties that are included in our inertia response, both of these tests need updating so the new ‘following’ property is included.

public function test_chirps_belonging_to_profile_owner_are_included(): void
{
    $user = User::factory()
                ->create();

    $profileUser = User::factory()
                ->has(Chirp::Factory()->count(5))
                ->create();

    $this->actingAs($user)
        ->get(route('profile.show',$profileUser->id))
        ->assertInertia(fn (Assert $page) => $page
            ->component('Profile/Show')
            ->has('user', fn (Assert $page) => $page
                ->where('id', $profileUser->id)
                ->where('name', $profileUser->name)
                ->missing('email')
                ->missing('password')
                ->etc()
            )
            ->has('chirps', 5, fn (Assert $page) => $page
                ->where('id', $profileUser->chirps()->first()->id)
                ->where('message', $profileUser->chirps()->first()->message)
                ->etc()
                ->has('user', fn (Assert $page) => $page
                    ->where('id', $profileUser->id)
                    ->etc()
                )
            )
+           ->has('following')
    );
}

public function test_chirps_belonging_to_other_uses_are_not_included(): void
{
    $user = User::factory()
                ->create();

    $otherUser = User::factory()
                ->has(Chirp::Factory()->count(5))
                ->create();

    $this->actingAs($user)
        ->get(route('profile.show',$user->id))
        ->assertInertia(fn (Assert $page) => $page
            ->component('Profile/Show')
            ->has('user', fn (Assert $page) => $page
                ->where('id', $user->id)
                ->where('name', $user->name)
                ->missing('email')
                ->missing('password')
                ->etc()
            )
            ->has('chirps',0)
+           ->has('following')
        );
}
Enter fullscreen mode Exit fullscreen mode

Create FollowController Tests

To create our test file run the command php artisan make:test Controllers/FollowControllerTest and update the file.

<?php

namespace Tests\Feature\Controllers;

use App\Events\UserFollowed;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class FollowControllerTest extends TestCase
{
    public function test_users_can_follow_each_other()
    {
        $user = User::factory()->create();

        $following = User::factory()->create();

        $response = $this
            ->actingAs($user)
            ->post(route('follow.store'),['id' => $following->id]);

        $response->assertRedirect();

        $this->assertContains($following->id, $user->follows->pluck('id'));
    }

    public function test_user_followed_event_is_dispatched()
    {
        Event::fake();

        $user = User::factory()->create();

        $following = User::factory()->create();

        $this
            ->actingAs($user)
            ->post(route('follow.store'),['id' => $following->id]);

        Event::assertDispatched(function (UserFollowed $event) use ($user) {
            return $event->follower->id === $user->id;
        });
    }

    public function test_user_must_be_logged_in_before_following()
    {
        $following = User::factory()->create();

        $response = $this->post(route('follow.store'),['id' => $following->id]);

        $response->assertRedirect(route('login'));
    }

    public function test_user_must_be_verified_before_following()
    {
        $user = User::factory()
            ->unverified()
            ->create();

        $following = User::factory()->create();

        $response = $this
            ->actingAs($user)
            ->post(route('follow.store'),['id' => $following->id]);

        $response->assertRedirect(route('verification.notice'));
    }

    public function test_id_required()
    {
        $response = $this
            ->actingAs(User::factory()->make())
            ->post(route('follow.store'));

        $response->assertSessionHasErrors([
            'id' => 'The id field is required.'
        ]);
    }

    public function test_id_must_be_a_number()
    {
        $response = $this
            ->actingAs(User::factory()->make())
            ->post(route('follow.store'),['id' => 'a']);

        $response->assertSessionHasErrors([
            'id' => 'The id field must be an integer.',
            'id' => 'The id field must be a number.'
        ]);
    }

    public function test_id_must_not_be_request_user()
    {
        $user = User::factory()->create();

        $response = $this
            ->actingAs($user)
            ->post(route('follow.store'),['id' => $user->id]);

        $response->assertSessionHasErrors([
            'id' => 'The selected id is invalid.',
        ]);
    }

    public function test_id_must_belong_to_existing_user()
    {
        $user = User::factory()->create();

        $response = $this
            ->actingAs($user)
            ->post(route('follow.store'),['id' => 999]);

        $response->assertSessionHasErrors([
            'id' => 'The selected id is invalid.',
        ]);
    }

    public function test_users_can_unfollow_each_other()
    {
        $user = User::factory()->create();

        $following = User::factory()->create();

        $response = $this
            ->actingAs($user)
            ->delete(route('follow.destroy',$following->id));

        $response->assertRedirect();

        $this->assertNotContains($following->id, $user->follows->pluck('id'));
    }

    public function test_user_must_be_logged_in_to_unfollow()
    {
        $following = User::factory()->create();

        $response = $this->delete(route('follow.destroy', 2));

        $response->assertRedirect(route('login'));
    }

    public function test_user_must_be_verified_to_unfollow()
    {
        $user = User::factory()
            ->unverified()
            ->create();

        $response = $this
            ->actingAs($user)
            ->delete(route('follow.destroy', 2));

        $response->assertRedirect(route('verification.notice'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Test our new listener

We have tested the new event is dispatched in the followControllerTest file, but now we need to test the new SendNewFollowerNotifications listener.

To create the test file use the command php artisan make:test Listeners/SendNewFollowerNotificationsTest. We will add two tests to our new file

<?php

namespace Tests\Feature\Listeners;

use App\Events\UserFollowed;
use App\Listeners\SendNewFollowerNotifications;
use App\Models\User;
use App\Notifications\NewFollower;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class SendNewFollowerNotificationsTest extends TestCase
{
    public function test_listener_is_attached_to_event(): void
    {
        Event::fake();

        Event::assertListening(
            UserFollowed::class,
            SendNewFollowerNotifications::class
        );
    }

    public function test_notification_is_sent()
    {
        Notification::fake();

        $user = User::factory()->create();
        $follower = User::factory()->create();

        $event = new UserFollowed($user, $follower);
        $listener = New SendNewFollowerNotifications();

        $listener->handle($event);

        Notification::assertSentTo($user, NewFollower::class);
        Notification::assertCount(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

We are first testing that we have connected the event and listener. We are then testing that our listener notifies the appropriate user.

Testing the notification

We are going to test the contents of the mail notification.

To create the test file use the command php artisan make:test Notifications/NewFollowerTest Add the following code.

<?php

namespace Tests\Feature\Notifications;

use App\Models\User;
use App\Notifications\NewFollower;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class NewFollowerTest extends TestCase
{
    /**
     * A basic feature test example.
     */
    public function test_mail_contents(): void
    {
       Notification::fake();

       $user = User::factory()->create();
       $follower = User::factory()->create();

        $user->notify(new NewFollower($follower));

        Notification::assertSentTo($user, NewFollower::class, function ($notification) use ($user, $follower){
            $mailNotification = $notification->toMail($user);

            $this->assertEquals("{$follower->name} started following you", $mailNotification->greeting);
            $this->assertEquals('View their profile', $mailNotification->actionText);
            $this->assertEquals(route('profile.show', $follower->id),$mailNotification->actionUrl);

            return true;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

In the test we are asserting the contents of the mail notification are as we expect, specifically the section that uses the followers data.

Top comments (0)