Railsチュートリアル 第12章 パスワードの再設定 - PasswordResets#createで、メールアドレスが有効な場合の処理をテスト駆動で実装していく

# メールアドレスが有効な場合の処理に対するテストの実装

Railsチュートリアル本文の通りに実装していくとすれば、以下のテストが「メールアドレスが有効な場合の処理に対するテスト」に該当することになります。

```diff:test/integration/password_resets_test.rb
  require 'test_helper'

  class PasswordResetsTest < ActionDispatch::IntegrationTest

    def setup
      ActionMailer::Base.deliveries.clear
      @user = users(:rhakurei)
    end

    test "password resets" do
      get new_password_reset_path
      assert_template 'password_resets/new'
      # メールアドレスが無効
      post password_resets_path, params: { password_reset: { email: "" } }
      assert_not flash.empty?
      assert_template 'password_resets/new'
      get new_password_reset_path
      assert flash.empty?
+     # メールアドレスが有効
+     post password_resets_path, params: { password_reset: { email: @user.email} }
+     assert_not_equal @user.reset_digest, @user.reload.reset_digest
+     assert_equal 1, Actionmailer::Base.deriveries.size
+     assert_not flash.empty?
+     assert_redirected_to root_url
    end
  end
```

# メールアドレスが有効な場合の処理に対するテストを実装した時点で、テストの結果はどうなるか

「メールアドレスが無効な場合の処理」が完了した時点における、`app/controllers/password_resets_controller.rb`のソースコードは以下です。

```ruby:app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  def new
  end

  def create
    if false #TODO: 有効なユーザー情報を与えられるようにする
      #TODO: 有効なメールアドレスが与えられた場合の処理を実装する
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

  def edit
  end
end
```

テストの結果は以下のようになります。

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 129
Started with run options --seed 13207

 FAIL["test_password_resets", PasswordResetsTest, 2.7478716999994504]
 test_password_resets#PasswordResetsTest (2.75s)
        Expected nil to not be equal to nil.
        test/integration/password_resets_test.rb:19:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.74939s
1 tests, 4 assertions, 1 failures, 0 errors, 0 skips
```

以下のテストが、「Expected nil to not be equal to nil」というメッセージを返して失敗しています。

```ruby:test/integration/password_resets_test.rb（19行目）
assert_not_equal @user.reset_digest, @user.reload.reset_digest
```

`debugger`で確認したところ、`@user.reset_digest`と`@user.reload.reset_digest`のいずれも`nil`となっていました。「現在のところ`reset_digest`を実装していない」からでしょうか。

ひとまずこの時点で確実なのは、「Userモデルに`reset_digest`属性に対する正しい実装が必要である」ということです。

# `User#create_reset_digest`メソッドの実装

「Userモデルの`reset_digest`属性に対する正しい実装」は、パスワード再設定全体の中では、以下の手順に関連しています。

* パスワード再設定用のトークンとダイジェストの組を生成する
* 生成されたパスワード再設定用ダイジェストをRDBに保存する

Railsチュートリアル本文においては、これら一連の処理について、`create_reset_digest`メソッドとして定義されています。

早速、当該メソッドと関連する実装を追加していきましょう。

## Userモデルに`create_reset_digest`メソッドと、関連する実装を追加する

```diff:app/models/user.rb
  class User < ApplicationRecord
-   attr_accessor :remember_token, :activation_token
+   attr_accessor :remember_token, :activation_token, :reset_token
    ...略
+
+   # パスワード再設定の属性を設定する
+   def create_reset_digest
+     self.reset_token = User.new_token
+     update_attribute(:reset_digest, User.digest(reset_token))
+     update_attribute(:reset_sent_at, Time.zone.now)
+   end

    private

      # メールアドレスをすべて小文字にする
      def downcase_email
        self.email.downcase!
      end

      # 有効化トークンとダイジェストを作成および代入する
      def create_activation_digest
        self.activation_token = User.new_token
        self.activation_digest = User.digest(activation_token)
      end
  end
```

Userモデルに新たに追加した実装は以下です。

* `create_reset_digest`メソッド
    * パスワード再設定用トークンの生成
    * 生成されたトークンに対するダイジェストのRDBへの保存
    * パスワード再設定用トークンの生成日時のRDBへの保存
* 仮想属性`:reset_token`に対するゲッターとセッターの追加

## PasswordResetsコントローラーで、`create_reset_digest`メソッドを使うようにする

```diff:app/controllers/password_resets_controller.rb
  class PasswordResetsController < ApplicationController
    def new
    end

    def create
+     @user = User.find_by(email: params[:password_reset][:email].downcase)
-     if false #TODO: 有効なユーザー情報を与えられるようにする
+     if @user
-       #TODO: 有効なメールアドレスが与えられた場合の処理を実装する
+       @user.create_reset_digest
+       # TODO:メール送信処理を実装する
      else
        flash.now[:danger] = "Email address not found"
        render 'new'
      end
    end

    def edit
    end
  end
```

PasswordResetsコントローラーに新たに追加した実装は以下です。

* `@user`を使わない仮実装を、`@user`を使う正式な実装に変更する
* `@user.create_reset_digest`により、パスワード再設定用トークン・ダイジェストの生成が正しく行われるようにする

# `create_reset_digest`メソッド、および、関連する実装を追加した時点でのテストの結果

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 165
Started with run options --seed 26520

ERROR["test_password_resets", PasswordResetsTest, 3.3415248000001156]
 test_password_resets#PasswordResetsTest (3.34s)
NameError:         NameError: uninitialized constant PasswordResetsTest::Actionmailer
            test/integration/password_resets_test.rb:20:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.34828s
1 tests, 4 assertions, 0 failures, 1 errors, 0 skips
```

今度は以下のテストがエラーを返して失敗するようになりました。

```ruby:test/integration/password_resets_test.rb（20行目）
assert_equal 1, Actionmailer::Base.deriveries.size
```

メールの送信処理が実装されていないことが原因のようです。

なお、Railsチュートリアル本文中の[演習 - `create`アクションでパスワード再設定](https://railstutorial.jp/chapters/password_reset?version=5.1#sec-exercises_password_reset_create_action)を行う場合、この時点で一旦実装を中断した上で演習を行っていきます。

# メールの送信処理の実装

「アプリケーションは、パスワード再設定用メールを作成し、フォームで指定されたメールアドレスに送信する」という処理の実装部分です。

## Usersモデルに`send_password_reset_email`メソッドを追加する

```diff:
  class User < ApplicationRecord
    ...略    

    # 有効化用のメールを送信する
    def send_activation_email
      UserMailer.account_activation(self).deliver_now
    end

    # パスワード再設定の属性を設定する
    def create_reset_digest
      self.reset_token = User.new_token
      update_attribute(:reset_digest, User.digest(reset_token))
      update_attribute(:reset_sent_at, Time.zone.now)
    end
+
+   # パスワード再設定のメールを送信する
+   def send_password_reset_email
+     UserMailer.password_reset(self).deliver_now
+   end

    private

      # メールアドレスをすべて小文字にする
      def downcase_email
        self.email.downcase!
      end

      # 有効化トークンとダイジェストを作成および代入する
      def create_activation_digest
        self.activation_token = User.new_token
        self.activation_digest = User.digest(activation_token)
      end
  end
```

パスワード再設定用のメールを送信するメソッドは、`send_password_reset_email`という名前で定義しています。

…よくよく見ると、`send_activation_email`と`send_password_reset_email`のコードって似てますよね。後々リファクタリングの対象として出てくるかもしれません。

## PasswordResetsコントローラーで、`send_password_reset_email`メソッドを使うようにする

```diff:
  class PasswordResetsController < ApplicationController
    def new
    end

    def create
      @user = User.find_by(email: params[:password_reset][:email].downcase)
      if @user
        @user.create_reset_digest
+       @user.send_password_reset_email
+       #TODO: フラッシュメッセージの定義とルートへのリダイレクト
      else
        flash.now[:danger] = "Email address not found"
        render 'new'
      end
    end

    def edit
    end
  end
```

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 191
Started with run options --seed 20579

ERROR["test_password_resets", PasswordResetsTest, 2.324982799999816]
 test_password_resets#PasswordResetsTest (2.33s)
ArgumentError:         ArgumentError: wrong number of arguments (given 1, expected 0)
            app/mailers/user_mailer.rb:8:in `password_reset'
            app/models/user.rb:61:in `send_password_reset_email'
            app/controllers/password_resets_controller.rb:9:in `create'
            test/integration/password_resets_test.rb:18:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.32883s
1 tests, 3 assertions, 0 failures, 1 errors, 0 skips
```

`app/mailers/user_mailer.rb`の8行目で、「引数の数が0でなければならないのに、実際には1つの引数が渡されている」というエラーでテストが失敗しています。

```ruby:app/mailers/user_mailer.rb（8行目）
def password_reset
```

今度はパスワード再設定用のメイラーメソッドを定義する必要がありそうですね。

## パスワード再設定用のメイラーメソッド`password_reset`において、引数の定義を変更する

まずは、現在のテストの失敗原因として指摘されている「引数の数が足りない」という問題を解決します。

```diff:
  class UserMailer < ApplicationMailer

    def account_activation(user)
      @user = user
      mail to: user.email, subject: "Account activation"
    end

-   def password_reset
+   def password_reset(user)
-     @greeting = "Hi"
-     
-     mail to: "to@example.org"
    end
  end
```

# `password_reset`の引数の定義を変更した時点でのテストの結果

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 232
Started with run options --seed 65406

ERROR["test_password_resets", PasswordResetsTest, 2.472823599999174]
 test_password_resets#PasswordResetsTest (2.47s)
NameError:         NameError: uninitialized constant PasswordResetsTest::Actionmailer
            test/integration/password_resets_test.rb:20:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.47449s
1 tests, 4 assertions, 0 failures, 1 errors, 0 skips
```

```
(byebug) Actionmailer::Base.deriveries.size
*** NameError Exception: uninitialized constant PasswordResetsTest::Actionmailer

nil
```

`debugger`で調べたところ、「uninitialized constant PasswordResetsTest::Actionmailer」というのは、「`Actionmailer::Base.deriveries.size`が初期化されていないこと」が原因で発生しているようです。「`password_reset`メソッドの実効的な定義と、テキストメール・HTMLメールそれぞれのテンプレートが必要になる」ということでしょうか。

# `password_reset`メソッドの実効的な定義と、テキストメール・HTMLメールそれぞれのテンプレート

## `password_reset`メソッドの実効的な定義

```diff:app/mailers/user_mailer.rb
  class UserMailer < ApplicationMailer

    def account_activation(user)
      @user = user
      mail to: user.email, subject: "Account activation"
    end

    def password_reset(user)
+     @user = user
+     mail to: user.email, subject: "Password reset"
    end
  end
```

`password_reset`メソッドの実効的な定義には、以下の内容が含まれます。

* メール本文中で使用する`@user`の内容
* メールの宛先と題名

## テキストメールのテンプレート

```erb:app/views/user_mailer/password_reset.text.erb
To reset your password click the link below:

<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>

This will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password stay as it is.
```

## HTMLメールのテンプレート

```erb:test/mailers/previews/user_mailer_preview.rb
<h1>Password reset</h1>

<p>To reset your password click the link below:</p>

<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>

<p>This will expire in two hours.</p>

<p>
If you did not request your password to be reset, please ignore this email and
your password stay as it is.
</p>
```

# パスワード再設定メールのプレビュー

## パスワード再設定メールをプレビューできるようにする

パスワード再設定メールをプレビューできるようにするために、`test/mailers/previews/user_mailer_preview.rb`の内容を書き換えていきます。

```diff:test/mailers/previews/user_mailer_preview.rb
  # Preview all emails at http://localhost:8080/rails/mailers/user_mailer
  class UserMailerPreview < ActionMailer::Preview

    # Preview this email at http://localhost:8080/rails/mailers/user_mailer/account_activation
    def account_activation
      user = User.first
      user.activation_token = User.new_token
      UserMailer.account_activation(user)
    end

    # Preview this email at http://localhost:8080/rails/mailers/user_mailer/password_reset
    def password_reset
-     UserMailer.password_reset
+     user = User.first
+     user.reset_token = User.new_token
+     UserMailer.password_reset(user)
    end

  end
```

## パスワード再設定メールをプレビューする

ここまでの実装が完了すれば、`test/mailers/previews/user_mailer_preview.rb`のコメント中に書かれたURLから、パスワード再設定用メールをプレビューすることができるようになります。

なお、事前に`rails server`でサーバーを起動しておく必要があります。

下記はHTMLメールのプレビューです。

<img width="898" alt="スクリーンショット 2019-12-14 8.35.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323778/a0bb960e-3982-3484-8e6d-b6161771f36b.png">

下記はテキストメールのプレビューです。

<img width="851" alt="スクリーンショット 2019-12-14 8.35.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323778/525a6cfa-0b05-b194-9515-7fd5c16368d8.png">

# メール送信処理を実装した時点でのテストの結果

メール送信処理を実装した時点でのテストの結果は、以下のようになります。

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 528
Started with run options --seed 12354

 FAIL["test_password_resets", PasswordResetsTest, 4.604623200000788]
 test_password_resets#PasswordResetsTest (4.60s)
        Expected true to be nil or false
        test/integration/password_resets_test.rb:23:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:04, Time: 00:00:04

Finished in 4.61010s
1 tests, 7 assertions, 1 failures, 0 errors, 0 skips
```

私の環境では、`test/integration/password_resets_test.rb`の23行目には以下の記述があります。

```ruby:test/integration/password_resets_test.rb（23行目）
assert_not flash.empty?
```

のフラッシュメッセージが定義されていないことに起因する失敗ですね。

# メール送信成功時のフラッシュメッセージを追加する

メール送信成功時のフラッシュメッセージの実装を、`app/controllers/password_resets_controller.rb`に追加していきます。

```diff:app/controllers/password_resets_controller.rb
  class PasswordResetsController < ApplicationController
    def new
    end

    def create
      @user = User.find_by(email: params[:password_reset][:email].downcase)
      if @user
        @user.create_reset_digest
        @user.send_password_reset_email
+       flash[:info] = "Email sent with password reset instructions"
      else
        flash.now[:danger] = "Email address not found"
        render 'new'
      end
    end

    def edit
    end
  end
```

# メール送信成功時のフラッシュメッセージを追加した時点でのテストの結果

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 541
Started with run options --seed 23162

 FAIL["test_password_resets", PasswordResetsTest, 4.58620990000054]
 test_password_resets#PasswordResetsTest (4.59s)
        Expected response to be a <3XX: redirect>, but was a <204: No Content>
        Response body: 
        test/integration/password_resets_test.rb:24:in `block in <class:PasswordResetsTest>'

  1/1: [===================================] 100% Time: 00:00:04, Time: 00:00:04

Finished in 4.59156s
1 tests, 8 assertions, 1 failures, 0 errors, 0 skips
```

私の環境では、`test/integration/password_resets_test.rb`の24行目には以下の記述があります。

```ruby:test/integration/password_resets_test.rb（24行目）
assert_redirected_to root_url
```

「/ へリダイレクトされるべきところ、リダイレクトされていない」という失敗ですね。

# メール送信成功時の処理に、/ へのリダイレクトを追加する

パスワード再設定用メールの送信が成功した場合、`PasswordResetsController#create`アクションの最後は / へのリダイレクトで終了します。ここで / へのリダイレクトを追加します。

```diff:app/controllers/password_resets_controller.rb
  class PasswordResetsController < ApplicationController
    def new
    end

    def create
      @user = User.find_by(email: params[:password_reset][:email].downcase)
      if @user
        @user.create_reset_digest
        @user.send_password_reset_email
        flash[:info] = "Email sent with password reset instructions"
+       redirect_to root_url
      else
        flash.now[:danger] = "Email address not found"
        render 'new'
      end
    end

    def edit
    end
  end
```

# / へのリダイレクトを追加した時点でのテストの結果

```
# rails test test/integration/password_resets_test.rb
Running via Spring preloader in process 554
Started with run options --seed 2662

  1/1: [===================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.55053s
1 tests, 8 assertions, 0 failures, 0 errors, 0 skips
```

ついにテストが成功しました。これにて、「有効なメールアドレスが与えられた際における、`PasswordResetsController#create`の実装」ならびに「`PasswordResetsController#create`の実装全体」が完了となりました。

# 現在までのテストが成功した時点における、パスワード再設定メールの送信用フォームに有効なメールアドレスを入力してSubmitボタンを押したときの挙動

まず、/password_resets に対して`POST`リクエストが送出され、PasswordResetsコントローラーの`create`メソッドが開始されます。

```
Started POST "/password_resets" for 172.17.0.1 at 2019-12-14 06:09:11 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by PasswordResetsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"0tty+a/E7Mp+BqIOZDaOGJQ2fH43di2VEeR3RaA9VC9MpoIb9a3BwgBNauyHYe4nBN1XC5M1TVglcsCUFNdAEQ==", "password_reset"=>"[FILTERED]", "commit"=>"Submit"}
  User Load (8.2ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "example-2@railstutorial.org"], ["LIMIT", 1]]
   (0.1ms)  begin transaction
  SQL (14.0ms)  UPDATE "users" SET "reset_digest" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["reset_digest", "$2a$10$vD1xJCRoVswPj2qHsJD81OLCO3e/aviIRNCamE6OWi8TUkkG6HytS"], ["updated_at", "2019-12-14 06:09:11.878637"], ["id", 3]]
   (11.2ms)  commit transaction
   (0.1ms)  begin transaction
  SQL (15.4ms)  UPDATE "users" SET "updated_at" = ?, "reset_sent_at" = ? WHERE "users"."id" = ?  [["updated_at", "2019-12-14 06:09:11.916190"], ["reset_sent_at", "2019-12-14 06:09:11.912111"], ["id", 3]]
   (14.7ms)  commit transaction
  Rendering user_mailer/password_reset.html.erb within layouts/mailer
  Rendered user_mailer/password_reset.html.erb within layouts/mailer (1.0ms)
  Rendering user_mailer/password_reset.text.erb within layouts/mailer
  Rendered user_mailer/password_reset.text.erb within layouts/mailer (0.7ms)
UserMailer#password_reset: processed outbound mail in 294.4ms
```

以下のはパスワード再設定用メールのヘッダー部分です。

```
Sent mail to example-2@railstutorial.org (3.5ms)
Date: Sat, 14 Dec 2019 06:09:12 +0000
From: noreply@example.com
To: example-2@railstutorial.org
Message-ID: <5df47c883f6f2_1c62ac8ce76084c8706b@705320d4d96d.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5df47c883e93a_1c62ac8ce76084c8691f";
 charset=UTF-8
Content-Transfer-Encoding: 7bit
```

以下のログはテキスト形式のパスワード再設定用メールの内容です。

```
----==_mimepart_5df47c883e93a_1c62ac8ce76084c8691f
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

To reset your password click the link below:

https://localhost:3000/password_resets/bbZxxIr2r21HmbfMzsKevA/edit?email=example-2%40railstutorial.org

This will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password stay as it is.
```

以下のログはHTML形式のパスワード再設定用メールの内容です。

```
----==_mimepart_5df47c883e93a_1c62ac8ce76084c8691f
Content-Type: text/html;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <h1>Password reset</h1>

<p>To reset your password click the link below:</p>

https://localhost:3000/password_resets/bbZxxIr2r21HmbfMzsKevA/edit?email=example-2%40railstutorial.org

<p>This will expire in two hours.</p>

<p>
If you did not request your password to be reset, please ignore this email and
your password stay as it is.
</p>

  </body>
</html>

----==_mimepart_5df47c883e93a_1c62ac8ce76084c8691f--
```

パスワード再設定用メールの内容についてのログは以上です。

```
Redirected to http://localhost:8080/
Completed 302 Found in 477ms (ActiveRecord: 63.7ms)
```

/password_resets に対する`POST`リクエスト（すなわちPasswordResetsコントローラーの`create`アクション）が、「302 FOUND」というステータスコードを返し、/ に対するリダイレクトによって完了しました。

以降は、リダイレクト後の、/ に対する`GET`リクエストのログです。

```
Started GET "/" for 172.17.0.1 at 2019-12-14 06:09:12 +0000
Cannot render console from 172.17.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by StaticPagesController#home as HTML
  Rendering static_pages/home.html.erb within layouts/application
  Rendered static_pages/home.html.erb within layouts/application (18.3ms)
  Rendered layouts/_rails_default.erb (220.1ms)
  Rendered layouts/_shim.html.erb (0.4ms)
  Rendered layouts/_header.html.erb (1.0ms)
  Rendered layouts/_footer.html.erb (0.7ms)
Completed 200 OK in 377ms (Views: 359.5ms | ActiveRecord: 0.0ms)
```

/ の描画が、「200 OK」というステータスコードとともに正常に完了していますね。

## 実際にWebブラウザでパスワード再設定メールの送信用フォームにSubmitしてみる

まずは、パスワード再設定メールの送信用フォームを表示します。

<img width="1171" alt="スクリーンショット 2019-12-14 15.09.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323778/d65bd824-5298-2c35-51a4-3bbe82a683f6.png">

有効なメールアドレスを入力し、「Submit」ボタンを押すと、以下の画面が表示されます。

<img width="1171" alt="スクリーンショット 2019-12-14 15.09.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323778/492e5618-0149-e57e-ba0d-707b55780a7a.png">

確かに「Email sent with password reset instructions」というフラッシュメッセージが表示されていますね。