RSpec с мультитенантностью — почему этот простой тест не проходит?

Что я делаю

Недавно я внедрил мультиарендность (с использованием областей) после мультитенантности с областями (требуется подписка ) в качестве ориентира. ПРИМЕЧАНИЕ. Я использую ужасную "default_scope" для определения области действия арендатора (как показано в Railscast Райана). В браузере все работает нормально, но многие (не все) мои тесты терпят неудачу, и я не могу понять, почему.

Я создал аутентификацию с нуля (на основе этого Railscast: Аутентификация с нуля (пересмотрено) — требуется подписка) и использование auth_token для функции «Запомнить меня» (на основе этого Railscast: Запомнить меня и сбросить пароль).

Мой вопрос

Почему этот тест не проходит и почему работают два обходных пути? Я уже пару дней в тупике и не могу понять.

Что, по моему мнению, происходит

Я вызываю действие Jobs#create, и Job.count уменьшается на 1, а не увеличивается на 1. Я думаю, что происходит создание задания, затем приложение теряет назначение «клиент» (клиент становится равным нулю), и тест подсчитывает задания для неправильного арендатора.

Что странно, так это то, что он ожидает «1» и получает «-1» (а не «0»), что означает, что он получает счет (обратите внимание, что в блоке «до» уже создано «начальное» задание, так что, вероятно, он считает « 1" перед вызовом #create), вызовом действия create (которое должно увеличить счетчик от 1 до 2), затем потеря клиента и переключение на нулевой клиент, где есть 0 заданий. Так что:

  • Считает 1 (начальное задание)
  • Создает работу
  • Теряет арендатора
  • Подсчитывает 0 рабочих мест в новом (вероятно, нулевом) арендаторе

... что приводит к изменению Job.count на -1.

Ниже вы можете видеть, что я частично подтвердил это, добавив «.unscoped» в мою строку Job.count в тесте. Это означает, что ожидаемое количество заданий есть, но их просто нет в клиенте, под которым тестируется приложение.

Чего я не понимаю, так это того, как он теряет арендатора.

Код

Я попытался взять соответствующие части своего кода и создал специальную спецификацию для одного теста, чтобы максимально упростить анализ. Если я могу сделать что-нибудь еще, чтобы облегчить задачу возможным ответчикам, просто дайте мне знать, что делать!

# application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery
  include SessionsHelper

  around_filter :scope_current_tenant

  private

  def current_user
    @current_user ||= User.unscoped.find_by_auth_token!(cookies[:auth_token]) if cookies[:auth_token]
  end
  helper_method :current_user

  def current_tenant
    @current_tenant ||= Tenant.find_by_id!(session[:tenant_id]) if session[:tenant_id]
  end
  helper_method :current_tenant

  def update_current_tenant
    Tenant.current_id = current_tenant.id if current_tenant
  end
  helper_method :set_current_tenant

  def scope_current_tenant
    update_current_tenant
    yield
  ensure
    Tenant.current_id = nil
  end
end

# sessions_controller.rb
class SessionsController < ApplicationController
  def create
    user = User.unscoped.authenticate(params[:session][:email], params[:session][:password])

    if user && user.active? && user.active_tenants.any?
      if params[:remember_me]
        cookies.permanent[:auth_token] = user.auth_token
      else
        cookies[:auth_token] = user.auth_token
      end

      if !user.default_tenant_id.nil? && (default_tenant = Tenant.find(user.default_tenant_id)) && default_tenant.active
        # The user has a default tenant set, and that tenant is active
        session[:tenant_id] = default_tenant.id
      else
        # The user doesn't have a default
        session[:tenant_id] = user.active_tenants.first.id
      end
      redirect_back_or  root_path
    else
      flash.now[:error] = "Invalid email/password combination."
      @title = "Sign in"
      render 'new'
    end  
  end

  def destroy
    cookies.delete(:auth_token)
    session[:tenant_id] = nil
    redirect_to root_path
  end
end

# jobs_controller.rb
class JobsController < ApplicationController
  before_filter :authenticate_admin

  # POST /jobs
  # POST /jobs.json
  def create    
    @job = Job.new(params[:job])
    @job.creator = current_user

    respond_to do |format|
      if @job.save
        format.html { redirect_to @job, notice: 'Job successfully created.' }
        format.json { render json: @job, status: :created, location: @job }
      else
        flash.now[:error] = 'There was a problem creating the Job.'
        format.html { render action: "new" }
        format.json { render json: @job.errors, status: :unprocessable_entity }
      end
    end
  end
end

# job.rb
class Job < ActiveRecord::Base
  has_ancestry

  default_scope { where(tenant_id: Tenant.current_id) }
  .
  .
  .

end

# sessions_helper.rb
module SessionsHelper

require 'bcrypt'

  def authenticate_admin
    deny_access unless admin_signed_in?
  end

  def deny_access
    store_location
    redirect_to signin_path, :notice => "Please sign in to access this page."
  end

  private

  def store_location
    session[:return_to] = request.fullpath
  end
end

# spec_test_helper.rb
module SpecTestHelper 
  def test_sign_in(user)
    request.cookies[:auth_token] = user.auth_token
    session[:tenant_id] = user.default_tenant_id
    current_user = user
    @current_user = user
  end

  def current_tenant
    @current_tenant ||= Tenant.find_by_id!(session[:tenant_id]) if session[:tenant_id]
  end
end


# test_jobs_controller_spec.rb
require 'spec_helper'

describe JobsController do
  before do
    # This is all just setup to support requirements that the admin is an "Admin" (role)
    # That there's a tenant for him to use
    # That there are some workdays - a basic requirement for the app - jobs, checklist
    # All of this is to satisfy assocations and 
    @role = FactoryGirl.create(:role)
    @role.name = "Admin"
    @role.save
    @tenant1 = FactoryGirl.create(:tenant)
    @tenant2 = FactoryGirl.create(:tenant)
    @tenant3 = FactoryGirl.create(:tenant)

    Tenant.current_id = @tenant1.id
    @user = FactoryGirl.create(:user)
    @workday1 = FactoryGirl.create(:workday)
    @workday1.name = Time.now.to_date.strftime("%A")
    @workday1.save
    @checklist1 = FactoryGirl.create(:checklist)
    @job = FactoryGirl.create(:job)
    @checklist1.jobs << @job
    @workday1.checklists << @checklist1
    @admin1 = FactoryGirl.create(:user)
    @admin1.tenants << @tenant1
    @admin1.roles << @role
    @admin1.default_tenant_id = @tenant1.id
    @admin1.pin = ""
    @admin1.save!
    # This is above in the spec_test_helper.rb code
    test_sign_in(@admin1)
  end

  describe "POST create" do
    context "with valid attributes" do      
      it "creates a new job" do
        expect{ # <-- This is line 33 that's mentioned in the failure below
          post :create, job: FactoryGirl.attributes_for(:job)
        # This will pass if I change the below to Job.unscoped
        # OR it will pass if I add Tenant.current_id = @tenant1.id right here.
        # But I shouldn't need to do either of those because
        # The tenant should be set by the around_filter in application_controller.rb
        # And the default_scope for Job should handle scoping
        }.to change(Job,:count).by(1)
      end
    end
  end
end

Вот сбой от rspec:

Failures:

  1) JobsController POST create with valid attributes creates a new job
     Failure/Error: expect{
       count should have been changed by 1, but was changed by -1
     # ./spec/controllers/test_jobs_controller_spec.rb:33:in `block (4 levels) in <top (required)>'

Finished in 0.66481 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/controllers/test_jobs_controller_spec.rb:32 # JobsController POST create with valid attributes creates a new job

Если я добавлю несколько строк «puts», чтобы увидеть, кто является current_tenant напрямую, и проверив хэш сеанса, я все время вижу один и тот же идентификатор клиента:

describe "POST create" do
  context "with valid attributes" do      
    it "creates a new job" do
      expect{
        puts current_tenant.id.to_s
        puts session[:tenant_id]
        post :create, job: FactoryGirl.attributes_for(:job)
        puts current_tenant.id.to_s
        puts session[:tenant_id]
      }.to change(Job,:count).by(1)
    end
  end
end

Урожайность...

87
87
87
87
F

Failures:

  1) JobsController POST create with valid attributes creates a new job
     Failure/Error: expect{
       count should have been changed by 1, but was changed by -1
     # ./spec/controllers/test_jobs_controller_spec.rb:33:in `block (4 levels) in <top (required)>'

Finished in 0.66581 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/controllers/test_jobs_controller_spec.rb:32 # JobsController POST create with valid attributes creates a new job

person JoshDoody    schedule 18.09.2013    source источник


Ответы (3)


Я думаю, дело не в том, что RSpec игнорирует область по умолчанию, а в том, что она сбрасывается в ApplicationController в фильтре вокруг, устанавливая для текущего пользователя значение nil.

Я столкнулся с этой проблемой с assigns(...) и это произошло потому, что отношение фактически разрешается, когда вы оцениваете assigns. Я думаю, что это может быть и в случае с ожиданием в вашем случае.

ОБНОВЛЕНИЕ: В моей ситуации самое чистое решение, которое я смог найти (хотя я все еще ненавижу его), — это разрешить утечку области действия по умолчанию, не устанавливая для текущего пользователя значение nil в тестовой среде.

В вашем случае это будет равно:

def scope_current_tenant
  update_current_tenant
  yield
ensure
  Tenant.current_id = nil unless Rails.env == 'test'
end

Я не тестировал его с вашим кодом, но, возможно, это поможет.

person Marcin Bilski    schedule 09.04.2014
comment
Это интересный способ решить эту проблему, и я очень нервничаю, добавляя If Rails.env == 'test' в контроллер приложения. Я попробую и посмотрю, поможет ли это. Спасибо! - person JoshDoody; 10.04.2014
comment
Я на самом деле также ненавидел это в конце и закончил тем, что вызвал функцию определения области вокруг вызова assigns(...) в спецификациях. В вашем случае это, вероятно, будет что-то вроде controller.scope_current_tenant { ... }. - person Marcin Bilski; 11.04.2014
comment
Конечно, пропуск областей по умолчанию и использование явной области для ограничения доступа позволяет обойтись без подобных трюков. Это, возможно, более утомительно, но менее подвержено странным ошибкам, связанным с тем, как arel обрабатывает отношения; мой случай сложнее, чем мультитенантность, но мне действительно пришлось обновить Rails до 4.1, чтобы обойти ошибку. - person Marcin Bilski; 11.04.2014
comment
@MarcinBilski, не могли бы вы подробнее рассказать о вызове функции определения области вокруг assigns(...), пожалуйста? Моя функция scope_current_tenant находится в ApplicationController, но я продолжаю держать пари об ошибке undefined method, когда пытаюсь вызвать ее в тесте. - person Dan Weaver; 04.07.2014
comment
Доступен ли controller в ваших тестах? - person Marcin Bilski; 07.07.2014

Мне удалось пройти тесты, хотя я до сих пор не знаю, почему они не сработали с самого начала. Вот что я сделал:

  describe "POST create" do
    context "with valid attributes" do      
      it "creates a new job" do
        expect{ # <-- This is line 33 that's mentioned in the failure below
          post :create, job: FactoryGirl.attributes_for(:job)
        }.to change(Job.where(tenant_id: @tenant1.id),:count).by(1)
      end
    end
  end

Я изменил:

change(Job,:count).by(1)

...to:

change(Job.where(tenant_id: @tenant1.id),:count).by(1)

ПРИМЕЧАНИЕ. @tenant1 – это арендатор вошедшего в систему администратора.

Я предполагал, что default_scopes будут применяться в RSpec, но, похоже, это не так (или, по крайней мере, не в части ":change" блока "ожидания"). В этом случае default_scope для задания:

default_scope { where(tenant_id: Tenant.current_id) }

На самом деле, если я изменю эту строку на:

change(Job.where(tenant_id: Tenant.current_id),:count).by(1)

... это тоже пройдет. Поэтому, если я явно имитирую default_scope для Job в рамках спецификации, это пройдет. Это похоже на подтверждение того, что RSpec игнорирует мой default_scope в Jobs.

В некотором смысле, я думаю, что мой новый тест — лучший способ убедиться, что данные арендатора остаются разделенными, потому что я явно проверяю счетчики внутри конкретного арендатора, а не неявно проверяю счетчики для арендатора (предполагая, что счетчик находится в «текущем жилец").

Я отмечаю свой ответ как правильный, потому что это единственный ответ, и если кто-то еще столкнется с этим, я думаю, что мой ответ поможет им решить проблему. Тем не менее, я действительно не ответил на свой первоначальный вопрос о том, почему тест не прошел. Если у кого-нибудь есть понимание того, почему RSpec, похоже, игнорирует default_scope в блоках «ожидания», это может помочь сделать этот вопрос полезным для других.

person JoshDoody    schedule 22.09.2013

У меня такая же проблема с вами, ребята. Я решил не так, как мне удобно, но это все же лучше, чем проверка вашего RAILS_ENV. Возьмите этот пример.

it "saves person" do
    expect {
      some_post_action
    }.to change(Person, :count).by(1)
  end

Каждый раз, когда я пытаюсь сохранить метод подсчета, он делает выбор, например: «выбрать подсчет (*) от лиц, где tenant_id равен нулю»

Мне удается решить эту проблему, установив Person.unscoped в методе изменения, который я изменил:

        }.to change(Person, :count).by(1)

к этому:

        }.to change(Person.unscoped, :count).by(1)

Это не лучшее решение, но я все еще пытаюсь найти способ обойти default_scope.

person Thiago Ramos    schedule 26.03.2015