Search code examples
ruby-on-railsrspecshoulda

How to make shoulda-matchers to use specific set of values for a field?


I have a Coupon model which can have multiple currencies with the same coupon code.

So I validate it like that:

validates :code, presence: true, uniqueness: { case_sensitive: false, scope: :currency }, length: { in: 1..40 }

and test it like that:

it { should validate_uniqueness_of(:code).case_insensitive.scoped_to(:currency) }

But also the coupon rewrites currency attribute to accept both currency symbol ($) and it's ISO code (USD). It rewrites a symbol to corresponding ISO code:

# Convert currency symbols to ISO code
def currency=(value)
  return write_attribute(:currency, nil) if value.blank?
  write_attribute(:currency, currency_to_iso(value.strip))
end

def currency_to_iso(symbol_or_iso)
  return unless symbol_or_iso
  (Money::Currency.find(symbol_or_iso) || Money::Currency.find_by_symbol(symbol_or_iso)).try(:iso_code)
end

When currency code is incorrect, it gets converted to nil.

So shoulda-matchers produces an error:

   Coupon did not properly validate that :code is case-insensitively unique
   within the scope of :currency.
     After taking the given Coupon, whose :code is ‹"t8u"›, and saving it
     as the existing record, then making a new Coupon and setting its :code
     to ‹"t8u"› as well and its :currency to a different value, ‹"dummy
     value"› (read back as ‹nil›), the matcher expected the new Coupon to
     be valid, but it was invalid instead, producing these validation
     errors:

     * code: ["has already been taken"]

How to make shoulda-matches use only real currency codes, like FFaker::Currency or predefined list?


Solution

  • I dived into shoulda code and found that it uses predefined set of values for validate_uniqueness_of and there is no way to change it (except of hacking Shoulda::Matchers::Util class).

    def self.dummy_value_for(column_type, array: false)
      if array
        [dummy_value_for(column_type, array: false)]
      else
        case column_type
        when :integer
          0
        when :date
          Date.new(2100, 1, 1)
        when :datetime, :timestamp
          DateTime.new(2100, 1, 1)
        when :time
          Time.new(2100, 1, 1)
        when :uuid
          SecureRandom.uuid
        when :boolean
          true
        else
          'dummy value'
        end
      end
    end
    

    So I built following workaround:

    context 'coupon code should be unique for the same currency' do
      before { create(:coupon_amount, currency: 'USD', code: 'aaa') }
      it 'allows to create a coupon with the same code and another currency' do
        create(:coupon_amount, currency: 'EUR', code: 'aaa')
      end
      it 'does not allow to create a coupon with the same code and currency' do
        expect { create(:coupon_amount, currency: 'USD', code: 'aaa') }.to(
          raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Code has already been taken')
        )
      end
    end
    

    It validates uniqueness with right values and works like charm.