diff --git a/CHANGELOG.md b/CHANGELOG.md index cb709cd..ada6a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## v2.11.0 + +* Added the `phone_verification_method`, + `phone_was_verification_successful`, and `phone_verification_time` + attributes to `Minfraud::Components::Billing` and + `Minfraud::Components::Shipping`. They describe the most recent + verification of the billing or shipping phone number. +* Boolean inputs are now always sent as JSON booleans, and `false` is no + longer dropped. Previously, every input set to `false` was omitted. + Now only `nil` inputs are omitted. In particular: + * `true` for `/credit_card/was_3d_secure_successful` was sent as the + string `"true"`, which the web service rejected and dropped. It is + now sent as a JSON boolean, so it reaches the web service. + * Custom input values of `true` and `false` are now sent as JSON + booleans, and `false` is no longer dropped. + * **Warning:** If a custom input is a string type on your account, a + Ruby `true` or `false` for it is now sent as a JSON boolean. The web + service rejects a boolean for a string custom input with an + `INPUT_INVALID` warning. Pass a string, such as `'true'`, instead. + * With validation on, `true` or `false` for a non-boolean input now + raises `InvalidInputError`. This includes `token` on + `Minfraud::Components::CreditCard`, `tracking_token` on + `Minfraud::Components::Device`, and `chargeback_code`, `notes`, and + `transaction_id` on `Minfraud::Components::Report::Transaction`. + Previously, these accepted a boolean, and `transaction_id: false` + raised `NoMethodError`. + * `/order/has_gift_message`, `/order/is_gift`, and + `/payment/was_authorized` already sent `true` as a JSON boolean. +* The `*_values` class methods of enum attributes, such as + `Minfraud::Components::Event.type_values`, now exist only on the class + that declares the enum and its subclasses. Previously, every class + responded to them. `mapping` on a subclass now includes the enums of + its parent class. +* Enum setters, such as `Minfraud::Components::Event#type=`, now raise + `NotEnumValueError` for a value that is not a String, a Symbol, or + `nil`. Previously, `false` cleared the attribute, and `true` raised + `NoMethodError`. The `*_values` class methods now return a frozen + array. +* With validation on, `Minfraud::Components::Event` now rejects a `time` + that the web service rejects. Previously, the client accepted hour 24, + second 60, a UTC offset of 24 hours or more, and a lowercase `t` or + `z`. + ## v2.10.0 (2026-07-21) * Added the `residential` attribute to the `anonymizer` object on diff --git a/README.md b/README.md index 605b912..90c2cd8 100644 --- a/README.md +++ b/README.md @@ -102,31 +102,37 @@ assessment = Minfraud::Assessments.new( domain: 'maxmind.com', }, billing: { - first_name: 'First', - last_name: 'Last', - company: 'Company', - address: '101 Address Rd.', - address_2: 'Unit 5', - city: 'New Haven', - region: 'CT', - country: 'US', - postal: '06510', - phone_number: '123-456-7890', - phone_country_code: '1', + first_name: 'First', + last_name: 'Last', + company: 'Company', + address: '101 Address Rd.', + address_2: 'Unit 5', + city: 'New Haven', + region: 'CT', + country: 'US', + postal: '06510', + phone_number: '123-456-7890', + phone_country_code: '1', + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: true, }, shipping: { - first_name: 'ShipFirst', - last_name: 'ShipLast', - company: 'ShipCo', - address: '322 Ship Addr. Ln.', - address_2: 'St. 43', - city: 'Nowhere', - region: 'OK', - country: 'US', - postal: '73003', - phone_number: '123-456-0000', - phone_country_code: '1', - delivery_speed: :same_day, + first_name: 'ShipFirst', + last_name: 'ShipLast', + company: 'ShipCo', + address: '322 Ship Addr. Ln.', + address_2: 'St. 43', + city: 'Nowhere', + region: 'OK', + country: 'US', + postal: '73003', + phone_number: '123-456-0000', + phone_country_code: '1', + delivery_speed: :same_day, + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: true, }, payment: { method: :card, diff --git a/lib/minfraud/components/addressable.rb b/lib/minfraud/components/addressable.rb index bb8a2a0..e38f9c9 100644 --- a/lib/minfraud/components/addressable.rb +++ b/lib/minfraud/components/addressable.rb @@ -5,6 +5,7 @@ module Components # This is a parent class for the Billing and Shipping components. class Addressable < Base include Minfraud::Validates + include ::Minfraud::Enum # The first name of the end user as provided in their billing / shipping # information. @@ -74,20 +75,52 @@ class Addressable < Base # @return [String, nil] attr_accessor :phone_country_code + # The most recent method used to verify the phone number. This must be + # one of +:delivered_code+, +:network+, or +:other+. + # +:delivered_code+ is a code delivered to the phone, such as by SMS, + # voice call, or messaging app. +:network+ is verification through the + # mobile network operator, such as silent network authentication. + # + # @!attribute phone_verification_method + # + # @return [Symbol, nil] + enum_accessor :phone_verification_method, %i[delivered_code network other] + + # Whether the most recent verification of the phone number + # succeeded. Do not include this field if no verification was + # attempted. + # + # @return [Boolean, nil] + attr_accessor :phone_was_verification_successful + + # The date and time of the most recent verification of the phone + # number. The string must be in the RFC 3339 date-time format, e.g., + # "2012-04-12T23:20:50.52Z". The time must not be before + # 1970-01-01T00:00:00Z or more than two days in the future. The web + # service ignores a time outside this range and returns a warning. + # + # @see https://datatracker.ietf.org/doc/html/rfc3339 + # + # @return [String, nil] + attr_accessor :phone_verification_time + # @param params [Hash] Hash of parameters. Each key/value should # correspond to one of the available attributes. def initialize(params = {}) - @first_name = params[:first_name] - @last_name = params[:last_name] - @company = params[:company] - @address = params[:address] - @address_2 = params[:address_2] - @city = params[:city] - @region = params[:region] - @country = params[:country] - @postal = params[:postal] - @phone_number = params[:phone_number] - @phone_country_code = params[:phone_country_code] + @first_name = params[:first_name] + @last_name = params[:last_name] + @company = params[:company] + @address = params[:address] + @address_2 = params[:address_2] + @city = params[:city] + @region = params[:region] + @country = params[:country] + @postal = params[:postal] + @phone_number = params[:phone_number] + @phone_country_code = params[:phone_country_code] + self.phone_verification_method = params[:phone_verification_method] + @phone_was_verification_successful = params[:phone_was_verification_successful] + @phone_verification_time = params[:phone_verification_time] validate end @@ -108,6 +141,8 @@ def validate validate_string('postal', 255, @postal) validate_string('phone_number', 255, @phone_number) validate_telephone_country_code('phone_country_code', @phone_country_code) + validate_boolean('phone_was_verification_successful', @phone_was_verification_successful) + validate_rfc3339('phone_verification_time', @phone_verification_time) end end end diff --git a/lib/minfraud/components/base.rb b/lib/minfraud/components/base.rb index 06ccc4f..8cf8e20 100644 --- a/lib/minfraud/components/base.rb +++ b/lib/minfraud/components/base.rb @@ -26,27 +26,22 @@ def to_json(*_args) # # @return [Hash] def populate!(hash, v_sym) - return hash unless (value = instance_variable_get(v_sym)) + value = instance_variable_get(v_sym) + return hash if value.nil? - key = v_sym.to_s.gsub(/@/, '') - hash.merge!(key => represent(key, value)) + key = v_sym.to_s.delete_prefix('@') + hash.merge!(key => represent(value)) end - # Return the value according to the request format. - # - # @param key [Symbol] An instance variable symbol. + # Return the value according to the request format. Booleans stay + # booleans. Other values become strings. # # @param value [Object] An instance variable value. # # @return [Object] - def represent(key, value) - BOOLS.include?(key) ? value : value.to_s + def represent(value) + Minfraud::Validates.boolean?(value) ? value : value.to_s end - - # Keys that have to remain boolean - BOOLS = %w[was_authorized is_gift has_gift_message].freeze - - private_constant :BOOLS end end end diff --git a/lib/minfraud/components/device.rb b/lib/minfraud/components/device.rb index bab10df..a043ed7 100644 --- a/lib/minfraud/components/device.rb +++ b/lib/minfraud/components/device.rb @@ -69,6 +69,7 @@ def validate validate_string('accept_language', 255, @accept_language) validate_nonnegative_number('session_age', @session_age) validate_string('session_id', 255, @session_id) + validate_not_boolean('tracking_token', @tracking_token) end end end diff --git a/lib/minfraud/components/report/transaction.rb b/lib/minfraud/components/report/transaction.rb index 59dba0e..7695a94 100644 --- a/lib/minfraud/components/report/transaction.rb +++ b/lib/minfraud/components/report/transaction.rb @@ -94,6 +94,9 @@ def validate validate_ip('ip_address', @ip_address) validate_string('maxmind_id', 8, @maxmind_id) validate_uuid('minfraud_id', @minfraud_id) + validate_not_boolean('chargeback_code', @chargeback_code) + validate_not_boolean('notes', @notes) + validate_not_boolean('transaction_id', @transaction_id) if ip_address.nil? && (minfraud_id.nil? || empty_uuid?(minfraud_id)) && diff --git a/lib/minfraud/components/shipping.rb b/lib/minfraud/components/shipping.rb index 3248b1e..37f632d 100644 --- a/lib/minfraud/components/shipping.rb +++ b/lib/minfraud/components/shipping.rb @@ -6,8 +6,6 @@ module Components # # @see https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--shipping class Shipping < Addressable - include ::Minfraud::Enum - # The shipping delivery speed for the order. Must be one of +:same_day+, # +:overnight+, +:expedited+, or +:standard+. # diff --git a/lib/minfraud/enum.rb b/lib/minfraud/enum.rb index 3613ca2..39905e4 100644 --- a/lib/minfraud/enum.rb +++ b/lib/minfraud/enum.rb @@ -17,14 +17,22 @@ def self.included(base) # types. module ClassMethods # Returns a hash in the following format: attribute_name => - # permitted_values + # permitted_values. It includes the attributes declared on parent + # classes. # # @return [Hash] def mapping # rubocop:disable ThreadSafety/ClassInstanceVariable - # This is a false positive - this is set during class definition and then only read - @mapping ||= {} + # This is a false positive - this only reads the mapping that + # enum_accessor sets during class definition + declared = @declared_mapping || {} # rubocop:enable ThreadSafety/ClassInstanceVariable + + if superclass.respond_to?(:mapping) + superclass.mapping.merge(declared) + else + declared.dup + end end # Create a set of methods for enum-like behavior of the attribute. @@ -35,23 +43,33 @@ def mapping # # @return [nil] def enum_accessor(attribute, assignable_values) - mapping[attribute] = assignable_values.map(&:intern) + values = assignable_values.map(&:intern).freeze + declared_mapping[attribute] = values - self.class.instance_eval do - define_method("#{attribute}_values") { mapping[attribute] } - end + define_singleton_method("#{attribute}_values") { values } class_eval do define_method(attribute.to_s) { instance_variable_get("@#{attribute}") } define_method "#{attribute}=" do |value| - raise NotEnumValueError, 'Value is not permitted' if value && !self.class.mapping[attribute].include?(value.intern) + value = value.intern if value.is_a?(String) + raise NotEnumValueError, 'Value is not permitted' if !value.nil? && !values.include?(value) - instance_variable_set("@#{attribute}", value ? value.intern : nil) + instance_variable_set("@#{attribute}", value) end end nil end + + private + + def declared_mapping + # rubocop:disable ThreadSafety/ClassInstanceVariable + # This is a false positive - only enum_accessor calls this, during class + # definition + @declared_mapping ||= {} + # rubocop:enable ThreadSafety/ClassInstanceVariable + end end end end diff --git a/lib/minfraud/validates.rb b/lib/minfraud/validates.rb index 4748766..6d1f096 100644 --- a/lib/minfraud/validates.rb +++ b/lib/minfraud/validates.rb @@ -12,6 +12,13 @@ module Minfraud # # rubocop:disable Metrics/ModuleLength module Validates + RFC3339_REGEX = / + \A(\d{4})-(\d{2})-(\d{2}) + T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)? + (?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z + /x + private_constant :RFC3339_REGEX + # Validates that a string value does not exceed a maximum length. # # @param field [String] The field name for error messages. @@ -22,7 +29,9 @@ module Validates # # @return [nil] def validate_string(field, length, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if value.to_s.length > length raise InvalidInputError, "The #{field} value is not valid. The maximum length is #{length}." @@ -38,7 +47,9 @@ def validate_string(field, length, value) # # @return [nil] def validate_md5(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.to_s.match(/\A[a-fA-F0-9]{32}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an MD5 hash as a string." @@ -54,7 +65,9 @@ def validate_md5(field, value) # # @return [nil] def validate_uuid(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) stripped_value = value.to_s.delete('-') @@ -75,7 +88,9 @@ def validate_uuid(field, value) # # @return [nil] def validate_subdivision_code(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.to_s.match(/\A[0-9A-Z]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-2 subdivision code." @@ -91,7 +106,9 @@ def validate_subdivision_code(field, value) # # @return [nil] def validate_country_code(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.to_s.match(/\A[A-Z]{2}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-1 alpha-2 country code." @@ -108,7 +125,9 @@ def validate_country_code(field, value) # # @return [nil] def validate_telephone_country_code(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.to_s.match(/\A[0-9]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be at most 4 digits." @@ -125,7 +144,9 @@ def validate_telephone_country_code(field, value) # # @return [nil] def validate_regex(field, regex, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !regex.match(value.to_s) raise InvalidInputError, "The #{field} value is not valid. It must match the pattern #{regex}." @@ -141,7 +162,9 @@ def validate_regex(field, regex, value) # # @return [nil] def validate_credit_card_token(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) s = value.to_s @@ -163,11 +186,9 @@ def validate_credit_card_token(field, value) # # @return [nil] def validate_custom_input_value(field, value) - return if !value + return if value.nil? - if [true, false].include?(value) - return - end + return if boolean?(value) if value.is_a? Numeric if value < -1e13 + 1 || value > 1e13 - 1 @@ -189,7 +210,9 @@ def validate_custom_input_value(field, value) # # @return [nil] def validate_ip(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) s = value.to_s if s.include? '/' @@ -216,7 +239,9 @@ def validate_ip(field, value) # # @return [nil] def validate_nonnegative_number(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.is_a? Numeric raise InvalidInputError, "The #{field} value is not valid. It must be numeric." @@ -237,7 +262,9 @@ def validate_nonnegative_number(field, value) # # @return [nil] def validate_nonnegative_integer(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if !value.is_a? Integer raise InvalidInputError, "The #{field} is not valid. It must be an integer." @@ -257,9 +284,9 @@ def validate_nonnegative_integer(field, value) # # @return [nil] def validate_email(field, value) - return if !value + return if value.nil? - if /.@./.match?(value) + if /.@./.match?(value.to_s) validate_string(field, 255, value) return end @@ -276,12 +303,16 @@ def validate_email(field, value) # # @return [nil] def validate_rfc3339(field, value) - return if !value + return if value.nil? - # rubocop:disable-next Style/RescueStandardError - begin - DateTime.rfc3339(value) - rescue + validate_not_boolean(field, value) + + # The regex checks the format, time, and offset that the web service + # requires. valid_date? rejects dates that do not exist, such as + # February 30. The web service uses the Gregorian calendar for all + # years. + match = RFC3339_REGEX.match(value.to_s) + if !match || !Date.valid_date?(match[1].to_i, match[2].to_i, match[3].to_i, Date::GREGORIAN) raise InvalidInputError, "The #{field} value is not valid. It must be in the RFC 3339 date-time format." end @@ -297,9 +328,9 @@ def validate_rfc3339(field, value) # # @return [nil] def validate_boolean(field, value) - return if !value + return if value.nil? - if ![false, true].include? value + if !boolean?(value) raise InvalidInputError, "The #{field} value is not valid. It must be boolean." end end @@ -313,7 +344,9 @@ def validate_boolean(field, value) # # @return [nil] def validate_uri(field, value) - return if !value + return if value.nil? + + validate_not_boolean(field, value) if value.to_s.length > 1_024 raise InvalidInputError, "The #{field} value is not valid. It must not exceed 1024 characters." @@ -329,6 +362,30 @@ def validate_uri(field, value) raise InvalidInputError, "The #{field} value is not valid. It must be an absolute URI." end end + + # Validates that a value is not a boolean. + # + # @param field [String] The field name for error messages. + # @param value [Object] The value to validate. + # + # @raise [InvalidInputError] If the value is true or false. + # + # @return [nil] + def validate_not_boolean(field, value) + if boolean?(value) + raise InvalidInputError, "The #{field} value is not valid. It must not be a boolean." + end + end + + # Returns true if the value is true or false. + # + # @param value [Object] The value to check. + # + # @return [Boolean] + def boolean?(value) + [true, false].include?(value) + end + module_function :boolean? # rubocop:enable Metrics/ModuleLength end end diff --git a/spec/components/base_spec.rb b/spec/components/base_spec.rb index dac2448..2b1395e 100644 --- a/spec/components/base_spec.rb +++ b/spec/components/base_spec.rb @@ -29,6 +29,76 @@ base_component.instance_variable_set(:@null, nil) expect(base_component.to_json).to eq(expected) end + + it 'returns json with true kept as a boolean' do + base_component.instance_variable_set(:@yes, true) + expect(base_component.to_json).to eq(expected.merge('yes' => true)) + end + + it 'returns json with false kept as a boolean' do + base_component.instance_variable_set(:@no, false) + expect(base_component.to_json).to eq(expected.merge('no' => false)) + end + end + end + + describe 'boolean inputs' do + [ + [Minfraud::Components::CreditCard, :was_3d_secure_successful], + [Minfraud::Components::Order, :has_gift_message], + [Minfraud::Components::Order, :is_gift], + [Minfraud::Components::Payment, :was_authorized], + [Minfraud::Components::CustomInputs, :boolean_input], + ].product([true, false]).each do |(component, field), value| + it "sends #{component}##{field} #{value} as a JSON boolean" do + expect(component.new(field => value).to_json).to eq(field.to_s => value) + end + end + end + + describe 'booleans with validation' do + before do + Minfraud.configure { |c| c.enable_validation = 1 } + end + + after do + Minfraud.configure { |c| c.enable_validation = false } + end + + [ + [Minfraud::Components::Billing, :company], + [Minfraud::Components::Billing, :country], + [Minfraud::Components::Billing, :phone_country_code], + [Minfraud::Components::Billing, :region], + [Minfraud::Components::CreditCard, :issuer_id_number], + [Minfraud::Components::CreditCard, :token], + [Minfraud::Components::Device, :ip_address], + [Minfraud::Components::Device, :tracking_token], + [Minfraud::Components::Email, :address], + [Minfraud::Components::Event, :time], + [Minfraud::Components::Order, :amount], + [Minfraud::Components::Order, :currency], + [Minfraud::Components::Order, :referrer_uri], + [Minfraud::Components::Report::Transaction, :chargeback_code], + [Minfraud::Components::Report::Transaction, :minfraud_id], + [Minfraud::Components::Report::Transaction, :notes], + [Minfraud::Components::Report::Transaction, :transaction_id], + [Minfraud::Components::ShoppingCartItem, :quantity], + ].product([true, false]).each do |(component, field), value| + it "raises an exception for #{value} in #{component}##{field}" do + expect do + component.new(field => value) + end.to raise_exception(Minfraud::InvalidInputError, /must not be a boolean/) + end + end + + [ + [Minfraud::Components::Order, :is_gift], + [Minfraud::Components::CustomInputs, :boolean_input], + ].each do |component, field| + it "accepts false for #{component}##{field}" do + expect(component.new(field => false).to_json).to eq(field.to_s => false) + end end end end diff --git a/spec/components/billing_spec.rb b/spec/components/billing_spec.rb index 2ddee4c..e990f88 100644 --- a/spec/components/billing_spec.rb +++ b/spec/components/billing_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Minfraud::Components::Billing do + it_behaves_like 'an addressable component' + describe 'validation' do before do Minfraud.configure { |c| c.enable_validation = 1 } diff --git a/spec/components/event_spec.rb b/spec/components/event_spec.rb index d6b8bed..a018a8c 100644 --- a/spec/components/event_spec.rb +++ b/spec/components/event_spec.rb @@ -34,6 +34,41 @@ end.to raise_exception(Minfraud::InvalidInputError) end + %w[ + 2026-10-01T24:00:00Z + 2026-10-01T14:60:00Z + 2026-10-01T14:30:60Z + 2026-10-01T14:30:00+24:00 + 2026-10-01T14:30:00+25:00 + 2026-10-01T14:30:00+05:60 + 2026-13-01T10:00:00Z + 2026-02-30T10:00:00Z + 2026-02-29T00:00:00Z + 1500-02-29T00:00:00Z + 2026-10-01t14:30:00Z + 2026-10-01T14:30:00z + 2026-1-01T14:30:00Z + ].each do |time| + it "raises an exception for time #{time}" do + expect do + described_class.new(time:) + end.to raise_exception(Minfraud::InvalidInputError) + end + end + + %W[ + 2026-10-01T14:30:00Z + 2026-10-01T14:30:00.123456789Z + 2026-10-01T14:30:00.#{'9' * 120}Z + 2026-10-01T14:30:00+23:59 + 2024-02-29T00:00:00Z + 2024-02-29T00:00:00-05:00 + ].each do |time| + it "accepts time #{time}" do + expect(described_class.new(time:).time).to eq(time) + end + end + it 'does not raise an exception for valid values' do described_class.new( time: '2020-08-28T14:00:00Z', diff --git a/spec/components/shipping_spec.rb b/spec/components/shipping_spec.rb new file mode 100644 index 0000000..bc91802 --- /dev/null +++ b/spec/components/shipping_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Minfraud::Components::Shipping do + it_behaves_like 'an addressable component' +end diff --git a/spec/enum_spec.rb b/spec/enum_spec.rb index f2facc9..2d0d0a8 100644 --- a/spec/enum_spec.rb +++ b/spec/enum_spec.rb @@ -28,6 +28,21 @@ expect(klass).to respond_to :test_values end + it 'defines #[attribute_name]_values only on the declaring class' do + klass + expect(String).not_to respond_to :test_values + end + + it 'keeps #[attribute_name]_values when another class declares the same attribute' do + klass + Class.new do + include Minfraud::Enum + + enum_accessor :test, %i[baz] + end + expect(klass.test_values).to eq %i[foo bar] + end + it 'defines attribute reader' do expect(instance).to respond_to :test end @@ -40,6 +55,22 @@ expect { instance.test = :unpermitted_value }.to raise_error Minfraud::NotEnumValueError end + [true, false, 1].each do |value| + it "raises NotEnumValueError for #{value.inspect}" do + expect { instance.test = value }.to raise_error Minfraud::NotEnumValueError + end + end + + it 'clears the value for nil' do + instance.test = :foo + instance.test = nil + expect(instance.test).to be_nil + end + + it 'returns frozen #[attribute_name]_values' do + expect { klass.test_values << :baz }.to raise_error FrozenError + end + it 'converts string value to symbol' do instance.test = 'foo' expect(instance.test).to eq :foo @@ -49,5 +80,30 @@ instance.test = :foo expect(instance.test).to eq :foo end + + it 'assigns a permitted value in a subclass' do + subinstance = Class.new(klass).new + subinstance.test = :foo + expect(subinstance.test).to eq :foo + end + + it 'raises NotEnumValueError for unpermitted values in a subclass' do + expect { Class.new(klass).new.test = :unpermitted_value }.to raise_error Minfraud::NotEnumValueError + end + + it 'returns the parent values from #[attribute_name]_values in a subclass' do + expect(Class.new(klass).test_values).to eq %i[foo bar] + end + + it 'does not change a subclass when it reads the mapping' do + subclass = Class.new(klass) + subclass.mapping + expect(subclass.instance_variable_defined?(:@declared_mapping)).to be false + end + + it 'includes parent attributes in the subclass mapping' do + subclass = Class.new(klass) { enum_accessor :other, %i[baz] } + expect(subclass.mapping).to eq(test: %i[foo bar], other: %i[baz]) + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0b1c732..04720c0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,3 +2,4 @@ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'minfraud' +require_relative 'support/addressable_examples' diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb new file mode 100644 index 0000000..eab45c2 --- /dev/null +++ b/spec/support/addressable_examples.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'an addressable component' do + it 'includes phone_verification_method in the mapping' do + expect(described_class.mapping).to include(phone_verification_method: %i[delivered_code network other]) + end + + it 'accepts each phone_verification_method value' do + %i[delivered_code network other].each do |method| + expect(described_class.new(phone_verification_method: method).phone_verification_method).to eq(method) + end + end + + it 'raises an exception for an invalid phone_verification_method' do + expect do + described_class.new(phone_verification_method: :sms) + end.to raise_exception(Minfraud::NotEnumValueError) + end + + [true, false].each do |successful| + it "sends phone_was_verification_successful #{successful} as a JSON boolean" do + component = described_class.new( + phone_verification_method: 'network', + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: successful, + ) + expect(component.to_json).to eq( + 'phone_verification_method' => 'network', + 'phone_verification_time' => '2026-10-01T14:30:00Z', + 'phone_was_verification_successful' => successful, + ) + end + end + + context 'with validation' do + around do |example| + previous = Minfraud.enable_validation + + Minfraud.configure { |c| c.enable_validation = 1 } + example.run + ensure + Minfraud.configure { |c| c.enable_validation = previous } + end + + it 'raises an exception for an invalid phone_was_verification_successful' do + expect do + described_class.new( + phone_was_verification_successful: 'true', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'raises an exception for an invalid phone_verification_time' do + expect do + described_class.new( + phone_verification_time: '2026-10-01 14:30:00', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'does not raise an exception for valid phone verification values' do + described_class.new( + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: false, + ) + end + end +end