LoginSignup
165
128

More than 3 years have passed since last update.

Request Specを使おう

Last updated at Posted at 2019-08-28

はじめに

こちらは社内技術勉強会用の資料として作成したものです。
Ruby on Railsで、RSpecの使い方の一つである、Request Specを使ってみます。

サンプルプログラムを使って、7つの課題に取り組みながら、Request Specの使い方を見ていきます。課題の内容は、RSpecを使ってみるところから、会議室予約の新規登録画面を完成させるまでの流れになっています。

サンプルプログラムについて

概要

サンプルプログラムMR2(Meeting Room Reservation)は、会議室予約をするWebアプリケーションです。

ソースコードはこちらです。

  • 以下のリソースを管理する機能があります。
    • ユーザ
    • 会議室
    • 予約
  • 各リソースについて、以下の画面があります。
    • 一覧
    • 新規登録
    • 編集
    • 詳細
  • 各リソースの編集と削除は、詳細画面から行います。
  • 各リソースの管理を行う画面にアクセスするにはログインが必要です。
  • ログインは、プログラムを簡単にするために、ユーザIDのみで行います。

画面イメージ

ログイン画面

login.png

予約新規登録画面

reservations_03.png

プロジェクトの説明

サンプルプログラムのソースコードには、Railsのプロジェクトが2つ含まれています。プロジェクトはappsディレクトリに配置されています。

プロジェクト名 用途
example 課題を終えた後の状態のソースコードです。Webサーバを起動し、Webブラウザからアクセスして操作できるようになっています。
web 課題を始める前の状態のソースコードです。

DB

DBは以下のテーブルで構成されています。

  • ユーザ(users)
  • 会議室(meeting_rooms)
  • 予約(reservations)
  • 予約ユーザ(reservation_users)

予約ユーザテーブルは、どの会議にどのユーザが参加するかを管理するための、中間テーブルです。

Database.png

課題を始める前に

覚えておくこと

その1: 初期データの登録

例えばユーザの一覧を表示するには、DBにユーザが登録されていなければいけません。RSpecを使ってプログラムを実行する時、そのプログラムが使用するデータ(初期データ)をあらかじめ登録しておかなければならないことがあります。

初期データの登録には、今回はfactory_botを使用します。factory_botを使用すると、あらかじめ用意しておいたテンプレートを使って、DBへのレコードの作成を簡単に行うことができます。

例えば、usersテーブルにレコードを登録するための:userという名前のテンプレートがある場合、以下のように記述すると、新しいレコードを1件登録することができます。

user = create(:user)

戻り値は登録したレコードを表すUserモデルのインスタンスになっています。

レコードは作成しないで、Userモデルのインスタンスだけ生成したい場合は以下のように記述します。

user = build(:user)

rails consoleで実行してみましょう。build()の実行により、Userモデルのインスタンスが生成されています。

$ RAILS_ENV=test bundle exec rails console
Loading test environment (Rails 5.2.3)
irb(main):001:0> include FactoryBot::Syntax::Methods
=> Object
irb(main):002:0> user = build(:user)
=> #<User id: nil, user_cd: "user1", user_nm: "User1", created_at: nil, updated_at: nil>
irb(main):003:0> exit

サンプルプログラムでは、テンプレートは各プロジェクトのspec/factoriesディレクトリに保存されています。書き方や使い方はGETTING_STARTEDなどを参照してください。

その2: HTTPリクエストの送信

Request Specではgetpostなどのメソッドを使ってHTTPリクエストを送信することができます。例えばログイン画面、サンプルプログラムのプロジェクトでは/sessions/newにGETメソッドでアクセスする場合、以下のように記述します。

get new_session_path

POSTメソッドでパラメータを送信する場合は、そのパラメータをparams引数に記述します。

post sessions_path, params: { session_form: { user_cd: 'user1' } }

その3: 結果の確認

ある画面の表示に成功したかどうかは、HTTPレスポンスのステータスコードが200かどうかで判定することができます。expectを使って以下のように書くことができます。

expect(response).to have_http_status(200)

その他、以下のような確認を行うことができます。

# 予約一覧画面にリダイレクトされること
expect(response).to redirect_to reservations_path
# HTTPレスポンスで出力されたHTMLに「認証に失敗しました。」という文字列が含まれていること
expect(response.body).to include '認証に失敗しました。'

その4: RSpecの基本的な使い方

ログイン画面の表示に成功することを確認するには、以下のようなspecファイルを作成します。

spec/requests/sessions_spec.rb
require 'rails_helper'

RSpec.describe "Sessions", type: :request do
  describe "GET /sessions/new" do
    it 'ログイン画面の表示に成功すること' do
      get new_session_path
      expect(response).to have_http_status(200)
#      puts response.body
    end
  end
end

ファイルを作成したら、プロジェクトのルートディレクトリで、以下のように実行します。

$ bundle exec rspec -fd spec/requests/sessions_spec.rb

実行が正常に終了すると、以下のように表示されます。

Sessions
  GET /sessions/new
    ログイン画面の表示に成功すること

Finished in 2.86 seconds (files took 1.25 seconds to load)
1 example, 0 failures

前述「結果の確認」で期待する結果に一致しなかった場合、failuresの件数が増えていきます。

その5: 初期データと入出力パラメータの定義

specファイル内のitには、「何をすればどうなるのか」を書きます。初期データの登録や入出力パラメータの定義などはletで記述します。例えば以下のような書き方ができます。

  describe 'user1で' do
    let (:user_cd) { 'user1' }
    it 'ログインに成功すること' do
      post sessions_path, params: { session_form: { user_cd: user_cd } }
      expect(response).to redirect_to reservations_path
    end
  end

postメソッド呼び出しのパラメータ内、user_cdの値がuser1になります。

letから別のletを参照することもできます。

  describe 'user1で' do
    let (:req_params) { { session_form: { user_cd: user_cd } } }
    let (:user_cd) { 'user1' }
    it 'ログインに成功すること' do
      post sessions_path, params: req_params
      expect(response).to redirect_to reservations_path
    end
  end

req_params定義内のuser_cdの値がuser1となり、その結果postメソッド呼び出しのパラメータが{ session_form: { user_cd: 'user1' } }となります。

上記の書き方ではあまり意味がありませんが、以下のような場合では効果が出てきます。

  describe 'POST /sessions' do
    let (:req_params) { { session_form: { user_cd: user_cd } } }
    describe 'user1で' do
      let (:user_cd) { 'user1' }
      it 'ログインに成功すること' do
        post sessions_path, params: req_params
        expect(response).to redirect_to reservations_path
      end
    end
    describe 'user2で' do
      let (:user_cd) { 'user2' }
      it 'ログインに失敗すること' do
        post sessions_path, params: req_params
        expect(response.body).to include '認証に失敗しました。'
      end
    end
  end

2つのitで、POST /sessionsアクションに与えるパラメータを切り替えつつ、postメソッド呼び出しのパラメータを生成する処理が共通化されました。

課題1

サンプルプログラムのwebプロジェクトにおいて、ログイン画面の表示に成功することを確認してください。

説明

まずはすでに開発済みのコードのspecを書いてみましょう。webプロジェクトで、specファイルを作成し、実行してください。specファイルの書き方は前章を参照してください。

Hint 1

前述「覚えておくこと」の「その4: RSpecの基本的な使い方」に解答例があります。コードをコピー&ペーストして実行してみてください。

課題2

存在するユーザで、ログインに成功することを確認してください。

説明

Webブラウザを使わずに、ログイン処理が正しく行われるかどうかを確認してみましょう。

Hint 1

ログインの処理は、sessionsコントローラのcreateアクションで行われています。このアクションが実行されるようにHTTPリクエストを送信すると、ログイン処理の動作を確認することができます。

送信するべきパラメータは、createアクションのストロングパラメータを確認したり、exampleプロジェクトを使って実際にWebブラウザからアクセスしてみたりするとわかります。以下のような内容になっています。

Started POST "/sessions" for 127.0.0.1 at 2019-08-10 17:37:41 +0900
Processing by SessionsController#create as HTML
  Parameters: {"session_form"=>{"user_cd"=>"user1"}}

Hint 2

ログインを成功させるためには、ログインできるユーザが必要です。usersテーブルにログインID(カラム名はuser_cd)が一致するレコードがあれば、ログイン成功としています。

Hint 3

ログインが成功すると、予約一覧画面にリダイレクトされるようになっています。すなわち、ログインが成功したかどうかは、予約一覧画面のパスにリダイレクトが行われたかどうかで確認することができます。

解答例1

spec/requests/sessions_spec.rb
  describe "POST /sessions" do
    it '存在するユーザでログインに成功すること' do
      user = create(:user)
      post sessions_path, params: { session_form: { user_cd: user.user_cd } }
      expect(response).to redirect_to reservations_path
    end
  end

解答例2

it内には、「何をすればどうなるのか」を書きます。初期データを登録したり、パラメータを用意したりする処理を、letを使って定義してみましょう。itのコードが簡潔になり、わかりやすくなります。

spec/requests/sessions_spec.rb
  describe "POST /sessions" do
    describe '存在するユーザで' do
      let (:user) { create(:user) }
      let (:req_params) { { session_form: { user_cd: user.user_cd } } }

      it 'ログインに成功すること' do
        post sessions_path, params: req_params
        expect(response).to redirect_to reservations_path
      end
    end
  end

課題3

存在しないユーザで、ログインに失敗することを確認してください。

Hint 1

ログインに失敗するには、存在しない、すなわちDBに登録されていないユーザの情報を使ってログインを試みればよいでしょう。factory_botのbuildメソッドを使うと、DBにレコードを作成しないでログインIDを生成することができます。

Hint 2

ログインに失敗すると、ログイン画面が再表示され、エラーメッセージとして「Failed to authenticate.」と表示されます。HTTPレスポンスで出力されたHTMLにこの文字列が含まれているかどうかで、ログイン失敗を確認することができます。

解答例1

spec/requests/sessions_spec.rb
  describe "POST /sessions" do
    it '存在しないユーザでログインに失敗すること' do
      user = build(:user)
      post sessions_path, params: { session_form: { user_cd: user.user_cd } }
      expect(response.body).to include 'Failed to authenticate.'
    end
  end

解答例2

HTTPリクエストに与えるパラメータの形は課題2と同じです。letを使って定義し、共通化してみましょう。

spec/requests/sessions_spec.rb
  describe "POST /sessions" do
    let (:req_params) { { session_form: { user_cd: user.user_cd } } }

    describe '存在するユーザで' do
      let (:user) { create(:user) }

      it 'ログインに成功すること' do
        post sessions_path, params: req_params
        expect(response).to redirect_to reservations_path
      end
    end

    describe '存在しないユーザで' do
      let (:user) { build(:user) }

      it 'ログインに失敗すること' do
        post sessions_path, params: req_params
        expect(response.body).to include 'Failed to authenticate.'
      end
    end
  end

課題4

ユーザの新規登録処理において、ログインIDが未入力の場合、登録に失敗するようにしてください。

説明

ここからいよいよ開発に入っていきます。

まずはモデルの開発をしてみましょう。ユーザの新規登録画面では、ログインID(DB上のテーブルのカラム名はuser_cd)とユーザ名(user_nm)を入力します。ログインIDはユーザを識別するコードであるため、空文字列での登録は許可するべきではありませんが、webプロジェクトのソースコードでは空文字列でも登録に成功してしまいます。

モデルにバリデーションルールを追加しましょう。

Hint 1

Userモデルの属性user_cdに、入力必須のバリデーションルールpresenceを追加します。

モデルにバリデーションルールを追加できたら、RSpecで空文字列を与えて実行してみます。

factory_botのcreatebuildメソッドは、第2引数にハッシュでパラメータを指定すると、テンプレートで定義されている値の代わりに、その値がレコードやモデルのインスタンスの生成に使用されます。user_cdを空文字列としてUserモデルのインスタンスを生成するには、以下のように指定します。

user = build(:user, { user_cd: '' })

Hint 2

モデルのバリデーションエラーのメッセージは設定次第になりますが、標準的にはLogin ID can't be blankのようなメッセージになります。HTMLではシングルクォーテーションが実体参照で表現され、Login ID can&#39;t be blankとなります。

Hint 3

RSpecを実行した時、もしかして以下のようなエラーで失敗になりましたか?

  expected "<html><body>You are being <a href=\"http://www.example.com/sessions/new\">redirected</a>.</body></html>" to include "Login ID can&#39;t be blank"

/sessions/newというパスにリダイレクトされた、という内容です。

ユーザの登録処理を行うためには、あらかじめいずれかのユーザでログインをしている必要があります。このエラーは、未ログイン状態でユーザの登録処理を実行しようとしたために、ログイン画面に遷移させられたことを表しています。

ユーザの登録処理を行うために、私たちはこの強固なセキュリティの壁を打ち破る必要があるのです。

解決策は単純です。ユーザの登録処理を実行する前に、ログイン処理を実行すればよいのです。

解答例1

app/models/user.rb
class User < ApplicationRecord
  validates :user_cd, presence: true
end
spec/requests/users_spec.rb
    describe 'ログインIDが未入力の場合' do
      it '登録に失敗すること' do
        login_user = create(:user)
        post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }

        user = build(:user, { user_cd: '' })
        req_params = { user: user.serializable_hash(only: [ :user_cd, :user_nm ]) }
        post users_path, params: req_params
        expect(response.body).to include 'Login ID can&#39;t be blank'
      end
    end

解答例2

it内の処理が多くなってきましたね。letを使って簡潔になるようにしてみましょう。

また、ログイン処理はitのたびに必要になります。before(:each)を使うと、itの前に必ず実行する処理を記述することができます。

spec/requests/users_spec.rb
  let (:login_user) { create(:user) }

  before (:each) do
    post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }
  end

  describe "POST /users" do
    let (:req_params) { { user: user.serializable_hash(only: [:user_cd, :user_nm]) } }

    describe 'ログインIDが未入力の場合' do
      let (:user) { build(:user, { user_cd: '' }) }

      it '登録に失敗すること' do
        post users_path, params: req_params
        expect(response.body).to include 'Login ID can&#39;t be blank'
      end
    end
  end

課題5

予約の新規登録画面を作成し、新規登録処理が成功するようにしてください。ただし、参加者一覧は除きます。

説明

次はコントローラとビューの開発をしてみましょう。予約の新規登録画面には、以下の入力欄を用意します。

  • タイトル (title、テキスト)
  • 会議室 (meeting_room_id、ドロップダウン)
  • 開始日時 (start_at、日時)
  • 終了日時 (endat、日時)
  • 参加者 (reservations_users、チェックボックス)
  • メモ内容 (memo_content、テキストエリア)

webプロジェクトのソースコードでは、reservationsコントローラのnewとcreateのアクションはscaffoldで生成されたままの内容になっています。これらのアクションの処理内容を変更していきます。

また、新規登録画面のビューテンプレートも、scaffoldで生成されたままの内容になっています。こちらも併せて修正していきます。

会議の参加者は、自分以外のすべてのユーザの一覧から、チェックボックスで選択するようにします。ただしこの項目は、次の課題で取り扱うこととして、ここでは省略します。

なお、会議の主催者は、ログインユーザであるとします。

Hint 1

まず、ビューテンプレートの内容を確認してみましょう。どの入力項目もtext_fieldかtext_areaになってしまっているので、適切なフォームヘルパーに置き換える必要があります。

例えば、会議室(meeting_room_id)という項目はどうでしょうか?ドロップダウンによる選択式にしたいですね。ドロップダウンを配置するには、フォームヘルパーselectを使用します。form_withのブロック引数の名前がformとなっている場合は、form.selectとして使います。

ドロップダウンの選択肢は、options_for_selectを使って生成するとよいでしょう。

Hint 2

日時の入力欄はどのように作成すればよいでしょうか?JavaScriptでDatePickerのようなユーザインターフェイスを用意するのがよいですが、ここではRailsの標準機能にあるAction View フォームヘルパーの一つ、datetime_selectを使ってみましょう。以下のように記述すると、年、月、日、時、分を選択する5つのドロップダウンを持つ、開始日時の入力欄が生成されます。

app/views/reservations/_form.html.erb
  <%= form.datetime_select :start_at %>

Hint 3

デザインはともかくとして、入力欄がうまく表示されるでしょうか?確認するには、/reservations/newにGETメソッドでアクセスしてみると、HTMLを出力させることができます。

spec/requests/reservations_spec.rb
  describe "GET /reservations/new" do
    it "works! (now write some real specs)" do
      get new_reservation_path
      expect(response).to have_http_status(200)
#      puts response.body
    end
  end

上記のコメントになっている行のコメントを解除してRSpecを実行すると、画面にHTMLが表示されます。

Hint 4

フォームヘルパーdatetime_selectで生成した入力欄を含むフォームがsubmitされた時、日時はどのような形で送信されるのでしょうか?exampleプロジェクトでWebブラウザからアクセスしてみると、以下のように送信されていることがわかります(一部省略しています)。

Parameters: {
  "reservation"=>{
    "title"=>"会議1",
    "meeting_room_id"=>"1",
    "start_at(1i)"=>"2019",
    "start_at(2i)"=>"8",
    "start_at(3i)"=>"11",
    "start_at(4i)"=>"16",
    "start_at(5i)"=>"00",
    "end_at(1i)"=>"2019",
    "end_at(2i)"=>"8",
    "end_at(3i)"=>"11",
    "end_at(4i)"=>"17",
    "end_at(5i)"=>"00",
    "memo_content"=>""
  },
  "commit"=>"登録"
}

どうやら5つのキーに分解されているようです。この日時の値はコントローラ側ではどのように受け取ればよいのでしょうか?

HTTPリクエストのパラメータとしては、日時の値は5つのキーに分かれていますが、Railsはこれらを自動的に一つの属性値として認識します。すなわち、ストロングパラメータには以下のように記述すればよいことになります。

app/controllers/reservations_controller.rb
      params.require(:reservation).permit(
        :title,
        :meeting_room_id,
        :start_at,
        :end_at,
        :memo_content,
      )

解答例

app/controllers/reservations_controller.rb
class ReservationsController < LoggedInController
  before_action :prepare_meeting_room_options, only: [ :new, :edit, :create, :update ]

  def new
    @reservation = Reservation.new
  end

  def create
    @reservation = Reservation.new(reservation_params)

    if @reservation.save
      redirect_to @reservation, notice: '登録に成功しました。'
    else
      render :new
    end
  end

  private
    def reservation_params
      params.require(:reservation).permit(
        :title,
        :meeting_room_id,
        :start_at,
        :end_at,
        :memo_content,
      ).merge({ user_id: @me.id })
    end

    def prepare_meeting_room_options
      @meeting_room_options = MeetingRoom.all.map { |it|
        [ it.meeting_room_nm, it.id ]
      }.unshift(['', 0])
    end
end
app/views/reservations/_form.html.erb
<%= form_with(model: reservation, local: true) do |form| %>
  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.label :meeting_room_id %>
    <%= form.select :meeting_room_id, options_for_select(@meeting_room_options, {
      selected: @reservation.meeting_room_id
    }) %>
  </div>

  <%= field_set_tag i18n_attr('reservation.start_at'), { class: 'field' } do %>
    <%= form.datetime_select :start_at %>
  <% end %>

  <%= field_set_tag i18n_attr('reservation.end_at'), { class: 'field' } do %>
    <%= form.datetime_select :end_at %>
  <% end %>

  <div class="field">
    <%= form.label :memo_content %>
    <%= form.text_area :memo_content %>
  </div>

  <div class="actions">
    <%= form.submit t('view.app.submit') %>
  </div>
<% end %>
spec/requests/reservations_spec.rb
require 'rails_helper'

module ReservationsSpecHelper
  # 日時を年、月、日、時、分に分解する
  def datetime_attrs(model, attr_key)
    attr_value = model.public_send(attr_key)
    {
      "#{attr_key}(1i)": attr_value.year,
      "#{attr_key}(2i)": attr_value.mon,
      "#{attr_key}(3i)": attr_value.mday,
      "#{attr_key}(4i)": attr_value.hour,
      "#{attr_key}(5i)": attr_value.min,
    }
  end

  # 予約のリクエストに与えるパラメータを生成
  def reservation_req_params(reservation)
    others = reservation.reservation_users.map { |it| it.user }
    reservation.serializable_hash(only: [
      :title,
      :user_id,
      :meeting_room_id,
      :memo_content,
    ]).merge(
      datetime_attrs(reservation, :start_at)
    ).merge(
      datetime_attrs(reservation, :end_at)
    )
  end
end

RSpec.describe "Reservations", type: :request do
  include ReservationsSpecHelper

  let (:login_user) { create(:user) }

  before (:each) do
    post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }
  end

  describe "GET /reservations/new" do
    it "予約の新規登録画面の表示に成功すること" do
      get new_reservation_path
      expect(response).to have_http_status(200)
#      puts response.body
    end
  end

  describe "POST /reservations" do
    let (:meeting_room) { create(:meeting_room) }
    let (:others) { create_list(:user, 2) }
    let (:reservation_attrs) do
      {
        user_id: login_user.id,
        meeting_room_id: meeting_room.id,
        start_at: Time.parse('2019-08-01 10:00:00+09'),
        end_at: Time.parse('2019-08-01 11:00:00+09'),
      }
    end
    let (:reservation) { build(:reservation, reservation_attrs) }
    let (:req_params) { { reservation: reservation_req_params(reservation) } }

    describe '新規登録に' do
      let (:created_reservation) { Reservation.last }

      it '成功すること' do
        post reservations_path, params: req_params
        expect(response).to redirect_to reservation_path(created_reservation.id)
      end
    end
  end
end

課題6

予約の新規登録画面で、会議の参加者を登録できるようにしてください。

説明

「課題5」では参加者の登録を省略しました。ここではその参加者を登録できるようにして、新規登録画面を完成させます。

Hint 1

会議には複数のユーザが参加することができるようにします。これは、DB上では予約1件に対して複数のユーザを関連付けることで表現します。

予約の新規登録画面ではどのようなインターフェイスにすればよいでしょうか?いろいろ方法はありそうですが、ここではNested Attributesの機能を使ってみることにします。

Nested Attributesを使うためには、まず親となるモデルクラスで、Nested Attributesの宣言をします。

app/models/reservation.rb
  accepts_nested_attributes_for :reservation_users, allow_destroy: true

次にコントローラにおいて、新規登録画面の表示の際に、関連付けられる子モデルのインスタンスを生成しておきます。

app/controllers/reservations_controller.rb
    @reservation = Reservation.new
    others = User.where.not({ id: @me.id })
    @reservation.reservation_users_attributes = others.map { |it| { user_id: it.id } }

ビューではfields_forを使って子モデル用の入力欄を生成させます。

app/views/reservations/_form.html.erb
<%= form.fields_for :reservation_users do |form_builder| %><% reservation_user = form_builder.object %>
  <li>
    <%= form_builder.check_box :attendance_flag, { checked: reservation_user.attendance_flag }, 1, 0 %>
    <%= form_builder.label :attendance_flag, reservation_user.user.user_nm %>
    <%= form_builder.hidden_field :reservation_id %>
    <%= form_builder.hidden_field :user_id %>
    <%= form_builder.hidden_field :id %>
  </li>
<% end %>

Hint 2

Nested Attributesで生成した入力欄を含むフォームがsubmitされた時、その値はどのような形で送信されるのでしょうか?もう一度確認してみましょう。

Parameters: {
  "reservation"=>{
    "title"=>"会議1",
    "meeting_room_id"=>"1",
    "start_at(1i)"=>"2019",
    "start_at(2i)"=>"8",
    "start_at(3i)"=>"11",
    "start_at(4i)"=>"16",
    "start_at(5i)"=>"00",
    "end_at(1i)"=>"2019",
    "end_at(2i)"=>"8",
    "end_at(3i)"=>"11",
    "end_at(4i)"=>"17",
    "end_at(5i)"=>"00",
    "reservation_users_attributes"=>{
      "0"=>{
        "attendance_flag"=>"1",
        "reservation_id"=>"",
        "user_id"=>"2",
        "id"=>""
      },
      "1"=>{
        "attendance_flag"=>"0",
        "reservation_id"=>"",
        "user_id"=>"3",
        "id"=>""
      }
    },
    "memo_content"=>""
  },
  "commit"=>"登録"
}

reservation_users_attributesというキーで送信されているようです。コントローラでは、以下のように記述すると受け取ることができます。

app/controllers/reservations_controller.rb
      params.require(:reservation).permit(
        :reservation_users_attributes => [ :id, :reservation_id, :user_id, :attendance_flag ],
      )

解答例

app/models/reservation.rb
class Reservation < ApplicationRecord
  has_many :reservation_users, inverse_of: :reservation
  accepts_nested_attributes_for :reservation_users, allow_destroy: true
  belongs_to :user
  belongs_to :meeting_room, required: false
end
app/controllers/reservations_controller.rb
class ReservationsController < LoggedInController
  def new
    @reservation = Reservation.new
    others = User.where.not({ id: @me.id })
    @reservation.reservation_users_attributes = others.map { |it| { user_id: it.id } }
  end

  private
    def reservation_params
      params.require(:reservation).permit(
        :title,
        :meeting_room_id,
        :start_at,
        :end_at,
        :memo_content,
        :reservation_users_attributes => [ :id, :reservation_id, :user_id, :attendance_flag ]
      ).merge({ user_id: @me.id })
    end
end
app/views/reservations/_form.html.erb
      <%= field_set_tag i18n_attr('reservation.reservation_users'), { class: 'field' } do %>
        <ul>
          <%= form.fields_for :reservation_users do |form_builder| %><% reservation_user = form_builder.object %>
            <li>
              <%= form_builder.check_box :attendance_flag, { checked: reservation_user.attendance_flag }, 1, 0 %>
              <%= form_builder.label :attendance_flag, reservation_user.user.user_nm %>
              <%= form_builder.hidden_field :reservation_id %>
              <%= form_builder.hidden_field :user_id %>
              <%= form_builder.hidden_field :id %>
            </li>
          <% end %>
        </ul>
      <% end %>
spec/requests/reservations_spec.rb
module ReservationsSpecHelper
  def nested_reservation_users(others)
    others.each_with_index.map { |user, idx| [ idx, { user_id: user.id } ] }.to_h
  end

  def reservation_req_params(reservation)
    others = reservation.reservation_users.map { |it| it.user }
    reservation.serializable_hash(only: [
      :title,
      :meeting_room_id,
      :memo_content,
    ]).merge(
      datetime_attrs(reservation, :start_at)
    ).merge(
      datetime_attrs(reservation, :end_at)
    ).merge({
      reservation_users_attributes: nested_reservation_users(others)
    })
  end
end

RSpec.describe "Reservations", type: :request do
  include ReservationsSpecHelper

  describe "POST /reservations" do
    let (:meeting_room) { create(:meeting_room) }
    let (:others) { create_list(:user, 2) }
    let (:reservation_attrs) do
      {
        user_id: login_user.id,
        meeting_room_id: meeting_room.id,
        start_at: Time.parse('2019-08-01 10:00:00+09'),
        end_at: Time.parse('2019-08-01 11:00:00+09'),
        reservation_users_attributes: nested_reservation_users(others),
      }
    end
    let (:reservation) { build(:reservation, reservation_attrs) }
    let (:req_params) { { reservation: reservation_req_params(reservation) } }

    describe '新規登録に' do
      let (:created_reservation) { Reservation.last }

      it '成功すること' do
        post reservations_path, params: req_params, headers: headers
        expect(response).to redirect_to reservation_path(created_reservation.id)
      end
    end
  end
end

課題7

予約の新規登録において、同じ会議室、時間帯にすでに予約が登録されている場合、登録に失敗するようにしてください。

説明

最後の課題です。会議室予約のシステムは二つの会議が同じ会議室で予定されてしまうことを防ぐためのものですが、現在のソースコードでは特に警告もなく登録できてしまいます。

実用的には、登録は成功するが警告が表示される、というような動作が望ましいかもしれませんが、ここでは簡単にするために登録を禁止することにします。

Hint 1

登録を禁止するのでバリデーションルールを定義することで対応できそうです。DBの検索にはfind_by_sqlを使ってみてもよいでしょう。

解答例

app/models/reservation.rb
class Reservation < ApplicationRecord
  validate :check_booking

  private

  def check_booking
    return if self.meeting_room_id.blank? || self.meeting_room_id.to_i <= 0
    sql = <<-SQL
    SELECT
      EXISTS (
        SELECT
          1
        FROM
          reservations AS t1
        WHERE
          (:id IS NULL OR t1.id <> :id)
          AND t1.meeting_room_id = :meeting_room_id
          AND (
            ( :start_at <= t1.start_at AND :end_at > t1.start_at )
            OR ( :start_at < t1.end_at AND :end_at >= t1.end_at )
          )
      ) AS booked_flag
    SQL
    res = self.class.find_by_sql([sql, {
      id: self.id,
      meeting_room_id: self.meeting_room_id,
      start_at: self.start_at.to_s,
      end_at: self.end_at.to_s,
    }])
    if res.first.booked_flag
      self.errors.add(:meeting_room_id, I18n.t('errors.messages.booked'))
    end
  end
end
spec/requests/reservations_spec.rb
RSpec.describe "Reservations", type: :request do
  describe "POST /reservations" do
    let (:meeting_room) { create(:meeting_room) }
    let (:others) { create_list(:user, 2) }
    let (:reservation_attrs) do
      {
        user_id: login_user.id,
        meeting_room_id: meeting_room.id,
        start_at: Time.parse('2019-08-01 10:00:00+09'),
        end_at: Time.parse('2019-08-01 11:00:00+09'),
        reservation_users_attributes: nested_reservation_users(others),
      }
    end
    let (:reservation) { build(:reservation, reservation_attrs) }
    let (:req_params) { { reservation: reservation_req_params(reservation) } }

    describe 'すでに予約されている場合' do
      let! (:prepare_reserved) { create(:reservation, reservation_attrs) }

      it '予約済みで失敗すること' do
        post reservations_path, params: req_params
        expect(response.body).to include 'Meeting Room has been already booked'
      end
    end
  end
end

宿題

ユーザの新規登録をした後、すでに登録済みの予約で、新しいユーザを参加できるようにしてください。

説明

user1、user2、user3が存在する状態で、user1でログインし、予約の新規登録画面を表示すると、参加者の選択部分にuser2、user3が表示されます。予約の登録を完了させた後、(予約の編集機能が完成していたとして、)予約の編集画面を表示すると、参加者の一覧には再びuser2、user3が表示されます。ここまでは期待している動作です。

しかしこの後、user4を新規登録してから先ほどの予約の編集画面を表示すると、相変わらずuser2、user3しか表示されず、user4をこの会議に参加させることができません。

原因は、編集画面に表示されるのはユーザテーブルのレコードではなく、予約ユーザテーブルのレコードだからです。ユーザの新規登録を行っても予約ユーザテーブルにレコードは増えないため、user4は予約編集画面には表示されません。

予約が存在する状態でユーザの新規登録した時、新しいユーザが予約ユーザにも追加されるように修正してください。

解答例

app/models/user.rb
class User < ApplicationRecord
  after_create :update_reservation_users

  def update_reservation_users
    list = Reservation.select(:id)

    list.each do |reservation|
      reservation_user = ReservationUser.new({
        reservation_id: reservation.id,
        user_id: self.id,
      })
      reservation_user.save
    end
  end
end
spec/requests/users_spec.rb
module UsersRequestSpecHelper
  def nested_reservation_users(others)
    others.each_with_index.map { |user, idx| [ idx, { user_id: user.id } ] }.to_h
  end
end

RSpec.describe "Users", type: :request do
  include UsersRequestSpecHelper

  let (:login_user) { create(:user) }

  before (:each) do
    post sessions_path, params: { session_form: { user_cd: login_user.user_cd } }, headers: headers
  end

  describe "POST /users" do
    let (:user) { build(:user) }
    let (:req_params) { { user: user.serializable_hash(only: [:user_cd, :user_nm]) } }

    let (:meeting_room) { create(:meeting_room) }
    let (:others) { create_list(:user, 2) }
    let (:reservation_attrs) do
      {
        user_id: login_user.id,
        meeting_room_id: meeting_room.id,
        reservation_users_attributes: nested_reservation_users(others),
      }
    end
    let (:reservation_base_at) { Time.parse('2019-08-01 10:00:00+09') }
    let! (:reservations) do
      (0..1).map do |i|
        meeting_time_attrs = {
          start_at: reservation_base_at + i.hour,
          end_at: reservation_base_at + (i + 1).hour,
        }
        create(:reservation, reservation_attrs.merge(meeting_time_attrs))
      end
    end

    describe '予約が存在する状態でユーザの新規登録を行うと' do
      let (:created_user) { User.last }
      let (:created_reservation_users) { ReservationUser.where({ user_id: created_user.id }) }

      it '新しいユーザが予約ユーザに追加されること' do
        post users_path, params: req_params, headers: headers
        expect(response).to redirect_to user_path(created_user.id)
        expect(created_reservation_users.length).to eq(reservations.length)
      end
    end
  end
end

おわりに

Request Specで、初期データの登録、HTTPリクエストの送信、結果の確認を行うことができました。

プログラミングはたくさん書かないと身に着かないので、RSpecもたくさん使って、時間をかけずにテストコードを書けるようにしていきたいと思います。

165
128
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
165
128