module Audited module RspecMatchers # Ensure that the model is audited. # # Options: # * associated_with - tests that the audit makes use of the associated_with option # * only - tests that the audit makes use of the only option *Overrides except option* # * except - tests that the audit makes use of the except option # * requires_comment - if specified, then the audit must require comments through the audit_comment attribute # * on - tests that the audit makes use of the on option with specified parameters # # Example: # it { should be_audited } # it { should be_audited.associated_with(:user) } # it { should be_audited.only(:field_name) } # it { should be_audited.except(:password) } # it { should be_audited.requires_comment } # it { should be_audited.on(:create).associated_with(:user).except(:password) } # def be_audited AuditMatcher.new end # Ensure that the model has associated audits # # Example: # it { should have_associated_audits } # def have_associated_audits AssociatedAuditMatcher.new end class AuditMatcher # :nodoc: def initialize @options = {} end def associated_with(model) @options[:associated_with] = model self end def only(*fields) @options[:only] = fields.flatten.map(&:to_s) self end def except(*fields) @options[:except] = fields.flatten.map(&:to_s) self end def requires_comment @options[:comment_required] = true self end def on(*actions) @options[:on] = actions.flatten.map(&:to_sym) self end def matches?(subject) @subject = subject auditing_enabled? && required_checks_for_options_satisfied? end def failure_message "Expected #{@expectation}" end def negative_failure_message "Did not expect #{@expectation}" end alias_method :failure_message_when_negated, :negative_failure_message def description description = "audited" description += " associated with #{@options[:associated_with]}" if @options.key?(:associated_with) description += " only => #{@options[:only].join ', '}" if @options.key?(:only) description += " except => #{@options[:except].join(', ')}" if @options.key?(:except) description += " requires audit_comment" if @options.key?(:comment_required) description end protected def expects(message) @expectation = message end def auditing_enabled? expects "#{model_class} to be audited" model_class.respond_to?(:auditing_enabled) && model_class.auditing_enabled end def model_class @subject.class end def associated_with_model? expects "#{model_class} to record audits to associated model #{@options[:associated_with]}" model_class.audit_associated_with == @options[:associated_with] end def records_changes_to_specified_fields? ignored_fields = build_ignored_fields_from_options expects "non audited columns (#{model_class.non_audited_columns.inspect}) to match (#{ignored_fields})" model_class.non_audited_columns.to_set == ignored_fields.to_set end def comment_required_valid? expects "to require audit_comment before #{model_class.audited_options[:on]} when comment required" validate_callbacks_include_presence_of_comment? && destroy_callbacks_include_comment_required? end def only_audit_on_designated_callbacks? { create: [:after, :audit_create], update: [:before, :audit_update], destroy: [:before, :audit_destroy] }.map do |(action, kind_callback)| kind, callback = kind_callback callbacks_for(action, kind: kind).include?(callback) if @options[:on].include?(action) end.compact.all? end def validate_callbacks_include_presence_of_comment? if @options[:comment_required] && audited_on_create_or_update? callbacks_for(:validate).include?(:presence_of_audit_comment) else true end end def audited_on_create_or_update? model_class.audited_options[:on].include?(:create) || model_class.audited_options[:on].include?(:update) end def destroy_callbacks_include_comment_required? if @options[:comment_required] && model_class.audited_options[:on].include?(:destroy) callbacks_for(:destroy).include?(:require_comment) else true end end def requires_comment_before_callbacks? [:create, :update, :destroy].map do |action| if @options[:comment_required] && model_class.audited_options[:on].include?(action) callbacks_for(action).include?(:require_comment) end end.compact.all? end def callbacks_for(action, kind: :before) model_class.send("_#{action}_callbacks").select { |cb| cb.kind == kind }.map(&:filter) end def build_ignored_fields_from_options default_ignored_attributes = model_class.default_ignored_attributes if @options[:only].present? (default_ignored_attributes | model_class.column_names) - @options[:only] elsif @options[:except].present? default_ignored_attributes | @options[:except] else default_ignored_attributes end end def required_checks_for_options_satisfied? { only: :records_changes_to_specified_fields?, except: :records_changes_to_specified_fields?, comment_required: :comment_required_valid?, associated_with: :associated_with_model?, on: :only_audit_on_designated_callbacks? }.map do |(option, check)| send(check) if @options[option].present? end.compact.all? end end class AssociatedAuditMatcher # :nodoc: def matches?(subject) @subject = subject association_exists? end def failure_message "Expected #{model_class} to have associated audits" end def negative_failure_message "Expected #{model_class} to not have associated audits" end alias_method :failure_message_when_negated, :negative_failure_message def description "has associated audits" end protected def model_class @subject.class end def reflection model_class.reflect_on_association(:associated_audits) end def association_exists? !reflection.nil? && reflection.macro == :has_many && reflection.options[:class_name] == Audited.audit_class.name end end end end