diff --git a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb index e74997900..8b583a4e3 100644 --- a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +++ b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb @@ -212,6 +212,47 @@ module ActiveRecord # with_default(:closed) # end # + # ##### validating + # + # Use `validating` to test that the enum is being validated. + # Can take a boolean value and an allowing_nil keyword argument: + # + # class Issue < ActiveRecord::Base + # enum status: [:open, :closed], validate: true + # end + # + # # RSpec + # RSpec.describe Issue, type: :model do + # it do + # should define_enum_for(:status). + # validating + # end + # end + # + # # Minitest (Shoulda) + # class ProcessTest < ActiveSupport::TestCase + # should define_enum_for(:status). + # validating + # end + # + # class Issue < ActiveRecord::Base + # enum status: [:open, :closed], validate: { allow_nil: true } + # end + # + # # RSpec + # RSpec.describe Issue, type: :model do + # it do + # should define_enum_for(:status). + # validating(allowing_nil: true) + # end + # end + # + # # Minitest (Shoulda) + # class ProcessTest < ActiveSupport::TestCase + # should define_enum_for(:status). + # validating(allowing_nil: true) + # end + # # @return [DefineEnumForMatcher] # def define_enum_for(attribute_name) @@ -247,6 +288,12 @@ def description description end + def validating(value = true, allowing_nil: false) + options[:validating] = value + options[:allowing_nil] = allowing_nil + self + end + def with_values(expected_enum_values) options[:expected_enum_values] = expected_enum_values self @@ -285,7 +332,8 @@ def matches?(subject) column_type_matches? && enum_value_methods_exist? && scope_presence_matches? && - default_value_matches? + default_value_matches? && + validating_matches? end def failure_message @@ -308,6 +356,30 @@ def failure_message_when_negated private + def validating_matches? + return true if options[:validating].nil? + + validator = find_enum_validator + + if expected_validating? == !!validator + if validator&.options&.dig(:allow_nil).present? == expected_allowing_nil? + true + else + @failure_message_continuation = + "However, #{attribute_name.inspect} is allowing nil values" + false + end + else + @failure_message_continuation = + if expected_validating? + "However, #{attribute_name.inspect} is not being validated" + else + "However, #{attribute_name.inspect} is being validated" + end + false + end + end + attr_reader :attribute_name, :options, :record, :failure_message_continuation @@ -328,6 +400,16 @@ def expectation # rubocop:disable Metrics/MethodLength expectation << Shoulda::Matchers::Util.inspect_value(expected_default_value) end + if expected_validating? + expectation << ', and being validated ' + expectation << + if expected_allowing_nil? + 'allowing nil values' + else + 'not allowing nil values' + end + end + if expected_prefix expectation << if expected_suffix @@ -602,6 +684,22 @@ def expected_suffix end end + def expected_validating? + options[:validating].present? + end + + def expected_allowing_nil? + options[:allowing_nil].present? + end + + def find_enum_validator + record.class.validators.detect do |validator| + validator.kind == :inclusion && + validator.attributes.include?(attribute_name.to_s) && + validator.options[:in] == expected_enum_values + end + end + def exclude_scopes? !options[:scopes] end diff --git a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb index e6d5300ed..06093c244 100644 --- a/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/define_enum_for_matcher_spec.rb @@ -905,6 +905,217 @@ def self.statuses end end + if rails_version >= 7.1 + describe 'qualified with #validating' do + context 'if enum is being validated' do + context 'but validating qualifier is not used' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published', validate: true) + + matcher = lambda do + define_enum_for(:attr).with_values(['published', 'unpublished', 'draft']) + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + end + + context 'and validating qualifier is used as false' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published', validate: true) + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating(false) + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›. However, :attr is being validated. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'and validating qualifier is used' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, validate: true) + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated not allowing nil values, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + end + + context 'using allow_nil' do + context 'when allowing nil on qualifier' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, validate: { allow_nil: true }) + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating(allowing_nil: true) + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated allowing nil values, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + end + + context 'when not allowing nil on qualifier' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, validate: { allow_nil: true }) + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated not allowing nil values. However, :attr is + allowing nil values. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + end + end + + context 'when not allowing nil values' do + it 'matches if qualifier does not allow' do + record = build_record_with_array_values(attribute_name: :attr, validate: { allow_nil: false }) + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating(allowing_nil: false) + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated not allowing nil values, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + + it 'rejects with an appropriate failure message if qualifier allows' do + record = build_record_with_array_values(attribute_name: :attr, validate: { allow_nil: false }) + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating(allowing_nil: true) + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated allowing nil values. However, :attr is allowing + nil values. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'if enum is not being validated' do + context 'but validating qualifier is used' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating + end + + message = format_message(<<-MESSAGE) + Expected Example to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, and being validated not allowing nil values. However, :attr + is not being validated. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'and validating qualifier is used as false' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + validating(false) + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + end + + context 'and validating qualifier is not used' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + matcher = lambda do + define_enum_for(:attr).with_values(['published', 'unpublished', 'draft']) + end + + message = format_message(<<-MESSAGE) + Expected Example not to define :attr as an enum backed by an integer, + mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, and ‹"draft"› + to ‹2›, but it did. + MESSAGE + + expect(&matcher).to match_against(record).or_fail_with(message) + end + end + end + end + end + if rails_version =~ '~> 6.0' context 'qualified with #without_scopes' do context 'if scopes are set to false on the enum but without_scopes is not used' do @@ -986,7 +1197,8 @@ def build_record_with_array_values( suffix: false, attribute_alias: nil, scopes: true, - default: nil + default: nil, + validate: false ) build_record_with_enum_attribute( model_name: model_name, @@ -998,6 +1210,7 @@ def build_record_with_array_values( attribute_alias: attribute_alias, scopes: scopes, default: default, + validate: validate, ) end @@ -1030,7 +1243,8 @@ def build_record_with_enum_attribute( scopes: true, prefix: false, suffix: false, - default: nil + default: nil, + validate: false ) enum_name = attribute_alias || attribute_name model = define_model( @@ -1048,7 +1262,7 @@ def build_record_with_enum_attribute( } if rails_version >= 7.0 - model.enum(enum_name, values, prefix: prefix, suffix: suffix, default: default) + model.enum(enum_name, values, prefix: prefix, suffix: suffix, validate: validate, default: default) else params.merge!(_scopes: scopes) model.enum(params)