diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb new file mode 100644 index 000000000..42877a9a6 --- /dev/null +++ b/spec/models/ability_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true +require 'spec_helper' +require 'cancan/matchers' + +describe Ability, type: :model do + before(:each) do + mock_app_config(enable_renewals: true) + end + + shared_examples 'all users' do + it { is_expected.to be_able_to(:hide, Announcement) } + end + + shared_examples 'normal user' do + it { is_expected.to be_able_to(:update, User, id: user.id) } + it { is_expected.to be_able_to(:show, User, id: user.id) } + + it { is_expected.to be_able_to(:read, EquipmentModel) } + it { is_expected.to be_able_to(:view_detailed, EquipmentModel) } + + it { is_expected.to be_able_to(:read, Reservation, reserver_id: user.id) } + it { is_expected.to be_able_to(:create, Reservation, reserver_id: user.id) } + it do + expect(ability).to be_able_to(:destroy, Reservation, reserver_id: user.id, + checked_out: nil) + end + it { is_expected.to be_able_to(:renew, Reservation, reserver_id: user.id) } + it { is_expected.to be_able_to(:update_index_dates, Reservation) } + it { is_expected.to be_able_to(:view_all_dates, Reservation) } + + it { is_expected.to be_able_to(:reload_catalog_cart, :all) } + it { is_expected.to be_able_to(:update_cart, :all) } + end + + context 'superuser' do + subject(:ability) { Ability.new(UserMock.new(:superuser)) } + it_behaves_like 'all users' + it { is_expected.to be_able_to(:view_as, :superuser) } + it { is_expected.to be_able_to(:change, :views) } + it { is_expected.to be_able_to(:manage, :all) } + end + + context 'admin' do + subject(:ability) { Ability.new(UserMock.new(:admin)) } + it_behaves_like 'all users' + it { is_expected.to be_able_to(:change, :views) } + it { is_expected.to be_able_to(:manage, :all) } + it { is_expected.not_to be_able_to(:view_as, :superuser) } + it { is_expected.not_to be_able_to(:appoint, :superuser) } + it { is_expected.not_to be_able_to(:destroy, User, role: 'superuser') } + it { is_expected.not_to be_able_to(:update, User, role: 'superuser') } + it { is_expected.not_to be_able_to(:access, :rails_admin) } + end + + context 'checkout person' do + let!(:user) { UserMock.new(:checkout_person) } + subject(:ability) { Ability.new(user) } + it_behaves_like 'all users' + it_behaves_like 'normal user' + + it { is_expected.to be_able_to(:manage, Reservation) } + it { is_expected.not_to be_able_to(:archive, Reservation) } + + it { is_expected.to be_able_to(:read, User) } + it { is_expected.to be_able_to(:update, User) } + it { is_expected.to be_able_to(:find, User) } + it { is_expected.to be_able_to(:autocomplete_user_last_name, User) } + + it { is_expected.to be_able_to(:read, EquipmentItem) } + + context 'checkout persons can edit' do + it do + mock_app_config(checkout_persons_can_edit: true) + ability = Ability.new(user) + expect(ability).to be_able_to(:update, Reservation) + end + end + context 'checkout persons cannot edit' do + it do + mock_app_config(checkout_persons_can_edit: false) + ability = Ability.new(user) + expect(ability).not_to be_able_to(:update, Reservation) + end + end + context 'new users enabled' do + it do + mock_app_config(enable_new_users: true) + ability = Ability.new(user) + expect(ability).to be_able_to(:create, User) + expect(ability).to be_able_to(:quick_new, User) + expect(ability).to be_able_to(:quick_create, User) + end + end + context 'new users disabled' do + it do + mock_app_config(enable_new_users: false) + ability = Ability.new(user) + expect(ability).not_to be_able_to(:create, User) + expect(ability).not_to be_able_to(:quick_new, User) + expect(ability).not_to be_able_to(:quick_create, User) + end + end + end + + context 'normal user' do + let!(:user) { UserMock.new(:user) } + subject(:ability) { Ability.new(user) } + it_behaves_like 'all users' + it_behaves_like 'normal user' + end + + context 'guest' do + before { mock_app_config(enable_guests: true) } + subject(:ability) { Ability.new(UserMock.new(:guest)) } + it_behaves_like 'all users' + + it { is_expected.to be_able_to(:read, EquipmentModel) } + it { is_expected.to be_able_to(:empty_cart, :all) } + it { is_expected.to be_able_to(:reload_catalog_cart, :all) } + it { is_expected.to be_able_to(:update_cart, :all) } + + context 'new users enabled' do + it do + mock_app_config(enable_new_users: true) + ability = Ability.new(UserMock.new(:guest)) + expect(ability).to be_able_to(:create, User) + end + end + context 'new users disabled' do + it do + mock_app_config(enable_new_users: true) + ability = Ability.new(UserMock.new(:guest)) + expect(ability).to be_able_to(:create, User) + end + end + end + + context 'banned' do + subject(:ability) { Ability.new(UserMock.new(:banned)) } + it_behaves_like 'all users' + end + + context 'renewals disabled' do + shared_examples 'cannot renew' do |role| + it do + mock_app_config(enable_renewals: false) + ability = Ability.new(UserMock.new(role)) + expect(ability).not_to be_able_to(:renew, Reservation) + end + end + [:admin, :checkout_person, :user].each do |role| + it_behaves_like 'cannot renew', role + end + end +end diff --git a/spec/support/mockers/category_mock.rb b/spec/support/mockers/category_mock.rb new file mode 100644 index 000000000..95a495a5d --- /dev/null +++ b/spec/support/mockers/category_mock.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require Rails.root.join('spec/support/mockers/mocker.rb') +require Rails.root.join('spec/support/mockers/equipment_model_mock.rb') + +class CategoryMock < Mocker + def self.klass + Category + end + + def self.klass_name + 'Category' + end + + private + + def with_equipment_models(models: nil, count: 1) + models ||= Array.new(count) { EquipmentModelMock.new } + parent_has_many(mocked_children: models, parent_sym: :category, + child_sym: :equipment_models) + end +end diff --git a/spec/support/mockers/equipment_item_mock.rb b/spec/support/mockers/equipment_item_mock.rb new file mode 100644 index 000000000..da7d12340 --- /dev/null +++ b/spec/support/mockers/equipment_item_mock.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +require Rails.root.join('spec/support/mockers/mocker.rb') +require Rails.root.join('spec/support/mockers/equipment_model_mock.rb') + +class EquipmentItemMock < Mocker + def self.klass + EquipmentItem + end + + def self.klass_name + 'EquipmentItem' + end + + private + + def with_model(model: nil) + model ||= EquipmentModelMock.new + child_of_has_many(mocked_parent: model, parent_sym: :equipment_model, + child_sym: :equipment_items) + end +end diff --git a/spec/support/mockers/equipment_model_mock.rb b/spec/support/mockers/equipment_model_mock.rb new file mode 100644 index 000000000..e7b7ad87c --- /dev/null +++ b/spec/support/mockers/equipment_model_mock.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require Rails.root.join('spec/support/mockers/mocker.rb') +require Rails.root.join('spec/support/mockers/category_mock.rb') +require Rails.root.join('spec/support/mockers/equipment_item_mock.rb') + +class EquipmentModelMock < Mocker + def self.klass + EquipmentModel + end + + def self.klass_name + 'EquipmentModel' + end + + private + + def with_item(item:) + with_items(items: [item]) + end + + def with_items(items: nil, count: 1) + items ||= Array.new(count) { EquipmentItemMock.new } + parent_has_many(mocked_children: items, parent_sym: :equipment_model, + child_sym: :equipment_items) + end + + def with_category(cat: nil) + cat ||= CategoryMock.new + child_of_has_many(mocked_parent: cat, parent_sym: :category, + child_sym: :equipment_models) + end +end diff --git a/spec/support/mockers/mocker.rb b/spec/support/mockers/mocker.rb new file mode 100644 index 000000000..8b95b22a8 --- /dev/null +++ b/spec/support/mockers/mocker.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true +require 'rspec/mocks/standalone' + +# This class behaves as an extension of rspec-mocks' instance_spy. +# It is intended to be extended and used to make mocking models much simpler! +# +# To create a new subclass, the following methods must be overridden: +# - self.klass must return the class that the subclass is mocking +# - self.klass_name must return a string that matches the class being mocked +# +# Some examples using the EquipmentModelMock subclass: +# A mock that can be "found" with EquipmentModel#find: +# EquipmentModelMock.new(traits: [:findable]) +# A mock with a set of attributes: +# EquipmentModelMock.new(name: 'Camera', late_fee: 3) +# A mock with attributes and method stubs: +# EquipmentModelMock.new(name: 'Camera', model_restriced: false) +# A findable mock with attributes: +# EquipmentModelMock.new(traits: [:findable], name: 'Camera') +# +# A trait can be any method that exists on the mocker superclass or child class. +# To create an EquipmentModel that belongs to an existing category, camera: +# EquipmentModelMock.new(traits: [[:with_category, cat: camera]]) +# +# Use caution before adding methods -- any method defined here should be usable +# by all subclasses, with the exception of the association stub methods. + +class Mocker < RSpec::Mocks::InstanceVerifyingDouble + include RSpec::Mocks + + FIND_METHODS = [:find, :find_by_id].freeze + + def initialize(traits: [], **attrs) + # from RSpec::Mocks::ExampleMethods + # combination of #declare_verifying_double and #declare_double + ref = ObjectReference.for(self.class.klass_name) + RSpec::Mocks.configuration.verifying_double_callbacks.each do |block| + block.call(ref) + end + attrs ||= {} + super(ref, attrs) + as_null_object + process_traits(traits) + end + + def process_traits(traits) + traits.each { |t| send(*t) } + end + + private + + def klass + Object + end + + def klass_name + 'Object' + end + + def spy + self + end + + # lets us use rspec-mock syntax in mockers + def receive(method_name, &block) + Matchers::Receive.new(method_name, block) + end + + def allow(target) + AllowanceTarget.new(target) + end + + # Traits + def findable + id = FactoryGirl.generate(:unique_id) + allow(spy).to receive(:id).and_return(id) + FIND_METHODS.each do |method| + allow(self.class.klass).to receive(method) + allow(self.class.klass).to receive(method).with(id).and_return(spy) + allow(self.class.klass).to receive(method).with(id.to_s).and_return(spy) + end + end + + # Generalized association stubs + def child_of_has_many(mocked_parent:, parent_sym:, child_sym:) + allow(spy).to receive(parent_sym).and_return(mocked_parent) + children = if mocked_parent.send(child_sym).is_a? Array + mocked_parent.send(child_sym) << spy + else + [spy] + end + allow(mocked_parent).to receive(child_sym).and_return(children) + end + + def parent_has_many(mocked_children:, parent_sym:, child_sym:) + if mocked_children.is_a? Array + mocked_children.each do |child| + allow(child).to receive(parent_sym).and_return(spy) + end + end + allow(spy).to receive(child_sym).and_return(mocked_children) + end +end diff --git a/spec/support/mockers/reservation_mock.rb b/spec/support/mockers/reservation_mock.rb new file mode 100644 index 000000000..4c1f09914 --- /dev/null +++ b/spec/support/mockers/reservation_mock.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require Rails.root.join('spec/support/mockers/mocker.rb') + +class ReservationMock < Mocker + def self.klass + Reservation + end + + def self.klass_name + 'Reservation' + end + + private + + def for_user(user:) + child_of_has_many(mocked_parent: user, parent_sym: :reserver, + child_sym: :reservations) + end +end diff --git a/spec/support/mockers/user_mock.rb b/spec/support/mockers/user_mock.rb new file mode 100644 index 000000000..1270ac456 --- /dev/null +++ b/spec/support/mockers/user_mock.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +require Rails.root.join('spec/support/mockers/mocker.rb') + +class UserMock < Mocker + def initialize(role = :user, traits: [], **attrs) + attrs = FactoryGirl.attributes_for(role).merge attrs + traits = [:findable] if traits.empty? + super(traits: traits, **attrs) + end + + def self.klass + User + end + + def self.klass_name + 'User' + end +end