Skip to content

Commit

Permalink
Add normalize matcher (#1558)
Browse files Browse the repository at this point in the history
  • Loading branch information
stephannv authored Dec 12, 2023
1 parent f2db1f2 commit baabf89
Show file tree
Hide file tree
Showing 3 changed files with 268 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/shoulda/matchers/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions lib/shoulda/matchers/active_record/normalize_matcher.rb
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions spec/unit/shoulda/matchers/active_record/normalize_matcher_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit baabf89

Please sign in to comment.