From baabf89127d712855684b351073764026a6c1df1 Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:43:27 -0300 Subject: [PATCH] Add `normalize` matcher (#1558) --- lib/shoulda/matchers/active_record.rb | 1 + .../active_record/normalize_matcher.rb | 151 ++++++++++++++++++ .../active_record/normalize_matcher_spec.rb | 116 ++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 lib/shoulda/matchers/active_record/normalize_matcher.rb create mode 100644 spec/unit/shoulda/matchers/active_record/normalize_matcher_spec.rb diff --git a/lib/shoulda/matchers/active_record.rb b/lib/shoulda/matchers/active_record.rb index 5552e65b2..dab0be1ff 100644 --- a/lib/shoulda/matchers/active_record.rb +++ b/lib/shoulda/matchers/active_record.rb @@ -24,6 +24,7 @@ require 'shoulda/matchers/active_record/uniqueness' require 'shoulda/matchers/active_record/validate_uniqueness_of_matcher' require 'shoulda/matchers/active_record/have_attached_matcher' +require 'shoulda/matchers/active_record/normalize_matcher' module Shoulda module Matchers diff --git a/lib/shoulda/matchers/active_record/normalize_matcher.rb b/lib/shoulda/matchers/active_record/normalize_matcher.rb new file mode 100644 index 000000000..b5cc616f7 --- /dev/null +++ b/lib/shoulda/matchers/active_record/normalize_matcher.rb @@ -0,0 +1,151 @@ +module Shoulda + module Matchers + module ActiveRecord + # The `normalize` matcher is used to ensure attribute normalizations + # are transforming attribute values as expected. + # + # Take this model for example: + # + # class User < ActiveRecord::Base + # normalizes :email, with: -> email { email.strip.downcase } + # end + # + # You can use `normalize` providing an input and defining the expected + # normalization output: + # + # # RSpec + # RSpec.describe User, type: :model do + # it do + # should normalize(:email).from(" ME@XYZ.COM\n").to("me@xyz.com") + # end + # end + # + # # Minitest (Shoulda) + # class User < ActiveSupport::TestCase + # should normalize(:email).from(" ME@XYZ.COM\n").to("me@xyz.com") + # end + # + # You can use `normalize` to test multiple attributes at once: + # + # class User < ActiveRecord::Base + # normalizes :email, :handle, with: -> value { value.strip.downcase } + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it do + # should normalize(:email, :handle).from(" Example\n").to("example") + # end + # end + # + # # Minitest (Shoulda) + # class User < ActiveSupport::TestCase + # should normalize(:email, handle).from(" Example\n").to("example") + # end + # + # If the normalization accepts nil values with the `apply_to_nil` option, + # you just need to use `.from(nil).to("Your expected value here")`. + # + # class User < ActiveRecord::Base + # normalizes :name, with: -> name { name&.titleize || 'Untitled' }, + # apply_to_nil: true + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should normalize(:name).from("jane doe").to("Jane Doe") } + # it { should normalize(:name).from(nil).to("Untitled") } + # end + # + # # Minitest (Shoulda) + # class User < ActiveSupport::TestCase + # should normalize(:name).from("jane doe").to("Jane Doe") + # should normalize(:name).from(nil).to("Untitled") + # end + # + # @return [NormalizeMatcher] + # + def normalize(*attributes) + if attributes.empty? + raise ArgumentError, 'need at least one attribute' + else + NormalizeMatcher.new(*attributes) + end + end + + # @private + class NormalizeMatcher + attr_reader :attributes, :from_value, :to_value, :failure_message, + :failure_message_when_negated + + def initialize(*attributes) + @attributes = attributes + end + + def description + %( + normalize #{attributes.to_sentence(last_word_connector: ' and ')} from + ‹#{from_value.inspect}› to ‹#{to_value.inspect}› + ).squish + end + + def from(value) + @from_value = value + + self + end + + def to(value) + @to_value = value + + self + end + + def matches?(subject) + attributes.all? { |attribute| attribute_matches?(subject, attribute) } + end + + def does_not_match?(subject) + attributes.all? { |attribute| attribute_does_not_match?(subject, attribute) } + end + + private + + def attribute_matches?(subject, attribute) + return true if normalize_attribute?(subject, attribute) + + @failure_message = build_failure_message( + attribute, + subject.class.normalize_value_for(attribute, from_value), + ) + false + end + + def attribute_does_not_match?(subject, attribute) + return true unless normalize_attribute?(subject, attribute) + + @failure_message_when_negated = build_failure_message_when_negated(attribute) + false + end + + def normalize_attribute?(subject, attribute) + subject.class.normalize_value_for(attribute, from_value) == to_value + end + + def build_failure_message(attribute, attribute_value) + %( + Expected to normalize #{attribute.inspect} from ‹#{from_value.inspect}› to + ‹#{to_value.inspect}› but it was normalized to ‹#{attribute_value.inspect}› + ).squish + end + + def build_failure_message_when_negated(attribute) + %( + Expected to not normalize #{attribute.inspect} from ‹#{from_value.inspect}› to + ‹#{to_value.inspect}› but it was normalized + ).squish + end + end + end + end +end diff --git a/spec/unit/shoulda/matchers/active_record/normalize_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/normalize_matcher_spec.rb new file mode 100644 index 000000000..43e4bdfc8 --- /dev/null +++ b/spec/unit/shoulda/matchers/active_record/normalize_matcher_spec.rb @@ -0,0 +1,116 @@ +require 'unit_spec_helper' + +describe Shoulda::Matchers::ActiveRecord::NormalizeMatcher, type: :model do + if rails_version >= 7.1 + describe '#description' do + it 'returns the message including the attribute names, from value and to value' do + matcher = normalize(:name, :email).from("jane doe\n").to('Jane Doe') + expect(matcher.description). + to eq('normalize name and email from ‹"jane doe\n"› to ‹"Jane Doe"›') + end + end + + context 'when subject normalizes single attribute correctly' do + it 'matches' do + model = define_model(:User, email: :string) do + normalizes :email, with: -> (email) { email.strip.downcase } + end + + expect(model.new).to normalize(:email).from(" XyZ@EXAMPLE.com\n").to('xyz@example.com') + end + end + + context 'when subject normalizes multiple attributes correctly' do + it 'matches' do + model = define_model(:User, email: :string, name: :string) do + normalizes :email, :name, with: -> (email) { email.strip.downcase } + end + + expect(model.new).to normalize(:email, :name).from(" XyZ\n").to('xyz') + end + end + + context 'when subject normalizes single attribute incorrectly' do + it 'fails' do + model = define_model(:User, email: :string) do + normalizes :email, with: -> (email) { email.titleize } + end + + assertion = lambda do + expect(model.new).to normalize(:email).from(" XyZ@EXAMPLE.com\n").to('xyz@example.com') + end + + message = %( + Expected to normalize :email from ‹" XyZ@EXAMPLE.com\\n"› to ‹"xyz@example.com"› + but it was normalized to ‹"Xy Z@Example.Com\\n"› + ).squish + + expect(&assertion).to fail_with_message(message) + end + end + + context 'when subject normalizes just one attribute incorrectly among multiple attributes' do + it 'fails' do + model = define_model(:User, email: :string, name: :string) do + normalizes :name, with: -> (name) { name.titleize.strip } + normalizes :email, with: -> (email) { email.downcase.strip } + end + + assertion = lambda do + expect(model.new).to normalize(:name, :email).from(" JaneDoe\n").to('Jane Doe') + end + + message = %( + Expected to normalize :email from ‹" JaneDoe\\n"› to ‹"Jane Doe"› + but it was normalized to ‹"janedoe"› + ).squish + + expect(&assertion).to fail_with_message(message) + end + end + + context 'when subject normalize nil values correctly' do + it 'matches' do + model = define_model(:User, name: :string) do + normalizes :name, with: -> (name) { name&.strip || 'Untitled' }, apply_to_nil: true + end + + record = model.new + + expect(record).to normalize(:name).from(' Jane Doe ').to('Jane Doe') + expect(record).to normalize(:name).from(nil).to('Untitled') + end + end + + context "when subject doesn't normalize attribute that it shouldn't normalize" do + it 'does not match' do + model = define_model(:User, email: :string) + + expect(model.new).not_to normalize(:email). + from(" XyZ@EXAMPLE.com\n"). + to('xyz@example.com') + end + end + + context "when subject normalizes attributes that it shouldn't normalize" do + it 'fails' do + model = define_model(:User, email: :string, name: :string) do + normalizes :email, with: -> (email) { email.strip.downcase } + end + + assertion = lambda do + expect(model.new).not_to normalize(:name, :email). + from(" XyZ@EXAMPLE.com\n"). + to('xyz@example.com') + end + + message = %( + Expected to not normalize :email from ‹" XyZ@EXAMPLE.com\\n"› to ‹"xyz@example.com"› + but it was normalized + ).squish + + expect(&assertion).to fail_with_message(message) + end + end + end +end