# frozen_string_literal: true require 'active_support' require 'active_support/core_ext/object/to_param' require 'active_model' module RSpec::ActiveModel::Mocks class IllegalDataAccessException < StandardError; end module Mocks module ActiveModelInstanceMethods # Stubs `persisted?` to return false and `id` to return nil # @return self def as_new_record RSpec::Mocks.allow_message(self, :persisted?).and_return(false) RSpec::Mocks.allow_message(self, :id).and_return(nil) self end # Returns true by default. Override with a stub. def persisted? true end # Returns false for names matching /_before_type_cast$/, # otherwise delegates to super. def respond_to?(message, include_private=false) message.to_s =~ /_before_type_cast$/ ? false : super end end # Starting with Rails 4.1, ActiveRecord associations are inversible # by default. This class represents an association from the mocked # model's perspective. # # @private class Association attr_accessor :target, :inversed def initialize(association_name) @association_name = association_name end def inversed_from(record) self.target = record @inversed = !!record end end module ActiveRecordInstanceMethods # Stubs `persisted?` to return `false` and `id` to return `nil`. def destroy RSpec::Mocks.allow_message(self, :persisted?).and_return(false) RSpec::Mocks.allow_message(self, :id).and_return(nil) end # Transforms the key to a method and calls it. def [](key) send(key) end # Rails>4.2 uses _read_attribute internally, as an optimized # alternative to record['id'] alias_method :_read_attribute, :[] # Returns the opposite of `persisted?` def new_record? !persisted? end # Returns an object representing an association from the mocked # model's perspective. For use by Rails internally only. def association(association_name) @associations ||= Hash.new { |h, k| h[k] = Association.new(k) } @associations[association_name] end end # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity # Creates a test double representing `string_or_model_class` with common # ActiveModel methods stubbed out. Additional methods may be easily # stubbed (via add_stubs) if `stubs` is passed. This is most useful for # impersonating models that don't exist yet. # # ActiveModel methods, plus new_record?, are # stubbed out implicitly. new_record? returns the inverse of # persisted?, and is present only for compatibility with # extension frameworks that have yet to update themselves to the # ActiveModel API (which declares persisted?, not # new_record?). # # `string_or_model_class` can be any of: # # * A String representing a Class that does not exist # * A String representing a Class that extends ActiveModel::Naming # * A Class that extends ActiveModel::Naming def mock_model(string_or_model_class, stubs={}) if String === string_or_model_class if Object.const_defined?(string_or_model_class) model_class = Object.const_get(string_or_model_class) else model_class = Object.const_set(string_or_model_class, Class.new do # rubocop:disable Style/SingleLineMethods extend ::ActiveModel::Naming def self.primary_key; :id; end # For detection of being a valid association in 7+ def self.<(other); other == ActiveRecord::Base; end def self._reflect_on_association(_other); nil; end def self.composite_primary_key?; false; end def self.has_query_constraints?; false; end def self.param_delimiter; "-"; end # rubocop:enable Style/SingleLineMethods end) end else model_class = string_or_model_class end unless model_class.kind_of? ::ActiveModel::Naming raise ArgumentError, <<-EOM The mock_model method can only accept as its first argument: * A String representing a Class that does not exist * A String representing a Class that extends ActiveModel::Naming * A Class that extends ActiveModel::Naming It received #{model_class.inspect} EOM end stubs = { :id => next_id }.merge(stubs) stubs = { :persisted? => !!stubs[:id], :destroyed? => false, :marked_for_destruction? => false, :valid? => true, :blank? => false }.merge(stubs) double("#{model_class.name}_#{stubs[:id]}", stubs).tap do |m| if model_class.method(:===).owner == Module && !stubs.key?(:===) allow(model_class).to receive(:===).and_wrap_original do |original, other| m === other || original.call(other) end end msingleton = class << m; self; end msingleton.class_eval do include ActiveModelInstanceMethods include ActiveRecordInstanceMethods if defined?(ActiveRecord) include ActiveModel::Conversion include ActiveModel::Validations end if defined?(ActiveRecord) && stubs.values_at(:save, :update_attributes, :update).include?(false) RSpec::Mocks.allow_message(m.errors, :empty?).and_return(false) RSpec::Mocks.allow_message(m.errors, :blank?).and_return(false) end msingleton.__send__(:define_method, :is_a?) do |other| model_class.ancestors.include?(other) end unless stubs.key?(:is_a?) msingleton.__send__(:define_method, :kind_of?) do |other| model_class.ancestors.include?(other) end unless stubs.key?(:kind_of?) msingleton.__send__(:define_method, :instance_of?) do |other| other == model_class end unless stubs.key?(:instance_of?) msingleton.__send__(:define_method, :__model_class_has_column?) do |method_name| model_class.respond_to?(:column_names) && model_class.column_names.include?(method_name.to_s) end msingleton.__send__(:define_method, :has_attribute?) do |attr_name| __model_class_has_column?(attr_name) end unless stubs.key?(:has_attribute?) msingleton.__send__(:define_method, :respond_to?) do |method_name, *args| include_private = args.first || false __model_class_has_column?(method_name) ? true : super(method_name, include_private) end unless stubs.key?(:respond_to?) msingleton.__send__(:define_method, :method_missing) do |missing_m, *a, &b| if respond_to?(missing_m) null_object? ? self : nil else super(missing_m, *a, &b) end end msingleton.__send__(:define_method, :class) do model_class end unless stubs.key?(:class) mock_param = to_param msingleton.__send__(:define_method, :to_s) do "#{model_class.name}_#{mock_param}" end unless stubs.key?(:to_s) yield m if block_given? end end # rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity module ActiveModelStubExtensions # Stubs `persisted` to return false and `id` to return nil def as_new_record RSpec::Mocks.allow_message(self, :persisted?).and_return(false) RSpec::Mocks.allow_message(self, :id).and_return(nil) self end # Returns `true` by default. Override with a stub. def persisted? true end end module ActiveRecordStubExtensions # Stubs `id` (or other primary key method) to return nil def as_new_record __send__("#{self.class.primary_key}=", nil) super end # Returns the opposite of `persisted?`. def new_record? !persisted? end # Raises an IllegalDataAccessException (stubbed models are not allowed to access the database) # @raises IllegalDataAccessException def connection raise RSpec::ActiveModel::Mocks::IllegalDataAccessException, "stubbed models are not allowed to access the database" end end # rubocop:disable Metrics/AbcSize,Metrics/MethodLength # Creates an instance of `Model` with `to_param` stubbed using a # generated value that is unique to each object. If `Model` is an # `ActiveRecord` model, it is prohibited from accessing the database. # # For each key in `stubs`, if the model has a matching attribute # (determined by `respond_to?`) it is simply assigned the submitted values. # If the model does not have a matching attribute, the key/value pair is # assigned as a stub return value using RSpec's mocking/stubbing # framework. # # persisted? is overridden to return the result of !id.nil? # This means that by default persisted? will return true. If you want # the object to behave as a new record, sending it `as_new_record` will # set the id to nil. You can also explicitly set :id => nil, in which # case persisted? will return false, but using `as_new_record` makes the # example a bit more descriptive. # # While you can use stub_model in any example (model, view, controller, # helper), it is especially useful in view examples, which are # inherently more state-based than interaction-based. # # @example # # stub_model(Person) # stub_model(Person).as_new_record # stub_model(Person, :to_param => 37) # stub_model(Person) {|person| person.first_name = "David"} def stub_model(model_class, stubs={}) model_class.new.tap do |m| m.extend ActiveModelStubExtensions if defined?(ActiveRecord) && model_class < ActiveRecord::Base && model_class.primary_key m.extend ActiveRecordStubExtensions primary_key = model_class.primary_key.to_sym stubs = { primary_key => next_id }.merge(stubs) stubs = { :persisted? => !!stubs[primary_key] }.merge(stubs) else stubs = { :id => next_id }.merge(stubs) stubs = { :persisted? => !!stubs[:id] }.merge(stubs) end stubs = { :blank? => false }.merge(stubs) stubs.each do |message, return_value| if m.respond_to?("#{message}=") begin m.__send__("#{message}=", return_value) rescue ActiveModel::MissingAttributeError RSpec::Mocks.allow_message(m, message).and_return(return_value) end else RSpec::Mocks.allow_message(m, message).and_return(return_value) end end yield m if block_given? end end # rubocop:enable Metrics/AbcSize,Metrics/MethodLength private # rubocop:disable Style/ClassVars @@model_id = 1000 def next_id @@model_id += 1 end # rubocop:enable Style/ClassVars end end RSpec.configuration.include RSpec::ActiveModel::Mocks::Mocks