shoulda-matchers 5.3.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +26 -9
  4. data/lib/shoulda/matchers/action_controller/permit_matcher.rb +7 -9
  5. data/lib/shoulda/matchers/action_controller/set_session_or_flash_matcher.rb +13 -15
  6. data/lib/shoulda/matchers/active_model/allow_value_matcher.rb +10 -1
  7. data/lib/shoulda/matchers/active_model/comparison_matcher.rb +157 -0
  8. data/lib/shoulda/matchers/active_model/have_secure_password_matcher.rb +7 -0
  9. data/lib/shoulda/matchers/active_model/numericality_matchers/range_matcher.rb +1 -1
  10. data/lib/shoulda/matchers/active_model/numericality_matchers/submatchers.rb +16 -6
  11. data/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb +0 -6
  12. data/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb +532 -0
  13. data/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb +3 -3
  14. data/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb +4 -3
  15. data/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb +64 -9
  16. data/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb +40 -96
  17. data/lib/shoulda/matchers/active_model/validation_matcher/build_description.rb +6 -7
  18. data/lib/shoulda/matchers/active_model/validation_matcher.rb +6 -0
  19. data/lib/shoulda/matchers/active_model/validator.rb +4 -0
  20. data/lib/shoulda/matchers/active_model.rb +2 -1
  21. data/lib/shoulda/matchers/active_record/association_matcher.rb +31 -11
  22. data/lib/shoulda/matchers/active_record/association_matchers/optional_matcher.rb +23 -19
  23. data/lib/shoulda/matchers/active_record/association_matchers/required_matcher.rb +27 -23
  24. data/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +0 -8
  25. data/lib/shoulda/matchers/active_record/encrypt_matcher.rb +174 -0
  26. data/lib/shoulda/matchers/active_record/have_db_column_matcher.rb +46 -6
  27. data/lib/shoulda/matchers/active_record/have_db_index_matcher.rb +24 -13
  28. data/lib/shoulda/matchers/active_record/have_implicit_order_column.rb +3 -5
  29. data/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb +1 -1
  30. data/lib/shoulda/matchers/active_record/normalize_matcher.rb +151 -0
  31. data/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb +82 -70
  32. data/lib/shoulda/matchers/active_record.rb +2 -0
  33. data/lib/shoulda/matchers/doublespeak/double_collection.rb +2 -6
  34. data/lib/shoulda/matchers/doublespeak/world.rb +2 -6
  35. data/lib/shoulda/matchers/independent/delegate_method_matcher.rb +13 -15
  36. data/lib/shoulda/matchers/rails_shim.rb +8 -6
  37. data/lib/shoulda/matchers/util/word_wrap.rb +1 -1
  38. data/lib/shoulda/matchers/util.rb +17 -19
  39. data/lib/shoulda/matchers/version.rb +1 -1
  40. data/lib/shoulda/matchers.rb +2 -2
  41. data/shoulda-matchers.gemspec +1 -1
  42. metadata +11 -8
  43. data/lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb +0 -136
@@ -0,0 +1,174 @@
1
+ module Shoulda
2
+ module Matchers
3
+ module ActiveRecord
4
+ # The `encrypt` matcher tests usage of the
5
+ # `encrypts` macro (Rails 7+ only).
6
+ #
7
+ # class Survey < ActiveRecord::Base
8
+ # encrypts :access_code
9
+ # end
10
+ #
11
+ # # RSpec
12
+ # RSpec.describe Survey, type: :model do
13
+ # it { should encrypt(:access_code) }
14
+ # end
15
+ #
16
+ # # Minitest (Shoulda)
17
+ # class SurveyTest < ActiveSupport::TestCase
18
+ # should encrypt(:access_code)
19
+ # end
20
+ #
21
+ # #### Qualifiers
22
+ #
23
+ # ##### deterministic
24
+ #
25
+ # class Survey < ActiveRecord::Base
26
+ # encrypts :access_code, deterministic: true
27
+ # end
28
+ #
29
+ # # RSpec
30
+ # RSpec.describe Survey, type: :model do
31
+ # it { should encrypt(:access_code).deterministic(true) }
32
+ # end
33
+ #
34
+ # # Minitest (Shoulda)
35
+ # class SurveyTest < ActiveSupport::TestCase
36
+ # should encrypt(:access_code).deterministic(true)
37
+ # end
38
+ #
39
+ # ##### downcase
40
+ #
41
+ # class Survey < ActiveRecord::Base
42
+ # encrypts :access_code, downcase: true
43
+ # end
44
+ #
45
+ # # RSpec
46
+ # RSpec.describe Survey, type: :model do
47
+ # it { should encrypt(:access_code).downcase(true) }
48
+ # end
49
+ #
50
+ # # Minitest (Shoulda)
51
+ # class SurveyTest < ActiveSupport::TestCase
52
+ # should encrypt(:access_code).downcase(true)
53
+ # end
54
+ #
55
+ # ##### ignore_case
56
+ #
57
+ # class Survey < ActiveRecord::Base
58
+ # encrypts :access_code, deterministic: true, ignore_case: true
59
+ # end
60
+ #
61
+ # # RSpec
62
+ # RSpec.describe Survey, type: :model do
63
+ # it { should encrypt(:access_code).ignore_case(true) }
64
+ # end
65
+ #
66
+ # # Minitest (Shoulda)
67
+ # class SurveyTest < ActiveSupport::TestCase
68
+ # should encrypt(:access_code).ignore_case(true)
69
+ # end
70
+ #
71
+ # @return [EncryptMatcher]
72
+ #
73
+ def encrypt(value)
74
+ EncryptMatcher.new(value)
75
+ end
76
+
77
+ # @private
78
+ class EncryptMatcher
79
+ def initialize(attribute)
80
+ @attribute = attribute.to_sym
81
+ @options = {}
82
+ end
83
+
84
+ attr_reader :failure_message, :failure_message_when_negated
85
+
86
+ def deterministic(deterministic)
87
+ with_option(:deterministic, deterministic)
88
+ end
89
+
90
+ def downcase(downcase)
91
+ with_option(:downcase, downcase)
92
+ end
93
+
94
+ def ignore_case(ignore_case)
95
+ with_option(:ignore_case, ignore_case)
96
+ end
97
+
98
+ def matches?(subject)
99
+ @subject = subject
100
+ result = encrypted_attributes_included? &&
101
+ options_correct?(
102
+ :deterministic,
103
+ :downcase,
104
+ :ignore_case,
105
+ )
106
+
107
+ if result
108
+ @failure_message_when_negated = "Did not expect to #{description} of #{class_name}"
109
+ if @options.present?
110
+ @failure_message_when_negated += "
111
+ using "
112
+ @failure_message_when_negated += @options.map { |opt, expected|
113
+ ":#{opt} option as ‹#{expected}›"
114
+ }.join(' and
115
+ ')
116
+ end
117
+
118
+ @failure_message_when_negated += ",
119
+ but it did"
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ def description
126
+ "encrypt :#{@attribute}"
127
+ end
128
+
129
+ private
130
+
131
+ def encrypted_attributes_included?
132
+ if encrypted_attributes.include?(@attribute)
133
+ true
134
+ else
135
+ @failure_message = "Expected to #{description} of #{class_name}, but it did not"
136
+ false
137
+ end
138
+ end
139
+
140
+ def with_option(option_name, value)
141
+ @options[option_name] = value
142
+ self
143
+ end
144
+
145
+ def options_correct?(*opts)
146
+ opts.all? do |opt|
147
+ next true unless @options.key?(opt)
148
+
149
+ expected = @options[opt]
150
+ actual = encrypted_attribute_scheme.send("#{opt}?")
151
+ next true if expected == actual
152
+
153
+ @failure_message = "Expected to #{description} of #{class_name} using :#{opt} option
154
+ as ‹#{expected}›, but got ‹#{actual}›"
155
+
156
+ false
157
+ end
158
+ end
159
+
160
+ def encrypted_attributes
161
+ @_encrypted_attributes ||= @subject.class.encrypted_attributes || []
162
+ end
163
+
164
+ def encrypted_attribute_scheme
165
+ @subject.class.type_for_attribute(@attribute).scheme
166
+ end
167
+
168
+ def class_name
169
+ @subject.class.name
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -48,6 +48,30 @@ module Shoulda
48
48
  # should have_db_column(:camera_aperture).of_type(:decimal)
49
49
  # end
50
50
  #
51
+ # ##### of_sql_type
52
+ #
53
+ # Use `of_sql_type` to assert that a column is defined as a certain sql_type.
54
+ #
55
+ # class CreatePhones < ActiveRecord::Migration
56
+ # def change
57
+ # create_table :phones do |t|
58
+ # t.string :camera_aperture, limit: 36
59
+ # end
60
+ # end
61
+ # end
62
+ #
63
+ # # RSpec
64
+ # RSpec.describe Phone, type: :model do
65
+ # it do
66
+ # should have_db_column(:camera_aperture).of_sql_type('varchar(36)')
67
+ # end
68
+ # end
69
+ #
70
+ # # Minitest (Shoulda)
71
+ # class PhoneTest < ActiveSupport::TestCase
72
+ # should have_db_column(:camera_aperture).of_sql_type('varchar(36)')
73
+ # end
74
+ #
51
75
  # ##### with_options
52
76
  #
53
77
  # Use `with_options` to assert that a column has been defined with
@@ -96,6 +120,11 @@ module Shoulda
96
120
  self
97
121
  end
98
122
 
123
+ def of_sql_type(sql_column_type)
124
+ @options[:sql_column_type] = sql_column_type
125
+ self
126
+ end
127
+
99
128
  def with_options(opts = {})
100
129
  validate_options(opts)
101
130
  OPTIONS.each do |attribute|
@@ -110,6 +139,7 @@ module Shoulda
110
139
  @subject = subject
111
140
  column_exists? &&
112
141
  correct_column_type? &&
142
+ correct_sql_column_type? &&
113
143
  correct_precision? &&
114
144
  correct_limit? &&
115
145
  correct_default? &&
@@ -129,12 +159,9 @@ module Shoulda
129
159
 
130
160
  def description
131
161
  desc = "have db column named #{@column}"
132
- if @options.key?(:column_type)
133
- desc << " of type #{@options[:column_type]}"
134
- end
135
- if @options.key?(:precision)
136
- desc << " of precision #{@options[:precision]}"
137
- end
162
+ desc << " of type #{@options[:column_type]}" if @options.key?(:column_type)
163
+ desc << " of sql_type #{@options[:sql_column_type]}" if @options.key?(:sql_column_type)
164
+ desc << " of precision #{@options[:precision]}" if @options.key?(:precision)
138
165
  desc << " of limit #{@options[:limit]}" if @options.key?(:limit)
139
166
  desc << " of default #{@options[:default]}" if @options.key?(:default)
140
167
  desc << " of null #{@options[:null]}" if @options.key?(:null)
@@ -178,6 +205,19 @@ module Shoulda
178
205
  end
179
206
  end
180
207
 
208
+ def correct_sql_column_type?
209
+ return true unless @options.key?(:sql_column_type)
210
+
211
+ if matched_column.sql_type.to_s == @options[:sql_column_type].to_s
212
+ true
213
+ else
214
+ @missing =
215
+ "#{model_class} has a db column named #{@column} " <<
216
+ "of sql type #{matched_column.sql_type}, not #{@options[:sql_column_type]}."
217
+ false
218
+ end
219
+ end
220
+
181
221
  def correct_precision?
182
222
  return true unless @options.key?(:precision)
183
223
 
@@ -152,18 +152,18 @@ module Shoulda
152
152
  end
153
153
 
154
154
  def description
155
- description = 'have '
156
-
157
- description <<
158
- if qualifiers.include?(:unique)
159
- "#{Shoulda::Matchers::Util.a_or_an(index_type)} "
160
- else
161
- 'an '
162
- end
155
+ String.new('have ').tap do |description|
156
+ description <<
157
+ if qualifiers.include?(:unique)
158
+ "#{Shoulda::Matchers::Util.a_or_an(index_type)} "
159
+ else
160
+ 'an '
161
+ end
163
162
 
164
- description << 'index on '
163
+ description << 'index on '
165
164
 
166
- description << inspected_expected_columns
165
+ description << inspected_expected_columns
166
+ end
167
167
  end
168
168
 
169
169
  private
@@ -197,17 +197,28 @@ module Shoulda
197
197
  def matched_index
198
198
  @_matched_index ||=
199
199
  if expected_columns.one?
200
- actual_indexes.detect do |index|
200
+ sorted_indexes.detect do |index|
201
201
  Array.wrap(index.columns) == expected_columns
202
202
  end
203
203
  else
204
- actual_indexes.detect do |index|
204
+ sorted_indexes.detect do |index|
205
205
  index.columns == expected_columns
206
206
  end
207
207
  end
208
208
  end
209
209
 
210
- def actual_indexes
210
+ def sorted_indexes
211
+ if qualifiers.include?(:unique)
212
+ # return indexes with unique matching the qualifier first
213
+ unsorted_indexes.sort_by do |index|
214
+ index.unique == qualifiers[:unique] ? 0 : 1
215
+ end
216
+ else
217
+ unsorted_indexes
218
+ end
219
+ end
220
+
221
+ def unsorted_indexes
211
222
  model.connection.indexes(table_name)
212
223
  end
213
224
 
@@ -2,7 +2,7 @@ module Shoulda
2
2
  module Matchers
3
3
  module ActiveRecord
4
4
  # The `have_implicit_order_column` matcher tests that the model has `implicit_order_column`
5
- # assigned to one of the table columns. (Rails 6+ only)
5
+ # assigned to one of the table columns.
6
6
  #
7
7
  # class Product < ApplicationRecord
8
8
  # self.implicit_order_column = :created_at
@@ -20,10 +20,8 @@ module Shoulda
20
20
  #
21
21
  # @return [HaveImplicitOrderColumnMatcher]
22
22
  #
23
- if RailsShim.active_record_gte_6?
24
- def have_implicit_order_column(column_name)
25
- HaveImplicitOrderColumnMatcher.new(column_name)
26
- end
23
+ def have_implicit_order_column(column_name)
24
+ HaveImplicitOrderColumnMatcher.new(column_name)
27
25
  end
28
26
 
29
27
  # @private
@@ -59,7 +59,7 @@ module Shoulda
59
59
  private
60
60
 
61
61
  def readonly_attributes
62
- @_readonly_attributes ||= (@subject.class.readonly_attributes || [])
62
+ @_readonly_attributes ||= @subject.class.readonly_attributes || []
63
63
  end
64
64
 
65
65
  def class_name
@@ -0,0 +1,151 @@
1
+ module Shoulda
2
+ module Matchers
3
+ module ActiveRecord
4
+ # The `normalize` matcher is used to ensure attribute normalizations
5
+ # are transforming attribute values as expected.
6
+ #
7
+ # Take this model for example:
8
+ #
9
+ # class User < ActiveRecord::Base
10
+ # normalizes :email, with: -> email { email.strip.downcase }
11
+ # end
12
+ #
13
+ # You can use `normalize` providing an input and defining the expected
14
+ # normalization output:
15
+ #
16
+ # # RSpec
17
+ # RSpec.describe User, type: :model do
18
+ # it do
19
+ # should normalize(:email).from(" ME@XYZ.COM\n").to("me@xyz.com")
20
+ # end
21
+ # end
22
+ #
23
+ # # Minitest (Shoulda)
24
+ # class User < ActiveSupport::TestCase
25
+ # should normalize(:email).from(" ME@XYZ.COM\n").to("me@xyz.com")
26
+ # end
27
+ #
28
+ # You can use `normalize` to test multiple attributes at once:
29
+ #
30
+ # class User < ActiveRecord::Base
31
+ # normalizes :email, :handle, with: -> value { value.strip.downcase }
32
+ # end
33
+ #
34
+ # # RSpec
35
+ # RSpec.describe User, type: :model do
36
+ # it do
37
+ # should normalize(:email, :handle).from(" Example\n").to("example")
38
+ # end
39
+ # end
40
+ #
41
+ # # Minitest (Shoulda)
42
+ # class User < ActiveSupport::TestCase
43
+ # should normalize(:email, handle).from(" Example\n").to("example")
44
+ # end
45
+ #
46
+ # If the normalization accepts nil values with the `apply_to_nil` option,
47
+ # you just need to use `.from(nil).to("Your expected value here")`.
48
+ #
49
+ # class User < ActiveRecord::Base
50
+ # normalizes :name, with: -> name { name&.titleize || 'Untitled' },
51
+ # apply_to_nil: true
52
+ # end
53
+ #
54
+ # # RSpec
55
+ # RSpec.describe User, type: :model do
56
+ # it { should normalize(:name).from("jane doe").to("Jane Doe") }
57
+ # it { should normalize(:name).from(nil).to("Untitled") }
58
+ # end
59
+ #
60
+ # # Minitest (Shoulda)
61
+ # class User < ActiveSupport::TestCase
62
+ # should normalize(:name).from("jane doe").to("Jane Doe")
63
+ # should normalize(:name).from(nil).to("Untitled")
64
+ # end
65
+ #
66
+ # @return [NormalizeMatcher]
67
+ #
68
+ def normalize(*attributes)
69
+ if attributes.empty?
70
+ raise ArgumentError, 'need at least one attribute'
71
+ else
72
+ NormalizeMatcher.new(*attributes)
73
+ end
74
+ end
75
+
76
+ # @private
77
+ class NormalizeMatcher
78
+ attr_reader :attributes, :from_value, :to_value, :failure_message,
79
+ :failure_message_when_negated
80
+
81
+ def initialize(*attributes)
82
+ @attributes = attributes
83
+ end
84
+
85
+ def description
86
+ %(
87
+ normalize #{attributes.to_sentence(last_word_connector: ' and ')} from
88
+ ‹#{from_value.inspect}› to ‹#{to_value.inspect}›
89
+ ).squish
90
+ end
91
+
92
+ def from(value)
93
+ @from_value = value
94
+
95
+ self
96
+ end
97
+
98
+ def to(value)
99
+ @to_value = value
100
+
101
+ self
102
+ end
103
+
104
+ def matches?(subject)
105
+ attributes.all? { |attribute| attribute_matches?(subject, attribute) }
106
+ end
107
+
108
+ def does_not_match?(subject)
109
+ attributes.all? { |attribute| attribute_does_not_match?(subject, attribute) }
110
+ end
111
+
112
+ private
113
+
114
+ def attribute_matches?(subject, attribute)
115
+ return true if normalize_attribute?(subject, attribute)
116
+
117
+ @failure_message = build_failure_message(
118
+ attribute,
119
+ subject.class.normalize_value_for(attribute, from_value),
120
+ )
121
+ false
122
+ end
123
+
124
+ def attribute_does_not_match?(subject, attribute)
125
+ return true unless normalize_attribute?(subject, attribute)
126
+
127
+ @failure_message_when_negated = build_failure_message_when_negated(attribute)
128
+ false
129
+ end
130
+
131
+ def normalize_attribute?(subject, attribute)
132
+ subject.class.normalize_value_for(attribute, from_value) == to_value
133
+ end
134
+
135
+ def build_failure_message(attribute, attribute_value)
136
+ %(
137
+ Expected to normalize #{attribute.inspect} from ‹#{from_value.inspect}› to
138
+ ‹#{to_value.inspect}› but it was normalized to ‹#{attribute_value.inspect}›
139
+ ).squish
140
+ end
141
+
142
+ def build_failure_message_when_negated(attribute)
143
+ %(
144
+ Expected to not normalize #{attribute.inspect} from ‹#{from_value.inspect}› to
145
+ ‹#{to_value.inspect}› but it was normalized
146
+ ).squish
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end