RSpec+Capybaraによるチェックボックスの存在確認とCookieの有効期限日を取得する方法

Ruby on Rails Tutorial*19.6 Exercisesを終えた後、"Remember me"というチェックボックスSign inページに追加しようと思いました。

この本で作成するマイクロブログSample App」は、ログイン後、Cookieに保存する"remember_token"の有効期限日を20年後*2に設定しています*3。[Remember me]チェックボックスをチェックせずに[Sign in]ボタンを押下した場合、このクッキーの有効期限日をセッション終了時になるよう改修してみました。

Sample Appは、RSpec+CapybaraによるTDDで開発されていますので、私もそれに従います。

[追記: 8/29 23:19]
第3版の第8章で[Remember me]チェックボックスを追加するそうです。本記事は、第3版が出版される以前に書いたもので、関係はありません。
*4

チェックボックスなど追加を確認するテストとその実装

Sign inページのパスワードを入力するフィールドの下に[Remember me]のラベルとチェックボックスを追加するので、authentication_pages_spec.rbにテストを書きました。

spec/requests/authentication_pages_spec.rb
describe "Authentication" do

   subject { page }

   describe "signin page" do
     before { visit signin_path }

     it { should have_content('Sign in') }
     it { should have_title('Sign in') }
     it { should have_css('input#session_remember_me[type="checkbox"]') }
     it { should have_css('label.checkbox', text: 'Remember me') }
   end
  :
  :

ラベルとチェックボックスの実装です。チェックボックス下のマージンが狭く(5px)、15pxに拡大したかったのでlabel要素のid属性に「remember_me」を指定し、custom.css.scssにスタイルを追加しています。

app/views/sessions/new.html.erb
    :
    :
       <%= f.password_field :password %>
       <%= f.label :remember_me, id: "remember_me", class: "checkbox" do %>
         <%= f.check_box :remember_me %>Remember me
       <% end %>
       <%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
    :
    :
app/assets/stylesheets/custom.css.scss
  :
  :
/* forms */
  :
  :
#remember_me {
  margin-bottom: 15px;
}

/* Users index */
  :
  :

[Remember me]チェックボックスを追加した後のSign inページにです。

Cookieの値と有効期限日の取得

追加した[Remember me]チェックボックスをチェックした・していない場合のテストを書く前に、utilities.rbにCookieの値と有効期限を取得するメソッドを追加しました。

cookieメソッドは、引数にCookie名を指定すると、その値を返します。cookie_expiresメソッドは、有効期限日が設定されている場合はTimeオブジェクトを返し、それ以外はnilを返します。また、既存のsign_inメソッドに[Remember me]チェックボックスをチェックする行を追加しています。

spec/support/utilities.rb
include ApplicationHelper

def sign_in(user, options={remember_me: true, no_capybara: false})
  if options[:no_capybara]
    # Sign in when not using Capybara.
    remember_token = User.new_remember_token
    cookies[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.digest(remember_token))
  else
    visit signin_path
    fill_in 'Email',    with: user.email
    fill_in 'Password', with: user.password
    check   'session_remember_me' if options[:remember_me]
    click_button "Sign in"
  end
end

def cookie(key)
  page.driver.browser.rack_mock_session.cookie_jar[key]
end

def cookie_expires
  expires = nil

  page.driver.browser.rack_mock_session.cookie_jar.instance_variable_get(:@cookies).each do |cookie|
    if cookie.instance_variable_defined?(:@options) &&
      (options = cookie.instance_variable_get(:@options)).key?('expires')
      date = options['expires']
      expires = Time.parse(date) unless date.blank?
      break
    end
  end

  expires
end

チェックボックスをチェックした・していない場合のテストとその実装

[Remember me]チェックボックスを有効にした場合、Cookieの有効期限日が現在から20年後以降になっているかテストしました。このチェックボックスを無効にした場合は、Cookieに有効期限日が設定されていないことも確認しています。

また、データベースのUsersテーブルにあるremember_tokenフィールドとCookieのremember_tokenが同一か、併せてテストしています。

spec/requests/authentication_pages_spec.rb
  :
  :
  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error') }

      describe "after visiting another page" do
        before { click_link "Home" }

        it { should_not have_selector('div.alert.alert-error') }
      end
    end

    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      let(:remember_token) { User.digest(cookie('remember_token')) }
      let!(:expire_date) { -20.years.ago }
      before { sign_in user }

      it { should have_title(user.name) }
      it { should have_link('Users',       href: users_path) }
      it { should have_link('Profile',     href: user_path(user)) }
      it { should have_link('Settings',    href: edit_user_path(user)) }
      it { should have_link('Sign out',    href: signout_path) }
      it { should_not have_link('Sign in', href: signin_path) }

      specify { expect(remember_token).to eq user.reload.remember_token }
      specify { expect(cookie_expires.to_i).to be >= expire_date.to_i }

      describe "followed by signout" do
        before { click_link "Sign out" }

        it { should have_link('Sign in') }
      end
    end

    describe "with valid information, but not remembered" do
      let(:user) { FactoryGirl.create(:user) }
      before { sign_in user, remember_me: false }

      specify { expect(cookie_expires).to be_nil }
    end
  end
  :
  :

最後に、sessions_helper.rbのsign_inメソッドとsessions_controller.rbのcreateメソッドを改修しました。

app/helpers/sessions_helper.rb
 module SessionsHelper
 
   def sign_in(user, permanently = true)
     remember_token = User.new_remember_token
     if permanently
       cookies.permanent[:remember_token] = remember_token
     else
       cookies[:remember_token] = remember_token
     end
     user.update_attribute(:remember_token, User.digest(remember_token))
     self.current_user = user
   end
  :
  :
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  :
  :
  def create
    user = User.find_by(email: params[:session][:email].downcase)

    if user && user.authenticate(params[:session][:password])
      sign_in user, params[:session][:remember_me] == '1'
      redirect_back_or user
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end
  :
  :

結果

[Remember me]チェックボックスをチェックしないと、有効期限日が「セッション終了時」になりました。

余談

実装していて気づいたのですが、Sign in後にCookieを削除し、Sign outするとアプリケーションエラーが発生するなど、元のソースに不具合がありました。今回の件とは直接関係しませんが、以下のように修正しました。

app/helpers/sessions_helper.rb
module SessionsHelper
  :
  :
  def current_user
    cookie = cookies[:remember_token]
    @current_user ||= User.find_by(remember_token: User.digest(cookie)) unless cookie.nil?
  end
  :
  :
  def sign_out
    current_user.update_attribute(:remember_token, User.digest(User.new_remember_token)) unless current_user.nil?
    cookies.delete(:remember_token)
    session.delete(:return_to)
    self.current_user = nil
  end

  def redirect_back_or(default)
    url = session[:return_to] || default
    session.delete(:return_to)
    redirect_to(url)
  end
end