servactory 3.1.0.rc3 → 3.1.0.rc4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/lib/generators/README.md +1 -1
  4. data/lib/generators/servactory/extension/USAGE +1 -1
  5. data/lib/generators/servactory/install/USAGE +2 -2
  6. data/lib/generators/servactory/rspec/USAGE +4 -4
  7. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  8. data/lib/generators/servactory/service/USAGE +5 -5
  9. data/lib/servactory/actions/collection.rb +21 -5
  10. data/lib/servactory/actions/stages/collection.rb +19 -1
  11. data/lib/servactory/actions/tools/rules.rb +1 -1
  12. data/lib/servactory/actions/tools/runner.rb +1 -1
  13. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  14. data/lib/servactory/configuration/configurable.rb +1 -1
  15. data/lib/servactory/configuration/factory.rb +30 -3
  16. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +2 -1
  17. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +150 -37
  18. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  19. data/lib/servactory/context/workspace/inputs.rb +2 -2
  20. data/lib/servactory/context/workspace/internals.rb +2 -2
  21. data/lib/servactory/context/workspace/outputs.rb +2 -2
  22. data/lib/servactory/context/workspace.rb +7 -7
  23. data/lib/servactory/info/builder.rb +4 -3
  24. data/lib/servactory/inputs/dsl.rb +5 -1
  25. data/lib/servactory/inputs/input.rb +1 -2
  26. data/lib/servactory/internals/dsl.rb +7 -1
  27. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  28. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  29. data/lib/servactory/maintenance/options/collection.rb +46 -15
  30. data/lib/servactory/maintenance/options/option.rb +3 -12
  31. data/lib/servactory/maintenance/options/registrar.rb +2 -12
  32. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  33. data/lib/servactory/outputs/dsl.rb +7 -1
  34. data/lib/servactory/result.rb +21 -8
  35. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  36. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  37. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -129
  38. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  39. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  40. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +110 -45
  41. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +26 -8
  42. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  43. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  44. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  45. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  46. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  47. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  48. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  49. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  50. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  51. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  52. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  53. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  54. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  55. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  56. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  58. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  59. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +29 -8
  60. data/lib/servactory/tool_kit/dynamic_options/format.rb +2 -3
  61. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +1 -1
  62. data/lib/servactory/tool_kit/dynamic_options/max.rb +1 -1
  63. data/lib/servactory/tool_kit/dynamic_options/min.rb +1 -1
  64. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +33 -5
  65. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  66. data/lib/servactory/tool_kit/dynamic_options/schema.rb +34 -21
  67. data/lib/servactory/tool_kit/dynamic_options/target.rb +1 -1
  68. data/lib/servactory/utils.rb +13 -3
  69. data/lib/servactory/version.rb +1 -1
  70. data/lib/servactory.rb +3 -0
  71. metadata +10 -2
@@ -18,21 +18,40 @@ module Servactory
18
18
  # ```ruby
19
19
  # it { is_expected.to have_service_input(:user_id).required }
20
20
  # it { is_expected.to have_service_input(:name).required("Name is mandatory") }
21
+ # it { is_expected.to have_service_input(:name).required(/is mandatory/) }
22
+ # it { is_expected.to have_service_input(:name).required(:default) }
21
23
  # ```
22
24
  #
23
25
  # ## Validation
24
26
  #
25
27
  # Checks the `:required` option in attribute data where `is: true`.
26
28
  # If a custom message is provided, also validates the message matches.
29
+ #
30
+ # The expected message follows the rules of `.message`: a String is
31
+ # compared with the message exactly, a Regexp is matched against it,
32
+ # an RSpec matcher is applied to the message as defined, and
33
+ # `:default` checks that the input defines no custom message.
34
+ # The default required message does not depend on the given value,
35
+ # so a String or Regexp is compared with it when there is no
36
+ # custom message.
37
+ #
38
+ # A Proc message is called with the keyword arguments the library
39
+ # passes to it: `service:`, `input:` and `value:`, which is `nil`.
40
+ # A Proc message that raises or does not return a String does not
41
+ # match, and the failure message shows the error or the returned value.
27
42
  class RequiredSubmatcher < Base::Submatcher
43
+ include Concerns::RequiredMessage
44
+
28
45
  # Creates a new required submatcher.
29
46
  #
30
47
  # @param context [Base::SubmatcherContext] The submatcher context
31
- # @param custom_message [String, nil] Optional expected error message
48
+ # @param custom_message [String, Regexp, Symbol, Object, nil] Optional expected error message,
49
+ # `:default` or an RSpec matcher
32
50
  # @return [RequiredSubmatcher] New submatcher instance
51
+ # @raise [ArgumentError] If the expected message is of another kind
33
52
  def initialize(context, custom_message = nil)
34
53
  super(context)
35
- @custom_message = custom_message
54
+ @expectation = Base::MessageExpectation.new(custom_message) unless custom_message.nil?
36
55
  end
37
56
 
38
57
  # Returns description for RSpec output.
@@ -48,41 +67,57 @@ module Servactory
48
67
  #
49
68
  # @return [Boolean] True if input is required with matching message
50
69
  def passes?
51
- required_data = attribute_data.fetch(:required)
52
- is_required = required_data.fetch(:is)
70
+ return false unless required?
71
+ return true if expectation.nil?
53
72
 
54
- return false unless is_required == true
55
- return true unless custom_message.present?
56
-
57
- actual_message = required_data.fetch(:message) || default_required_message
58
- actual_message.casecmp(custom_message).zero?
73
+ @message_mismatch = find_message_mismatch
74
+ @message_mismatch.nil?
59
75
  end
60
76
 
61
77
  # Builds the failure message for required validation.
62
78
  #
63
79
  # @return [String] Failure message with expected vs actual
64
80
  def build_failure_message
65
- <<~MESSAGE
66
- should be required
81
+ return not_required_failure_message unless required?
67
82
 
68
- expected required: true
69
- got required: false
70
- MESSAGE
83
+ "should be required with the expected message\n\n#{@message_mismatch.indent(2)}"
71
84
  end
72
85
 
73
86
  private
74
87
 
75
- attr_reader :custom_message
88
+ attr_reader :expectation
89
+
90
+ # Checks if the input has `required: true`.
91
+ #
92
+ # @return [Boolean] True if input is required
93
+ def required?
94
+ attribute_data.fetch(:required).fetch(:is) == true
95
+ end
96
+
97
+ # Compares the message of the required option with the expected message.
98
+ #
99
+ # Uses the custom message from `required: { message: }` if provided,
100
+ # otherwise the default message. A Proc message is called with the
101
+ # keyword arguments the library passes to it.
102
+ #
103
+ # @return [String, nil] Explanation of the mismatch, or nil if the messages match
104
+ def find_message_mismatch
105
+ expectation.mismatch_for(
106
+ required_custom_message,
107
+ default_message: default_required_message
108
+ ) { |message| call_required_message(message) }
109
+ end
76
110
 
77
- # Generates the default I18n message for required validation.
111
+ # Builds the failure message for an optional input.
78
112
  #
79
- # @return [String] Localized default required message
80
- def default_required_message
81
- I18n.t(
82
- "#{i18n_root_key}.inputs.validations.required.default_error.default",
83
- service_class_name: described_class.name,
84
- input_name: attribute_name
85
- )
113
+ # @return [String] Failure message with expected vs actual required state
114
+ def not_required_failure_message
115
+ <<~MESSAGE
116
+ should be required
117
+
118
+ expected required: true
119
+ got required: false
120
+ MESSAGE
86
121
  end
87
122
  end
88
123
  end
@@ -35,6 +35,10 @@ module Servactory
35
35
  # 5. `failure_inclusion_passes?` - fails with value outside inclusion
36
36
  # 6. `failure_target_passes?` - fails with value outside target
37
37
  class ValidWithSubmatcher < Base::Submatcher # rubocop:disable Metrics/ClassLength
38
+ include Concerns::OptionHelperRules
39
+ include Concerns::ProcMessage
40
+ include Concerns::RequiredMessage
41
+
38
42
  # Creates a new valid_with submatcher.
39
43
  #
40
44
  # @param context [Base::SubmatcherContext] The submatcher context
@@ -93,44 +97,59 @@ module Servactory
93
97
  # Checks that service fails with wrong type.
94
98
  #
95
99
  # @return [Boolean] True if type validation fails as expected
96
- def failure_type_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
97
- option_types = attribute_data.fetch(:types)
100
+ def failure_type_passes?
101
+ wrong_value = Servactory::TestKit::FakeType.new
98
102
 
99
103
  prepared_attributes = attributes.dup
100
- prepared_attributes[attribute_name] = Servactory::TestKit::FakeType.new
104
+ prepared_attributes[attribute_name] = wrong_value
105
+
106
+ expect_failure_with!(prepared_attributes, type_error_message_for(wrong_value))
107
+ end
108
+
109
+ # Builds the message expected for a value of wrong type.
110
+ #
111
+ # Uses the custom message from `type: { is:, message: }` if provided,
112
+ # otherwise the default message.
113
+ #
114
+ # @param value [Object] The value of wrong type
115
+ # @return [String] Expected error message
116
+ def type_error_message_for(value)
117
+ message = attribute_data.fetch(:type).fetch(:message)
118
+ expected_type = attribute_data.fetch(:types).join(", ")
119
+ given_type = value.class.name
120
+
121
+ return default_type_error_message(expected_type:, given_type:) if message.blank?
122
+ return message unless message.is_a?(Proc)
123
+
124
+ call_proc_message(message, :type, value:, expected_type:, given_type:)
125
+ end
101
126
 
102
- input_required_message = I18n.t(
127
+ # Builds the default message for a value of wrong type.
128
+ #
129
+ # @param expected_type [String] Expected type names
130
+ # @param given_type [String] Name of the given value's class
131
+ # @return [String] Default error message
132
+ def default_type_error_message(expected_type:, given_type:)
133
+ I18n.t(
103
134
  "#{i18n_root_key}.inputs.validations.type.default_error.default",
104
135
  service_class_name: described_class.name,
105
136
  input_name: attribute_name,
106
- expected_type: option_types.join(", "),
107
- given_type: Servactory::TestKit::FakeType.new.class.name
137
+ expected_type:,
138
+ given_type:
108
139
  )
109
-
110
- expect_failure_with!(prepared_attributes, input_required_message)
111
140
  end
112
141
 
113
142
  # Checks that required validation fails when input is nil.
114
143
  #
115
144
  # @return [Boolean] True if required validation fails as expected
116
- def failure_required_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
145
+ def failure_required_passes?
117
146
  input_required = attribute_data.fetch(:required).fetch(:is)
118
147
  return true unless input_required
119
148
 
120
149
  prepared_attributes = attributes.dup
121
150
  prepared_attributes[attribute_name] = nil
122
151
 
123
- input_required_message = attribute_data.fetch(:required).fetch(:message)
124
-
125
- if input_required_message.nil?
126
- input_required_message = I18n.t(
127
- "#{i18n_root_key}.inputs.validations.required.default_error.default",
128
- service_class_name: described_class.name,
129
- input_name: attribute_name
130
- )
131
- end
132
-
133
- expect_failure_with!(prepared_attributes, input_required_message)
152
+ expect_failure_with!(prepared_attributes, required_message)
134
153
  end
135
154
 
136
155
  # Checks that optional input accepts nil without failure.
@@ -149,7 +168,7 @@ module Servactory
149
168
  # Checks that inclusion validation fails with wrong value.
150
169
  #
151
170
  # @return [Boolean] True if inclusion validation fails as expected
152
- def failure_inclusion_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
171
+ def failure_inclusion_passes?
153
172
  input_inclusion_in = attribute_data.dig(:inclusion, :in)
154
173
  return true if input_inclusion_in.blank?
155
174
 
@@ -158,31 +177,43 @@ module Servactory
158
177
  prepared_attributes = attributes.dup
159
178
  prepared_attributes[attribute_name] = wrong_value
160
179
 
161
- input_required_message = attribute_data.fetch(:inclusion).fetch(:message)
162
-
163
- # If message is a Proc, we can't easily evaluate it in the test context
164
- # (it may require runtime args like input:, value:), so skip message comparison
165
- if input_required_message.is_a?(Proc)
166
- return expect_failure_with!(prepared_attributes, :skip_message_check)
167
- end
180
+ expect_failure_with!(prepared_attributes, inclusion_error_message_for(wrong_value, input_inclusion_in))
181
+ end
168
182
 
169
- if input_required_message.nil?
170
- input_required_message = I18n.t(
171
- "#{i18n_root_key}.inputs.validations.must.dynamic_options.inclusion.default",
172
- service_class_name: described_class.name,
173
- input_name: attribute_name,
174
- input_inclusion: input_inclusion_in.inspect,
175
- value: wrong_value.inspect
176
- )
177
- end
183
+ # Builds the message expected for a value outside the inclusion set.
184
+ #
185
+ # Uses the custom message from `inclusion: { in:, message: }` if provided,
186
+ # otherwise the default message.
187
+ #
188
+ # @param value [Object] The value outside the inclusion set
189
+ # @param option_value [Range, Array, Object] The inclusion constraint
190
+ # @return [Object] Expected error message
191
+ def inclusion_error_message_for(value, option_value)
192
+ message = attribute_data.fetch(:inclusion).fetch(:message)
193
+ return default_inclusion_error_message(value:, option_value:) if message.blank?
194
+
195
+ dynamic_option_message(message, :inclusion, value:, option_value:)
196
+ end
178
197
 
179
- expect_failure_with!(prepared_attributes, input_required_message)
198
+ # Builds the default message for a value outside the inclusion set.
199
+ #
200
+ # @param value [Object] The value outside the inclusion set
201
+ # @param option_value [Object] The inclusion constraint
202
+ # @return [String] Default error message
203
+ def default_inclusion_error_message(value:, option_value:)
204
+ I18n.t(
205
+ "#{i18n_root_key}.inputs.validations.must.dynamic_options.inclusion.default",
206
+ service_class_name: described_class.name,
207
+ input_name: attribute_name,
208
+ input_inclusion: option_value.inspect,
209
+ value: value.inspect
210
+ )
180
211
  end
181
212
 
182
213
  # Checks that target validation fails with wrong value.
183
214
  #
184
215
  # @return [Boolean] True if target validation fails as expected
185
- def failure_target_passes? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
216
+ def failure_target_passes?
186
217
  input_target_in = attribute_data.dig(:target, :in)
187
218
  return true if input_target_in.blank?
188
219
 
@@ -192,40 +223,74 @@ module Servactory
192
223
  prepared_attributes = attributes.dup
193
224
  prepared_attributes[attribute_name] = wrong_value
194
225
 
195
- input_required_message = attribute_data.fetch(:target).fetch(:message)
226
+ expect_failure_with!(prepared_attributes, target_error_message_for(wrong_value, input_target_in))
227
+ end
196
228
 
197
- # If message is a Proc, we can't easily evaluate it in the test context
198
- # (it may require runtime args like input:, value:), so skip message comparison
199
- if input_required_message.is_a?(Proc)
200
- return expect_failure_with!(prepared_attributes, :skip_message_check)
201
- end
229
+ # Builds the message expected for a value outside the target set.
230
+ #
231
+ # Uses the custom message from `target: { in:, message: }` if provided,
232
+ # otherwise the default message.
233
+ #
234
+ # @param value [Object] The value outside the target set
235
+ # @param option_value [Array, Object] The target constraint
236
+ # @return [Object] Expected error message
237
+ def target_error_message_for(value, option_value)
238
+ message = attribute_data.fetch(:target).fetch(:message)
239
+ return default_target_error_message(value:, option_value:) if message.blank?
240
+
241
+ dynamic_option_message(message, :target, value:, option_value:)
242
+ end
202
243
 
203
- if input_required_message.nil?
204
- input_required_message = I18n.t(
205
- "#{i18n_root_key}.inputs.validations.must.dynamic_options.target.default",
206
- service_class_name: described_class.name,
207
- input_name: attribute_name,
208
- expected_target: input_target_in.inspect,
209
- value: wrong_value.inspect
210
- )
211
- end
244
+ # Builds the default message for a value outside the target set.
245
+ #
246
+ # @param value [Object] The value outside the target set
247
+ # @param option_value [Object] The target constraint
248
+ # @return [String] Default error message
249
+ def default_target_error_message(value:, option_value:)
250
+ I18n.t(
251
+ "#{i18n_root_key}.inputs.validations.must.dynamic_options.target.default",
252
+ service_class_name: described_class.name,
253
+ input_name: attribute_name,
254
+ expected_target: option_value.inspect,
255
+ value: value.inspect
256
+ )
257
+ end
212
258
 
213
- expect_failure_with!(prepared_attributes, input_required_message)
259
+ # Returns the custom message of a dynamic option as the library builds it.
260
+ #
261
+ # A Proc message is called with the keywords the library passes to it
262
+ # when the value is outside the option, where the failure has no reason.
263
+ #
264
+ # @param message [Proc, Object] The custom message of the option
265
+ # @param option_name [Symbol] The option name, such as `inclusion`
266
+ # @param value [Object] The value outside the option
267
+ # @param option_value [Object] The value of the option
268
+ # @return [Object] Expected error message
269
+ def dynamic_option_message(message, option_name, value:, option_value:)
270
+ return message unless message.is_a?(Proc)
271
+
272
+ call_proc_message(
273
+ message,
274
+ :dynamic_option,
275
+ value:,
276
+ code: option_helper_rule_name(option_name),
277
+ reason: nil,
278
+ option_name:,
279
+ option_value:
280
+ )
214
281
  end
215
282
 
216
283
  # Calls service and verifies it fails with expected message.
217
284
  #
218
285
  # @param prepared_attributes [Hash] Attributes to pass to service
219
- # @param expected_message [String, Symbol, nil] Expected error message
286
+ # @param expected_message [Object, nil] Expected error message
220
287
  # @return [Boolean] True if service fails with expected message
221
288
  def expect_failure_with!(prepared_attributes, expected_message)
222
289
  described_class.call!(prepared_attributes).success?
223
290
  rescue Servactory::Exceptions::Input => e
224
- return true if expected_message == :skip_message_check # Just verify error was raised
225
291
  return false if expected_message.blank?
226
292
 
227
- message_to_compare = expected_message.is_a?(Proc) ? expected_message.call : expected_message
228
- message_to_compare.to_s.casecmp(e.message.to_s).zero?
293
+ expected_message.to_s.casecmp(e.message.to_s).zero?
229
294
  rescue Servactory::Exceptions::Internal, Servactory::Exceptions::Output
230
295
  true
231
296
  end
@@ -17,16 +17,64 @@ module Servactory
17
17
  # ## Usage
18
18
  #
19
19
  # ```ruby
20
+ # it { is_expected.to have_service_input(:id).type(Integer).message("ID must be an Integer") }
20
21
  # it { is_expected.to have_service_input(:email).inclusion(%w[a b]).message("Invalid email") }
21
22
  # it { is_expected.to have_service_input(:data).schema({ key: String }).message("Invalid schema") }
23
+ # it { is_expected.to have_service_input(:email).inclusion(%w[a b]).message(/Invalid/) }
24
+ # it { is_expected.to have_service_input(:email).inclusion(%w[a b]).message(be_a(Proc)) }
25
+ # it { is_expected.to have_service_input(:email).inclusion(%w[a b]).message(:default) }
26
+ # it { is_expected.to have_service_input(:age).must(:be_adult).message("Must be an adult") }
22
27
  # ```
23
28
  #
24
29
  # ## Note
25
30
  #
26
31
  # Requires `requires_last_submatcher: true` - must follow another
27
- # submatcher. Uses the previous submatcher's OPTION_NAME constant
28
- # to find the message field.
32
+ # submatcher. Uses the previous submatcher's OPTION_NAME constant,
33
+ # or its `option_name` for options with a configurable name such as
34
+ # `target`, to find the message field. Chaining it after a submatcher
35
+ # without such an option raises ArgumentError.
36
+ #
37
+ # After `.must`, the must submatcher stores the expected message
38
+ # itself (see MustSubmatcher).
39
+ #
40
+ # Every `.message` in a chain is checked, each against the option
41
+ # chained right before it:
42
+ #
43
+ # ```ruby
44
+ # it do
45
+ # is_expected.to have_service_input(:status)
46
+ # .type(Symbol).message("Status must be a Symbol")
47
+ # .inclusion(%i[active inactive]).message("Status must be active or inactive")
48
+ # end
49
+ # ```
50
+ #
51
+ # A String is compared with the message exactly, a Regexp is matched
52
+ # against it, and an RSpec matcher is applied to the message as
53
+ # defined, for example to check that it is a Proc.
54
+ #
55
+ # A Proc message is called before a String or Regexp comparison with
56
+ # every keyword argument the library passes to it while the service
57
+ # runs: `service:`, the attribute (`input:`, `internal:` or
58
+ # `output:`) and `value:`, plus `expected_type:` and `given_type:`
59
+ # for `type`, or `code:`, `reason:`, `option_name:` and
60
+ # `option_value:` for the other options, and `key_name:`,
61
+ # `expected_type:` and `given_type:` for `schema` as well (see
62
+ # Concerns::ProcMessage). The library passes the same keywords for
63
+ # every failure reason of an option. Values known only while the
64
+ # service runs, such as `value:` and `reason:`, are `nil`.
65
+ # A Proc message that raises with them, for example because it does
66
+ # not accept every keyword, or does not return a String does not
67
+ # match, and the failure message shows the error or the returned
68
+ # value.
69
+ #
70
+ # `:default` checks that the option defines no custom message.
71
+ # Without a custom message, a String or Regexp does not match:
72
+ # the default message of these options depends on values known
73
+ # only while the service runs. Check it with `raise_error` instead.
29
74
  class MessageSubmatcher < Base::Submatcher
75
+ include Concerns::OptionHelperRules
76
+ include Concerns::ProcMessage
77
+
30
78
  # Option name in attribute data (unused - uses last submatcher's)
31
79
  OPTION_NAME = :message
32
80
  # Key for the message within the option
@@ -35,18 +83,22 @@ module Servactory
35
83
  # Creates a new message submatcher.
36
84
  #
37
85
  # @param context [Base::SubmatcherContext] The submatcher context
38
- # @param custom_message [String] Expected error message
86
+ # @param custom_message [String, Regexp, Symbol, Object] Expected error message, `:default`
87
+ # or an RSpec matcher
39
88
  # @return [MessageSubmatcher] New submatcher instance
89
+ # @raise [ArgumentError] If the previous submatcher checks no option with a message,
90
+ # or the expected message is of another kind
40
91
  def initialize(context, custom_message)
41
92
  super(context)
42
- @custom_message = custom_message
93
+ ensure_option_with_message!
94
+ @expectation = Base::MessageExpectation.new(custom_message)
43
95
  end
44
96
 
45
97
  # Returns description for RSpec output.
46
98
  #
47
99
  # @return [String] Human-readable description with message
48
100
  def description
49
- "message: #{@attribute_schema_message}"
101
+ "message: #{expectation.description}"
50
102
  end
51
103
 
52
104
  protected
@@ -55,56 +107,98 @@ module Servactory
55
107
  #
56
108
  # @return [Boolean] True if messages match
57
109
  def passes?
58
- last_submatcher = context.last_submatcher
59
- attribute_schema = attribute_data.fetch(last_submatcher.class::OPTION_NAME)
60
- @attribute_schema_is = attribute_schema.fetch(last_submatcher.class::OPTION_BODY_KEY)
61
- @attribute_schema_message = attribute_schema.fetch(:message)
110
+ attribute_schema = attribute_data[option_name]
111
+ @mismatch = attribute_schema.is_a?(Hash) ? find_mismatch(attribute_schema) : missing_option_explanation
62
112
 
63
- schema_message_equal?
113
+ @mismatch.nil?
64
114
  end
65
115
 
66
116
  # Builds the failure message for message validation.
67
117
  #
68
118
  # @return [String] Failure message with expected vs actual message
69
119
  def build_failure_message
70
- return "" if schema_message_equal?
120
+ "should return expected message in case of problem:\n\n#{@mismatch.indent(2)}"
121
+ end
71
122
 
72
- <<~MESSAGE
73
- should return expected message in case of problem:
123
+ private
74
124
 
75
- expected #{@attribute_schema_message.inspect}
76
- got #{custom_message.inspect}
77
- MESSAGE
125
+ attr_reader :expectation
126
+
127
+ # Ensures that the previous submatcher checks an option with a message.
128
+ #
129
+ # @return [void]
130
+ # @raise [ArgumentError] If there is no previous submatcher or its option has no message
131
+ def ensure_option_with_message!
132
+ last_submatcher = context.last_submatcher
133
+ return if last_submatcher.respond_to?(:option_name)
134
+ return if last_submatcher.is_a?(Base::Submatcher) && last_submatcher.class.const_defined?(:OPTION_NAME)
135
+
136
+ raise ArgumentError, option_with_message_error_message(last_submatcher)
78
137
  end
79
138
 
80
- private
139
+ # Builds the error message for a `message` chained without an option with a message.
140
+ #
141
+ # @param last_submatcher [Base::Submatcher, nil] The previous submatcher
142
+ # @return [String] Error message
143
+ def option_with_message_error_message(last_submatcher)
144
+ position = last_submatcher.nil? ? "first in the chain" : "after `#{last_submatcher.description}`"
145
+ required = last_submatcher.is_a?(Input::RequiredSubmatcher)
146
+ hint = required ? " To check the required message, pass it to `required`." : ""
81
147
 
82
- attr_reader :custom_message
148
+ "`message` checks the message of the option chained right before it: chain it after " \
149
+ "`type`, `consists_of`, `schema`, `inclusion`, `target` or `must`, not #{position}.#{hint}"
150
+ end
83
151
 
84
- # Compares expected and actual messages with type-aware logic.
152
+ # Compares the option's message with the expected message.
85
153
  #
86
- # Handles RSpec matchers, Procs, and plain strings.
154
+ # @param attribute_schema [Hash] The option in attribute data
155
+ # @return [String, nil] Explanation of the mismatch, or nil if the messages match
156
+ def find_mismatch(attribute_schema)
157
+ @attribute_schema_is = attribute_schema[context.last_submatcher.class::OPTION_BODY_KEY]
158
+ @attribute_schema_message = attribute_schema[:message]
159
+
160
+ expectation.mismatch_for(@attribute_schema_message) { |message| call_message(message) }
161
+ end
162
+
163
+ # Explains a mismatch for an attribute without the option.
87
164
  #
88
- # @return [Boolean] True if messages match
89
- def schema_message_equal? # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
90
- @schema_message_equal ||= begin
91
- if custom_message.present? && !@attribute_schema_message.nil?
92
- if custom_message.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
93
- RSpec::Expectations::ValueExpectationTarget
94
- .new(@attribute_schema_message)
95
- .to(custom_message)
96
- true
97
- elsif @attribute_schema_message.is_a?(Proc)
98
- @attribute_schema_message.call.casecmp(custom_message).zero?
99
- else
100
- @attribute_schema_message.casecmp(custom_message).zero?
101
- end
102
- else
103
- true
104
- end
105
- rescue RSpec::Expectations::ExpectationNotMetError
106
- false
165
+ # @return [String] Explanation of the mismatch
166
+ def missing_option_explanation
167
+ <<~MESSAGE
168
+ expected #{expectation.description}
169
+ got no `#{option_name}` option
170
+ MESSAGE
171
+ end
172
+
173
+ # Calls a Proc message with the keyword arguments the library passes to it.
174
+ #
175
+ # `code:` is the name of the must rule the option helper generates,
176
+ # such as `be_inclusion`.
177
+ #
178
+ # @param message [Proc] The attribute's message
179
+ # @return [Object] The message built by the Proc
180
+ def call_message(message)
181
+ if option_name == :type
182
+ return call_proc_message(message, :type, expected_type: @attribute_schema_is.join(", "))
107
183
  end
184
+
185
+ call_proc_message(
186
+ message,
187
+ option_name == :schema ? :schema : :dynamic_option,
188
+ code: option_helper_rule_name(option_name),
189
+ option_name:,
190
+ option_value: @attribute_schema_is
191
+ )
192
+ end
193
+
194
+ # Returns the name of the option validated by the previous submatcher.
195
+ #
196
+ # @return [Symbol] Option name in attribute data
197
+ def option_name
198
+ last_submatcher = context.last_submatcher
199
+ return last_submatcher.option_name if last_submatcher.respond_to?(:option_name)
200
+
201
+ last_submatcher.class::OPTION_NAME
108
202
  end
109
203
  end
110
204
  end