Rails」カテゴリーアーカイブ

Rails-tutorialのまとめ14.2(FollowのWebインターフェイス)

その14から続く

14.2 FollowのWebインターフェイス

フォロー/フォロー解除の基本的なインターフェイスを実装します。また、
フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成します。
ユーザーのステータスフィードを追加して、サンプルアプリケーションを完成させます。

14.2.1 フォローのサンプルデータ

サンプルデータを自動作成するrails db:seedを使って、
データベースにサンプルデータを登録できるとやはり便利です。
先にサンプルデータを自動作成できるようにしておけば、
Webページの見た目のデザインから先にとりかかることができ、
バックエンド機能の実装を後に回すことができます。

最初のユーザーにユーザー3からユーザー51までをフォローさせ、
それから逆にユーザー4からユーザー41に最初のユーザーをフォローさせます。

ソースを見るとわかるように、このような設定を自由に行うことができます。こうしてリレーションシップを作成しておけば、
アプリケーションのインターフェイスを開発するには十分です。

サンプルデータにfollowing/followerの関係性を追加する
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

# マイクロポスト
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end

# リレーションシップ
users = User.all
user  = users.first
following = users[2..50]
followers = users[3..40]
following.each { |followed| user.follow(followed) }
followers.each { |follower| follower.follow(user) }
rails db:migrate:reset
rails db:seed

演習

1:コンソールを開き、User.first.followers.countの結果が
期待している結果と合致していることを確認してみましょう。

User.first.followers.count
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
(0.4ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]]
=> 38

2:先ほどの演習と同様に、User.first.following.countの結果も合致していることを確認してみましょう。

User.first.following.count
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
(0.2ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? [["follower_id", 1]]
=> 49

14.2.2 統計とFollowフォーム

これでサンプルユーザーに、フォローしているユーザーとフォロワーができました。プロフィールページとHomeページを更新して、
これを反映しましょう。
最初に、プロフィールページとHomeページに、
フォローしているユーザーとフォロワーの統計情報を表示するための
パーシャルを作成します。次に、フォロー用とフォロー解除用のフォームを作成します。
それから、フォローしているユーザーの一覧 (“following”)と
フォロワーの一覧 (“followers”) を表示する専用のページを作成します。

resourcesブロックの内側で:memberメソッドを使っています。
これは初登場のメソッドですが、まずはどんな動作をするのか推測してみてください

Usersコントローラにfollowingアクションとfollowersアクションを追加する
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 do
    member do
      get :following, :followers
    end
  end
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
end

どちらもデータを表示するページなので、
適切なHTTPメソッドはGETリクエストになります。
したがって、getメソッドを使って適切なレスポンスを返すようにします。

続きを読む

Rails-tutorialのまとめ(第14章 ユーザーをフォロー 主に演習)

13章から続く

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

他のユーザーをフォロー(およびフォロー解除)できるソーシャルな仕組みの追加と、
フォローしているユーザーの投稿をステータスフィードに表示する機能を追加します。

ここで学んだデータモデルは、今後自分用のWebアプリケーションを開発するときに必ず役に立ちます。

14.1 Relationshipモデル

ユーザーをフォローする機能を実装する第一歩は、データモデルを構成することです。ただし、これは見た目ほど単純ではありません。

素朴に考えれば、has_many (1対多) の関連付けを用いて「1人のユーザーが複数のユーザーをhas_manyとしてフォローし、1人のユーザーに複数のフォロワーがいることをhas_manyで表す」といった方法でも実装できそうです。しかし後ほど説明しますが、この方法ではたちまち壁に突き当たってしまいます。これを解決するためのhas_many throughについてもこの後で説明します。

14.1.1 データモデルの問題 (および解決策)

あるユーザーをフォローしているすべてのユーザーの集合はfollowersとなりuser.followersはそれらのユーザーの配列を表すことになります。

Twitterの慣習にならい、本チュートリアルではfollowingという
呼称を採用します (例: “50 following, 75 followers”)。

したがって、あるユーザーがフォローしているすべてのユーザーの集合はcalvin.followingとなります。

followingテーブルと has_many関連付けを使って、
フォローしているユーザーのモデリングができます。
user.followingはユーザーの集合でなければならないため、

followingテーブルのそれぞれの行は、followed_idで識別可能なユーザーでなければなりません (これはfollower_idの関連付けについても同様です)。

さらに、それぞれの行はユーザーなので、
これらのユーザーに名前やパスワードなどの属性も追加する必要があるでしょう。

2つの疑問が生じます。

1. あるユーザーが別のユーザーをフォローするとき、何が作成される?

2. あるユーザーが別のユーザーをフォロー解除するとき、何が削除される?。

1人のユーザーは1対多の関係を持つことができ、
さらにユーザーはリレーションシップを経由して多くのfollowing
(またはfollowers) と関係を持つことができるということです。

Facebookのような友好関係 (Friendships) では本質的に
左右対称のデータモデルが成り立ちますが、
Twitterのようなフォロー関係では左右非対称の性質があります。
すなわち、CalvinはHobbesをフォローしていても、
HobbesはCalvinをフォローしていないといった関係性が成り立つのです。

左右非対称な関係性を見分けるために、
それぞれを能動的関係 (Active Relationship)と
受動的関係 (Passive Relationship)と呼ぶことにします

CalvinがHobbesをフォローしているが、
HobbesはCalvinをフォローしていない場合では、
CalvinはHobbesに対して「能動的関係」を持っていることになります。

つまりアイドルとファンの関係のようなものでCalvinはアイドルなわけよ

逆に、HobbesはCalvinに対して「受動的関係」を持っていることになります。

つまりアイドルとファンの関係のようなものでHobbesはファンなわけよ
フォローしているユーザーを生成するために、能動的関係に焦点を当てていきます
フォローしているユーザーはfollowed_idがあれば識別することができるので、

先ほどのfollowingテーブルをactive_relationshipsテーブル
と見立ててみましょう。

ただしユーザー情報は無駄なので、ユーザーid以外の情報は削除します。そして、followed_idを通して、
usersテーブルのフォローされているユーザーを見つけるようにします。

テーブル名にはこの「関係」を表す「relationships」を使いましょう。モデル名はRailsの慣習にならって、Relationshipとします。

続きを読む

Rails-tutorial(13.5画像の検証)

その13.4から続く

13.4.2 画像の検証

アップローダーも悪くはありませんが、いくつかの目立つ欠点があります。例えば、アップロードされた画像に対する制限がないため、
もしユーザーが巨大なファイルを上げたり、無効なファイルを上げると問題が発生してしまいます。

この欠点を直すために、画像サイズやフォーマットに対するバリデーションを実装し、サーバー用とクライアント (ブラウザ)用の両方に追加しましょう。

画像のファイル名から有効な拡張子 (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_whitelist
    %w(jpg jpeg gif png)
  end
end

2つ目のバリデーションでは、画像のサイズを制御します。
これはMicropostモデルに書き足していきます。

今回は手動で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

  private

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

validateメソッドでは、引数にシンボル (:picture_size) を取り、
そのシンボル名に対応したメソッドを呼び出します。
また、呼び出されたpicture_sizeメソッドでは、5MBを上限とし、
それを超えた場合はカスタマイズした
エラーメッセージをerrorsコレクションに追加しています。

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

まずはフォーマットのバリデーションを反映するためには、
file_fieldタグにacceptパラメータを付与して使います。

<%= f.file_field :picture, accept: 'image/jpeg,image/gif,image/png' %>

このときacceptパラメータでは、リスト 13.64で許可したファイル形式を、MIMEタイプで指定するようにします。

次に、大きすぎるファイルサイズに対して警告を出すために、
ちょっとしたJavaScript (正確にはjQuery) を書き加えます。
こうすることで、長すぎるアップロード時間を防いだり、
サーバーへの負荷を抑えたりすることに繋がります。

$('#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.');
}
});

上のコードでは(ハッシュマーク#から分かるように)
CSS idのmicropost_pictureを含んだ要素を見つけ出し、
この要素を監視しています。

そしてこのidを持った要素とは、マイクロポストのフォームを指します(なお、ブラウザ上で画面を右クリックし、インスペクターで要素を調べることで確認できます)。

つまり、このCSS idを持つ要素が変化したとき、このjQueryの関数が動き出します。そして、もしファイルサイズが大きすぎた場合、
alertメソッドで警告を出すといった仕組みです。

これらの追加的なチェック機能をまとめると、↓のようになります。

ファイルサイズをjQueryでチェックする
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, accept: 'image/jpeg,image/gif,image/png' %>
 </span>
<% end %>

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

実装はまだ不完全です。

仮に送信フォームを使った投稿をうまく制限できても、ブラウザのインスペクタ機能でJavaScriptをいじったり、curlなどを使って直接POSTリクエストを送信する場合には対応しきれません

演習

1:5MB以上の画像ファイルを送信しようとした場合、どうなりますか?
また無効な拡張子のファイルを送信しようとした場合、どうなりますか?

エラーメッセージが以下のようになる。
画像は5MB未満である必要があります
画像「xmind」ファイルのアップロードは許可されていません。
許可されているタイプ:jpg、jpeg、gif、png

13.4.3 画像のリサイズ

画像を表示させる前にサイズを変更する

画像をリサイズするためには、画像を操作するプログラムが必要になります。今回はImageMagickという
プログラムを使うので、これを開発環境にインストールします。

本番環境がHerokuであれば、既に本番環境でImageMagickが使えるようになっています。クラウドIDEでは、
次のコマンドでこのプログラムをインストールできます。

sudo yum install -y ImageMagick

もしローカル環境で開発している場合、それぞれの環境に応じてImagiMagickをインストールする手順が異なります。

例えばMacの場合であれば、Homebrewを導入し、
brew install imagemagick
コマンドを使ってインストールします。

次に、MiniMagickというImageMagickとRubyを繋ぐgemを使って、
画像をリサイズしてみましょう。

MiniMagickのドキュメントを見ると
様々な方法でリサイズできることがわかりますが、
今回はresize_to_limit: [400, 400]という方法を使います。これは、
縦横どちらかが400pxを超えていた場合、適切なサイズに縮小するオプションです(ただし小さい画像であっても拡大はしません)。

画像をリサイズするために画像アップローダーを修正する
app/uploaders/picture_uploader.rb

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

  storage :file

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

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

演習

1:解像度の高い画像をアップロードし、リサイズされているかどうか確認してみましょう。画像が長方形だった場合、リサイズはうまく行われているでしょうか?

うまく出来る。バリデーション実装前のはもちろんリサイズされない

2:テストを追加していた場合、この時点でテストスイートを走らせると
紛らわしいエラーメッセージが表示されることがあります。
このエラーを取り除いてみましょう。
設定ファイルを修正し、テスト時はCarrierWaveに画像のリサイズを
させないようにしてみましょう。

テスト時は画像のリサイズをさせない設定config/initializers/skip_image_resizing.rb
if Rails.env.test?
  CarrierWave.configure do |config|
    config.enable_processing = false
  end
end

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

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

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

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

世の中には多くのクラウドストレージサービスがありますが、今回は有名で信頼性も高いアマゾンの「Simple Storage Service (S3) 」を使います。

Amazon Web Servicesアカウントにサインアップする

AWS Identity and Access Management (IAM)でユーザーを
作成し、AccessキーとSecretキーをメモする

AWS ConsoleからS3 bucketを作成し(bucketの名前はなんでも大丈夫です)作成したユーザーに対してRead権限とWrite権限を付与する

fogでリージョンを指定する場合は
:region => ENV[‘S3_REGION’] といったパラメータを渡し、
heroku config:set S3_REGION=”リージョン名”
といったコマンドを実行することで設定できます。
なお、東京のリージョン名は “ap-northeast-1” です。

CarrierWaveを通してS3を使うように修正するconfig/initializers/carrier_wave.rb
if Rails.env.production?
  CarrierWave.configure do |config|
    config.fog_credentials = {
      # Amazon S3用の設定
      :provider              => 'AWS',
      :region                => ENV['S3_REGION'],     # 例: 'ap-northeast-1'
      :aws_access_key_id     => ENV['S3_ACCESS_KEY'],
      :aws_secret_access_key => ENV['S3_SECRET_KEY']
    }
    config.fog_directory     =  ENV['S3_BUCKET']
  end
end

今回は手動で設定する必要があります。heroku config:setコマンドを使って、次のようにHeroku上の環境変数を設定してください。

heroku config:set S3_ACCESS_KEY="ココに先ほどメモしたAccessキーを入力" 
heroku config:set S3_SECRET_KEY="同様に、Secretキーを入力" 
heroku config:set S3_BUCKET="Bucketの名前を入力" 
heroku config:set S3_REGION="Regionの名前を入力"

.gitignoreファイルにアップロード用ディレクトリを追加する

# アップロードされたテスト画像を無視する
/public/uploads

それでは、これまでの変更をトピックブランチにコミットし、masterブランチにマージしていきましょう。

rails test
git add -A
git commit -m "Add user microposts"
git checkout master
git merge user-microposts
git push

次に、Herokuへのデプロイ、データベースのリセット、サンプルデータの生成を順に実行していきます。

git push heroku
heroku pg:reset DATABASE
heroku run rails db:migrate
heroku run rails db:seed

最終状態のGemfile

source 'https://rubygems.org'

gem 'rails',                   '5.1.6'
gem 'bcrypt',                  '3.1.12'
gem 'faker',                   '1.7.3'
gem 'carrierwave',             '1.2.2'
gem 'mini_magick',             '4.7.0'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'
gem 'bootstrap-sass',          '3.3.7'
gem 'puma',                    '3.9.1'
gem 'sass-rails',              '5.0.6'
gem 'uglifier',                '3.2.0'
gem 'coffee-rails',            '4.2.2'
gem 'jquery-rails',            '4.3.1'
gem 'turbolinks',              '5.0.1'
gem 'jbuilder',                '2.7.0'

group :development, :test do
  gem 'sqlite3', '1.3.13'
  gem 'byebug',  '9.0.6', platform: :mri
end

group :development do
  gem 'web-console',           '3.5.1'
  gem 'listen',                '3.1.5'
  gem 'spring',                '2.0.2'
  gem 'spring-watcher-listen', '2.0.1'
end

group :test do
  gem 'rails-controller-testing', '1.0.2'
  gem 'minitest',                 '5.10.3'
  gem 'minitest-reporters',       '1.1.14'
  gem 'guard',                    '2.14.1'
  gem 'guard-minitest',           '2.4.6'
end

group :production do
  gem 'pg',   '0.20.0'
  gem 'fog',  '1.42'
end

# Windows環境ではtzinfo-dataというgemを含める必要があります
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

13.5.1 本章のまとめ

1:Active Recordモデルの力によって、マイクロポストも
(ユーザーと同じで) リソースとして扱える

2:Railsは複数のキーインデックスをサポートしている

3:Userは複数のMicropostsを持っていて (has_many)、Micropostは
1人のUserに依存している (belongs_to) といった関係性をモデル化した

4:has_manyやbelongs_toを利用することで、
関連付けを通して多くのメソッドが使えるようになった

5:user.microposts.build(…)というコードは、
引数で与えたユーザーに関連付けされたマイクロポストを返す

6:default_scopeを使うとデフォルトの順序を変更できる

7:default_scopeは引数に無名関数 (->) を取る

8:dependent: :destroyオプションを使うと、
関連付けされたオブジェクトと自分自身を同時に削除する

9:paginateメソッドやcountメソッドは、
どちらも関連付けを通して実行され、効率的にデータベースに問い合わせしている

10:fixtureは、関連付けを使ったオブジェクトの作成もサポートしている

11:パーシャルを呼び出すときに、一緒に変数を渡すことができる

12:whereメソッドを使うと、Active Recordを通して選択 (部分集合を取り出すこと) ができる

13:依存しているオブジェクトを作成/削除するときは、
常に関連付けを通すようにすることで、よりセキュアな操作が実現できる

14:CarrierWaveを使うと画像アップロードや画像リサイズができる

14章に続く

Rails-tutorial(13.4マイクロポストを削除する 主に演習)

13.3から続く

13.3.4 マイクロポストを削除する

最後の機能として、マイクロポストリソースにポストを削除する機能を追加します。
これはユーザー削除と同様に”delete” リンクで実現します。
ユーザーの削除は管理者ユーザーのみが行えるように制限されていたのに対し、
今回は自分が投稿したマイクロポストに対してのみ削除リンクが
動作するようにします。

最初のステップとして、マイクロポストのパーシャルに削除リンクを追加します。作成したコードを示します。

マイクロポストのパーシャルに削除リンクを追加する
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アクションを定義しましょう。これも、ユーザーにおける実装とだいたい同じです。
大きな違いは、admin_userフィルターで@user変数を使うのではなく、関連付けを使ってマイクロポストを見つけるようにしている点です。

これにより、あるユーザーが他のユーザーのマイクロポストを
削除しようとすると、自動的に失敗するようになります。
具体的には、correct_userフィルター内でfindメソッドを呼び出すことで、現在のユーザーが削除対象のマイクロポストを保有しているかどうかを確認します。作成したコードを↓に示します。

Micropostsコントローラのdestroyアクション
app/controllers/microposts_controller.rb

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

private

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

destroyメソッドではリダイレクトを使っている点に注目してください。

request.referrerというメソッドを使っています。
このメソッドはフレンドリーフォワーディングのrequest.url変数
と似ていて、一つ前のURLを返します(今回の場合、Homeページになります)

演習

1:マイクロポストを作成し、その後作成したマイクロポストを
削除してみましょう。Railsサーバーのログを見てみて、
DELETE文の内容を確認してみてください。

DELETE FROM "microposts" WHERE "microposts"."id" = ? [["id", 303]]

2:redirect_to request.referrer || root_urlの行を
redirect_back(fallback_location: root_url)と置き換えても
うまく動くことを、ブラウザを使って確認してみましょう
(このメソッドはRails 5から新たに導入されました)。

redirect_back(fallback_location: root_url)

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

残っている箇所は、Micropostsコントローラの認可をチェックする短いテストとそれらをまとめる統合テストを書くことです。

まずはマイクロポスト用の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

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

間違ったユーザーによるマイクロポスト削除に対してテストする green
test/controllers/microposts_controller_test.rb

require 'test_helper'

class MicropostsControllerTest < ActionDispatch::IntegrationTest

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

最後に、統合テストを書きます。今回の統合テストでは、ログイン、
マイクロポストのページ分割の確認、無効なマイクロポストを投稿、
有効なマイクロポストを投稿、マイクロポストの削除、
そして他のユーザーのマイクロポストには [delete] リンクが
表示されないことを確認、といった順でテストしていきます。
いつものように、統合テストを生成するところから始めましょう。

rails generate integration_test microposts_interface

先ほどの順で書いた統合テストは、↓のようになります。書いたコードと、先ほどのステップが結合されている点に注意してください。

マイクロポストのUIに対する統合テスト green
test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest

def setup
  @user = users(:michael)
end

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

演習

1:4つのコメント(無効な送信など)のそれぞれに対して、
テストが正しく動いているか確認してみましょう。
具体的には、対応するアプリケーション側のコードをコメントアウトし、テストが redになることを確認し、元に戻すとgreenになることを確認してみましょう。

@micropost = @user.microposts.build(content: "")にする
Expected false to be truthy.
test/models/micropost_test.rb:11:in `block in <class:MicropostTest>'

@micropost.destroy
flash[:success] = "Micropost deleted"

"Micropost.count" didn't change by -1.
Expected: 38
Actual: 39

ifの部分を消して誰でも見れるようにする

Expected exactly 0 elements matching "a", found 2..
Expected: 0
Actual: 2

2:サイドバーにあるマイクロポストの合計投稿数をテストしてみましょう。このとき、単数形 (micropost) と複数形 (microposts) が
正しく表示されているかどうかもテストしてください。

サイドバーでマイクロポストの投稿数をテストするためのテンプレート
test/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

def setup
  @user = users(:michael)
end
.
.
.
test "micropost sidebar count" do
 log_in_as(@user)
 get root_path
 assert_match "#{FILL_IN} microposts", response.body
# まだマイクロポストを投稿していないユーザー
 other_user = users(:malory)
 log_in_as(other_user)
 get root_path
 assert_match "0 microposts", response.body
 other_user.microposts.create!(content: "A micropost")
 get root_path
 assert_match FILL_IN, response.body
 end
end

@user.microposts.count
“1 micropost”

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

ここまででマイクロポストに関する基本的な操作はすべて実装できました。
この節では、応用編として画像付きマイクロポストを投稿できるようにしてみます。
手順としては、まずは開発環境用のβ版を実装し、その後、
いくつかの改善をとおして本番環境用の完成版を実装します。

画像アップロード機能を追加するためには、2つの視覚的な要素が必要です。1つは画像をアップロードするためのフォーム、もう1つは投稿された画像そのものです。

13.4.1 基本的な画像アップロード

投稿した画像を扱ったり、その画像をMicropostモデルと関連付けするために、今回はCarrierWaveという画像アップローダーを使います。
まずはcarrierwave gemをGemfileに追加しましょう。

gem 'carrierwave', '1.2.2'
gem 'mini_magick', '4.7.0'
group :production do
gem 'fog', '1.42'
end
bundle install

CarrierWaveを導入すると、
Railsのジェネレーターで画像アップローダーが生成できるようになります。早速、次のコマンドを実行してみましょう。

rails generate uploader Picture

(画像のことをimageとすると一般的過ぎるので、
今回はpictureと呼ぶことにします)

picture属性をMicropostモデルに追加するために、マイグレーションファイルを生成し、開発環境のデータベースに適用します。

rails generate migration add_picture_to_microposts picture:string
rails db:migrate

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

mount_uploader :picture, PictureUploader

(picture_uploader.rbというファイルでPictureUploaderクラスが定義されています。後で修正しますが、今はデフォルトのままで大丈夫です。)
Micropostモデルにアップローダーを追加した結果を↓に示します。

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 %>
  </span>
<% end %>

Webから更新できる許可リストにpicture属性を追加しましょう。
追加すると、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)
    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ヘルパーでその画像を描画できるようになります。

画像の無い (テキストのみの) マイクロポストでは画像を表示させないようにするために、picture?という論理値を返すメソッドを使っている点に
注目してください。このメソッドは、画像用の属性名に応じて、
CarrierWaveが自動的に生成してくれるメソッドです。

マイクロポストの画像表示を追加する
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 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>

演習

1:テンプレートを参考に、画像アップローダーをテストしてください。
テストの準備として、まずはサンプル画像をfixtureディレクトリに
追加してください(cp app/assets/images/rails.png test/fixtures/)。

追加したテストでは、Homeページにあるファイルアップロードと、
投稿に成功した時に画像が表示されているかどうかをチェックしています。
なお、テスト内にあるfixture_file_uploadというメソッドは、
fixtureで定義されたファイルをアップロードする特別なメソッドです。
ヒント: picture属性が有効かどうかを確かめるときは、assignsメソッドを使ってください。

このメソッドを使うと、
投稿に成功した後にcreateアクション内のマイクロポストにアクセスするようになります。

画像アップロードをテストするためのテンプレートtest/integration/microposts_interface_test.rb

require 'test_helper'

class MicropostInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    assert_select 'input[type=FILL_IN]'
    # 無効な送信
    post microposts_path, params: { micropost: { content: "" } }
    assert_select 'div#error_explanation'
    # 有効な送信
    content = "This micropost really ties the room together"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost:
                                      { content: content,
                                        picture: FILL_IN } }
    end
    assert FILL_IN.picture?
    follow_redirect!
    assert_match content, response.body
    # 投稿を削除する
    assert_select 'a', '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

答え:↑FILL_INを↓にする。

file,picture,

assigns(:micropost)

13.5に続く

Rails-tutorial(13.3マイクロポストを操作する 主に演習)

その13.2から続く

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

次はWeb経由でそれらを作成するためのインターフェイスに
取りかかりましょう。この節では、ステータスフィード
(第14章で完成させます) の最初のヒントをお見せします。
最後に、ユーザーがマイクロポストをWeb経由で破棄できるようにします。

Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して実行されるので、
Micropostsコントローラにはnewやeditのようなアクションは不要と
いうことになります。つまり、createとdestroyがあれば十分です。

マイクロポストリソースのルーティング
config/routes.rb

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

13.3.1 マイクロポストのアクセス制御

Micropostsリソースの開発では、Micropostsコントローラ内の
アクセス制御から始めることにしましょう。
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければなりません。

ログイン済みかどうかを確かめるテストでは、
Usersコントローラ用のテストがそのまま役に立ちます。

正しいリクエストを各アクションに向けて発行し、
マイクロポストの数が変化していないかどうか、
また、リダイレクトされるかどうかを確かめればよいのです。

Micropostsコントローラの認可テスト red
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

少しアプリケーション側のコードをリファクタリングしておく必要があります。
というのも、beforeフィルターのlogged_in_userメソッドを使って、
ログインを要求したことについて思い出してください。

Usersコントローラ内にこのメソッドがあったので、
beforeフィルターで指定していましたが、
このメソッドはMicropostsコントローラでも必要です。
そこで、各コントローラが継承するApplicationコントローラに
(4.4.4)、このメソッドを移してしまいましょう。

ogged_in_userメソッドをApplicationコントローラに移す
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

コードが重複しないよう、このときUsersコントローラからも
logged_in_userを削除しておきましょう

Usersコントローラ内のlogged_in_userフィルターを削除する red
app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  .
  .
  .
  private

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

    # beforeフィルター

    # 正しいユーザーかどうかを確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end

    # 管理者かどうかを確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

Micropostsコントローラからもlogged_in_userメソッドを呼び出せる
ようになりました。これにより、createアクションやdestroyアクションに
対するアクセス制限が、beforeフィルターで簡単に実装できるようになります

Micropostsコントローラの各アクションに認可を追加する green
app/controllers/microposts_controller.rb

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

 def create
 end

 def destroy
 end
end

演習

1:なぜUsersコントローラ内にあるlogged_in_userフィルターを
残したままにするとマズイのでしょうか? 考えてみてください。

コードが重複して思わぬ動作が起きるから

13.3.2 マイクロポストを作成する

最後にホーム画面を実装したときは[Sign up now!]ボタンが中央にありましたマイクロポスト作成フォームは、
ログインしている特定のユーザーのコンテキストでのみ機能するので、
この節の一つの目標は、
ユーザーのログイン状態に応じて、ホーム画面の表示を変更することです。

次に、マイクロポストのcreateアクションを作り始めましょう。
このアクションも、ユーザー用アクションと似ています。違いは、
新しいマイクロポストをbuildするためにUser/Micropost関連付けを
使っている点です

micropost_paramsでStrong Parametersを使っていることにより、
マイクロポストのcontent属性だけがWeb経由で変更可能になっている点に
注目してください。

Micropostsコントローラの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

マイクロポスト作成フォームを構築するために、
サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使います

Homeページ (/) にマイクロポストの投稿フォームを追加する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 %>


<% end %>

コードを動かすためには、いくつかのパーシャルを作る必要があります。まずはHomeページの新しいサイドバーからです。
次のようになります。

サイドバーで表示するユーザー情報のパーシャル
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>

そのユーザーが投稿したマイクロポストの総数が表示されていることに注目してください。ただし少し表示に違いがあります。

プロフィールサイドバーでは、 “Microposts” をラベルとし、
「Microposts (1)」と表示することは問題ありません。
しかし、今回のように “1 microposts” と表示してしまうと
英語の文法上誤りになってしまいます。そこで、pluralizeメソッドを使って
“1 micropost” や “2 microposts” と表示するように調整しています。

 

次はマイクロポスト作成フォームを定義します。
これはユーザー登録フォームに似ています。
マイクロポスト投稿フォームのパーシャル

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

フォームが動くようにするためには、2箇所の変更が必要です。1つは、
(以前と同様) 関連付けを使って次のように@micropostを定義することです。

homeアクションにマイクロポストのインスタンス変数を追加する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

current_userメソッドはユーザーがログインしているときしか使えません
つまり、@micropost変数もログインしているときのみ
定義されるようになります。

リスト13.39を動かすためのもう1つの変更は、
エラーメッセージのパーシャルを再定義することです。でなければ、
次のコードが動きません。

<%= render 'shared/error_messages', object: f.object %>

form_for(@user) do |f|上のようにf.objectが@userとなる場合と、

form_for(@micropost) do |f|

上のようにf.objectが@micropostになる場合などがあります。

パーシャルにオブジェクトを渡すために、値がオブジェクトで、
キーがパーシャルでの変数名と同じハッシュを利用します。
これで、2行目のコードが完成します。言い換えると、object: f.objectはerror_messagesパーシャルの中でobjectという変数名を
作成してくれるので、この変数を使ってエラーメッセージを更新すればよいということです。

Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する red
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 %>

なぜ失敗しているのでしょうか。error_messagesパーシャルの他の
出現場所です。
このパーシャルは他の場所でも使われていたため、
ユーザー登録、パスワード再設定、そしてユーザー編集のそれぞれのビューを更新する必要があったのです。
各ビューを更新した結果を示します。

ユーザー登録時のエラー表示を更新するapp/views/users/new.html.erb
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>
      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

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

      <%= 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 "Create my account", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
リスト 13.44: ユーザー編集時のエラー表示を更新するapp/views/users/edit.html.erb
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages', object: f.object %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

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

      <%= 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 "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails">change</a>
    </div>
  </div>
</div>
リスト 13.45: パスワード再設定時のエラー表示を更新するapp/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Password reset</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', object: f.object %>

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

これで、すべてのテストが greenになるはずです。

rails test

演習

1:Homeページをリファクタリングして、if-else文の分岐のそれぞれに対してパーシャルを作ってみましょう。

<% if logged_in? %>
<%= render 'static_pages/if'%>
<% else %>
<%= render 'static_pages/else'%>
<% end %>

13.3.3 フィードの原型

マイクロポスト投稿フォームが動くようになりましたが、今の段階では投稿した内容をすぐに見ることができません。Homeページにまだマイクロポストを表示する部分が実装されていないからです。

すべてのユーザーがフィードを持つので、feedメソッドはUserモデルで作るのが自然です。
フィードの原型では、まずは現在ログインしているユーザーの
マイクロポストをすべて取得してきます。
なお、次章で完全なフィードを実装するため、
今回は紹介したwhereメソッドでこれを実現します。
Userモデルに変更を加えた結果を示します

マイクロポストのステータスフィードを実装するための準備
app/models/user.rb

# 試作feedの定義
# 完全な実装は次章の「ユーザーをフォローする」を参照
def feed
  Micropost.where("user_id = ?", id)
end

疑問符は、セキュリティ上重要な役割を↑果たしています。

SQLクエリに代入する前にidがエスケープされるため、SQLインジェクション(SQL Injection)と呼ばれる深刻なセキュリティホールを避けることができます。
この場合のid属性は単なる整数 (すなわちself.idはユーザーのid)
であるため危険はありませんが、
SQL文に変数を代入する場合は常にエスケープする習慣をぜひ身につけてください。

↑のコードは本質的に↓のコードと同等であることに気付くかもしれません。

def feed
  microposts
end

サンプルアプリケーションにフィード機能を導入するため、
ログインユーザーのフィード用にインスタンス変数@feed_itemsを追加し、Homeページにはフィード用のパーシャルを追加します。

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

ステータスフィードのパーシャル
app/views/shared/_feed.html.erb

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

このとき、@feed_itemsの各要素がMicropostクラスを持っていたため、RailsはMicropostのパーシャルを呼び出すことができました。
このように、Railsは対応する名前のパーシャルを、
渡されたリソースのディレクトリ内から探しにいくことができます。

app/views/microposts/_micropost.html.erb

Homeページにステータスフィードを追加する
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 class="col-md-8">
 <h3>Micropost Feed</h3>
  <%= render 'shared/feed' %>
 </div>
</div>
<% else %>
#後略
<% end %>

マイクロポストの投稿が失敗すると、Homeページは
@feed_itemsインスタンス変数を期待しているため、現状では壊れてしまいます。

最も簡単な解決方法は、空の配列を渡しておくことです。
残念ですが、この場合はページ分割されたフィードを返してもうまく動きません。

createアクションに空の@feed_itemsインスタンス変数を追加する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
      @feed_items = []
      render 'static_pages/home'
    end
  end

  def destroy
  end

  private

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

演習

1:新しく実装したマイクロポストの投稿フォームを使って、
実際にマイクロポストを投稿してみましょう。
Railsサーバーのログ内にあるINSERT文では、
どういった内容をデータベースに送っているでしょうか? 確認してみてください。

INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["content", "tet\r\n"], ["user_id", 1], ["created_at", "2020-04-10 09:48:54.428673"], ["updated_at", "2020-04-10 09:48:54.428673"]]

2:コンソールを開き、user変数にデータベース上の最初のユーザーを
代入してみましょう。
その後、Micropost.where(“user_id = ?”, user.id)とuser.microposts、そしてuser.feedをそれぞれ実行してみて、
実行結果がすべて同じであることを確認してみてください。
ヒント: ==で比較すると結果が同じかどうか簡単に判別できます。

user = User.first
Micropost.where("user_id = ?", user.id) == user.microposts
Micropost Load (1.1ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC [["user_id", 1]]
Micropost Load (0.3ms) SELECT "microposts".* FROM "microposts" WHERE (user_id = 1) ORDER BY "microposts"."created_at" DESC
=> true
>> user.feed == user.microposts

13.4に続く

Rails-tutorialのまとめ(13.2.1 マイクロポストの描画 主に演習)

その13から続く

13.2.1 マイクロポストの描画

ユーザーのプロフィール画面 (show.html.erb) でそのユーザーの
マイクロポストを表示させたり、これまでに投稿した総数も表示させたりしていきます。

Micropostのコントローラとビューを作成するために、コントローラを生成しましょう。なお、今回使うのはビューだけで、Micropostsコントローラは 13.3から使っていきます。

rails db:migrate:reset
rails db:seed
rails generate controller Microposts

_micropost.html.erbパーシャルを使ってマイクロポストのコレクションを
表示しようとすると、次のようになります。

<ol class=”microposts”>
<%= render @microposts %>
</ol>

順序無しリストのulタグではなく、順序付きリストのolタグを使っている点に注目してください。
これは、マイクロポストが特定の順序 (新しい→古い)
に依存しているためです。

次に、対応するパーシャルを示します。
1つのマイクロポストを表示するパーシャル
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>

time_ago_in_wordsという↑ヘルパーメソッドを使っています。
メソッド名の表すとおりですが、「3分前に投稿」といった文字列を出力します。

各マイクロポストに対してCSSのidを割り振っています。

<li id=”micropost-<%= micropost.id %>”>

これは一般的に良いとされる慣習で、例えば将来、JavaScriptを使って
各マイクロポストを操作したくなったときなどに役立ちます。

一度にすべてのマイクロポストが表示されてしまう潜在的問題に対処します。10ではページネーションを使いましたが、今回も同じ方法でこの問題を解決します。
前回同様、will_paginateメソッドを使うと次のようになります。

<%= will_paginate @microposts %>

以前は次のように単純なコードでした。

<%= will_paginate %>

実は、上のコードは引数なしで動作していました。
これはwill_paginateが、Usersコントローラのコンテキストに
おいて、@usersインスタンス変数が存在していることを前提としているためです

今回の場合はUsersコントローラのコンテキストからマイクロポストをページネーションしたいため (つまりコンテキストが異なるため)、
明示的に@microposts変数をwill_paginateに渡す必要があります。

したがって、そのようなインスタンス変数をUsersコントローラの
showアクションで定義しなければなりません
@micropostsインスタンス変数を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

マイクロポストの投稿数を表示することですが、これはcountメソッドを使うことで解決できます。

user.microposts.count

paginateと同様に、関連付けをとおしてcountメソッドを呼び出すことができます。
大事なことは、countメソッドではデータベース上の
マイクロポストを全部読みだしてから結果の配列に対してlengthを呼ぶ、といった無駄な処理はしていないという点です。
そんなことをしたら、
マイクロポストの数が増加するにつれて効率が低下してしまいます。
そうではなく、(データベース内での計算は高度に最適化されているので)データベースに代わりに計算してもらい、
特定のuser_idに紐付いた
マイクロポストの数をデータベースに問い合わせています。

すべての要素が揃ったので、プロフィール画面にマイクロポストを
表示させてみましょう(このとき、if @user.microposts.any?を使って、ユーザーのマイクロポストが1つもない場合には空のリストを
表示させていない点にも注目してください。)

マイクロポストをユーザーのshowページ (プロフィール画面) に追加する
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>

演習

今回ヘルパーメソッドとして使ったtime_ago_in_wordsメソッドは、
Railsコンソールのhelperオブジェクトから呼び出すことができます。
このhelperオブジェクトのtime_ago_in_wordsメソッドを使って、
3.weeks.agoや6.months.agoを実行してみましょう。

helper.time_ago_in_words(3.weeks.ago)
=> "21 days"
>> helper.time_ago_in_words(6.months.ago)
=> "6 months"

2:helper.time_ago_in_words(1.year.ago)と実行すると、どういった結果が返ってくるでしょうか?

helper.time_ago_in_words(1.year.ago)
=> "about 1 year"

3:micropostsオブジェクトのクラスは何でしょうか? ヒント: リスト 13.23内のコードにあるように、まずはpaginateメソッド (引数はpage: nil) でオブジェクトを取得し、その後classメソッドを呼び出してみましょう。

user = User.find(1)
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-04-10 02:03:10", updated_at: "2020-04-10 02:03:10", password_digest: "$2a$10$cwFqu4HHjAMUEhZpbM294.4JUOfjV08XlIzGu37zTSD...", remember_digest: nil, admin: true, activation_digest: "$2a$10$mRd3DEhj6awejp8QoTEbouySA73h7GJW4h5MQYUJ3ax...", activated: true, activated_at: "2020-04-10 02:03:09", reset_digest: nil, reset_sent_at: nil>
>> microposts = user.microposts.paginate(page:nil)
Micropost Load (0.2ms) SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC LIMIT ? OFFSET ? [["user_id", 1], ["LIMIT", 11], ["OFFSET", 0]]
=> #<ActiveRecord::AssociationRelation []>
>> microposts.class
=> Micropost::ActiveRecord_AssociationRelation

13.2.2 マイクロポストのサンプル

すべてのユーザーにマイクロポストを追加しようとすると時間が掛かり過ぎるので、takeメソッドを使って最初の6人だけに追加します。

User.order(:created_at).take(6)

このとき、orderメソッドを経由することで、
作成されたユーザーの最初の6人を明示的に呼び出すようにしています。

この6人については、1ページの表示限界数(30)を越えさせるために、
それぞれ50個分のマイクロポストを追加するようにしています。
また、各投稿内容についてですが、Faker gem
にLorem.sentenceという便利なメソッドがあるので、これを使います。

サンプルデータにマイクロポストを追加する
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 (本章で利用する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;
  }
}

演習

1:(1..10).to_a.take(6)というコードの実行結果を推測できますか?
推測した値が合っているかどうか、コンソールを使って確認してみましょう。

(1..10).to_a.take(6)
=> [1, 2, 3, 4, 5, 6]

2:先ほどの演習にあったto_aメソッドの部分は本当に必要でしょうか? 確かめてみてください。

(1..10).take(6)
=> [1, 2, 3, 4, 5, 6]

3:Fakerのドキュメント (英語) を眺めながら画面に出力する方法を学び、実際に大学名や電話番号を画面に出力してみましょう。

Faker::Hipster.words
=> ["whatever", "schlitz", "kickstarter"]
>> Faker::ChuckNorris.fact
=> "Quantum cryptography does not work on Chuck Norris. When something is being observed by Chuck it stays in the same state until he's finished."

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

プロフィール画面で表示されるマイクロポストに対して、
統合テストを書いていきます。まずは、プロフィール画面用の統合テストを生成してみましょう。

rails generate integration_test users_profile

プロフィール画面におけるマイクロポストをテストするためには、
ユーザーに紐付いたマイクロポストのテスト用データが必要になります。
Railsの慣習に従って、関連付けされたテストデータをfixtureファイルに追加すると、次のようになります。

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

userにmichaelという値を渡すと、
Railsはfixtureファイル内の対応するユーザーを探し出して、
(もし見つかれば) マイクロポストに関連付けてくれます。

マイクロポストのページネーションをテストするためには、
マイクロポスト用のfixtureにいくつかテストデータを追加する必要が
ありますが、これはリスト 10.47でユーザーを追加したときと同様に、埋め込みRubyを使うと簡単です。

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

ユーザーと関連付けされたマイクロポストの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プロフィール画面に対するテストgreen

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

response.bodyにはそのページの完全なHTMLが含まれています
(HTMLのbodyタグだけではありません)。つまり、そのページのどこかしらに
マイクロポストの投稿数が存在するのであれば、
次のように探し出してマッチできるはずです。

assert_match @user.microposts.count.to_s, response.body

これはassert_selectよりもずっと抽象的なメソッドです。
特に、assert_selectではどのHTMLタグを探すのか伝える必要が
ありますが、assert_matchメソッドではその必要がない点が違います。

assert_selectの引数では、
ネストした文法を使っている点にも注目してください。

assert_select 'h1>img.gravatar'

h1タグ (トップレベルの見出し) の内側にある、
gravatarクラス付きのimgタグがあるかどうかをチェックできます。

green
rails test

演習

1:2つの’h1’のテストが正しいか確かめるため、
該当するアプリケーション側のコードをコメントアウトしてみましょう。greenからredに変わることを確認してみてください。

Expected at least 1 element matching "h1", found 0..
Expected 0 to be >= 1.
REDになる

2:テストを変更して、will_paginateが1度のみ表示されていることを
テストしてみましょう。ヒント: 表 5.2を参考にしてください。

assert_select 'div.pagination', count:1

13.3に続く

Rails-tutorialのまとめ(第13章ユーザーのマイクロポスト 主に演習)

その12から続く

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

ユーザーというリソースだけが、
Active Recordによってデータベース上のテーブルと紐付いています。
全ての準備が整った今、
ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していきます。

Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行い、さらに、結果を処理し表示するために必要なフォームとその部品を作成します

13.1 Micropostモデル

この新しいMicropostモデルもデータ検証とUserモデルの関連付けを含んでいます。
以前のモデルとは違って、
今回のマイクロポストモデルは完全にテストされ、
デフォルトの順序を持ち、また親であるユーザーが破棄された場合には自動的に破棄されるようにします。

13.1.1 基本的なモデル

Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持ちます。

マイクロポストの投稿にString型ではなくText型を使っている。
Text型の方が表現豊かなマイクロポストを実現できるから
Text型の方が将来における柔軟性に富んでいて
Text用のテキストエリアを使うため、
より自然な投稿フォームが実現できます。

rails generate model Micropost content:text user:references

ApplicationRecordを継承したモデルが作られます。
ただし、今回は生成されたモデルの中に、
ユーザーと1対1の関係であることを表す。
belongs_toのコードも追加されています。
user:referencesという引数も含めていたからです。

このgenerateコマンドはmicropostsテーブルを作成するための
マイグレーションファイルを生成します。

Userモデルとの最大の違いはreferences型を利用している点です。
これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、
UserとMicropostを関連付けする下準備をしてくれます。

インデックスが付与されたMicropostのマイグレーション
db/migrate/[timestamp]_create_microposts.rb

  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

user_idとcreated_atカラムにインデックスが付与されていることに注目こうすることで、
user_idに関連付けられたすべてのマイクロポストを
作成時刻の逆順で取り出しやすくなります。

user_idとcreated_atの両方を1つの配列に含めている点にも注目です。
こうすることでActive Recordは、両方のキーを同時に扱う複合キーインデックス(Multiple Key Index) を作成します。

rails db:migrate

続きを読む

Rails-tutorialのまとめ12.3(パスワードの再設定をテストする 主に演習)

12章から続く

12.3.3 パスワードの再設定をテストする

パスワード再設定をテストする手順は、アカウント有効化のテストと多くの共通点がありますが、
テストの冒頭部分には次のような違いがあります。
最初に「forgot password」フォームを表示して無効なメールアドレスを送信し、
次はそのフォームで有効なメールアドレスを送信します。
後者ではパスワード再設定用トークンが作成され、再設定用メールが送信されます。

続いて、メールのリンクを開いて無効な情報を送信し、

次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを
確認します。作成したテストを示します。
このテストはコードリーディングのよい練習台になりますよ。

パスワード再設定の統合テスト
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

assert_select “input[name=email][type=hidden][value=?]”
, user.email

上のコードは、inputタグに正しい名前、type=”hidden”、
メールアドレスがあるかどうかを確認します。

green
rails test

演習

1:create_reset_digestメソッドはupdate_attributeを
2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。

テンプレートを使って、update_attributeの呼び出しを
1回のupdate_columns呼び出しにまとめてみましょう。
(これでデータベースへの問い合わせが1回で済むようになります)。
また、変更後にテストを実行し、 greenになることも確認してください。

user.rb
(前略)
  # パスワード再設定の属性を設定する
  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

2:テンプレートを埋めて、期限切れのパスワード再設定で発生する分岐を統合テストで網羅してみましょう(response.bodyは、
そのページのHTML本文をすべて返すメソッドです)。
期限切れをテストする方法はいくつかありますが、
オススメした手法を使えば、
レスポンスの本文に「expired」という語があるかどうかでチェックできます(なお、大文字と小文字は区別されません)。

assert_match /FILL_IN/i, response.body
expired
password_resets_test.rb

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

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

3:2時間経ったらパスワードを再設定できなくする方針は、セキュリティ的に好ましいやり方でしょう。
しかし、もっと良くする方法はまだあります。
例えば、公共の (または共有された) コンピューターでパスワード再設定が行われた場合を考えてみてください。
仮にログアウトして離席したとしても、2時間以内であれば、そのコンピューターの履歴からパスワード再設定フォームを
表示させ、パスワードを更新してしまうことができてしまいます
(しかもそのままログイン機構まで突破されてしまいます!)。
この問題を解決するために、コードを追加し、
パスワードの再設定に成功したらダイジェストをnilになるように変更してみましょ。

パスワード再設定が成功したらダイジェストをnilにする
app/controllers/password_resets_controller.rb

@user.update_attribute(:reset_digest, nil)

4:1行追加し、1つ前の演習課題に対するテストを書いてみましょう。assert_nilメソッドとuser.reloadメソッドを組み合わせて、
reset_digest属性を直接テストしてみましょう。

password_resets_test.rb
require 'test_helper'
class PasswordResetsTest < ActionDispatch::IntegrationTest
(中略)
  test "password resets" do
(中略)
  assert_redirected_to user
  assert_nil user.reload['reset_digest']
end

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

もし既に前章でセットアップを終わらせていたら 演習までスキップしてしまっても大丈夫です。

演習

1:production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

動作確認するだけ

2:メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
ヒント: ターミナルからheroku logsコマンドを実行してみましょう。

動作確認するだけ: ターミナルからheroku logsコマンドを実行。

3:アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?

動作確認するだけ

12.6 証明: 期限切れの比較

12.3では、パスワードの期限が切れたかどうかを調べるために、次の比較を行いました。

reset_sent_at < 2.hours.ago

リスト 12.17で説明したように、この式を「少ない」と解釈すると逆の意味になってしまいますので、「早い」と解釈してみてください 

最初に、期間を2つ定義します。Δtrをパスワード再設定メールを送信してからの期間、Δteをパスワード再設定の有効な期間 (例: 2時間) と定めます。

パスワードの再設定は、メールが送信された時刻から経過した期間が、有効期間よりも長くなった場合に「期限切れ」となります。これを次のように表します。

ここで、現在時刻 (訳注: 比較を行った時刻) をtN、パスワード再設定メールの送信時刻をtr、有効期間が切れる時刻 (例: 2時間経過後) をteと表すと、次の2つの関係式を得ることができます。


式 (12.2)式 (12.3)式 (12.1)に代入すると、次の結果が得られます。





両辺に−1をかけると、次の式が得られます。

式 (12.4)をRailsのコードに置き換え、値をte=2 時間前とすると、
password_reset_expired?メソッドと同じコードになります。

def password_reset_expired?
  reset_sent_at < 2.hours.ago
end

<記号を「〜より少ない」ではなく「〜より早い時刻」と解釈すれば、「パスワードの再設定は、現在より2時間以上前の時刻に行われた」という言明と一致します。

12章のまとめ

1:パスワードの再設定は Active Recordオブジェクトではないが、
セッションやアカウント有効化の場合と同様に、リソースでモデル化できる

2:Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる

3:Action MailerではテキストメールとHTMLメールの両方を利用できる

4:メイラーアクションで定義したインスタンス変数は、
他のアクションやビューと同様、メイラーのビューから参照できる

5:パスワードを再設定させるために、生成したトークンを使って一意のURLを作る

6:より安全なパスワード再設定のために、ハッシュ化したトークン (ダイジェスト) を使う

7:メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用

8:SendGridを使うとproduction環境からメールを送信できる

その13に続く

Rails-tutorialのまとめ(第12章 パスワードの再設定 主に演習)

その11から続く

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

全体の流れは次のとおりです。

  1. ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける。
  2. 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定ダイジェストを生成する。
  3. 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。
  4. ユーザーがメールのリンクをクリックしたら、メールアドレスをキーとしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較する (トークンを認証する)。
  5. 認証に成功したら、パスワード変更用のフォームをユーザーに表示する。

12.1 PasswordResetsリソース
12.1.1 PasswordResetsコントローラ

今回はビューも扱うので、newアクションとeditアクションも一緒に生成している点に注意してください。

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

新しいパスワードを再設定するためのフォームと、Userモデル内のパスワードを変更するためのフォームが必要になるので、
new、create、edit、updateのルーティングも用意しましょう。
この変更は、前回と同様にルーティングファイルのresources行で行います。

パスワード再設定用リソースを追加する
config/routes.rb

resources :password_resets, only: [:new, :create, :edit, :update]

HTTPリクエスト URL                        Action      名前付きルート
GET        /password_resets/new          new     new_password_reset_path
POST       /password_resets              create  password_resets_path
GET        /password_resets/<token>/edit edit    edit_password_reset_url(token)
PATCH      /password_resets/<token>      update  password_reset_url(token)

PasswordResetsリソースで提供されるRESTfulルーティング

パスワード再設定画面へのリンクを追加する
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>

演習

1:この時点で、テストスイートが greenになっていることを確認してみましょう

rails test

2:名前付きルートでは、_pathではなく_urlを使うがそれはなぜか?

トークンのURLは絶対パス(https://〜)を使用する必要があるからだよ

12.1.2 新しいパスワードの設定

パスワードの再設定でも、トークン用の仮想的な属性とそれに対応するダイジェストを用意していきます。
もしトークンをハッシュ化せずに (つまり平文で)データベースに
保存してしまうとすると、
攻撃者によってデータベースからトークンを読み出されたとき、
セキュリティ上の問題が生じます。

パスワードの再設定では必ずダイジェストを使うようにしてください。
セキュリティ上の注意点はもう1つあります。
それは再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。

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>

演習

1:form_forメソッドでは、なぜ@password_resetではなく:password_resetを使っているのでしょうか?考えてみてください。

シンボルを使ってフォームをするとRailsが自動で送信先に値を割り当ててくれるから。

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

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

パスワード再設定用のcreateアクション
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モデル内のコードは、before_createコールバック内で
使わるcreate_activation_digestメソッドと似ています

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

演習

1:試しに有効なメールアドレスをフォームから送信してみましょう
どんなエラーメッセージが表示されたでしょうか?

ArgumentError in PasswordResetsController#create
wrong number of arguments (given 1, expected 0)

2:コンソールに移り、先ほどの演習課題で送信した結果、
(エラーと表示されてはいるものの) 該当するuserオブジェクトには
reset_digestとreset_sent_atがあることを確認してみましょう。
また、それぞれの値はどのようになっていますか?

"reset_digest",
"$2a$10$an7jBdlMlSmv2peunVbzZODIILfhU3EaXCvqYIG"

“reset_sent_at”, “2021-04-08 13:57:15.435675”

12.2 パスワード再設定のメール送信(11章やってれば飛ばしてOK)

12.2.1 パスワード再設定のメールとテンプレート

パスワード再設定のリンクをメール送信する
app/mailers/user_mailer.rb

class UserMailer < ApplicationMailer

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

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

演習

1:ブラウザから、送信メールのプレビューをしてみましょう。
「Date」の欄にはどんな情報が表示されているでしょうか?

http://3cfb3f407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

↑にアクセスすると
Date:Wed, 08 Apr 2021 14:12:22 +0000

2:パスワード再設定フォームから有効なメールアドレスを送信してみましょう。また、Railsサーバーのログを見て、生成された送信メールの内容を確認してみてください。

rails sで確認するだけ

3:コンソールに移り、先ほどの演習課題でパスワード再設定をしたUserオブジェクトを探してください。
オブジェクトを見つけたら、そのオブジェクトが
持つreset_digestとreset_sent_atの値を確認してみましょう。

reset_digest: "$2a$10$5aTNJOr.f
/EGDHrzKmwMEOQ0.dgECVhOaKh8Y7e..."

reset_sent_at: "2021-04-08 14:09:52">

演習

1:メイラーのテストだけを実行してみてください。このテストは
greenになっているでしょうか?

rails test:mailers

2:2つ目のCGI.escapeを削除すると、
テストがredになることを確認してみましょう。

ArgumentError: ArgumentError: wrong number of arguments
(given 1, expected 2..3)

12.3.1 editアクションで再設定

パスワード再設定フォームを表示するビューが必要です。このビューはユーザーの編集フォームと似ていますが、
今回はパスワード入力フィールドと確認用フィールドだけで
十分です。

メールアドレスをキーとしてユーザーを検索するためには、
editアクションとupdateアクションの両方でメールアドレスが必要になるからです。
例のメールアドレス入りリンクのおかげで、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>

フォームタグヘルパーを使っている点にご注意ください。

hidden_field_tag :email, @user.email

これまでは次のようなコードを書いていましたが、今回は書き方が異なっています。

f.hidden_field :email, @user.email

これは再設定用のリンクをクリックすると、前者 (hidden_field_tag) ではメールアドレスがparams[:email]に保存されますが、
後者ではparams[:user][:email] に保存されてしまうからです。

このフォームを描画するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義していきます。

params[:email]のメールアドレスに対応するユーザーをこの変数に保存します。
続いて、params[:id]の再設定用トークンと、抽象化したauthenticated?メソッドを使って、
このユーザーが正当なユーザーである
(ユーザーが存在する、有効化されている、認証済みである) ことを確認します。

editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使って@userの検索とバリデーションを行います。

パスワード再設定のeditアクション
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

演習

1:手順に従って、Railsサーバーのログから送信メールを探し出し、
そこに記されているリンクを見つけてください。そのリンクをブラウザから表示してみて下さい。

http://3cfb3f48c69407f8f01afa.vfs.cloud9.ap-northeast
-1.amazonaws.com/rails/mailers/user_mailer/password_reset

2:先ほど表示したページから、実際に新しいパスワードを送信してみましょう。どのような結果になるでしょうか?

成功する

12.3.2 パスワードを更新する

AccountActivationsコントローラのeditアクションでは、
ユーザーの有効化ステータスをfalseからtrueに変更しましたが、
今回の場合はフォームから新しいパスワードを送信するようになっています。
したがって、フォームからの送信に対応するupdateアクションが必要になります。
このupdateアクションでは、次の4つのケースを考慮する必要があります。

1:パスワード再設定の有効期限が切れていないか
2:無効なパスワードであれば失敗させる (失敗した理由も表示する)
3:新しいパスワードが空文字列になっていないか (ユーザー情報の編集ではOKだった)
4:新しいパスワードが正しければ、更新する

check_expirationメソッドでは、期限切れかどうかを確認する
インスタンスメソッド「password_reset_expired?」を使っています

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

errors.addを使ってエラーメッセージを追加します。

このように書くと、パスワードが空だった時に空の文字列に対するデフォルトの
メッセージを表示してくれるようになります。

パスワード再設定の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
user_paramsメソッドを使ってpasswordpassword_confirmation属性を精査している点に注意してください。
Userモデルにパスワード再設定用メソッドを追加するapp/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

演習

1:Railsサーバーのログから取得をブラウザで表示し、
passwordとconfirmationの文字列をわざと間違えて送信してみましょう。どんなエラーメッセージが表示されるでしょうか?

「フォームに1つのエラーが含まれています。
パスワードの確認がパスワードと一致しません」

2:コンソールに移り、パスワード再設定を送信したユーザーオブジェクトを見つけてください。見つかったら、そのオブジェクトのpassword_digestの値を取得してみましょう。
次に、パスワード再設定フォームから有効なパスワードを入力し、送信してみましょう 。
パスワードの再設定は成功したら、再度password_digestの値を取得し、先ほど取得した値と異なっていることを確認してみましょう。 新しい値はuser.reloadを通して取得する必要があります。

動作確認するだけ

12.3に続く

Rails-tutorialのまとめ11.3(アカウントの有効化 主に演習)

その11.2から続く

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

メールが生成できたら、今度はAccountActivationsコントローラのeditアクション
を書いていきましょう。また、アクションへのテストを書き、しっかりとテスト
できていることが確認できたら、AccountActivationsコントローラからUser
モデルにコードを移していく作業 (リファクタリング) にも取り掛かっていきます。

11.3.1 authenticated?メソッドの抽象化

有効化トークンとメールをそれぞれparams[:id]とparams[:email]で参照できることを思い出してみましょう。
パスワードのモデルと記憶トークンで学んだことを
元に、次のようなコードでユーザーを検索して認証することにします。

user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])

これから実装するauthenticated?メソッドでは、受け取ったパラメータに応じて呼び出すメソッドを切り替える手法を使います。

この一見不思議な手法は「メタプログラミング」と呼ばれています。
メタプログラミングを一言で言うと「プログラムでプログラムを作成する」ことです。メタプログラミングはRubyが有するきわめて強力な機能であり、
Railsの一見魔法のような機能 (「黒魔術」とも呼ばれます) の多くは、
Rubyのメタプログラミングによって実現されています。
ここで重要なのは、sendメソッドの強力きわまる機能です。
このメソッドは、渡されたオブジェクトに「メッセージを送る」ことによって、呼び出すメソッドを動的に決めることが
できます。
例を見てみましょう。Railsコンソールを開き、
Rubyのオブジェクトに対してsendメソッドを実行し、配列の長さを得るとします

rails console
>> a = [1, 2, 3]
>> a.length
=> 3
>> a.send(:length)
=> 3
>> a.send("length")
=> 3

sendを通して渡したシンボル:lengthや文字列"length"は、いずれもlengthメソッドと同じ結果になりました。つまり、どちらもオブジェクトにlengthメソッドを渡しているため、等価なのです。もう1つ例をお見せします。データベースの最初のユーザーが持つactivation_digest属性にアクセスする例です。

>> user = User.first
>> user.activation_digest
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> user.send("activation_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> attribute = :activation
>> user.send("#{attribute}_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"

シンボル:activationと等しいattribute変数を定義し、文字列の式展開 (interpolation) を使って引数を正しく組み立ててから、sendに渡しています。文字列'activation'でも同じことができますが、Rubyではシンボルを使う方が一般的です。

"#{attribute}_digest"

シンボルと文字列どちらを使った場合でも、上のコードは次のように文字列に変換されます。

"activation_digest"

sendメソッドの動作原理がわかったので、この仕組みを利用してauthenticated?メソッドを書き換えてみましょう。

抽象化されたauthenticated?メソッド red
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
  .
  .
  .
red
rails test

テストが失敗する理由は、current_userメソッド とnilダイジェストのテストの両方で、authenticated?が古いままになっており、引数も2つではなくまだ1つのままだからです。これを解消するため、両者を更新して、新しい一般的なメソッドを使うようにします。

 current_user内の抽象化したauthenticated?メソッド red
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 現在ログイン中のユーザーを返す (いる場合)
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(:remember, cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end
Userテスト内の抽象化したauthenticated?メソッド green
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 "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?(:remember, '')
  end
end

上のような変更を加えると、テストは greenに変わります。

rails test

演習

1:コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか?
また、各トークンに対応するダイジェストの値はどうなっているでしょうか?

created_at: nil, updated_at: nil, password_digest:
"$2a$10$D80rMhemJkoM8JmOU5IGN8YwsOHhBb...",
remember_digest: nil, admin: false, activation_digest: nil,
activated: false, activated_at: nil

2:抽象化したauthenticated?メソッドを使って、先ほどの
各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。

動作確認するだけ

11.3.2 editアクションで有効化

editアクションを書く準備ができました。
このアクションは、paramsハッシュで
渡されたメールアドレスに対応するユーザーを認証します。
ユーザーが有効であることを確認する中核は、次の部分になります。

if user && !user.activated? && user.authenticated(:activation,params[:id])

既に有効になっているユーザーを誤って再度有効化しないために必要です

上の論理値に基いてユーザーを認証するには、ユーザーを認証してからactivated_atタイムスタンプを更新する必要があります。

user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)

アカウントを有効化する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

user.activated?がtrueの場合にのみログインを許可し、そうでない場合はルートURLにリダイレクトしてwarningで警告を表示します。

有効でないユーザーがログインすることのないようにする
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

def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

演習

1:コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?

Hi testerer,

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

https://ap-northeast-1.amazonaws.com/account_activations/O0VRZve0xN9l27LNySAnVQ/edit?email=exxample%40gmal.com

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

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

<body>
<h1>Sample App</h1>

<p>Hi tt,</p>

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

<a href="https://ap-northeast-1.amazonaws.com/account_activations/xliAdIUvihhp2b1S3vD2CA/edit?email=rena%40gma.com">Activate</a>

</body>
</html>

O0VRZve0xN9l27LNySAnVQが有効化トークン

2:先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。
また、有効化ステータスがtrueになっていることを確認してみてください。

rails c で確認するだけ

11.3.3 有効化のテストとリファクタリング

ユーザー登録のテストにアカウント有効化を追加する green
test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
  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)
    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

本当に重要な部分は次の1行です。

assert_equal 1, ActionMailer::Base.deliveries.size

上のコードは、配信されたメッセージがきっかり1つであるかどうかを確認します。

assignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできるようになります。
例えば、Usersコントローラのcreateアクションでは@user
というインスタンス変数が定義されていますが、
テストでassigns(:user)と
書くとこのインスタンス変数にアクセスできるようになる、といった具合です。

green
rails test
activateメソッドを作成してユーザーの有効化属性を更新し、send_activation_emailメソッドを作成して有効化メールを送信します。
Userモデルにユーザー有効化メソッドを追加する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
      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
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end
green
rails test

演習

1:activateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。
テンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。

user.rb

class User < ApplicationRecord
(中略)
  # アカウントを有効にする
  def activate
    update_columns(activated: true, activated_at: Time.zone.now)
  end
(後略)

2:現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、テンプレートを使って、この動作を変更してみましょう 

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
  
  def index
    @users = User.where(activated: true).paginate(page: params[:page])
  end

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless @user.activated?
  end

(後略)

3:ここまでの演習課題で変更したコードをテストするために、/users と /users/:id の両方に対する統合テストを作成してみましょう。

update_columnsメソッドは、コールバックとバリデーションを実行せずにスキップしますので、コールバックやバリデーションをかける必要がある場合は注意が必要です。

update_columnsを使用するテンプレート
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_columns(activated: FILL_IN, activated_at: FILL_IN)
  end

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

  private

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

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end
有効なユーザーだけを表示するコードのテンプレートapp/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def index
    @users = User.where(activated: FILL_IN).paginate(page: params[:page])
  end

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless FILL_IN
  end
  .
  .
  .
end

11.4 本番環境でのメール送信

本番環境からメール送信するために、「Mailgun」というHerokuアドオンを利用してアカウントを検証します (このアドオンを利用するためにはHerokuアカウントにクレジットカードを設定する必要がありますが、アカウント検証では料金は発生しません)。

Railsのproduction環境でMailgunを使う設定config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '<あなたのHerokuサブドメイン名>.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :port           => ENV['MAILGUN_SMTP_PORT'],
    :address        => ENV['MAILGUN_SMTP_SERVER'],
    :user_name      => ENV['MAILGUN_SMTP_LOGIN'],
    :password       => ENV['MAILGUN_SMTP_PASSWORD'],
    :domain         => host,
    :authentication => :plain,
  }
  .
  .
  .
end
rails test
git add -A
git commit -m "Add account activation"
git checkout master
git merge account-activation

続いてリモートリポジトリにプッシュし、Herokuにデプロイします。

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

MailgunのHerokuアドオンを追加するために、次のコマンドを実行します。

heroku addons:create mailgun:starter

: herokuコマンドのバージョンが古いとここで失敗するかもしれません。その場合は、Heroku Toolbeltを使って最新版に更新するか、次の古い文法のコマンドを試してみてください。

heroku addons:add mailgun:starter

Herokuの環境変数を表示したい場合は、次のコマンドを実行します。

heroku config:get MAILGUN_SMTP_LOGIN heroku config:get MAILGUN_SMTP_PASSWORD
#受信メールの認証を行います
heroku addons:open mailgun

演習

1:実際に本番環境でユーザー登録をしてみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

動作確認する。

メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。
また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
ターミナルからheroku logsコマンドを実行してみましょう。

<a href="https://&lt;your heroku app&gt;.herokuapp.com/account_activations/uiXkg8zRNKlluX3pX7-vwg/edit?email=mochikichi%40live.jp">Activate</a>

11章のまとめ

1:アカウント有効化は Active Recordオブジェクトではないが、セッションの場合と同様に、リソースでモデル化できる

2:Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる

3:Action MailerではテキストメールとHTMLメールの両方を利用できる

4:メイラーアクションで定義したインスタンス変数は、他のアクションやビューと同様、メイラーのビューから参照できる

5:アカウントを有効化させるために、生成したトークンを使って一意のURLを作る

6:より安全なアカウント有効化のために、ハッシュ化したトークン (ダイジェスト) を使う

7:メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用

8:SendGridを使うと、production環境からメールを送信できる

その12に続く