学習日記

Ruby on Rails勉強してます

今日やったこと15

13.3 マイクロポストを操作する

マイクロポストリソースのルーティングを設定する。

resources :microposts,          only: [:create, :destroy]
HTTPリクエス URL アクション 名前付きルート
POST /microposts create microposts_path
DELETE /microposts/1 destroy micropost_path(micropost)

関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。正しいリクエストを各アクションに向けて発行し、マイクロポストの数が変化していないかどうか、また、リダイレクトされるかどうかをテストする。

test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when not logged in" do
    assert_no_difference 'Micropost.count' do
      delete micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
end

テストは失敗。


Micropostsコントローラでもlogged_in_userメソッドを使えるようにするために、各コントローラが継承するApplicationコントローラに 、このメソッドを移す。
コードが重複しないよう、このときUsersコントローラからもlogged_in_userを削除しておく。

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper

  private

    # ユーザーのログインを確認する
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

Micropostsコントローラの各アクションでlogged_in_userメソッドを使えるようにする。

app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
  end

  def destroy
  end
end

再びテストし成功。


マイクロポストを作成する.
マイクロポストのcreateアクションを作っていく。

app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
    @micropost = current_user.microposts.build(micropost_params)
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      render 'static_pages/home'
    end
  end

  def destroy
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end
end

マイクロポスト作成フォームを構築するために、if-else文の分岐を使ってサイト訪問者がログインしているかどうかに応じてコードを書き分ける。

app/views/static_pages/home.html.erb

<% if logged_in? %>
  <div class="row">
    <aside class="col-md-4">
      <section class="user_info">
        <%= render 'shared/user_info' %>
      </section>
      <section class="micropost_form">
        <%= render 'shared/micropost_form' %>
      </section>
    </aside>
  </div>
<% else %>
  <div class="center jumbotron">
    <h1>Welcome to the Sample App</h1>

    <h2>
      This is the home page for the
      <a href="https://railstutorial.jp/">Ruby on Rails Tutorial</a>
      sample application.
    </h2>

    <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
  </div>

  <%= link_to image_tag("rails.png", alt: "Rails logo"),
              'http://rubyonrails.org/' %>
<% end %>

パーシャルを作る。

サイドバーで表示するユーザー情報のパーシャル

app/views/shared/_user_info.html.erb

<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>

マイクロポスト投稿フォームのパーシャル

app/views/shared/_micropost_form.html.erb

<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>

フォームが動くようにするために@micropostを定義する.。

app/controllers/static_pages_controller.rb

class StaticPagesController < ApplicationController

  def home
    @micropost = current_user.microposts.build if logged_in?
  end

  def help
  end

  def about
  end

  def contact
  end
end

エラーメッセージのパーシャルを再定義する。
Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する。

app/views/shared/_error_messages.html.erb

<% if object.errors.any? %>
  <div id="error_explanation">
    <div class="alert alert-danger">
      The form contains <%= pluralize(object.errors.count, "error") %>.
    </div>
    <ul>
    <% object.errors.full_messages.each do |msg| %>
      <li><%= msg %></li>
    <% end %>
    </ul>
  </div>
<% end %>

このパーシャルは他の場所でも使われているので、それぞれのビューでも使えるように更新する。
これでマイクロポスト作成フォームが表示できる。


Homeページに投稿したマイクロポストを表示する部分を実装する。
Userモデルにfeedメソッドを作る。

app/models/user.rb

class User < ApplicationRecord
  .
  .
  .

  def feed
    Micropost.where("user_id = ?", id)
  end

    private
    .
    .
    .
end

サンプルアプリケーションでフィードを使うために、現在のユーザーのページ分割されたフィードに@feed_itemsインスタンス変数を追加して、フィード用のパーシャルをHomeページに追加する。

app/controllers/static_pages_controller.rb

class StaticPagesController < ApplicationController

  def home
    if logged_in?
      @micropost  = current_user.microposts.build
      @feed_items = current_user.feed.paginate(page: params[:page])
    end
  end

  .
  .
  .

end
app/views/shared/_feed.html.erb

<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

パーシャルで、renderに@feed_itemsを渡しているのは、Railsが対応する名前のパーシャルを、渡されたリソースのディレクトリ内から探しにいくことができるため。

Homeページにステータスフィードを追加する。

app/views/static_pages/home.html.erb

<% if logged_in? %>
  <%= render 'shared/home_login' %>
<% else %>
  <%= render 'shared/home_logout' %>
<% end %>
app/views/shared/_home_login.html.erb

<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <%= render 'shared/user_info' %>
    </section>
    <section class="micropost_form">
      <%= render 'shared/micropost_form' %>
    </section>
  </aside>
  <div class="col-md-8">
      <h3>Micropost Feed</h3>
      <%= render 'shared/feed' %>
    </div>
</div>

マイクロポストを削除する
自分が投稿したマイクロポストに対してのみ削除リンクが動作するように機能を追加する。
最初にマイクロポストのパーシャルに削除リンクを追加。

app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
    <% if current_user?(micropost.user) %>
      <%= link_to "delete", micropost, method: :delete,
                                       data: { confirm: "You sure?" } %>
    <% end %>
  </span>
</li>

次に、Micropostsコントローラのdestroyアクションを定義する。

app/controllers/microposts_controller.rb

class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  before_action :correct_user,   only: :destroy
  .
  .
  .
  def destroy
    @micropost.destroy
    flash[:success] = "Micropost deleted"
    redirect_to request.referrer || root_url
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end

    def correct_user
      @micropost = current_user.microposts.find_by(id: params[:id])
      redirect_to root_url if @micropost.nil?
    end
end


フィード画面のマイクロポストをテストする

まずはマイクロポスト用のfixtureに、別々のユーザーに紐付けられたマイクロポストを追加していく。

test/fixtures/microposts.yml
.
.
.
ants:
  content: "Oh, is that what you want? Because that's how you get ants!"
  created_at: <%= 2.years.ago %>
  user: archer

zone:
  content: "Danger zone!"
  created_at: <%= 3.days.ago %>
  user: archer

tone:
  content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
  created_at: <%= 10.minutes.ago %>
  user: lana

van:
  content: "Dude, this van's, like, rolling probable cause."
  created_at: <%= 4.hours.ago %>
  user: lana

次に、自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストする。

test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

  def setup
    @micropost = microposts(:orange)
  end

  .
  .
  .

  test "should redirect destroy for wrong micropost" do
    log_in_as(users(:michael))
    micropost = microposts(:ants)
    assert_no_difference 'Micropost.count' do
      delete micropost_path(micropost)
    end
    assert_redirected_to root_url
  end
end

最後に統合テストを書くためにファイルを生成。

$ rails generate integration_test microposts_interface

統合テストでは、ログイン、マイクロポストのページ分割の確認、無効なマイクロポストを投稿、有効なマイクロポストを投稿、マイクロポストの削除、そして他のユーザーのマイクロポストには [delete] リンクが表示されないことを確認、といった順でテストしていく。

test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  
  # マイクロポストのUIに対する統合テスト
  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    # 無効な送信
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "" } }
    end
    assert_select 'div#error_explanation'
    # 有効な送信
    content = "This micropost really ties the room together"
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    assert_redirected_to root_url
    follow_redirect!
    assert_match content, response.body
    # 投稿を削除する
    assert_select 'a', text: 'delete'
    first_micropost = @user.microposts.paginate(page: 1).first
    assert_difference 'Micropost.count', -1 do
      delete micropost_path(first_micropost)
    end
    # 違うユーザーのプロフィールにアクセス (削除リンクがないことを確認)
    get user_path(users(:archer))
    assert_select 'a', text: 'delete', count: 0
  end
end

今日やったこと14

第13章 ユーザーのマイクロポスト


13.1 Micropostモデル

Micropostデータモデルの構造

microposts

カラム名
id integer
content text
user_id integer
create_at datetime
update_at datetime

マイクロポストの投稿にString型ではなくText型を使っている理由は、Text型の方が投稿フォームを表現豊かにしたり、変更に柔軟に対応できるため。

Micropostモデルを生成。

$ rails generate model Micropost content:text user:references

references型を利用することで、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、UserとMicropostを関連付けする下準備をしてくれる。
生成されたマイグレーションファイルでuser_idとcreated_atカラムにインデックスを付与する。こうすることで、user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。

db/migrate/[timestamp]_create_microposts.rb

class CreateMicroposts < ActiveRecord::Migration[5.0]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at] # インデックスを付与する
  end
end

マイグレーションを使って、データベースを更新。

$ rails db:migrate

Micropostモデルのバリデーションに対するテストを追加。

test/models/micropost_test.rb

require 'test_helper'

class MicropostTest < ActiveSupport::TestCase

  def setup
    @user = users(:michael)
    @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
  end

  test "should be valid" do
    assert @micropost.valid?
  end

  test "user id should be present" do
    @micropost.user_id = nil
    assert_not @micropost.valid?
  end

  test "content should be present" do
    @micropost.content = "   "
    assert_not @micropost.valid?
  end

  test "content should be at most 140 characters" do
    @micropost.content = "a" * 141
    assert_not @micropost.valid?
  end
end

この時点ではテストは失敗するのでMicropostモデルのバリデーションを追加する。

app/models/micropost.rb

class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

これでテストに成功しました。

UserモデルとMicropostモデルを関連付ける。
関連付けにはbelongs_to/has_manyを使う。
Micropostモデルの方では、belongs_to :userというコードが必要になるが、マイグレーションによって自動生成されている。Userモデルはhas_many :micropostsと追加する必要があるので追加していく。

app/models/user.rb

class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end

ユーザーの最も新しいマイクロポストを最初に表示するようにするために、default scopeというテクニックを使う。
まずはマイクロポストの順序付けのテストを先に作っていく。

test/models/micropost_test.rb

require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end

テストは失敗。
テストではマイクロポスト用のfixtureファイルからサンプルデータを読み出しているので、fixtureファイルを用意する。

test/fixtures/microposts.yml

orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>

tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>

cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>

most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>

default_scopeを使いマイクロポストを投稿の古い順番に並べる。

app/models/micropost.rb

class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

再びテストし成功。


dependent: :destroyというオプションを使うと、ユーザーが削除されたときに、そのユーザーに関連づいたマイクロポストも一緒に削除されるようになる。

app/models/user.rb

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  .
  .
  .
end

正しく動くかどうか、テストする。

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "associated microposts should be destroyed" do
    @user.save
    @user.microposts.create!(content: "Lorem ipsum")
    assert_difference 'Micropost.count', -1 do
      @user.destroy
    end
  end
end


13.2 マイクロポストを表示する

ユーザーのプロフィール画面 (show.html.erb) でそのユーザーのマイクロポストや投稿した総数を表示させる。
まずは、Micropostのコントローラとビューを作成。

$ rails generate controller Microposts

マイクロポストを表示するパーシャルを用意して、ユーザーのshowページ (プロフィール画面) にマイクロポストを追加する。

app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
  </span>
</li>
app/views/users/show.html.erb

<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
  <div class="col-md-8">
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>

@microposts変数をwill_paginateに渡す必要があるので、showアクションに追加する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(page: params[:page])
  end
  .
  .
  .
end

サンプルデータにマイクロポストを追加し、開発環境用のデータベースで再度サンプルデータを生成する。

db/seeds.rb

.
.
.
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end
$ rails db:migrate:reset
$ rails db:seed

マイクロポスト用のCSSを追加する。

app/assets/stylesheets/custom.scss

.
.
.
/* microposts */

.microposts {
  list-style: none;
  padding: 0;
  li {
    padding: 10px 0;
    border-top: 1px solid #e8e8e8;
  }
  .user {
    margin-top: 5em;
    padding-top: 0;
  }
  .content {
    display: block;
    margin-left: 60px;
    img {
      display: block;
      padding: 5px 0;
    }
  }
  .timestamp {
    color: $gray-light;
    display: block;
    margin-left: 60px;
  }
  .gravatar {
    float: left;
    margin-right: 10px;
    margin-top: 5px;
  }
}

aside {
  textarea {
    height: 100px;
    margin-bottom: 5px;
  }
}

span.picture {
  margin-top: 10px;
  input {
    border: 0;
  }
}

プロフィール画面で表示されるマイクロポストに対して、テストを書いていく。

$ rails generate integration_test users_profile

テストのためにfixtureファイルを更新。

test/fixtures/microposts.yml

orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>
  user: michael

tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>
  user: michael

cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
  user: michael

most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>
  user: michael

<% 30.times do |n| %>
micropost_<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  created_at: <%= 42.days.ago %>
  user: michael
<% end %>

Userプロフィール画面に対するテスト

test/integration/users_profile_test.rb

require 'test_helper'

class UsersProfileTest < ActionDispatch::IntegrationTest
  include ApplicationHelper

  def setup
    @user = users(:michael)
  end

  test "profile display" do
    get user_path(@user)
    assert_template 'users/show'
    assert_select 'title', full_title(@user.name)
    assert_select 'h1', text: @user.name
    assert_select 'h1>img.gravatar'
    assert_match @user.microposts.count.to_s, response.body
    assert_select 'div.pagination'
    @user.microposts.paginate(page: 1).each do |micropost|
      assert_match micropost.content, response.body
    end
  end
end
$ rails t

今日やったこと13

12.3 パスワードを再設定する

PasswordResetsコントローラの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アクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行う。

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]))
        redirect_to root_url
      end
    end
end



パスワードを更新するためには、フォームからの送信に対応するupdateアクションが必要になる。
updateアクションでは、下記の4つのケースを考慮する必要がある。


1. パスワード再設定の有効期限が切れていないか

2. 無効なパスワードであれば失敗させる (失敗した理由も表示する)

3. 新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)

4. 新しいパスワードが正しければ、更新する


1のために、editとupdateアクションに期限切れかどうかを確認するメソッドとbeforeフィルターを用意することで対応する。

before_action :check_expiration, only: [:edit, :update] 
# 期限切れかどうかを確認する
def check_expiration
  if @user.password_reset_expired?
    flash[:danger] = "Password reset has expired."
    redirect_to new_password_reset_url
  end
end

パスワード再設定のupdateアクション

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]    # 1 への対応

  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

    # beforeフィルタ

    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

password_reset_expired?メソッドをUserモデルに追加する。

app/models/user.rb

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

  private
    .
    .
    .
end



パスワード再設定のテストをするためにファイルを生成してテストを追加する。

$ rails generate integration_test password_resets
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
end



演習 12.3.3.1

# パスワード再設定の属性を設定する
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

演習 12.3.3.2

assert_match "expired", response.body

演習 12.3.3.4

require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest
  
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do

   ・
   ・
   ・

    assert is_logged_in?
    assert_nil user.reload.reset_digest #ダイジェストがnilになっているか
    assert_not flash.empty?
    assert_redirected_to user
  end


12.4 本番環境でのメール送信 (再掲)

production環境とSendGridの設定は11章でやっているため省略。
Gitのトピックブランチをmasterにマージ。

$ rails test
$ git add -A
$ git commit -m "Add password reset"
$ git checkout master
$ git merge password-reset

リモートリポジトリにプッシュし、Herokuにデプロイする。

$ rails test
$ git push
$ git push heroku
$ heroku run rails db:migrate

Heroku上でも動作を確認。

今日やったこと12

第12章 パスワードの再設定

全体の流れ

1.ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける

2.該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応するリセットダイジェストを生成する

3.再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく

4.ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)

5.認証に成功したら、パスワード変更用のフォームをユーザーに表示する

12.1 PasswordResetsリソース

トピックブランチを作成。

$ git checkout -b password-reset

パスワード再設定用のコントローラを作る。その際、newアクションとeditアクションも一緒に生成する。

$ rails generate controller PasswordResets new edit --no-test-framework

ルーティングを設定。

config/routes.rb

Rails.application.routes.draw do
  ・
  ・
  ・
  resources :users
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
end
HTTPリクエス URL Action 名前付きルート
GET /password_reset/new new new_password_resets_path
POST /password_reset create password_resets_path
GET /password_reset/(token)/edit edit edit_password_resets_url(token)
PATCH /password_reset/(token) update password_resets_url(token)

パスワード再設定画面へのリンクを追加する。

app/views/sessions/new.html.erb

<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= link_to "(forgot password)", new_password_reset_path %> <!-- リンクを追加 -->
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

reset_digest属性とreset_sent_at属性をUserモデルに追加する。
下記を実行して、マイグレーションに属性を追加。

$ rails generate migration add_reset_to_users reset_digest:string reset_sent_at:datetime
$ rails db:migrate

新しいパスワード再設定画面ビューを作成する。

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>

フォームから送信後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要がある。続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示する。送信が無効の場合は、ログインと同様にnewページを出力してflash.nowメッセージを表示する。

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

Userモデルにパスワード再設定用メソッドを追加。

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

  .
  .
  .

end


12.2 パスワード再設定のメール送信

Userメイラーのpassword_resetメソッドを変更し、テキストメールのテンプレートとHTMLメールのテンプレートもそれぞれ変更する。

app/mailers/user_mailer.rb

class UserMailer < ApplicationMailer

  ・
  ・
  ・

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

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

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(@user.reset_token,
                                                      email: @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>

Railsのメールプレビュー機能でパスワード再設定のメールをプレビューするためにコードを追加する。

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

パスワード再設定用メイラーメソッドのテストを追加する。

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


12.3 パスワードを再設定する

今日やったこと11

第11章 アカウントの有効化

アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。

アカウントを有効化する手順

1.ユーザーの初期状態は「有効化されていない」(unactivated) にしておく。

2.ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。

3.有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。

4.ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。

5.ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する。

11.1 AccountActivationsリソース

新機能用のトピックブランチを作成。

$ git checkout -b account-activation

まずはAccountActivationsコントローラを生成。

$ rails generate controller AccountActivations

ルーティングにアカウント有効化用のresources行を追加。

config/routes.rb

 Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
end

Userモデルに3つの属性を追加する。

$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime

マイグレーションファイルでactivated属性のデフォルトの論理値をfalseに設定して実行。

db/migrate/[timestamp]_add_activation_to_users.rb
 class AddActivationToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end
$ rails db:migrate

Userモデルにアカウント有効化のコードを追加する。

app/models/user.rb

 class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email

  # オブジェクトが作成されたときだけコールバックを呼び出す。
  before_create :create_activation_digest

  validates :name,  presence: true, length: { maximum: 50 }
  .
  .
  .
  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

サンプルデータとfixtureを更新し、テスト時のサンプルとユーザーを事前に有効化しておく。

db/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
              email: email,
              password:              password,
              password_confirmation: password,
              activated: true,
              activated_at: Time.zone.now)
end
test/fixtures/users.yml
 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
  activated: true
  activated_at: <%= Time.zone.now %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>
<% end %>

データベースを初期化して、サンプルデータを再度生成し直し、上記の変更を反映する。

$ rails db:migrate:reset
$ rails db:seed


11.2 アカウント有効化のメール送信

Action Mailerライブラリを使ってUserのメイラーを追加する。
メイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使う。
メールのテンプレートの中に有効化トークンとメールアドレス (= 有効にするアカウントのアドレス) のリンクを含めて、ビューと同じ要領で定義できる。

rails generateでメイラーを生成。

$ rails generate mailer UserMailer account_activation password_reset

生成されたメイラーのテンプレートを使えるように変更する。

app/mailers/application_mailer.rb

 class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"
  layout 'mailer'
end
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
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end

生成されたビューのテンプレートの2つを変更。

app/views/user_mailer/account_activation.text.erb

 Hi <%= @user.name %>,

Welcome to the Sample App! Click on the link below to activate your account:

<%= edit_account_activation_url(@user.activation_token, email: @user.email) %>
app/views/user_mailer/account_activation.html.erb

 <h1>Sample App</h1>

<p>Hi <%= @user.name %>,</p>

<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>

<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
                                                    email: @user.email) %>
演習 11.2.1.1
>> CGI.escape("Don't panic!")
=> "Don%27t+panic%21"

送信メールのプレビュー

Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができる。これを利用するために、アプリケーションのdevelopment環境の設定に手を加える。

config/environments/development.rb

 Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = 'localhost:3000' #  自分の環境に合わせて変更する。
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }
  .
  .
  .
end

developmentサーバーを再起動して上記の設定を読み込み、自動生成したUserメイラーのプレビューファイルを更新する。

test/mailers/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/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:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    UserMailer.password_reset
  end
end

下記のURLでアカウント有効化メールの内容を確認。

http://localhost:3000/rails/mailers/user_mailer/account_activation
http://localhost:3000/rails/mailers/user_mailer/account_activation.txt

Railsによる自動生成された送信メールのテストを変更。

test/mailers/user_mailer_test.rb

 require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI.escape(user.email),  mail.body.encoded
  end
end

テストファイル内のドメイン名を正しく設定する。

config/environments/test.rb

Rails.application.configure do
  .
  .
  .
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
  .
  .
  .
end


ユーザーのcreateアクションを更新する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.account_activation(@user).deliver_now
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end


11.3 アカウントを有効化する

authenticated?メソッドを抽象化する。

app/models/user.rb

 class User < ApplicationRecord
  .
  .
  .
  # トークンがダイジェストと一致したらtrueを返す
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end
  .
  .
  .

editアクションでユーザーを有効化する。

app/controllers/account_activations_controller.rb

class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.update_attribute(:activated,    true)
      user.update_attribute(:activated_at, Time.zone.now)
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end

ユーザーの有効化を有効的につかい、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する。

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      if user.activated?
        log_in user
        params[:session][:remember_me] == '1' ? remember(user) : forget(user)
        redirect_back_or user
      else
        message  = "Account not activated. "
        message += "Check your email for the activation link."
        flash[:warning] = message
        redirect_to root_url
      end
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

アカウント有効化の統合テストを追加する。

test/integration/users_signup_test.rb

require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear # deliveriesは変数なので、setupメソッドでこれを初期化
  end

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, params: { user: { name:  "",
                                         email: "user@invalid",
                                         password:              "foo",
                                         password_confirmation: "bar" } }
    end
    assert_template 'users/new'
    assert_select 'div#error_explanation'
    assert_select 'div.field_with_errors'
  end
    
  test "valid signup information with account activation" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    assert_equal 1, ActionMailer::Base.deliveries.size
    user = assigns(:user) #assignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできる
    assert_not user.activated?
    # 有効化していない状態でログインしてみる
    log_in_as(user)
    assert_not is_logged_in?
    # 有効化トークンが不正な場合
    get edit_account_activation_path("invalid token", email: user.email)
    assert_not is_logged_in?
    # トークンは正しいがメールアドレスが無効な場合
    get edit_account_activation_path(user.activation_token, email: 'wrong')
    assert_not is_logged_in?
    # 有効化トークンが正しい場合
    get edit_account_activation_path(user.activation_token, email: user.email)
    assert user.reload.activated?
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end

リファクタリングのため、Userモデルにユーザー有効化メソッドを追加する。
activateメソッドはユーザーの有効化属性を更新し、send_activation_emailメソッドは有効化メールを送信する。

app/models/user.rb

class User < ApplicationRecord
  .
  .
  .
  # アカウントを有効にする
  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

  private
    .
    .
    .
end

作成したメソッドを各アクションで使用する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      @user.send_activation_email # send_activation_emailメソッド
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end
app/controllers/account_activations_controller.rb

class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.activate # activateメソッド
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end

rails server実行時にエラーがでて起動しない

rails s

上記のコマンドを実行

ec2-user:~/environment/sample_app (updateing-users) $ rails s
=> Booting Puma
=> Rails 5.1.4 application starting in development 
=> Run `rails server -h` for more startup options
[12158] Puma starting in cluster mode...
[12158] * Version 3.9.1 (ruby 2.4.1-p111), codename: Private Caller
[12158] * Min threads: 5, max threads: 5
[12158] * Environment: development
[12158] * Process workers: 2
[12158] * Preloading application
[12158] * Listening on tcp://localhost:8080
Exiting

といったログが出てサーバが起動しなくなりました


コマンドでプロセスを確認してみると

ps aux | grep puma

pumaのプロセスが2つあるのがいけないのかな

ec2-user  4090  0.0 11.2 875716 114244 ?       Sl   00:45   0:01 puma: cluster worker 0: 4081 [sample_app]            
ec2-user  4997  0.0 17.0 939724 172476 ?       Sl   01:52   0:03 puma: cluster worker 1: 4081 [sample_app]            
ec2-user 12324  0.0  0.2 110520  2072 pts/1    S+   05:31   0:00 grep --color=auto puma

一応killコマンドで2つとも排除します

kill -9 4090
kill -9 4997
rails s

再び上記コマンドを実行して無事起動できました

今日やったこと10

第10章ユーザーの更新・表示・削除  続きから



フレンドリーフォワーディングを実装する。
ユーザーを希望のページに転送するようにさせるため、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
Sessionsヘルパーでstore_locationとredirect_back_orの2つのメソッドを定義して使用する。

app/helpers/sessions_helper.rb

 module SessionsHelper
  .
  .
  .
  # 記憶したURL (もしくはデフォルト値) にリダイレクト
  def redirect_back_or(default)
    # リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクト
    redirect_to(session[:forwarding_url] || default)
    # 転送用のURLを削除
    session.delete(:forwarding_url)
  end

  # アクセスしようとしたURLを覚えておく
  def store_location
    # GETリクエストが送られたときだけrequest.original_urlでリクエスト先を取得しsessionに保存
    session[:forwarding_url] = request.original_url if request.get?
  end
end
app/controllers/users_controller.rb

def logged_in_user
    unless logged_in?
      store_location #ログインユーザー用beforeフィルターにstore_locationを追加
      flash[:danger] = "Please log in."
      redirect_to login_url
    end
end
app/controllers/sessions_controller.rb

def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    log_in user
    params[:session][:remember_me] == '1' ? remember(user) : forget(user)
    redirect_back_or user #redirect_back_orを追加
  else
    flash.now[:danger] = 'Invalid email/password combination'
    render 'new'
  end
end


10.3 すべてのユーザーを表示する

indexアクションを追加し、すべてのユーザーを一覧表示する。
ユーザーのshowページについては、ログインしているかどうかに関わらずサイトを訪れたすべてのユーザーから見えるようにするが、ユーザーのindexページはログインしたユーザーにしか見せないようにして、未登録のユーザーがデフォルトで表示できるページを制限する。

indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書く。

test/controllers/users_controller_test.rb

 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end

  # indexアクションのリダイレクトをテスト
  test "should redirect index when not logged in" do
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end

次に、beforeフィルターのlogged_in_userにindexアクションを追加して、このアクションを保護する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]  <==
  before_action :correct_user,   only: [:edit, :update]

  def index
  end
  .
  .
  .
end

すべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装する。

def index
  @users = User.all
end
app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

CSSでレイアウトを調整し、ヘッダーにユーザー一覧表示用のリンクを追加する。

app/assets/stylesheets/custom.scss

 .
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}
app/views/layouts/_header.html.erb

 <header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", users_path %></li>  #ユーザー一覧ページへのリンクを追加
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>


演習 10.3.1.1

test/integration/site_layout_test.rb

require 'test_helper'

class SiteLayoutTest < ActionDispatch::IntegrationTest
  
  def setup
    @user = users(:michael)
  end
  ・
  ・
  ・
  # ログイン時のレイアウトリンクに対するテスト
  test "layout links when logged in" do
    log_in_as(@user)
    get root_path
    assert_template 'static_pages/home'
    assert_select "a[href=?]", users_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)    
    assert_select "a[href=?]", logout_path
  end
  
end


サンプルのユーザーを追加する。
まず、GemfileにFaker gem(実際にいそうなユーザー名を作成するgem)を追加する。

gem 'faker',          '1.7.3'

bundle installを実行

$ bundle install

サンプルユーザーを生成するRubyスクリプト (Railsタスクとも呼ぶ) を追加ためにRailsではdb/seeds.rbというファイルを標準として使う。

db/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

データベースをリセットして、Railsタスクを実行。

$ rails db:migrate:reset
$ rails db:seed


ページネーションを使い1つのページに表示するユーザーの数を制限する。
Railsには豊富なページネーションメソッドがあり、その中のwill_paginateメソッドを使う。
Bootstrapのページネーションスタイルも使いたいので、
will_paginate gem とbootstrap-will_paginate gemをGemfileに追加する。

gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'

bundle installを実行

$ bundle install

ページネーションを動作させるため、ユーザーのページネーションを行うようにRailsに指示するwill_paginateメソッドををindexビューに追加する。

app/views/users/index.html.erb

 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %>

will_paginateではpaginateメソッドを使った結果が必要なので
indexアクション内のallをpaginateメソッドに置き換える。
ここで:pageパラメーターにはparams[:page]が使われているが、これはwill_paginateによって自動的に生成されたものである。

def index
  @users = User.paginate(page: params[:page])
end



ページネーションに対するテストを書いていく。
テスト用のデータベースに31人以上のユーザーがいる必要があるのでfixtureファイルに追加する。
なお、今後必要になるので、2人の名前付きユーザーも一緒に追加しておく。

test/fixtures/users.yml

 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

indexページに対するテストのファイルを生成しコードを作成する。

$ rails generate integration_test users_index
      invoke  test_unit
      create    test/integration/users_index_test.rb
test/integration/users_index_test.rb

 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  
  # ページネーションを含めたUsersIndexのテスト
  test "index including pagination" do
    log_in_as(@user) #ログイン
    get users_path #indexページにアクセス
    assert_template 'users/index' #最初のページにユーザーがいることを確認
    assert_select 'div.pagination' #ページネーションのリンクがあることを確認
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

各ユーザーを表示するパーシャルを作りindexページをリファクタリングする

app/views/users/index.html.erb

<% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %>
</ul>

<%= will_paginate %>
app/views/users/_user.html.erb

 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>




10.4 ユーザーを削除する

削除を実行できる権限を持つ管理 (admin) ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。

属性の型をbooleanと指定しマイグレーションを実行する。

$ rails generate migration add_admin_to_users admin:boolean
$ rails db:migrate

admin属性が追加されて値はデフォルトでfalseとなり、疑問符の付いたadmin?メソッドも利用できるようになる。

>> user.admin?
=> false

最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新し、
データベースをリセットして、サンプルデータを再度生成する。

b/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: true)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end
$ rails db:migrate:reset
$ rails db:seed

演習 10.4.1.1

test/controllers/users_controller_test.rb
 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  
  # admin属性の変更が禁止されていることをテスト
  test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: {
                                    user: { password:              @other_user,
                                            password_confirmation: @other_user,
                                            admin: true } }
    assert_not @other_user.reload.admin?
  end
  .
  .
  .
end

ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。
これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示されるようになる。

app/views/users/_user.html.erb

 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
</li>

削除リンクを動作させるために、destroyアクションを追加する。
このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーindexに移動するようにする。
ユーザーを削除するためにはログインしていなくてはならないので、destroyアクションもlogged_in_userフィルターに追加する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  .
  .
  .
  def destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

  private
  .
  .
  .
end

サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要があるので、
今回はbeforeフィルター(admin_user)を使ってdestroyアクションへのアクセスを制御する。

app/controllers/users_controller.rb

 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

ユーザー削除のテストを書く。
まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にする。

test/fixtures/users.yml

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true

管理者権限の制御をアクションレベルでテストする

test/controllers/users_controller_test.rb

 require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  end
  .
  .
  .
  test "should redirect destroy when not logged in" do
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when logged in as a non-admin" do
    log_in_as(@other_user)
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to root_url
  end
end

削除リンクとユーザー削除に対する統合テスト

test/integration/users_index_test.rb

 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @admin     = users(:michael)
    @non_admin = users(:archer)
  end

  test "index as admin including pagination and delete links" do
    log_in_as(@admin)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    first_page_of_users = User.paginate(page: 1)
    first_page_of_users.each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin)
    get users_path
    assert_select 'a', text: 'delete', count: 0
  end
end



すべての変更をmasterブランチにマージ

$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push