servactory 3.1.0.rc2 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +7 -2
  4. data/config/locales/ja.yml +134 -0
  5. data/lib/generators/README.md +2 -2
  6. data/lib/generators/servactory/extension/USAGE +1 -1
  7. data/lib/generators/servactory/install/USAGE +2 -2
  8. data/lib/generators/servactory/rspec/USAGE +4 -4
  9. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  10. data/lib/generators/servactory/service/USAGE +5 -5
  11. data/lib/servactory/actions/collection.rb +21 -5
  12. data/lib/servactory/actions/stages/collection.rb +19 -1
  13. data/lib/servactory/actions/tools/rules.rb +1 -1
  14. data/lib/servactory/actions/tools/runner.rb +1 -1
  15. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  16. data/lib/servactory/configuration/config.rb +16 -0
  17. data/lib/servactory/configuration/configurable.rb +7 -4
  18. data/lib/servactory/configuration/factory.rb +30 -3
  19. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
  20. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
  21. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  22. data/lib/servactory/context/workspace/inputs.rb +2 -2
  23. data/lib/servactory/context/workspace/internals.rb +2 -2
  24. data/lib/servactory/context/workspace/outputs.rb +2 -2
  25. data/lib/servactory/context/workspace.rb +7 -7
  26. data/lib/servactory/info/builder.rb +4 -3
  27. data/lib/servactory/inputs/dsl.rb +5 -1
  28. data/lib/servactory/inputs/input.rb +1 -2
  29. data/lib/servactory/inputs/tools/validation.rb +10 -18
  30. data/lib/servactory/internals/dsl.rb +7 -1
  31. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  32. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  33. data/lib/servactory/maintenance/options/collection.rb +71 -30
  34. data/lib/servactory/maintenance/options/option.rb +3 -12
  35. data/lib/servactory/maintenance/options/registrar.rb +3 -13
  36. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  37. data/lib/servactory/maintenance/validations/performer.rb +12 -23
  38. data/lib/servactory/outputs/dsl.rb +7 -1
  39. data/lib/servactory/result.rb +21 -8
  40. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  41. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  42. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
  43. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  44. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  45. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
  46. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
  47. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  48. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  49. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  50. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  51. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  52. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  53. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  54. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  55. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  56. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  58. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  59. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  60. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  61. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  62. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  63. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  64. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
  65. data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
  66. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
  67. data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
  68. data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
  69. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
  70. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  71. data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
  72. data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
  73. data/lib/servactory/utils.rb +13 -3
  74. data/lib/servactory/version.rb +1 -1
  75. data/lib/servactory.rb +3 -4
  76. metadata +20 -4
@@ -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
@@ -13,33 +13,113 @@ module Servactory
13
13
  # Validates that an attribute has the expected custom validation rules
14
14
  # defined via the `must` option. Excludes dynamic options that are
15
15
  # tested separately (consists_of, schema, be_inclusion, be_target).
16
+ # Optionally validates the messages of the rules.
16
17
  #
17
18
  # ## Usage
18
19
  #
19
20
  # ```ruby
20
21
  # it { is_expected.to have_service_input(:email).must([:be_valid_email]) }
21
22
  # it { is_expected.to have_service_input(:age).must([:be_positive, :be_adult]) }
23
+ # it { is_expected.to have_service_input(:age).must(:be_positive, be_adult: "Must be an adult") }
24
+ # it { is_expected.to have_service_input(:age).must(be_positive: /positive/, be_adult: :default) }
25
+ # it { is_expected.to have_service_input(:age).must(:be_adult).message("Must be an adult") }
22
26
  # ```
23
27
  #
24
28
  # ## Comparison
25
29
  #
26
30
  # Uses set difference to compare validation names - order doesn't matter.
31
+ # Bare names and the keys of expected messages together must name
32
+ # every rule of the attribute.
33
+ #
34
+ # ## Messages
35
+ #
36
+ # `.message` chained right after `.must` naming one rule is the same
37
+ # as the keyed form for that rule. After `.must` naming several
38
+ # rules, `.message` raises ArgumentError.
39
+ #
40
+ # An expected message follows the rules of `.message`: a String is
41
+ # compared with the message exactly, a Regexp is matched against it,
42
+ # an RSpec matcher is applied to the message as defined, and
43
+ # `:default` checks that the rule defines no custom message.
44
+ #
45
+ # Without a custom message, the rule uses the default message of
46
+ # the library, which depends only on the service, the attribute and
47
+ # the rule name, so a String or Regexp is compared with it.
48
+ #
49
+ # A Proc message is called with the keyword arguments the library
50
+ # passes to it: `service:`, the attribute (`input:`, `internal:` or
51
+ # `output:`), `value:`, `code:`, `reason:` and `meta:`. Values known
52
+ # only while the service runs are `nil`. A Proc message that raises
53
+ # with them, for example because it does not accept all of them,
54
+ # does not match.
55
+ #
56
+ # Rules generated by option helpers, such as `be_in_format` of the
57
+ # `format` option or `be_greater_than_or_equal_to` of `min`, may be
58
+ # listed by name, but their messages depend on the validated value:
59
+ # expecting a message for them raises ArgumentError. Check these
60
+ # messages with `raise_error` instead.
27
61
  class MustSubmatcher < Base::Submatcher
62
+ include Concerns::OptionHelperRules
63
+ include Concerns::ProcMessage
64
+
65
+ # NOTE: Dynamic options that are also `must` but tested separately
66
+ BUILT_IN_RULE_NAMES = %i[consists_of schema be_inclusion be_target].freeze
67
+
68
+ # Splits the arguments of the `must` chain method.
69
+ #
70
+ # A trailing Hash, which Ruby passes for keyword arguments,
71
+ # maps rule names to their expected messages.
72
+ #
73
+ # @param args [Array] The arguments of the chain method
74
+ # @return [Array(Array<Symbol>, Hash{Symbol => Object})] Rule names and expected messages
75
+ def self.arguments_from(args)
76
+ expectations, names = Array(args).flatten.partition { |argument| argument.is_a?(Hash) }
77
+
78
+ [names, expectations.reduce({}, :merge)]
79
+ end
80
+
28
81
  # Creates a new must submatcher.
29
82
  #
30
83
  # @param context [Base::SubmatcherContext] The submatcher context
31
84
  # @param must_names [Array<Symbol>] Expected validation rule names
85
+ # @param message_expectations [Hash{Symbol => Object}] Expected messages by rule name
32
86
  # @return [MustSubmatcher] New submatcher instance
33
- def initialize(context, must_names)
87
+ # @raise [ArgumentError] If an expected message is of an unsupported kind,
88
+ # or belongs to a rule generated by an option helper
89
+ def initialize(context, must_names, message_expectations = {})
34
90
  super(context)
35
- @must_names = must_names
91
+ @must_names = (must_names + message_expectations.keys).uniq
92
+ @message_expectations = message_expectations.transform_values do |expected|
93
+ Base::MessageExpectation.new(expected)
94
+ end
95
+
96
+ message_expectations.each_key { |name| ensure_message_checkable!(name) }
97
+ end
98
+
99
+ # Stores the message expected by `.message` chained right after `.must`.
100
+ #
101
+ # `.must(:rule).message(expected)` is the same as `.must(rule: expected)`.
102
+ # Each rule accepts one expected message.
103
+ #
104
+ # @param expected [String, Regexp, Symbol, Object] Expected message, `:default` or an RSpec matcher
105
+ # @return [void]
106
+ # @raise [ArgumentError] If `must` does not name exactly one rule, the rule already has
107
+ # an expected message, or the message cannot be checked
108
+ def expect_message(expected)
109
+ raise ArgumentError, several_rules_error_message unless must_names.one?
110
+
111
+ name = must_names.first
112
+ raise ArgumentError, "Rule `#{name}` already has an expected message" if message_expectations.key?(name)
113
+
114
+ ensure_message_checkable!(name)
115
+ message_expectations[name] = Base::MessageExpectation.new(expected)
36
116
  end
37
117
 
38
118
  # Returns description for RSpec output.
39
119
  #
40
120
  # @return [String] Human-readable description with rule names
41
121
  def description
42
- "must: #{must_names.join(', ')}"
122
+ "must: #{must_names.map { |name| rule_description(name) }.join(', ')}"
43
123
  end
44
124
 
45
125
  protected
@@ -48,31 +128,126 @@ module Servactory
48
128
  #
49
129
  # Filters out dynamic options that are tested by other submatchers.
50
130
  #
51
- # @return [Boolean] True if must rules match (order-independent)
131
+ # @return [Boolean] True if must rules and their messages match (order-independent)
52
132
  def passes?
53
- attribute_must = attribute_data.fetch(:must)
54
- attribute_must_keys = attribute_must.keys.dup
55
-
56
- # NOTE: Dynamic options that are also `must` but tested separately
57
- attribute_must_keys.delete(:consists_of)
58
- attribute_must_keys.delete(:schema)
59
- attribute_must_keys.delete(:be_inclusion)
60
- attribute_must_keys.delete(:be_target)
133
+ @actual_must_names = actual_must_names
134
+ @message_mismatches = find_message_mismatches
61
135
 
62
- attribute_must_keys.difference(must_names).empty? &&
63
- must_names.difference(attribute_must_keys).empty?
136
+ names_match? && @message_mismatches.empty?
64
137
  end
65
138
 
66
139
  # Builds the failure message for must validation.
67
140
  #
68
- # @return [String] Simple failure message with expected rules
141
+ # @return [String] Failure message with expected vs actual rules and messages
69
142
  def build_failure_message
70
- "should #{must_names.join(', ')}"
143
+ messages = @message_mismatches.map do |name, mismatch|
144
+ "should return expected message for must rule `#{name}`:\n\n#{mismatch.indent(2)}"
145
+ end
146
+
147
+ messages.unshift(names_failure_message) unless names_match?
148
+ messages.join("\n")
71
149
  end
72
150
 
73
151
  private
74
152
 
75
- attr_reader :must_names
153
+ attr_reader :must_names, :message_expectations
154
+
155
+ # Ensures that the message of a rule does not depend on the validated value.
156
+ #
157
+ # @param name [Symbol] Rule name
158
+ # @return [void]
159
+ # @raise [ArgumentError] If an option helper generated the rule
160
+ def ensure_message_checkable!(name)
161
+ return if BUILT_IN_RULE_NAMES.include?(name) || !option_helper_rule?(name)
162
+
163
+ raise ArgumentError,
164
+ "The message of must rule `#{name}` cannot be checked: an option helper generated the rule, " \
165
+ "and its message depends on the validated value. List the rule by name, as in " \
166
+ "`must(:#{name})`, and check the error message with `raise_error`."
167
+ end
168
+
169
+ # Builds the error message for `.message` after `.must` naming several rules.
170
+ #
171
+ # @return [String] Error message
172
+ def several_rules_error_message
173
+ return "`message` after `must` needs a rule name, e.g. `must(:rule).message(...)`." if must_names.empty?
174
+
175
+ keyed = must_names.map { |name| "#{name}: ..." }.join(", ")
176
+
177
+ "`message` after `must` needs exactly one rule, got #{must_names.size}. " \
178
+ "Use the keyed form to expect a message for each rule, as in `must(#{keyed})`."
179
+ end
180
+
181
+ # Describes a rule name with its expected message.
182
+ #
183
+ # @param name [Symbol] Rule name
184
+ # @return [String] Rule description
185
+ def rule_description(name)
186
+ expectation = message_expectations[name]
187
+ return name.to_s if expectation.nil?
188
+
189
+ "#{name} with message #{expectation.description}"
190
+ end
191
+
192
+ # Returns the attribute's must rules.
193
+ #
194
+ # @return [Hash{Symbol => Hash, Proc}] Rules by name
195
+ def attribute_must
196
+ attribute_data[:must] || {}
197
+ end
198
+
199
+ # Returns the names of the attribute's rules checked by this submatcher.
200
+ #
201
+ # @return [Array<Symbol>] Rule names
202
+ def actual_must_names
203
+ attribute_must.keys - BUILT_IN_RULE_NAMES
204
+ end
205
+
206
+ # Compares the expected rule names with the attribute's rule names.
207
+ #
208
+ # @return [Boolean] True if both name the same rules
209
+ def names_match?
210
+ @actual_must_names.difference(must_names).empty? &&
211
+ must_names.difference(@actual_must_names).empty?
212
+ end
213
+
214
+ # Builds the failure message for mismatching rule names.
215
+ #
216
+ # @return [String] Failure message with expected vs actual rules
217
+ def names_failure_message
218
+ <<~MESSAGE
219
+ should #{must_names.join(', ')}
220
+
221
+ expected must rules: #{build_list_message(must_names)}
222
+ got must rules: #{build_list_message(@actual_must_names)}
223
+ MESSAGE
224
+ end
225
+
226
+ # Compares the messages of the attribute's rules with the expected messages.
227
+ #
228
+ # Rules missing from the attribute are reported by the name comparison.
229
+ #
230
+ # @return [Hash{Symbol => String}] Explanations of the mismatches by rule name
231
+ def find_message_mismatches
232
+ message_expectations.filter_map do |name, expectation|
233
+ next unless attribute_must.key?(name)
234
+
235
+ mismatch = expectation.mismatch_for(
236
+ rule_message(attribute_must.fetch(name)),
237
+ default_message: Servactory::Maintenance::Validations::Translator::Must.default_message
238
+ ) { |message| call_proc_message(message, :must, code: name) }
239
+
240
+ [name, mismatch] unless mismatch.nil?
241
+ end.to_h
242
+ end
243
+
244
+ # Returns the custom message of a rule.
245
+ #
246
+ # @param rule [Hash, Proc] The rule in advanced or simple mode
247
+ # @return [String, Proc, nil] The custom message
248
+ def rule_message(rule)
249
+ rule.is_a?(Hash) ? rule[:message] : nil
250
+ end
76
251
  end
77
252
  end
78
253
  end
@@ -29,6 +29,9 @@ module Servactory
29
29
  # Key for the target values within the option
30
30
  OPTION_BODY_KEY = :in
31
31
 
32
+ # @return [Symbol] The name of the target option
33
+ attr_reader :option_name
34
+
32
35
  # Creates a new target submatcher.
33
36
  #
34
37
  # @param context [Base::SubmatcherContext] The submatcher context
@@ -77,8 +80,7 @@ module Servactory
77
80
 
78
81
  private
79
82
 
80
- attr_reader :option_name,
81
- :values
83
+ attr_reader :values
82
84
 
83
85
  # Formats values for human-readable description.
84
86
  #
@@ -19,6 +19,7 @@ module Servactory
19
19
  # it { is_expected.to have_service_input(:user_id).type(Integer) }
20
20
  # it { is_expected.to have_service_input(:data).types(String, Hash) }
21
21
  # it { is_expected.to have_service_internal(:result).type(Array) }
22
+ # it { is_expected.to have_service_input(:user_id).type(Integer).message("User ID must be an Integer") }
22
23
  # ```
23
24
  #
24
25
  # ## Comparison
@@ -26,6 +27,11 @@ module Servactory
26
27
  # Types are compared by name, sorted alphabetically. Order of types
27
28
  # in the definition doesn't matter - only the set of types must match.
28
29
  class TypesSubmatcher < Base::Submatcher
30
+ # Option name in attribute data
31
+ OPTION_NAME = :type
32
+ # Key for the types within the option
33
+ OPTION_BODY_KEY = :is
34
+
29
35
  # Creates a new types submatcher.
30
36
  #
31
37
  # @param context [Base::SubmatcherContext] The submatcher context
@@ -44,8 +44,12 @@
44
44
  require_relative "matchers/concerns/attribute_data_access"
45
45
  require_relative "matchers/concerns/error_message_builder"
46
46
  require_relative "matchers/concerns/value_comparison"
47
+ require_relative "matchers/concerns/option_helper_rules"
48
+ require_relative "matchers/concerns/proc_message"
49
+ require_relative "matchers/concerns/required_message"
47
50
 
48
51
  # Base classes (loaded after concerns)
52
+ require_relative "matchers/base/message_expectation"
49
53
  require_relative "matchers/base/submatcher_context"
50
54
  require_relative "matchers/base/submatcher"
51
55
  require_relative "matchers/base/submatcher_registry"
@@ -89,6 +93,12 @@ module Servactory
89
93
  # expect(MyService).to have_service_input(:user_id)
90
94
  # .type(Integer)
91
95
  # .required
96
+ #
97
+ # @example With messages
98
+ # expect(MyService).to have_service_input(:age)
99
+ # .type(Integer).message("Age must be an Integer")
100
+ # .required(/is required/)
101
+ # .must(:be_adult, be_positive: :default)
92
102
  def have_service_input(input_name) # rubocop:disable Naming/PredicatePrefix
93
103
  HaveServiceInputMatcher.new(described_class, input_name)
94
104
  end
@@ -101,6 +111,11 @@ module Servactory
101
111
  # @example
102
112
  # expect(MyService).to have_service_internal(:processed_data)
103
113
  # .type(Hash)
114
+ #
115
+ # @example With messages
116
+ # expect(MyService).to have_service_internal(:total)
117
+ # .type(Integer).message(:default)
118
+ # .must(:be_positive).message("Total must be positive")
104
119
  def have_service_internal(internal_name) # rubocop:disable Naming/PredicatePrefix
105
120
  HaveServiceInternalMatcher.new(described_class, internal_name)
106
121
  end
@@ -31,7 +31,7 @@ module Servactory
31
31
  # Specify type directly as the option value:
32
32
  #
33
33
  # ```ruby
34
- # class ProcessUsersService < ApplicationService::Base
34
+ # class Users::Process < ApplicationService::Base
35
35
  # input :user_ids, type: Array, consists_of: Integer
36
36
  # input :tags, type: Array, consists_of: [String, Symbol]
37
37
  # input :scores, type: Array, consists_of: Float
@@ -88,8 +88,9 @@ module Servactory
88
88
  # Creates a ConsistsOf validator instance.
89
89
  #
90
90
  # @param option_name [Symbol] The option name (default: :consists_of)
91
- # @param collection_mode_class_names [Array<Class>] Valid collection types
92
- # @return [Servactory::Maintenance::Attributes::OptionHelper]
91
+ # @param collection_mode_class_names [Servactory::Configuration::CollectionMode::ClassNamesCollection]
92
+ # Valid collection types
93
+ # @return [Servactory::Maintenance::Options::Helper]
93
94
  def self.use(option_name = :consists_of, collection_mode_class_names:)
94
95
  instance = new(option_name, :type, false)
95
96
  instance.assign(collection_mode_class_names)
@@ -98,7 +99,8 @@ module Servactory
98
99
 
99
100
  # Assigns the list of valid collection class names.
100
101
  #
101
- # @param collection_mode_class_names [Array<Class>] Collection types to accept
102
+ # @param collection_mode_class_names [Servactory::Configuration::CollectionMode::ClassNamesCollection]
103
+ # Collection types to accept
102
104
  # @return [void]
103
105
  def assign(collection_mode_class_names)
104
106
  @collection_mode_class_names = collection_mode_class_names
@@ -160,7 +162,7 @@ module Servactory
160
162
  # @param option [WorkOption] Type configuration
161
163
  # @return [Boolean, Array] true if valid, or [false, reason]
162
164
  def validate_for!(attribute:, values:, option:)
163
- consists_of_types = Array(option.value).uniq
165
+ consists_of_types = Array(option.value)
164
166
 
165
167
  # Check presence requirements.
166
168
  return [false, :required] if fails_presence_validation?(attribute:, values:, consists_of_types:)
@@ -215,8 +217,7 @@ module Servactory
215
217
  service_class_name: service.class_name,
216
218
  input_name: input.name,
217
219
  option_name:,
218
- expected_type: Array(option_value).uniq.join(", "),
219
- given_type: given_type_for(values: value, option_value:)
220
+ **type_names_for(attribute: input, values: value, option_value:, reason:)
220
221
  )
221
222
  end
222
223
 
@@ -238,8 +239,7 @@ module Servactory
238
239
  service_class_name: service.class_name,
239
240
  internal_name: internal.name,
240
241
  option_name:,
241
- expected_type: Array(option_value).uniq.join(", "),
242
- given_type: given_type_for(values: value, option_value:)
242
+ **type_names_for(attribute: internal, values: value, option_value:, reason:)
243
243
  )
244
244
  end
245
245
 
@@ -261,11 +261,34 @@ module Servactory
261
261
  service_class_name: service.class_name,
262
262
  output_name: output.name,
263
263
  option_name:,
264
- expected_type: Array(option_value).uniq.join(", "),
265
- given_type: given_type_for(values: value, option_value:)
264
+ **type_names_for(attribute: output, values: value, option_value:, reason:)
266
265
  )
267
266
  end
268
267
 
268
+ # Builds the expected and given type names for a failure message.
269
+ #
270
+ # For `:wrong_type` the attribute itself is not of a collection type,
271
+ # so the configured collection types are expected and the declared
272
+ # attribute types are given. For other reasons the element types
273
+ # are compared.
274
+ #
275
+ # @param attribute [Object] The attribute being validated
276
+ # @param values [Object] Failed value
277
+ # @param option_value [Object] Expected element types
278
+ # @param reason [Symbol] Failure reason
279
+ # @return [Hash] Hash with :expected_type and :given_type keys
280
+ def type_names_for(attribute:, values:, option_value:, reason:)
281
+ if reason == :wrong_type
282
+ expected_type = @collection_mode_class_names.to_a.join(", ")
283
+ given_type = attribute.types.join(", ")
284
+ else
285
+ expected_type = Array(option_value).uniq.join(", ")
286
+ given_type = given_type_for(values:, option_value:)
287
+ end
288
+
289
+ { expected_type:, given_type: }
290
+ end
291
+
269
292
  # Extracts type names of elements that don't match expected types.
270
293
  #
271
294
  # @param values [Array, nil] Collection elements