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 81539614a..d80689ea2 100644 --- a/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +++ b/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb @@ -187,6 +187,31 @@ module ActiveRecord # without_scopes # end # + # ##### with_default + # + # Use `with_default` to test that the enum is defined with a + # default value. A proc can also be passed, and will be called once each + # time a new value is needed. (If using Time or Date, it's recommended to + # freeze time or date to avoid flaky tests): + # + # class Issue < ActiveRecord::Base + # enum status: [:open, :closed], default: :closed + # end + # + # # RSpec + # RSpec.describe Issue, type: :model do + # it do + # should define_enum_for(:status). + # with_default(:closed) + # end + # end + # + # # Minitest (Shoulda) + # class ProcessTest < ActiveSupport::TestCase + # should define_enum_for(:status). + # with_default(:closed) + # end + # # @return [DefineEnumForMatcher] # def define_enum_for(attribute_name) @@ -247,6 +272,11 @@ def without_scopes self end + def with_default(default_value) + options[:default] = default_value + self + end + def matches?(subject) @record = subject @@ -254,7 +284,8 @@ def matches?(subject) enum_values_match? && column_type_matches? && enum_value_methods_exist? && - scope_presence_matches? + scope_presence_matches? && + default_value_matches? end def failure_message @@ -292,6 +323,11 @@ def expectation # rubocop:disable Metrics/MethodLength ) end + if options[:default].present? + expectation << ", with a default value of " + expectation << Shoulda::Matchers::Util.inspect_value(expected_default_value) + end + if expected_prefix expectation << if expected_suffix @@ -476,6 +512,40 @@ def missing_methods_message end end + def default_value_matches? + return true unless options[:default].present? + + if actual_default_value.nil? + @failure_message_continuation = 'However, no default value was set' + return false + end + + if actual_default_value == expected_default_value + true + else + @failure_message_continuation = 'However, the default value is ' + @failure_message_continuation << Shoulda::Matchers::Util.inspect_value(actual_default_value) + false + end + end + + def expected_default_value + options[:default].respond_to?(:call) ? options[:default].call : options[:default] + end + + def actual_default_value + attribute_schema = model.attributes_to_define_after_schema_loads[attribute_name.to_s] + + value = case attribute_schema + in [_, { default: default_value } ] + default_value + in [_, default_value] + default_value + end + + value.respond_to?(:call) ? value.call : value + end + def singleton_methods_exist? expected_singleton_methods.all? do |method| model.singleton_methods.include?(method) 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 509d2b0e8..64039b3a7 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 @@ -789,6 +789,122 @@ def self.statuses end end + describe 'qualified with #with_default' do + context 'if default are defined on the enum' do + context 'but with_default is not used' do + it 'matches' do + record = build_record_with_array_values(attribute_name: :attr, default: 'published') + + expect(record).to define_enum_for(:attr).with_values(['published', 'unpublished', 'draft']) + end + end + + context 'with_default is used and default is the same' 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']). + with_default('published') + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, + and ‹"draft"› to ‹2›, with a default value of ‹"published"›, but it did. + MESSAGE + end + end + + context 'with_default is used but default is different' 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']). + with_default('unpublished') + 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›, with a default value of ‹"unpublished"›. However, the default value + is ‹"published"›. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + + context 'when a Proc is used as the default value' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr, default: 'draft') + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default(-> { 'published' }) + 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›, with a default value of ‹"published"›. However, the default + value is ‹"draft"›. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'matches when the default value is the same' do + record = build_record_with_array_values(attribute_name: :attr, default: 'draft') + + matcher = lambda do + define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default(-> { 'draft' }) + end + + expect(&matcher). + to match_against(record). + or_fail_with(<<-MESSAGE, wrap: true) + Expected Example not to define :attr as an enum backed by an + integer, mapping ‹"published"› to ‹0›, ‹"unpublished"› to ‹1›, + and ‹"draft"› to ‹2›, with a default value of ‹"draft"›, but it did. + MESSAGE + end + end + end + + context 'if default is not defined on the enum' do + it 'rejects with an appropriate failure message' do + record = build_record_with_array_values(attribute_name: :attr) + + assertion = lambda do + expect(record). + to define_enum_for(:attr). + with_values(['published', 'unpublished', 'draft']). + with_default('published') + 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›, with a default value of ‹"published"›. However, no default + value was set. + MESSAGE + + expect(&assertion).to fail_with_message(message) + 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 @@ -869,7 +985,8 @@ def build_record_with_array_values( prefix: false, suffix: false, attribute_alias: nil, - scopes: true + scopes: true, + default: nil ) build_record_with_enum_attribute( model_name: model_name, @@ -880,6 +997,7 @@ def build_record_with_array_values( suffix: suffix, attribute_alias: attribute_alias, scopes: scopes, + default: default ) end @@ -911,7 +1029,8 @@ def build_record_with_enum_attribute( attribute_alias:, scopes: true, prefix: false, - suffix: false + suffix: false, + default: nil ) enum_name = attribute_alias || attribute_name model = define_model( @@ -925,10 +1044,11 @@ def build_record_with_enum_attribute( enum_name => values, _prefix: prefix, _suffix: suffix, + _default: default } if rails_version >= 7.0 - model.enum(enum_name, values, prefix: prefix, suffix: suffix) + model.enum(enum_name, values, prefix: prefix, suffix: suffix, default: default) else params.merge!(_scopes: scopes) model.enum(params)