DEV Community

Cover image for Chirp Beyond (Bootcamp) part 4 Who's Following Who
Silver343
Silver343

Posted on

Chirp Beyond (Bootcamp) part 4 Who's Following Who

Previously we added the ability for users to follow each other, but there is no way for users to see who is following them and who they are following.

We can solve this with a couple of new pages where we list the followers or users who are user are following.

Before we add the new pages, there is an issue we overlooked.

Notifying users who aren’t verified

We set up the app so that users must verify their email addresses before they can follow each other, but the user they are following may have yet to verify their email address; this is an issue as we may now be sending email notifications to an unverified email.

We can solve this by checking if the user's email address is verified before notifying them in our 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
    {
+        if(! $event->user->hasVerifiedEmail()){
+            return;
+        }

        $event->user->notify(new NewFollower($event->follower));
    }
}
Enter fullscreen mode Exit fullscreen mode

We check if the user has verified their email and if they haven’t we return early.

We will add a new test to the SendNewFollowerNotificationsTest file to check this works.

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

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

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

        $listener->handle($event);

        Notification::assertNothingSent();
    }
Enter fullscreen mode Exit fullscreen mode

Who are you following

To create a new page to list the other users followed by a particular user, we will use the index method of the FollowController for this.

Let's create a new route that points to the index method.

Route::get('users/{user}/following', [FollowController::class, 'index'])->middleware('verified')->name('follow.index');
Enter fullscreen mode Exit fullscreen mode

In the index method, we want to return a new page and include the user whose information we are looking at and a list of the users they are following.

public function index(User $user): Response
-   //
+    {
+        $following = $user
+                ->follows()
+                ->orderByPivot('created_at','desc')
+                ->select('user_id as id', 'name')
+                ->addSelect(['following' => +function ($query) {
+                $query->select('id as following')
+                    ->from('follower_user')
+                    ->whereColumn('follower_id', Auth()->id())
+                    ->whereColumn('user_id', 'users.id');
+                }])
+                ->get()->makeHidden('pivot');
+        return Inertia::render('Follow/Index', [
+            'user' => $user->only(['id', 'name']),
+            'following' => fn() => $following,
+        ]);
Enter fullscreen mode Exit fullscreen mode

As well as querying the users followed by the authenticated user, we are also using a subquery to confirm if each of the users is being followed by the authenticated user.

Let’s create a new page to render our data. Create a new Vue file at resources/js/Pages/Follow/Index.vue

<script setup>

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Link, Head } from '@inertiajs/vue3';

const props = defineProps(['user', 'following']);

</script>
<template>
     <Head>
        <title>{{ user.name }} Follows</title>
     </Head>

    <AuthenticatedLayout>

        <h1 class="bg-white p-6 lg:p-8">
            <Link :href="route('profile.show',user.id)" class="text-2xl font-bold text-gray-900 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-950">{{ user.name }} Follows</Link>
        </h1>

    </AuthenticatedLayout>
</template>
Enter fullscreen mode Exit fullscreen mode

We will create a new component UserCard, to render the users details.

In UserCard we want to show the user's name and give them an option to follow or unfollow the user unless the user is the authenicated user.

<script setup>

import PrimaryButton from './PrimaryButton.vue';
import SecondaryButton from './SecondaryButton.vue';

import { useForm, Link } from '@inertiajs/vue3';

const props = defineProps(['user']);

const follow = useForm({
    id: props.user.id,
});

const unfollow = useForm({});

</script>

<template>
    <div class="flex items-center justify-between space-x-6 p-6">
        <div class="flex gap-x-4">
            <div class="min-w-0 flex-auto">
                <Link :href="route('profile.show', user.id)" class="text-md font-semibold leading-6 text-gray-800 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-900">{{ person.name }}</Link>
            </div>
        </div>
        <template v-if="user.id !== $page.props.auth.user.id">
            <form v-if="!user.following" @submit.prevent="follow.post(route('follow.store'), {
                preserveScroll: true,
                only:['following']
            })">
                <PrimaryButton>Follow</PrimaryButton>
            </form>
            <form v-if="user.following" @submit.prevent="unfollow.delete(route('follow.destroy', user.id), {
                preserveScroll: true,
                only:['following']
            })">
                <SecondaryButton type="submit">Unfollow</SecondaryButton>
            </form>
        </template>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The styling for this component is adapted from this Tailwind UI component.

Add this component to the Index page.

 <script setup>

 import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
 import { Link, Head } from '@inertiajs/vue3';

 const props = defineProps(['user', 'following']);

 </script>
 <template>
   <Head>
        <title>{{ user.name }} Follows</title>
   </Head>

   <AuthenticatedLayout>

     <h1 class="bg-white p-6 lg:p-8">
        <Link :href="route('profile.show',user.id)" class="text-2xl font-bold text-gray-900 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-950">{{ user.name }} Follows</Link>
     </h1>
+     <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
+       <div class="divide-y bg-white mt-6 rounded-lg">
+           <UserCard
+             v-for="user in following"
+         :key="user.id"
+         :user="user"
+        />
+         </div>
+      </div>

   </AuthenticatedLayout>
 </template>

Enter fullscreen mode Exit fullscreen mode

Who follows me?

We have a page to show who a user follows. We now need a page to show all the users who follow a particular user.

First, create a new controller and route.

To create the controller, run the command php artisan make:controller ListFollowers --invokable. The invokable option creates a controller with only a __invoke() method.

Update the method to return the authenticated user and their followers.

<?php
namespace App\Http\Controllers;
use App\Models\User;
use Inertia\Response;
use Inertia\Inertia;
class ListFollowers extends Controller
{
  /**
  * Handle the incoming request.
  */
  public function __invoke(User $user): Response
  {
     $followers = $user
             ->followers()
             ->orderByPivot('created_at','desc')
             ->select('follower_id as id', 'name')
             ->addSelect(['following' => function ($query) {
                 $query->select('id as following')
                     ->from('follower_user')
                     ->whereColumn('follower_id', Auth()->id())
                     ->whereColumn('user_id', 'users.id');
             }])
             ->get()->makeHidden('pivot');

     return Inertia::render('Follow/Index', [
         'user' => $user->only(['id', 'name']),
         'followers' => fn() => $followers,
     ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

We’ll create a route for this new page.

Route::get('users/{user}/followers', ListFollowers::class)->middleware('verified')->name('followers');
Enter fullscreen mode Exit fullscreen mode

The ListFollowers __invoke() method returns the authenticated user and the users who follow them; this is the same data structure the followingController Index method uses, the only difference being the key used for users. We can generalize the controllers' methods to use the same Follow/index component for both pages.

In the FollowController's index method, update the name of the following prop to users.

public function index(User $user): Response
{
     $following = $user
             ->follows()
             ->orderByPivot('created_at','desc')
             ->get(['user_id', 'name'])
             ->map(function ($item) {
                 return array_merge($item->makeHidden('pivot')->toArray(), [
                     'following' => Auth()->user()->follows()->where('user_id', $item->user_id)->exists()
                 ]);
             });

    return Inertia::render('Follow/Index', [
        'user' => $user->only(['id', 'name']),
-       'following' => fn() => $following,
+       'users' => fn() => $following,
      ]);
 }
Enter fullscreen mode Exit fullscreen mode

Do the same in the newly created ListFollowersController.

{
  /**
  * Handle the incoming request.
  */
  public function __invoke(User $user): Response
  {
     $followers = $user
             ->followers()
             ->orderByPivot('created_at','desc')
             ->select('follower_id as id', 'name')
             ->addSelect(['following' => function ($query) {
                 $query->select('id as following')
                     ->from('follower_user')
                     ->whereColumn('follower_id', Auth()->id())
                     ->whereColumn('user_id', 'users.id');
             }])
             ->get()->makeHidden('pivot');

     return Inertia::render('Follow/Index', [
         'user' => $user->only(['id', 'name']),
-        'followers' => fn() => $followers,
+        'users' => fn() => $followers,
     ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Update the Follow/Index page to accept the users prop and update the component to use users rather than following.

 <script setup>

 import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
 import { Link, Head } from '@inertiajs/vue3';

-const props = defineProps(['user', 'following']);
+const props = defineProps(['user', 'users']);

 </script>
 <template>
   <Head>
        <title>{{ user.name }} Follows</title>
   </Head>

   <AuthenticatedLayout>

     <h1 class="bg-white p-6 lg:p-8">
        <Link :href="route('profile.show',user.id)" class="text-2xl font-bold text-gray-900 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-950">{{ user.name }} Follows</Link>
     </h1>
     <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
       <div class="divide-y bg-white mt-6 rounded-lg">
           <UserCard
-            v-for="user in following"
+            v-for="user in users"
            :key="user.id"
            :user="user"
         />
         </div>
      </div>

   </AuthenticatedLayout>
 </template>
Enter fullscreen mode Exit fullscreen mode

We can use route().current() to determine the route that is using the page and set the title to reflect this.

 <script setup>

 import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
 import { Link, Head } from '@inertiajs/vue3';

 const props = defineProps(['user', 'following']);
 const props = defineProps(['user', 'users']);

 </script>
 <template>
   <Head>
-      <title>{{ user.name }} Follows</title>
+      <title v-if="route().current('followers')">{{ user.name }} Followers</title>
+      <title v-else>{{ user.name }} Follows</title>
   </Head>

   <AuthenticatedLayout>

     <h1 class="bg-white p-6 lg:p-8">
        <Link :href="route('profile.show',user.id)" class="text-2xl font-bold text-gray-900 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-950">{{ user.name }} Follows</Link>
     </h1>
     <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
       <div class="divide-y bg-white mt-6 rounded-lg">
           <UserCard
               v-for="user in following"
               v-for="user in users"
           :key="user.id"
           :user="user"
         />
         </div>
      </div>

   </AuthenticatedLayout>
 </template>
Enter fullscreen mode Exit fullscreen mode

Navigating to our user lists.

We will create two new links in our profile page to allow users to navigate to the newly created user list pages.

We will update the profileController's show method to load the relationship counts for the 'follows' and 'followers' relationships and display the count in the links.

 public function show(User $user): Response
 {
     return Inertia::render('Profile/Show',[
-        'user' => $user->only(['id', 'name', 'created_at']),
+        'user' => fn() => $user->loadCount(['followers', 'follows'])->only(['id', 'name', 'created_at', 'followers_count', 'follows_count']),
         '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 can now use these counts in the Heading component.

We will also update the forms to follow and unfollow so the user prop is updated with the new relationship count when the page reloads.

<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 class="mt-3 sm:mt-3">
+                       <Link :href="route('follow.index', user.id)">
+                           <span>{{ user.follows_count }}</span>
+                           <span class="text-sm font-medium text-gray-500"> Following</span>
+                       </Link>
+                       <Link :href="route('followers', user.id)">
+                           <span class="ml-4">{{ user.followers_count }}</span>
+                           <span class="text-sm font-medium text-gray-500"> Followers</span>
+                       </Link>
+                   </div>                 
                </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,
-    only:['following']
+    only:['following', 'user']
  })">
             <PrimaryButton class="justify-center">Follow</PrimaryButton>
         </form>
         <form class="flex flex-col" @submit.prevent="unfollow.delete(route('follow.destroy', user.id), {
            preserveScroll: true,
-           only:['following']
+           only:['following', 'user']
    })">
           <SecondaryButton class="justify-center" type='submit'>Unfollow</SecondaryButton>
        </form>
        </div>
      </div>
    </div>
 </template>
Enter fullscreen mode Exit fullscreen mode

Navigating between the pages

We will use the FilterTabs component to navigate between the Follow index and Following index pages created for the chirp filter. The FilterTabs component was built only for the Chirps index page; we will refactor it to allow us to reuse it.

Rename the FilterTabs component to Tabs.
In the newly renamed Tabs component, we shall pass the tabs down from the parent component,

 <script setup>
 import { Link } from '@inertiajs/vue3'
+defineprops(['tabs'])

 </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

Separate the actual links to a new component called TabLink. The new tabLink component requires the data for the tab, its position within the tabs component, and the total number of tabs.

 <script setup>
-import { Link } from '@inertiajs/vue3'
+import Tablink from './Tablink.vue';
 defineprops(['tabs'])

 </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>
+       <template v-for="(tab, index) in tabs">
+      <TabLink :tab="tab" :tabIdx="index" :tabLength="tabs.length">
+           <span> {{ tab.text }}</span>
+      </TabLink>
+       </template>
    </nav>

 </template>
Enter fullscreen mode Exit fullscreen mode

We will use the total number of tabs and the tabLinks position to apply selective tailwind classes.

resources/js/Components/Tablink.vue

<script setup>

import { Link } from '@inertiajs/vue3'

defineProps(['tab', 'tabIdx', 'tabLength'])
</script>

<template>
    <Link
        :href=tab.href
        :only=tab.only
        :class="[
            tab.active ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700',
            tabIdx === 0 ? 'rounded-l-lg' : '',
            tabIdx === tabLength - 1 ? 'rounded-r-lg' : '',
            '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="tab.active ? 'page' : undefined">
        <slot/>
        <span aria-hidden="true" :class="[tab.active ? 'bg-indigo-500' : 'bg-transparent', 'absolute inset-x-0 bottom-0 h-0.5']" />
    </Link>
</template>
Enter fullscreen mode Exit fullscreen mode

Now that the Tabs component can be used in multiple pages, we can update the Chirp/Index page and the New Follow/Index page.

resources/js/Pages/Chirps/Index.vue

 <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 Tabs from '@/Components/Tabs.vue';
 import { useForm, Head } from '@inertiajs/vue3';

 defineProps(['chirps']);
+const tabs = [
+    {href:route('chirps.index', { filter: 'false'}), active:route().current('chirps.index', { filter: 'false'}), only:['chirps'], text:'All'},
+    {href:route('chirps.index', { filter: 'true'}), active:route().current('chirps.index', { filter: 'true'}), only:['chirps'],  text:'Following'},
]

 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/>
+            <Tabs :tabs="tabs"/>

             <div class="mt-6 bg-white shadow-sm rounded-lg divide-y">
                 <Chirp
                     v-for="chirp in chirps"
                     :key="chirp.id"
                     :chirp="chirp"
                 />
             </div>
         </div>
     </AuthenticatedLayout>
 </template>
Enter fullscreen mode Exit fullscreen mode

resources/js/Pages/Follow/Index.vue

 <script setup>
+import Tabs from '@/Components/Tabs.vue';
 import UserCard from '@/Components/UserCard.vue';
 import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
 import { Link, Head } from '@inertiajs/vue3';

 const props = defineProps(['user', 'users']);

+const tabs = [
+    {href:route('followers', props.user.id), active:route().current('followers', props.user.id), text:'Followers'},
+    {href:route('follow.index', props.user.id), active:route().current('follow.index', props.user.id), text:'Following'},
 ]

 </script>
 <template>
      <Head>
         <title v-if="route().current('followers')">{{ user.name }} Followers</title>
         <title v-else>{{ user.name }} Follows</title>
      </Head>

     <AuthenticatedLayout>

         <h1 class="bg-white p-6 lg:p-8">
             <Link :href="route('profile.show',user.id)" class="text-2xl font-bold text-gray-900 capitalize hover:text-gray-500 hover:underline focus:text-gray-500 active:text-gray-950">{{ user.name }} </Link>
         </h1>

         <div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
+            <Tabs :tabs="tabs"/>
              <div class="divide-y bg-white mt-6 rounded-lg">
                 <UserCard
                     v-for="user in users"
                     :id="user.user_id ?? user.follower_id"
                     :name="user.name"
                     :following="user.following"
                 />
             </div>
         </div>
     </AuthenticatedLayout>
 </template>
Enter fullscreen mode Exit fullscreen mode

Testing

Alongside adding new functionality, we have updated the existing pages, so we need to update our existing tests.
We are now including the follower and follows count in the ProfileController's show method, to test that this data is included update the tests inside the ProfileTest file.

<?php

namespace Tests\Feature\Controllers;

use App\Models\User;
use App\Models\Chirp;
use Database\Factories\ChirpFactory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use SebastianBergmann\Type\VoidType;
use Tests\TestCase;
use Inertia\Testing\AssertableInertia as Assert;

class ProfileTest extends TestCase
{
    use RefreshDatabase;

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

        $response = $this
            ->actingAs($user)
            ->get('/profile');

        $response->assertOk();
    }

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

        $response = $this
            ->actingAs($user)
            ->patch('/profile', [
                'name' => 'Test User',
                'email' => 'test@example.com',
            ]);

        $response
            ->assertSessionHasNoErrors()
            ->assertRedirect('/profile');

        $user->refresh();

        $this->assertSame('Test User', $user->name);
        $this->assertSame('test@example.com', $user->email);
        $this->assertNull($user->email_verified_at);
    }

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

        $response = $this
            ->actingAs($user)
            ->patch('/profile', [
                'name' => 'Test User',
                'email' => $user->email,
            ]);

        $response
            ->assertSessionHasNoErrors()
            ->assertRedirect('/profile');

        $this->assertNotNull($user->refresh()->email_verified_at);
    }

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

        $response = $this
            ->actingAs($user)
            ->delete('/profile', [
                'password' => 'password',
            ]);

        $response
            ->assertSessionHasNoErrors()
            ->assertRedirect('/');

        $this->assertGuest();
        $this->assertNull($user->fresh());
    }

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

        $response = $this
            ->actingAs($user)
            ->from('/profile')
            ->delete('/profile', [
                'password' => 'wrong-password',
            ]);

        $response
            ->assertSessionHasErrors('password')
            ->assertRedirect('/profile');

        $this->assertNotNull($user->fresh());
    }

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

        $response = $this
            ->actingAs($user)
            ->get(route('profile.show',$user->id));

        $response->assertOk();
    }

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

        $profileUser = User::factory()
+                    ->has(User::factory()->count(2),'followers')
+                    ->has(User::factory()->count(3),'follows')
                    ->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)
+                    ->where('followers_count', $profileUser->followers()->count())
+                    ->where('follows_count', $profileUser->follows()->count())
                    ->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()
+                ->has(User::factory()->count(2),'followers')
+                ->has(User::factory()->count(3),'follows')
                ->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)
+                    ->where('followers_count', $user->followers()->count())
+                    ->where('follows_count', $user->follows()->count())
                    ->missing('email')
                    ->missing('password')
                    ->etc()
                )
                ->has('chirps',0)
                ->has('following')
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

We can add additional tests for the new index method of the FollowController in the FollowControllerTest.

We want to test that the data passed to the page is as expected and that only verified logged-in users can access the page.

<?php

namespace Tests\Feature\Controllers;

use App\Events\UserFollowed;
use App\Models\User;
+use Database\Factories\UserFactory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Event;
+use Inertia\Testing\AssertableInertia as Assert;
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'));
    }

+    public function test_users_can_see_who_each_other_are_following()
+    {
+        $user = User::factory()
+            ->has(User::factory(3),'follows')
+            ->create();

+        $otherUser = User::factory()
+            ->create();

+        $this->actingAs($otherUser)
+            ->get(route('follow.index', $user->id))
+            ->assertInertia(fn (Assert $page) => $page
+                ->component('Follow/Index')
+                ->has('user', fn (Assert $page) => $page
+                    ->where('id', $user->id)
+                    ->where('name', $user->name)
+                    ->missing('password')
+                    ->missing('email')
+                )
+                ->has('users', 3, fn (Assert $page) => $page
+                    ->where('user_id', $user->follows()->first()->id)
+                    ->where('name', $user->follows()->first()->name)
+                    ->where('following', false)
+                    )
+            );
+    }

+    public function test_follow_property_depends_on_auth_user()
+    {
+        $following = User::factory();

+        $user = User::factory()
+            ->has($following,'follows')
+            ->create();

+        $otherUser = User::factory()->create();

+        $this->actingAs($otherUser)
+            ->get(route('follow.index', $user->id))
+            ->assertInertia(fn (Assert $page) => $page
+                ->component('Follow/Index')
+                ->has('users', 1, fn (Assert $page) => $page
+                    ->where('user_id', $user->follows()->first()->id)
+                    ->where('name', $user->follows()->first()->name)
+                    ->where('following', false)
+                    )
+            );

+        $this->actingAs($user)
+            ->get(route('follow.index', $user->id))
+            ->assertInertia(fn (Assert $page) => $page
+                ->component('Follow/Index')
+                ->has('users', 1, fn (Assert $page) => $page
+                    ->where('user_id', $user->follows()->first()->id)
+                    ->where('name', $user->follows()->first()->name)
+                    ->where('following', true)
+                    )
+            );

+    }

+    public function test_must_be_logged_in_to_view_following_index()
+    {
+        $response = $this->get(route('follow.index',1));

+        $response->assertRedirectToRoute('login');
+    }

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

+        $response = $this->actingAs($user)
+            ->get(route('follow.index', 1));

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

Create a new test file for the ListFollowers Controller with the command php artisan make:test Controllers/ListFollowersTest

We want to test that the correct data is passed to the page, including the list of users who follow the profile user and if the authenticated user is following them. We must test that only verified logged-in users can reach the page.

<?php

namespace Tests\Feature\Controllers;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Illuminate\Foundation\Testing\WithFaker;
use App\Models\User;
use Tests\TestCase;

class ListFollowersTest extends TestCase
{
    public function test_users_can_see_who_is_following_a_particular_user()
    {
        $user = User::factory()
            ->has(User::factory(3),'followers')
            ->create();

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

        $this->actingAs($otherUser)
            ->get(route('followers', $user->id))
            ->assertInertia(fn (Assert $page) => $page
                ->component('Follow/Index')
                ->has('user', fn (Assert $page) => $page
                    ->where('id', $user->id)
                    ->where('name', $user->name)
                    ->missing('password')
                    ->missing('email')
                )
                ->has('users', 3, fn (Assert $page) => $page
                    ->where('id', $user->followers()->first()->id)
                    ->where('name', $user->followers()->first()->name)
                    ->where('following', null)
                    )
            );
    }

    public function test_follow_property_depends_on_auth_user()
    {
        $follower = User::factory()->create();

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

        $user->follows()->attach($follower);
        $user->followers()->attach($follower);

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

        $this->actingAs($otherUser)
            ->get(route('followers', $user->id))
            ->assertInertia(fn (Assert $page) => $page
                ->component('Follow/Index')
                ->has('users', 1, fn (Assert $page) => $page
                    ->where('id', $user->followers()->first()->id)
                    ->where('name', $user->followers()->first()->name)
                    ->where('following', null)
                    )
            );

        $this->actingAs($user)
            ->get(route('followers', $user->id))
            ->assertInertia(fn (Assert $page) => $page
                ->component('Follow/Index')
                ->has('users', 1, fn (Assert $page) => $page
                    ->where('id', $user->followers()->first()->id)
                    ->where('name', $user->followers()->first()->name)
                    ->whereNot('following', null)
                    )
            );

    }

    public function test_must_be_logged_in_to_view_following_index()
    {
        $response = $this->get(route('follow.index',1));

        $response->assertRedirectToRoute('login');
    }

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

        $response = $this->actingAs($user)
            ->get(route('follow.index', 1));

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

Top comments (0)