Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsチュートリアル12章のUserモデルからパスワード再設定機能を切り出す

概要

はじめに

  • 自己紹介:Rails歴~半年、入社歴半年の技術勉強中な新入社員です。
  • 社内新人研修の一環として Railsチュートリアル ver5.1 に取り組ませてもらっており、各章ごとに振り返り会 & Advancedなるその章をより深く理解できるような課題を出してもらっています。
  • 今回の取り組みは、以下の第12章Advanced課題(社内独自)を取り組んだ内容になっています。
    • 「PasswordResetをModelとして実装したバージョンを作成せよ。Model名はPasswordResetではないより適切な名前にしても構わない」
  • バージョンは以下のとおりとなっています。
    Rails:5.1.6、Ruby:2.6.3

何をやっている?

Ruby on Rails チュートリアル 12章終了時点からの、Userモデルのソースリファクタリングを行っています。
第12章では既にUserモデルが少し複雑になってきているため、12章追加機能である「パスワード再設定」機能に関わるUserモデル内の記述部分を切り離してPasswordReset モデルを新たに生成しました。またそれに伴う影響範囲の修正なども行っています

※リファクタリングとは

ソフトウェアの外部的振る舞いを保ちつつ、理解や修正が簡単になるように、内部構造を改善すること)参考: - リファクタリングとは何か?

どんなレベルの人が対象?

Railsチュートリアルを進めている方(特に12章まで進んでいる方) やRails初心者の方を対象としています。

作業方針について

Railsチュートリアルを進めている方はご存じかと思いますが、RailsチュートリアルではRails動作環境の構築だったり、ユーザーモデル作成、ログイン機構の実装から、Twitterのようなユーザーごとにつぶやきを投稿、閲覧できるWebアプリケーションを作成します。
第12章では「パスワードの再設定」機能を実装していますがざっくり以下のようなことを行っています。

  • PasswordResetコントローラーの作成
  • PasswordReset用のビューの作成
  • UserモデルPasswordReset用のカラム・処理の追加 etc

基本的には第9章「発展的なログイン機構」や第11章「アカウント有効化」などもUserに関わる機能はUserモデル内に追加で処理を記述していくスタイルとなっているようです。

そこで今回の取り組みでは、一度Userモデルに追加された「パスワードの再設定」機能をPasswordResetモデル(新しく生成)へ移行し、影響のあるファイルの対応を行っています。具体的には以下の作業を実施しました。

  • PasswordResetコントローラー、ビューの削除
  • Userモデル内のPasswordReset用で記述したところを削除
  • Userモデル、PasswordResetモデルの定義、生成
  • PasswordResetコントローラー、ビューの作成
  • PasswordResetモデルへの処理追加 etc

具体的な作業内容は、後述の「作業log」で記載しています。備忘録的にまとめているものなので
簡略化して書いている部分が多々あります。
詳細な修正箇所については作成したPRから確認いただければと思います。よろしくお願いいたします!

作成したPR

こちら

作業log

準備

  • パスワード再設定機能に関わる記述を保存
    ※Railsチュートリアル第12章が終わった状態のソースです。Rails tutorialソースサンプルはこちら

model

# app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_attribute(:activated,    true)
    update_attribute(:activated_at, Time.zone.now)
  end

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

  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest:  User.digest(reset_token), reset_sent_at: Time.zone.now)
  end

  # パスワード再設定のメールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

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

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

view

<!-- app/views/password_resets/edit.html.erb -->
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
<!-- app/views/password_resets/new.html.erb -->
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:password_reset, url: password_resets_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

controller

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update]

  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

  def update
    if params[:user][:password].empty?                  # (3) への対応
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)          # (4) への対応
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'                                     # (2) への対応
    end
  end

private

    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 正しいユーザーかどうか確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end

    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end
  • 12章完了時のブランチから、トピックブランチをきる
$ git checkout -b password-reset-new
  • パスワード再設定用のコントローラを削除する
$ rails destroy controller PasswordResets new edit --no-test-framework
  • Userモデルのパスワード再設定用に追加した記述を削除する

model生成

テーブル:users

カラム名
id integer
email string
create_at datetime
update_at datetime
password_digest string
remember_digest string
admin boolean
activation_digest string
activated_at datetime

テーブル:passwordResets

カラム名
id integer
reset_digest string
reset_sent_at datetime
user_id integer
create_at datetime
update_at datetime
  • usersテーブルのカラムを安全に削除する
$ rails generate migration RemovePasswordResetFromUsers reset_digest:string reset_sent_at:datetime
  • PasswordResetモデルを生成する
$ rails generate model PasswordReset reset_digest:string reset_sent_at:datetime user:references
  • インデックスが付与されたpasswordResetのマイグレーションを記述する
# db/migrate/[timestamp]_create_password_resets.rb
class CreatePasswordResets < ActiveRecord::Migration[5.1]
  def change
    create_table :password_resets do |t|
      t.string :reset_digest
      t.datetime :reset_sent_at
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :password_resets, [:user_id, :created_at]
  end
end
  • データベースを更新する
# エラー回避のため①(ActiveRecord::NoEnvironmentInSchemaError)
# https://blog.freedom-man.com/no-environment-in-schema-error
$ rails db:environment:set

# エラー回避のため②(StandardError: An error has occurred, this and all later migrations canceled:))
# https://qiita.com/azusanakano/items/a2847e4e582b9a627e3a
$ rails db:migrate:set

$ rails db:migrate

とおった 🎉

  • Model同士の関連付けを宣言する
# app/model/user.rb
class User < ApplicationRecord
  has_many :password_resets
  .
  .
  .
end
# app/model/password_reset.rb
class PasswordReset < ApplicationRecord
  belongs_to :user, foreign_key: 'user_id'
  validates :user_id, presence: true
end

controller、view生成

  • パスワード再設定用のコントローラ、ビューを作成する
$ rails generate controller PasswordResets new edit --no-test-framework
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  def new
  end

  def edit
  end
end
<!-- app/views/password_resets/new.html.erb -->
<h1>PasswordResets#new</h1>
<p>Find me in app/views/password_resets/new.html.erb</p>
<!-- app/views/password_resets/edit.html.erb -->
<h1>PasswordResets#edit</h1>
<p>Find me in app/views/password_resets/edit.html.erb</p>

これ以降は、Railsチュートリアル第12章の内容に沿って修正。

新しいパスワード再設定画面ビュー

  • 新しいパスワード再設定画面ビュー
<!-- app/views/password_resets/new.html.erb -->
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:password_reset, url: password_resets_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

表示OK

createアクションでパスワード再設定

  • 12章記載のコードのままだとメールが送れないため、書き換える

12章記載のコード(☆の部分の対応が必要)。

# app/controllers/pasword_resets_controller.rb
  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest  <- ☆1
      @user.send_password_reset_email <- ☆2
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

※Rails tutorialソースサンプルの対応箇所はこちら

☆1の対応

# app/models/password_reset.rb
class PasswordReset < ApplicationRecord
  attr_accessor :reset_token
    .
    .
    .
  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now)
  end
end
# app/controllers/pasword_resets_controller.rb
  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @password_reset = @user.password_resets.create
      @password_reset.create_reset_digest
      @user.send_password_reset_email <- ☆2
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

☆2の対応

# app/models/password_reset.rb
class PasswordReset < ApplicationRecord
    .
    .
    .
  # パスワード再設定のメールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end
end
# app/controllers/pasword_resets_controller.rb
  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @password_reset = @user.password_resets.create
      @password_reset.create_reset_digest
      @password_reset.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
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  .
  .
  .
  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.user_mailer.password_reset.subject
  #
  def password_reset(password_reset)
    @password_reset = password_reset
    mail to: password_reset.user.email, subject: "Password reset"
  end
end
<!-- app/views/user_mailer/password_reset.html.erb -->
<h1>Password reset</h1>

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

<%= link_to "Reset password", edit_password_reset_url(@password_reset.reset_token,
                                                      email: @password_reset.user.email) %>

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

<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
<!-- app/views/user_mailer/password_reset.text.erb -->
To reset your password click the link below:

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

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
  • 確認する
# railsサーバーのログ
Sent mail to example@railstutorial.org (24.4ms)
Date: Wed, 12 Aug 2020 07:19:23 +0000
From: noreply@example.com
To: example@railstutorial.org
Message-ID: <5f3397fbe6734_fc615e15e06949f@ip-172-31-31-137.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5f3397fbe3eb7_fc615e15e0693ca";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


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

To reset your password click the link below:

https://8632ff13d35b4c219446f66f948001f5.vfs.cloud9.us-east-2.amazonaws.com/password_resets/L0-z9YbV3cjzlnfOnjT5ag/edit?email=example%40railstutorial.org

This link will expire in two hours.

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

----==_mimepart_5f3397fbe3eb7_fc615e15e0693ca
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>

<a href="https://8632ff13d35b4c219446f66f948001f5.vfs.cloud9.us-east-2.amazonaws.com/password_resets/L0-z9YbV3cjzlnfOnjT5ag/edit?email=example%40railstutorial.org">Reset password</a>

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

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

----==_mimepart_5f3397fbe3eb7_fc615e15e0693ca--

Redirected to https://8632ff13d35b4c219446f66f948001f5.vfs.cloud9.us-east-2.amazonaws.com/
Completed 302 Found in 343ms (ActiveRecord: 18.2ms)

OK

パスワード再設定のプレビューメソッド(テスト用)

  • 12章記載のコードのままだとプレビューが確認できないため、書き換える

12章記載のコード

# test/mailer/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
  .
  .
  .
  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    user = User.first
    user.reset_token = User.new_token <--
    UserMailer.password_reset(user) <--
  end
end

※Rails tutorialソースサンプルの対応箇所はこちら

TOBE

# test/mailer/previews/user_mailer_preview.rb
  # Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
  .
  .
  .
  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    user = User.first
    reset_token = User.new_token <--
    password_reset = user.password_resets.build(reset_token: reset_token, reset_digest: User.digest(reset_token), reset_sent_at: Time.zone.now) <--
    UserMailer.password_reset(password_reset) <--
  end
end

OK

送信メールのテスト

  • 12章記載のコードのままだとテストが通らないため、書き換える

12章記載のコード

# test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  .
  .
  .
  test "password_reset" do
    user = users(:michael) <--
    user.reset_token = User.new_token <--
    mail = UserMailer.password_reset(user) <--
    assert_equal "Password reset", mail.subject
    assert_equal [user.email], mail.to <--
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.reset_token,        mail.body.encoded <--
    assert_match CGI.escape(user.email),  mail.body.encoded <--
  end
end

※Rails tutorialソースサンプルの対応箇所はこちら

TOBE

# test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase
  .
  .
  .
  test "password_reset" do
    reset_token = User.new_token <--
    password_reset = users(:michael).password_resets.build(reset_token: reset_token) <--
    mail = UserMailer.password_reset(password_reset) <--
    assert_equal "Password reset", mail.subject
    assert_equal [password_reset.user.email], mail.to <--
    assert_equal ["noreply@example.com"], mail.from
    assert_match password_reset.reset_token,        mail.body.encoded <--
    assert_match CGI.escape(password_reset.user.email),  mail.body.encoded <--
  end
end
  • rails test:mailers を実行し、GREENを確認する

OK

editアクションで再設定

  • パスワード再設定のフォームを記述する
<!-- app/views/password_resets/edit.html.erb -->
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
  • パスワード再設定のeditアクションはそのままでは編集画面を開けないため、書き換える

12章記載のコード

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  .
  .
  .
  def edit
  end

  private

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 正しいユーザーかどうか確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id])) <-- (reset_digest がUserモデルでなくなったため落ちる)
        redirect_to root_url
      end
    end
end

※Rails tutorialソースサンプルの対応箇所はこちら

TOBE

# app/models/password_reset.rb
class PasswordReset < ApplicationRecord
  .
  .
  .
  # トークンがダイジェストと一致したらtrueを返す
  def authenticated?(token)
    # reset_digestはUserに紐づくレコードのうち作成日が最新のもののデータを取得
    digest = self.user.password_resets.order(created_at: :desc).limit(1)[0].reset_digest
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end
end
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
    .
    .
    .
    # 正しいユーザーかどうか確認する
    def valid_user
      @password_reset = @user.password_resets.build if @user <--
      unless (@user && @user.activated? &&
              @password_reset.authenticated?(params[:id])) <--
        redirect_to root_url
      end
    end
end
  • 確認する

OK

パスワードを更新する

  • パスワード再設定のupdateアクションについてそのままだと更新できないため、書き換える

12章記載のコード

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,         only: [:edit, :update]
  before_action :valid_user,       only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update] 
  .
  .
  def update
    if params[:user][:password].empty?
      @user.errors.add(:password, :blank)
      render 'edit'
    elsif @user.update_attributes(user_params)
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'
    end
  end

  private
    .
    .
    # トークンが期限切れかどうか確認する
    def check_expiration
      if @user.password_reset_expired? <--
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end

※Rails tutorialソースサンプルの対応箇所はこちら

TOBE

# app/models/password_reset.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    # latest_sent_atはUserに紐づくレコードのうち作成日が最新のもののデータを取得
    latest_sent_at = self.user.password_resets.order(created_at: :desc).limit(1)[0].reset_sent_at
    latest_sent_at < 2.hours.ago
  end
end
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
    .
    .
    .
    # トークンが期限切れかどうか確認する
    def check_expiration
      @password_reset = @user.password_resets.build if @user <--
      if @password_reset.password_reset_expired? <--
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end

    # updateアクションでuserモデルを更新する際に必要なメソッドを定義
    def user_params
      params.require(:user).permit(:name, :email, :password,
                                  :password_confirmation)
    end
end
  • 確認する

OK

  • パスワード再設定の統合テストを書き換える

12章記載のコード

# test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  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'
    # メールアドレスが有効
    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.deliveries.size
    assert_not flash.empty?
    assert_redirected_to root_url
    # パスワード再設定フォームのテスト
    user = assigns(:user)
    # メールアドレスが無効
    get edit_password_reset_path(user.reset_token, email: "")
    assert_redirected_to root_url
    # 無効なユーザー
    user.toggle!(:activated)
    get edit_password_reset_path(user.reset_token, email: user.email)
    assert_redirected_to root_url
    user.toggle!(:activated)
    # メールアドレスが有効で、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
    # メールアドレスもトークンも有効
    get edit_password_reset_path(user.reset_token, email: user.email)
    assert_template 'password_resets/edit'
    assert_select "input[name=email][type=hidden][value=?]", user.email
    # 無効なパスワードとパスワード確認
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "barquux" } }
    assert_select 'div#error_explanation'
    # パスワードが空
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:              "",
                            password_confirmation: "" } }
    assert_select 'div#error_explanation'
    # 有効なパスワードとパスワード確認
    patch password_reset_path(user.reset_token),
          params: { email: user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "foobaz" } }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
  end

  test "expired token" do
    get new_password_reset_path
    post password_resets_path,
         params: { password_reset: { email: @user.email } }

    @user = assigns(:user)
    @user.update_attribute(:reset_sent_at, 3.hours.ago)
    patch password_reset_path(@user.reset_token),
          params: { email: @user.email,
                    user: { password:              "foobar",
                            password_confirmation: "foobar" } }
    assert_response :redirect
    follow_redirect!
    assert_match /expired/i, response.body
  end
end

※Rails tutorialソースサンプルの対応箇所はこちら

TOBE

# test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  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'
    # メールアドレスが有効
    assert_difference 'PasswordReset.count', 1 do
      post password_resets_path,
           params: { password_reset: { email: @user.email } }
    end
    assert_equal 1, ActionMailer::Base.deliveries.size
    assert_not flash.empty?
    assert_redirected_to root_url
    # パスワード再設定フォームのテスト
    user = assigns(:user)
    password_reset = assigns(:password_reset)
    # メールアドレスが無効
    get edit_password_reset_path(password_reset.reset_token, email: "")
    assert_redirected_to root_url
    # 無効なユーザー
    user.toggle!(:activated)
    get edit_password_reset_path(password_reset.reset_token, email: user.email)
    assert_redirected_to root_url
    user.toggle!(:activated)
    # メールアドレスが有効で、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
    # メールアドレスもトークンも有効
    get edit_password_reset_path(password_reset.reset_token, email: user.email)
    assert_template 'password_resets/edit'
    assert_select "input[name=email][type=hidden][value=?]", user.email
    # 無効なパスワードとパスワード確認
    patch password_reset_path(password_reset.reset_token),
          params: { email: user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "barquux" } }
    assert_select 'div#error_explanation'
    # パスワードが空
    patch password_reset_path(password_reset.reset_token),
          params: { email: user.email,
                    user: { password:              "",
                            password_confirmation: "" } }
    assert_select 'div#error_explanation'
    # 有効なパスワードとパスワード確認
    patch password_reset_path(password_reset.reset_token),
          params: { email: user.email,
                    user: { password:              "foobaz",
                            password_confirmation: "foobaz" } }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
  end

  test "expired token" do
    get new_password_reset_path
    post password_resets_path,
         params: { password_reset: { email: @user.email } }

    @user = assigns(:user)
    @password_reset = assigns(:password_reset)
    @password_reset.update_attribute(:reset_sent_at, 3.hours.ago)
    patch password_reset_path(@password_reset.reset_token),
          params: { email: @user.email,
                    user: { password:              "foobar",
                            password_confirmation: "foobar" } }
    assert_response :redirect
    follow_redirect!
    assert_match /expired/i, response.body
  end
end
  • rails test:integration を実行し、GREENを確認する

OK

  • rails t を実行し、GREENを確認する

仕上げ

  • git pushし、PRを作成する
$ rails t
$ git add -A
$ git commit -m "Add password reset new"
$ git push

振り返り

  • 今後やった方がよさそうなこと
    • PasswordResetのModelのテスト記述
    • UserMailerPasswordResetMailerを作って処理を分ける
  • 検討?
    • Userモデル→PasswordResetモデルの関連付けについてhas_oneの方がよい?
    • 現在はhas_manyでパスワード再設定メールを送るイベントの度にレコード生成され、削除はしていないので全てのレコードが蓄積される(ソースがシンプルなので今の作りにした)
  • 感想
    • Railsチュートリアル第12章のドキュメントがあったのでなんとか進められた
    • 疲れたけどちゃんと動いているのでよかった


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。