学習日記

Ruby on Rails勉強してます

今日やったこと18

第14章 ユーザーをフォローする

14.1 Relationshipモデル

ユーザーをフォローする機能を実装するために、Relationshipデータモデルを作成する。

relationships
id integer
follower_id integer
followed_id integer
created_at datetime
updated_at datetime
$ rails generate model Relationship follower_id:integer followed_id:integer

relationshipsテーブルにインデックスを追加し、データベースのマイグレーションを行う。

db/migrate/[timestamp]_create_relationships.rb

class CreateRelationships < ActiveRecord::Migration[5.0]
  def change
    create_table :relationships do |t|
      t.integer :follower_id
      t.integer :followed_id

      t.timestamps
    end
    add_index :relationships, :follower_id
    add_index :relationships, :followed_id
    add_index :relationships, [:follower_id, :followed_id], unique: true
  end
end
$ rails db:migrate

User/Relationshipの関連付け

app/models/user.rb

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  has_many :active_relationships, class_name:  "Relationship",
                                  foreign_key: "follower_id",
                                  dependent:   :destroy
  .
  .
  .
end
app/models/relationship.rb

class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
end

Relationshipのバリデーション

今の時点では生成されたRelationship用のfixtureファイルは空にしておく。

test/models/relationship_test.rb

require 'test_helper'

class RelationshipTest < ActiveSupport::TestCase

  def setup
    @relationship = Relationship.new(follower_id: users(:michael).id,
                                     followed_id: users(:archer).id)
  end

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

  test "should require a follower_id" do
    @relationship.follower_id = nil
    assert_not @relationship.valid?
  end

  test "should require a followed_id" do
    @relationship.followed_id = nil
    assert_not @relationship.valid?
  end
end
app/models/relationship.rb

class Relationship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
  validates :follower_id, presence: true
  validates :followed_id, presence: true
end

フォローしているユーザー

Userモデルにfollowingの関連付けを追加する。

app/models/user.rb

class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  has_many :active_relationships, class_name:  "Relationship",
                                  foreign_key: "follower_id",
                                  dependent:   :destroy
  has_many :following, through: :active_relationships, source: :followed # 追加
  .
  .
  .
end

followingで取得した集合をより簡単に取り扱うために、followやunfollowといったメソッドを追加する。
追加するメソッドはまずテストから先に書いていく。

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase
  .
  .
  .
  # following” 関連のメソッドをテスト
  test "should follow and unfollow a user" do
    michael = users(:michael)
    archer  = users(:archer)
    assert_not michael.following?(archer)
    michael.follow(archer)
    assert michael.following?(archer)
    michael.unfollow(archer)
    assert_not michael.following?(archer)
  end
end

この時点でテストは失敗するので、次にfollowing関連のメソッドを追加する。

app/models/user.rb

class User < ApplicationRecord
  .
  .
  .
  def feed
    .
    .
    .
  end

  # ユーザーをフォローする
  def follow(other_user)
    active_relationships.create(followed_id: other_user.id)
  end

  # ユーザーをフォロー解除する
  def unfollow(other_user)
    active_relationships.find_by(followed_id: other_user.id).destroy
  end

  # 現在のユーザーがフォローしてたらtrueを返す
  def following?(other_user)
    following.include?(other_user)
  end

  private
  .
  .
  .
end

再びテストをして成功。

今日やったこと17

画像の検証

画像サイズやフォーマットに対するバリデーションを実装し、サーバー用とクライアント (ブラウザ) 用の両方に追加する。 生成されたアップローダーの中のコメントアウトされたコードを取り消すことで、画像のファイル名から有効な拡張子 (PNG/GIF/JPEGなど) を検証することができる。

app/uploaders/picture_uploader.rb

class PictureUploader < CarrierWave::Uploader::Base
  storage :file

  # アップロードファイルの保存先ディレクトリは上書き可能
  # 下記はデフォルトの保存先  
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # アップロード可能な拡張子のリスト
  def extension_white_list
    %w(jpg jpeg gif png)
  end
end

ファイルサイズに対するバリデーションはRailsの既存のオプション (presenceやlengthなど) にはないので、手動でpicture_sizeという独自のバリデーションを定義する。独自のバリデーションを定義するためには今まで使っていたvalidatesメソッドではなく、validateメソッドを使う。

app/models/micropost.rb

class Micropost < ApplicationRecord
  belongs_to :user
  default_scope -> { order(created_at: :desc) }
  mount_uploader :picture, PictureUploader
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
  validate  :picture_size #validateメソッドを使う

  private

    # アップロードされた画像のサイズをバリデーションする
    def picture_size
      if picture.size > 5.megabytes
        errors.add(:picture, "should be less than 5MB")
      end
    end
end

上記で定義した画像のバリデーションをビューに組み込むために、クライアント側に2つの処理を追加する。

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" %>
  <span class="picture">
    <!-- acceptパラメータを付与する -->
    <%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>
  </span>
<% end %>

<!-- jQueryを追加 -->
<script type="text/javascript">
  $('#micropost_picture').bind('change', function() {
    var size_in_megabytes = this.files[0].size/1024/1024;
    if (size_in_megabytes > 5) {
      alert('Maximum file size is 5MB. Please choose a smaller file.');
    }
  });
</script>

画像のリサイズ

画像をリサイズするためには、画像を操作するプログラムが必要になる。
ImageMagickというプログラムを開発環境にインストールする。

$ sudo yum install -y ImageMagick

次に、MiniMagickというImageMagickRubyを繋ぐgemを使って、画像をリサイズする。

app/uploaders/picture_uploader.rb

class PictureUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick

  # 縦横どちらかが400pxを超えていた場合、適切なサイズに縮小する
  process resize_to_limit: [400, 400]

  storage :file

  # アップロードファイルの保存先ディレクトリは上書き可能
  # 下記はデフォルトの保存先  
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # アップロード可能な拡張子のリスト
  def extension_white_list
    %w(jpg jpeg gif png)
  end
end

本番環境での画像アップロード

開発環境ではstorage :fileという行によって、ローカルのファイルシステムに画像を保存するようになっているが、本番環境ではファイルシステムではなくクラウドストレージサービスに画像を保存するようにする。
クラウドストレージに保存するためには、fog gemを使うと簡単できる。

app/uploaders/picture_uploader.rb

class PictureUploader < CarrierWave::Uploader::Base
  include CarrierWave::MiniMagick
  process resize_to_limit: [400, 400]

  # 環境ごとに保存先を切り替える
  if Rails.env.production?
    storage :fog
  else
    storage :file
  end

今日やったこと16

13.4 マイクロポストの画像投稿

投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、CarrierWaveという画像アップローダーを使う。まずはcarrierwave gemをGemfileに追加する。画像をリサイズしたり、本番環境で画像をアップロードするために、mini_magick gemとfog gemsも追加する。

source 'https://rubygems.org'

gem 'rails',                   '5.1.4'
gem 'bcrypt',                  '3.1.11'
gem 'faker',                   '1.7.3'
gem 'carrierwave',             '1.2.2' #追加
gem 'mini_magick',             '4.7.0' #追加
gem 'will_paginate',           '3.1.5'
gem 'bootstrap-will_paginate', '1.0.0'
.
.
.
group :production do
  gem 'pg',  '0.20.0'
  gem 'fog', '1.42' #追加
  end
.
.
.
$ bundle install

CarrierWaveを導入すると、Railsのジェネレーターで画像アップローダーが生成できるようになる。

$ rails generate uploader Picture

関連付けされる属性には画像のファイル名が格納されるため、String型にしてpicture属性をMicropostモデルに追加する。

$ rails generate migration add_picture_to_microposts picture:string
$ rails db:migrate

CarrierWaveに画像と関連付けたモデルを伝えるためには、mount_uploaderというメソッドを使い、引数に属性名のシンボルと生成されたアップローダーのクラス名を取る。これをMicropostモデルに追加する。

app/models/micropost.rb

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

Homeページにアップローダーを追加するために、マイクロポストのフォームにfile_fieldタグを追加。

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" %>
  <span class="picture">
    <%= f.file_field :picture %>        <!-- file_fieldタグを追加 -->
  </span>
<% end %>

micropost_paramsメソッドにpicture属性を追加する。

app/controllers/microposts_controller.rb

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

    def micropost_params
      params.require(:micropost).permit(:content, :picture) # picture属性を追加
    end

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

Micropostパーシャルのimage_tagヘルパーでアップロードされた画像を描画する。

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 %>
    <!-- image_tagヘルパーで画像を描画 -->
    <%= image_tag micropost.picture.url if micropost.picture? %>
  </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>

今日やったこと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 パスワードを再設定する