Header Ads

Twitter From Scratch Using Laravel: Email Verification with Test


In our last lesson we've learned about how to customize the Laravel's built-in authentication scaffolding. In this lesson we will be talking about email verification with test. We can also learn about Laravel's Mailables and how to test email in Laravel in the process.

First, in our .env file you'll notice a mail variables:
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
This is where we put our mail related credentials. As you can see it's default to SMTP and uses Mailtrap which we will cover in our future lessons.

Next, let's add:
MAIL_FROM_ADDRESS=no-reply@twitter.com
MAIL_FROM_NAME=Twitter
This will be used in config/mail.php as a global from for our application

Driver Prerequisites

In production it is recommended to use an API based driver such as Mailgun or SparkPost because it is often simpler and faster than SMTP. If you decide to use one you need to install the Guzzle HTTP library which can be installed via Composer package manager:
composer require guzzlehttp/guzzle

Next, add your credentials to your config/services.php file:
'mailgun' => [
    'domain' => env('MAILGUN_DOMAIN'),
    'secret' => env('MAILGUN_KEY'),
],
'sparkpost' => [
    'secret' => env('SPARKPOST_SECRET'),
],

Next, update your MAIL_DRIVER in your .env file:
MAIL_DRIVER=mailgun

For testing purposes we're just gonna use log driver to make our test much faster, it means we're not gonna send an email to an email address, we're just gonna log it to a file. So let's override our mail driver for testing by adding MAIL_DRIVER config to our phpunit.xml:
<env name="MAIL_DRIVER" value="log"/>

Next, let's create MailTracking.php under our tests directory and grab Jeffrey Way's awesome email test assertions. You can watch the full video on how it's made in Laracasts for free.

Next, let's auto load it for development only in our composer.json file:
"autoload-dev": {
    "files": [
        "tests/MailTracking.php"
    ]
}

Next, let's run:
composer dump-autoload

Next, let's create a feature test in our tests/Feature/UserTest.php that will make sure that an email is sent after registration:
class UserTest extends TestCase
{
    use DatabaseMigrations, \MailTracking;

    /** @test */
    public function a_confirmation_email_must_be_sent_after_registration()
    {
        $email = 'johndoe@gmail.com';

        $response = $this->post('/register', [
            'first_name' => 'John',
            'last_name' => 'Doe',
            'email' => $email,
            'password' => 'secret',
            'password_confirmation' => 'secret'
        ]);

        $this->seeEmailWasSent();
    }
}
Notice that we're using a backslash in \MailTracking because we're on a Tests\Feature namespace.

Next, let's run our test:
phpunit --filter a_confirmation_email_must_be_sent_after_registration
There was 1 failure:

1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
No emails have been sent.
Failed asserting that an array is not empty.
As expected it failed.

Let's fix that. First, let's listen for a created event in our User model:
use App\Events\UserHasRegistered;

class User extends Authenticatable
{
    /**
     *  The event map for the model.
     * 
     *  @var array
     */
    protected $events = [
        'created' => UserHasRegistered::class,
    ];
}
This means every time a new user is created that UserHasRegistered event class will be fired.

Next, let's add that to our app/Providers/EventServiceProvider.php with a listener:
class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        'App\Events\UserHasRegistered' => [
            'App\Listeners\SendVerificationEmail',
        ],
    ];
}

Next let's run:
php artisan event:generate
This will automatically create those classes in the $listen property if it's not yet existed.

Next, let's add our User model instances to our UserHasRegistered event class:
use App\User;

class UserHasRegistered
{
    public $user;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(User $user)
    {
        $this->user = $user;
    }
}
That's all there is to it. Laravel will automatically inject those dependencies in the constructor for us.

Next, let's create a UserHasRegistered mailable class with markdown:
php artisan make:mail UserHasRegistered --markdown=user.email.verification
This will create a UserHasRegistered.php file under app/Mail directory and a verification.blade.php file under resources/views/user/email directory.

Next, let's work on app/Mail/UserHasRegistered.php:
use App\User;

class UserHasRegistered extends Mailable
{
    use Queueable, SerializesModels;

    /**
     *  The \App\User instance
     * 
     *  @var \App\User
     */
    public $user;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct(User $user)
    {
        $this->user = $user;
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        return $this->markdown('user.email.verification');
    }
}
Again, we just need to inject our dependencies in the constructor and assigned it to a public property and it will be automatically available in our views. Then in the build method we have to use $this->markdown() method instead of $this->view() because we're gonna use markdown.

Next, let's update our resources/views/user/email/verification.blade.php:
@component('mail::message')
# Final step...

Confirm your email address to complete your Twitter account {{ $user->username }}. It's easy — just click the button below.

@component('mail::button', ['url' => $user->email_verification_url])
Confirm now
@endcomponent

Thanks,

{{ config('app.name') }}
@endcomponent

Next, let's add an email verification url accessor in our User model:
/**
 *  Get the email verification url.
 * 
 *  @return string
 */
public function getEmailVerificationUrlAttribute()
{
    return route('account.verify.email', ['token' => $this->email_verification_token]);
}

Next, let's add an email_verfication_token column in our users table.
$table->string('email_verification_token')->nullable();
It must be nullable because it indicates that the user already verified their email if the value is null.

Next, let's create a route for our email verification url:
Route::group(['prefix' => 'account', 'namespace' => 'Account', 'as' => 'account.'], function () {
    Route::group(['prefix' => 'verify', 'namespace' => 'Verification', 'as' => 'verify.'], function () {
        Route::get('email/{token}', 'EmailController@verify')->name('email');
    });
});

Route::group(['prefix' => '{username}', 'namespace' => 'User'], function () {
    Route::resource('tweet', 'TweetController');
});
Notice we have a nested route group and an as array key. This is to achieve our route name account.verify.email. Also make sure that all of your routes must be above on a route that has a wild card in the first segment of the uri to avoid conflict.

Next, let's go to App\Listeners\SendVerificationEmail.php where the actual sending of the email will happen, particularly in the handle method.
use App\Events\UserHasRegistered;
use Illuminate\Support\Facades\Mail;
use App\Mail\UserHasRegistered as UserHasRegisteredMail;

class SendVerificationEmail
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  UserHasRegistered  $event
     * @return void
     */
    public function handle(UserHasRegistered $event)
    {
        Mail::to($event->user->email)->send(new UserHasRegisteredMail($event->user));
    }
}
Notice we just use the to method in the Mail facade and pass the user email, then chain the send method and pass the new instance of UserHasRegistered mailble class which in this case we have to alias it to UserHasRegisteredMail because we already have a UserHasRegistered class name for our event. This time our test should get greed.

Next, let's add an assertion that our subject is correct:
$this->seeEmailSubject("Confirm your Twitter account, John");

Our test shoul failed this time:
1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
No email with a subject of Confirm your Twitter account, John was found.
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'Confirm your Twitter account, John'
+'User Has Registered'

Let's fix that by adding a subject in our UserHasRegistered mailable class:
public function build()
{
    return $this->subject("Confirm your Twitter account, {$this->user->first_name}")
                ->markdown('user.email.verification');
}

Next, let's assert that the email is sent to the correct email address:
$this->seeEmailTo('johndoe@gmail.com')

Next, let's assert that the email contains the correct verification url:
/** @test */
public function a_confirmation_email_must_be_sent_after_registration()
{
    $email = 'johndoe@gmail.com';
    $token = str_random(60);

    $response = $this->post('/register', [
        'first_name' => 'John',
        'last_name' => 'Doe',
        'email' => $email,
        'password' => 'secret',
        'password_confirmation' => 'secret',
        'email_verification_token' => $token,
    ]);

    $this->seeEmailWasSent()
         ->seeEmailSubject("Confirm your Twitter account, John")
         ->seeEmailTo('johndoe@gmail.com')
         ->seeEmailContains(route('account.verify.email', ['token' => $token]));
}
There was 1 failure:

1) Tests\Feature\UserTest::a_confirmation_email_must_be_sent_after_registration
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.email_verification_token
And our test failed.

Let's fix that by adding our email_verification_token column to our fillable fields in our User model:
protected $fillable = [
    'first_name', 'last_name', 'email', 'password', 'email_verification_token',
];

Next, let's update our app/Http/Controllers/Auth/RegisterController.php:
protected function create(array $data)
{
    return User::create([
        'first_name' => $data['first_name'],
        'last_name' => $data['last_name'],
        'email' => $data['email'],
        'password' => bcrypt($data['password']),
        'email_verification_token' => $data['email_verification_token'] ?? str_random(60)
    ]);
}
Notice we're using a new PHP7 feature called "Null coalescing operator", basically it's just a syntactic sugar for isset($data['email_verification_token']) ? $data['email_verification_token'] : str_random(60). Next, when we run our test again we should get green.

Next, in the same test file let's create a test that will make sure our email verification url is working:
/** @test */
public function a_user_can_verify_their_email()
{
    $user = factory('App\User')->create();

    $this->get(route('account.verify.email', ['token' => $user->email_verification_token]));

    $this->assertNull($user->fresh()->email_verification_token);
}
What we're doing here is first we create a user, then visit the email verification url, after that we assert that the email_verification_token is null which indicates that it's verified. We also use the fresh() method here to get a fresh data from the users table.

Run our test again:
There was 1 error:

1) Tests\Feature\UserTest::a_user_can_verify_their_email
Illuminate\Database\QueryException: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: users.email_verification_token

It failed because we don't have an email_verification_token in our user model factory:
return [
    'email_verification_token' => str_random(60),
];

Now, we get a different error:
There was 1 error:

1) Tests\Feature\UserTest::a_user_can_verify_their_email
ReflectionException: Method App\Http\Controllers\Account\Verification\EmailController::verify() does not exist

Let's fix that by adding a verify method in our EmailController:
use App\User;

public function verify($token)
{
    $user = User::whereEmailVerificationToken($token)->first();

    if (is_null($user)) {
        return redirect('/home')->with('message', 'The email confirmation link you followed has expired.Click "Resend confirmation" from Settings for a new one.');
    }

    $user->verifyEmail();

    return redirect('/home')->with('message', 'Your account is now verified.');
}
What we're doing here is we fetch the user that corresponds with the token, then if it's null we redirect them to the home page and flashing data to the session. Otherwise, we call the verifyEmail() method from our User model.

Next, let's add a verifyEmail() method in our User model:
public function verifyEmail()
{
    $this->email_verification_token = null;
    $this->save();

    return $this;
}
At this point our test should be back to green again.

Lastly, let's add an alert box for now in our layouts/app.blade.php for the message:
@if (session()->has('message'))
    
{{ session('message') }}
@endif @yield('content')

Now we're confident that our email verification feature is working perfectly because we have a test for it. That's all for this lesson, as always if you have any questions please write it down in the comment below and see you in the next lesson. Thanks!

View the source code for this lesson on GitHub.

Previous: Custom AuthenticationNext: Create Tweet


No comments