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
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Base
8
+ # Message expected by a test for an attribute option.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # MessageExpectation compares the message defined for an attribute
13
+ # option with the message expected by a test and explains a mismatch.
14
+ # Submatchers that check messages share it, so every message
15
+ # assertion follows the same rules.
16
+ #
17
+ # ## Comparison
18
+ #
19
+ # - String - equals the message text exactly
20
+ # - Regexp - matches the message text
21
+ # - RSpec matcher - matches the message as defined, for example a Proc
22
+ # - `:default` - the option defines no custom message, so the
23
+ # library uses its default message
24
+ #
25
+ # A Proc message is built before a String or Regexp comparison.
26
+ # A Proc that raises or returns an object other than a String
27
+ # does not match.
28
+ #
29
+ # Without a custom message, a String or Regexp is compared with the
30
+ # default message only if it is known before the service runs.
31
+ # Otherwise the check fails, because the default message depends
32
+ # on values such as the given value.
33
+ #
34
+ # ## Usage
35
+ #
36
+ # ```ruby
37
+ # expectation = MessageExpectation.new(/must be/)
38
+ # expectation.mismatch_for(message) { |proc_message| proc_message.call(value: nil) }
39
+ # # => nil when the message matches, otherwise the explanation
40
+ # ```
41
+ class MessageExpectation
42
+ # Expected value meaning that the option defines no custom message
43
+ DEFAULT = :default
44
+
45
+ # @return [String, Regexp, Symbol, Object] The expected message, `:default` or an RSpec matcher
46
+ attr_reader :expected
47
+
48
+ # Creates a new message expectation.
49
+ #
50
+ # @param expected [String, Regexp, Symbol, Object] The expected message, `:default` or an RSpec matcher
51
+ # @return [MessageExpectation] New expectation instance
52
+ # @raise [ArgumentError] If the expected message is of another kind
53
+ def initialize(expected)
54
+ @expected = expected
55
+
56
+ return if default? || expected.is_a?(String) || expected.is_a?(Regexp) || matcher?
57
+
58
+ raise ArgumentError,
59
+ "Expected message must be a String, a Regexp, an RSpec matcher or :default, " \
60
+ "got #{expected.inspect}"
61
+ end
62
+
63
+ # Describes the expected message for descriptions and failure messages.
64
+ #
65
+ # @return [String] The expected message or the description of the matcher
66
+ def description
67
+ return "the default message" if default?
68
+ return expected.description if matcher? && expected.respond_to?(:description)
69
+
70
+ expected.inspect
71
+ end
72
+
73
+ # Checks the message defined for the option.
74
+ #
75
+ # @param message [String, Proc, nil] The message defined for the option
76
+ # @param default_message [String, Proc, nil] The message used when the option defines none,
77
+ # or nil if it depends on values known only while the service runs
78
+ # @yieldparam message [Proc] A Proc message to build
79
+ # @yieldreturn [Object] The built message
80
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
81
+ def mismatch_for(message, default_message: nil, &block)
82
+ return default_mismatch_for(message) if default?
83
+ return matcher_mismatch_for(message) if matcher?
84
+ return runtime_default_explanation if message.blank? && default_message.nil?
85
+
86
+ text_mismatch_for(message.presence || default_message, &block)
87
+ end
88
+
89
+ private
90
+
91
+ # Checks whether the default message is expected.
92
+ #
93
+ # @return [Boolean] True for `:default`
94
+ def default?
95
+ expected == DEFAULT
96
+ end
97
+
98
+ # Checks that the option defines no custom message.
99
+ #
100
+ # @param message [String, Proc, nil] The message defined for the option
101
+ # @return [String, nil] Explanation of the mismatch, or nil if there is no custom message
102
+ def default_mismatch_for(message)
103
+ return if message.blank?
104
+
105
+ <<~MESSAGE
106
+ expected #{description}
107
+ got the custom message #{message.inspect}
108
+ MESSAGE
109
+ end
110
+
111
+ # Checks whether the expected message is an RSpec matcher.
112
+ #
113
+ # @return [Boolean] True for RSpec matchers
114
+ def matcher?
115
+ RSpec::Matchers.is_a_matcher?(expected)
116
+ end
117
+
118
+ # Applies the RSpec matcher to the message as defined.
119
+ #
120
+ # @param message [String, Proc, nil] The message defined for the option
121
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
122
+ def matcher_mismatch_for(message)
123
+ return if expected.matches?(message)
124
+
125
+ diff_explanation(message)
126
+ end
127
+
128
+ # Compares the message text with the expected String or Regexp.
129
+ #
130
+ # @param message [String, Proc, nil] The message to compare
131
+ # @yieldparam message [Proc] A Proc message to build
132
+ # @return [String, nil] Explanation of the mismatch, or nil if the message matches
133
+ def text_mismatch_for(message)
134
+ text = message.is_a?(Proc) ? yield(message) : message
135
+ rescue StandardError => e
136
+ proc_error_explanation(e)
137
+ else
138
+ return "#{diff_explanation(text).chomp}, which is not a String\n" unless text.is_a?(String)
139
+
140
+ diff_explanation(text) unless text_matches?(text)
141
+ end
142
+
143
+ # Compares a message text with the expected String or Regexp.
144
+ #
145
+ # @param text [String] The message text
146
+ # @return [Boolean] True if the text matches
147
+ def text_matches?(text)
148
+ expected.is_a?(Regexp) ? expected.match?(text) : expected == text
149
+ end
150
+
151
+ # Explains a mismatch with the expected and the actual message.
152
+ #
153
+ # @param actual [Object] The actual message
154
+ # @return [String] Explanation of the mismatch
155
+ def diff_explanation(actual)
156
+ <<~MESSAGE
157
+ expected #{description}
158
+ got #{actual.inspect}
159
+ MESSAGE
160
+ end
161
+
162
+ # Explains why a String or Regexp cannot be checked without a custom message.
163
+ #
164
+ # @return [String] Explanation of the mismatch
165
+ def runtime_default_explanation
166
+ <<~MESSAGE
167
+ expected #{description}
168
+ got no custom message
169
+
170
+ The default message depends on values known only while the service runs.
171
+ Use `message(:default)` to check that no custom message is defined,
172
+ or check the error message with `raise_error`.
173
+ MESSAGE
174
+ end
175
+
176
+ # Explains an error raised while building a Proc message.
177
+ #
178
+ # @param error [StandardError] The raised error
179
+ # @return [String] Explanation of the error
180
+ def proc_error_explanation(error)
181
+ <<~MESSAGE
182
+ could not build the Proc message to compare with #{description}:
183
+ #{error.class}: #{error.message}
184
+
185
+ The Proc receives every keyword the library passes to it while the service runs,
186
+ with nil for values known only then, such as `value:`. Accept unused keywords with `**`.
187
+ MESSAGE
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -92,6 +92,14 @@ module Servactory
92
92
  "expected not to #{description}"
93
93
  end
94
94
 
95
+ # Checks whether this submatcher checks the option of the given submatcher.
96
+ #
97
+ # @param submatcher [Submatcher] The submatcher that may precede this one in the chain
98
+ # @return [Boolean] True if this submatcher was chained right after the given one
99
+ def attached_to?(submatcher)
100
+ context.last_submatcher.equal?(submatcher)
101
+ end
102
+
95
103
  # Returns a human-readable description of what this submatcher validates.
96
104
  #
97
105
  # @abstract Subclasses must implement this method
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for recognizing must rules generated by option helpers.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # Option helpers expand into `must` rules: dynamic options such as
13
+ # `format`, `min`, `max`, `multiple_of` and `target`, subclasses of
14
+ # `Servactory::ToolKit::DynamicOptions::Must`, and custom helpers
15
+ # registered with `*_option_helpers`. The messages of these rules
16
+ # depend on the validated value, so they cannot be checked before
17
+ # the service runs.
18
+ #
19
+ # ## Detection
20
+ #
21
+ # A rule is generated by an option helper when:
22
+ # - its condition was built by a `DynamicOptions::Must` instance, or
23
+ # - it is a rule of a helper registered for the attribute type,
24
+ # compared by equality and by the source location of its condition
25
+ #
26
+ # ## Usage
27
+ #
28
+ # ```ruby
29
+ # class MustSubmatcher < Base::Submatcher
30
+ # include Concerns::OptionHelperRules
31
+ #
32
+ # def initialize(context, names)
33
+ # super(context)
34
+ # raise ArgumentError if names.any? { |name| option_helper_rule?(name) }
35
+ # end
36
+ # end
37
+ # ```
38
+ module OptionHelperRules
39
+ # Includes InstanceMethods in the including class.
40
+ #
41
+ # @param base [Class] The class including this concern
42
+ # @return [void]
43
+ def self.included(base)
44
+ base.include(InstanceMethods)
45
+ end
46
+
47
+ # Instance methods added by this concern.
48
+ module InstanceMethods
49
+ # Checks whether a must rule of the attribute was generated by an option helper.
50
+ #
51
+ # @param name [Symbol] The rule name
52
+ # @return [Boolean] True if an option helper generated the rule
53
+ def option_helper_rule?(name)
54
+ rule = (attribute_data[:must] || {})[name]
55
+ return false if rule.nil?
56
+
57
+ dynamic_option_rule?(rule) || option_helper_rules.any? { |helper_rule| same_rule?(helper_rule, rule) }
58
+ end
59
+
60
+ # Returns the name of the must rule the option helper of an option generates.
61
+ #
62
+ # The library passes it as `code:` to the Proc message of the option.
63
+ #
64
+ # @param option_name [Symbol] The option name, such as `inclusion`
65
+ # @return [Symbol, nil] The rule name, such as `be_inclusion`, or nil if no helper generates one
66
+ def option_helper_rule_name(option_name)
67
+ helpers = described_class.config.public_send(:"#{attribute_type}_option_helpers")
68
+ helper = helpers.find_by(name: option_name)
69
+ return if helper.nil?
70
+
71
+ rules = helper_option(helper)
72
+ rules[:must].keys.first if rules.is_a?(Hash) && rules[:must].is_a?(Hash)
73
+ end
74
+
75
+ private
76
+
77
+ # Returns the condition of a rule in advanced or simple mode.
78
+ #
79
+ # @param rule [Hash, Proc] The rule
80
+ # @return [Object] The condition
81
+ def rule_condition(rule)
82
+ rule.is_a?(Hash) ? rule[:is] : rule
83
+ end
84
+
85
+ # Checks whether a `DynamicOptions::Must` instance built the rule's condition.
86
+ #
87
+ # @param rule [Hash, Proc] The rule
88
+ # @return [Boolean] True for rules of dynamic options
89
+ def dynamic_option_rule?(rule)
90
+ condition = rule_condition(rule)
91
+ return false unless condition.is_a?(Proc)
92
+
93
+ condition.binding.receiver.is_a?(Servactory::ToolKit::DynamicOptions::Must)
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ # Compares a rule with a rule generated by an option helper.
99
+ #
100
+ # @param helper_rule [Hash, Proc] The rule generated by an option helper
101
+ # @param rule [Hash, Proc] The attribute's rule
102
+ # @return [Boolean] True if both are the same rule
103
+ def same_rule?(helper_rule, rule)
104
+ return true if helper_rule == rule
105
+
106
+ location = condition_location(rule)
107
+ !location.nil? && location == condition_location(helper_rule)
108
+ end
109
+
110
+ # Returns the source location of a rule's condition.
111
+ #
112
+ # @param rule [Hash, Proc] The rule
113
+ # @return [Array(String, Integer), nil] The source location
114
+ def condition_location(rule)
115
+ condition = rule_condition(rule)
116
+ condition.source_location if condition.is_a?(Proc)
117
+ end
118
+
119
+ # Returns the must rules of the option helpers registered for the attribute type.
120
+ #
121
+ # @return [Array<Hash, Proc>] Rules generated by option helpers
122
+ def option_helper_rules
123
+ @option_helper_rules ||=
124
+ described_class.config.public_send(:"#{attribute_type}_option_helpers").flat_map do |helper|
125
+ rules = helper_option(helper)
126
+ rules.is_a?(Hash) && rules[:must].is_a?(Hash) ? rules[:must].values : []
127
+ end
128
+ end
129
+
130
+ # Expands an option helper the way the library does for the attribute.
131
+ #
132
+ # @param helper [Servactory::Maintenance::Options::Helper] The option helper
133
+ # @return [Object] The options the helper expands into
134
+ def helper_option(helper)
135
+ return helper.equivalent unless helper.equivalent.is_a?(Proc)
136
+
137
+ values = attribute_data.fetch(:actor).options[helper.name].deep_dup
138
+ values.is_a?(Hash) ? helper.equivalent.call(**values) : helper.equivalent.call(values)
139
+ rescue StandardError
140
+ nil
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for calling Proc messages the way the library calls them.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # ProcMessage calls a Proc message of an attribute option with the
13
+ # complete set of keyword arguments the library passes to it while
14
+ # the service runs, so every submatcher builds Proc messages the same
15
+ # way. A Proc that would raise while the service runs, for example
16
+ # because it does not accept every keyword, raises here too.
17
+ #
18
+ # ## Keyword Arguments
19
+ #
20
+ # Every Proc message receives `service:`, the attribute (`input:`,
21
+ # `internal:` or `output:`) and `value:`, plus the keywords of its
22
+ # option:
23
+ #
24
+ # - `:required` - no other keywords
25
+ # - `:type` - `expected_type:`, `given_type:`
26
+ # - `:must` - `code:`, `reason:`, `meta:`
27
+ # - `:dynamic_option` (`consists_of`, `inclusion`, `target`) -
28
+ # `code:`, `reason:`, `option_name:`, `option_value:`
29
+ # - `:schema` - `code:`, `reason:`, `option_name:`, `option_value:`,
30
+ # `key_name:`, `expected_type:`, `given_type:`
31
+ #
32
+ # The library passes the same keywords for every failure reason of an
33
+ # option, for example `key_name:` is passed as `nil` when a `schema`
34
+ # attribute is not Hash-compatible. `reason:` itself is `nil` unless
35
+ # given, because the reason of a failure is known only while the
36
+ # service runs.
37
+ #
38
+ # Keywords without a given value, such as `value:`, are `nil`.
39
+ #
40
+ # ## Usage
41
+ #
42
+ # ```ruby
43
+ # class MySubmatcher < Base::Submatcher
44
+ # include Concerns::ProcMessage
45
+ #
46
+ # def passes?
47
+ # call_proc_message(message, :type, expected_type: "Integer") == "Must be an Integer"
48
+ # end
49
+ # end
50
+ # ```
51
+ module ProcMessage
52
+ # Keywords the library passes to Proc messages by option, besides
53
+ # `service:`, the attribute and `value:`.
54
+ OPTION_KEYWORDS = {
55
+ required: [].freeze,
56
+ type: %i[expected_type given_type].freeze,
57
+ must: %i[code reason meta].freeze,
58
+ dynamic_option: %i[code reason option_name option_value].freeze,
59
+ schema: %i[code reason option_name option_value key_name expected_type given_type].freeze
60
+ }.freeze
61
+
62
+ # Includes InstanceMethods in the including class.
63
+ #
64
+ # @param base [Class] The class including this concern
65
+ # @return [void]
66
+ def self.included(base)
67
+ base.include(InstanceMethods)
68
+ end
69
+
70
+ # Instance methods added by this concern.
71
+ module InstanceMethods
72
+ # Calls a Proc message with the keyword arguments the library passes to it.
73
+ #
74
+ # @param message [Proc] The message of the option
75
+ # @param option [Symbol] The option, a key of OPTION_KEYWORDS
76
+ # @param values [Hash{Symbol => Object}] Known values of the keywords
77
+ # @return [Object] The message built by the Proc
78
+ # @raise [ArgumentError] If a value is given for a keyword the library does not pass
79
+ def call_proc_message(message, option, **values)
80
+ message.call(**proc_message_arguments(option, **values))
81
+ end
82
+
83
+ # Builds the keyword arguments the library passes to a Proc message.
84
+ #
85
+ # @param option [Symbol] The option, a key of OPTION_KEYWORDS
86
+ # @param values [Hash{Symbol => Object}] Known values of the keywords
87
+ # @return [Hash{Symbol => Object}] Keyword arguments, `nil` for keywords without a given value
88
+ # @raise [ArgumentError] If a value is given for a keyword the library does not pass
89
+ def proc_message_arguments(option, **values)
90
+ arguments = {
91
+ service: proc_message_service_info,
92
+ attribute_type => attribute_data.fetch(:actor),
93
+ value: nil,
94
+ **OPTION_KEYWORDS.fetch(option).to_h { |keyword| [keyword, nil] }
95
+ }
96
+
97
+ unknown = values.keys - arguments.keys
98
+ raise ArgumentError, "Unknown keywords for #{option} messages: #{unknown.join(', ')}" if unknown.any?
99
+
100
+ arguments.merge(values)
101
+ end
102
+
103
+ private
104
+
105
+ # Returns the service information passed to Proc messages.
106
+ #
107
+ # @return [Object] The service information
108
+ def proc_message_service_info
109
+ @proc_message_service_info ||= described_class.send(:new).send(:servactory_service_info)
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Matchers
7
+ module Concerns
8
+ # Concern for building the message of the `required` option of an input.
9
+ #
10
+ # ## Purpose
11
+ #
12
+ # RequiredMessage builds the message the library uses for a missing
13
+ # required value, so submatchers that check it build it the same way.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # ```ruby
18
+ # class MySubmatcher < Base::Submatcher
19
+ # include Concerns::RequiredMessage
20
+ #
21
+ # def passes?
22
+ # required_message == "Name is mandatory"
23
+ # end
24
+ # end
25
+ # ```
26
+ #
27
+ # ## Methods Provided
28
+ #
29
+ # - `required_custom_message` - the custom message as defined
30
+ # - `default_required_message` - the default message of the library
31
+ # - `call_required_message` - builds a Proc message
32
+ # - `required_message` - the message used for a missing value
33
+ module RequiredMessage
34
+ # Includes ProcMessage and InstanceMethods in the including class.
35
+ #
36
+ # @param base [Class] The class including this concern
37
+ # @return [void]
38
+ def self.included(base)
39
+ base.include(ProcMessage)
40
+ base.include(InstanceMethods)
41
+ end
42
+
43
+ # Instance methods added by this concern.
44
+ module InstanceMethods
45
+ # Returns the custom message from `required: { message: }`.
46
+ #
47
+ # @return [String, Proc, nil] The custom message as defined
48
+ def required_custom_message
49
+ attribute_data.fetch(:required).fetch(:message)
50
+ end
51
+
52
+ # Generates the default I18n message for required validation.
53
+ #
54
+ # @return [String] Localized default required message
55
+ def default_required_message
56
+ I18n.t(
57
+ "#{i18n_root_key}.inputs.validations.required.default_error.default",
58
+ service_class_name: described_class.name,
59
+ input_name: attribute_name
60
+ )
61
+ end
62
+
63
+ # Calls a Proc message with the keyword arguments the library passes to it.
64
+ #
65
+ # `value:` is `nil`, as for a missing value.
66
+ #
67
+ # @param message [Proc] The custom message
68
+ # @return [Object] The message built by the Proc
69
+ def call_required_message(message)
70
+ call_proc_message(message, :required)
71
+ end
72
+
73
+ # Builds the message the library uses for a missing required value.
74
+ #
75
+ # Uses the custom message if provided, otherwise the default message.
76
+ #
77
+ # @return [Object] The message, a String unless a Proc message returns another object
78
+ def required_message
79
+ message = required_custom_message
80
+
81
+ return default_required_message if message.blank?
82
+ return message unless message.is_a?(Proc)
83
+
84
+ call_required_message(message)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -22,17 +22,38 @@ module Servactory
22
22
  # end
23
23
  # ```
24
24
  #
25
+ # ## Messages
26
+ #
27
+ # `.message` checks the message of the option chained right before it,
28
+ # `.required(message)` checks the required message, and `.must` takes
29
+ # the expected message of each rule by its name:
30
+ #
31
+ # ```ruby
32
+ # it { is_expected.to have_service_input(:status).inclusion(%w[active inactive]).message("Unknown status") }
33
+ # it { is_expected.to have_service_input(:status).type(String).message(:default) }
34
+ # it { is_expected.to have_service_input(:email).required(/is required/) }
35
+ # it { is_expected.to have_service_input(:age).must(:be_adult, be_positive: "Age must be positive") }
36
+ # it { is_expected.to have_service_input(:age).must(:be_positive).message(be_a(Proc)) }
37
+ # ```
38
+ #
39
+ # A String must equal the message, a Regexp must match it, an RSpec
40
+ # matcher is applied to the message as defined, and `:default` checks
41
+ # that no custom message is defined. See MessageSubmatcher and
42
+ # MustSubmatcher for the details.
43
+ #
25
44
  # ## Chain Methods
26
45
  #
27
46
  # - `.type(Class)` / `.types(Class, ...)` - expected type(s)
28
- # - `.required` / `.optional` - required status
47
+ # - `.required` / `.optional` - required status, `.required(message)` also checks the message
29
48
  # - `.default(value)` - expected default value
30
49
  # - `.consists_of(Class)` - for Array/Hash element types
31
50
  # - `.schema(Hash)` - expected schema definition
32
51
  # - `.inclusion(Array)` - expected inclusion values
33
- # - `.must(Array)` - custom validation rules
52
+ # - `.must(Array)` / `.must(*names, **messages)` - custom validation rules,
53
+ # optionally with the expected message of each rule
34
54
  # - `.target(value, name:)` - target validation
35
- # - `.message(String)` - expected error message (after other chain)
55
+ # - `.message(String | Regexp | matcher | :default)` - expected error message (after other chain);
56
+ # after `.must(:rule)` it is the expected message of that rule
36
57
  class HaveServiceInputMatcher < Base::AttributeMatcher
37
58
  for_attribute_type :input
38
59
 
@@ -69,7 +90,9 @@ module Servactory
69
90
 
70
91
  register_submatcher :must,
71
92
  class_name: "Shared::MustSubmatcher",
72
- transform_args: ->(args, _kwargs = {}) { [Array(args).flatten] }
93
+ transform_args: (lambda do |args, _kwargs = {}|
94
+ Submatchers::Shared::MustSubmatcher.arguments_from(args)
95
+ end)
73
96
 
74
97
  register_submatcher :message,
75
98
  class_name: "Shared::MessageSubmatcher",
@@ -19,6 +19,9 @@ module Servactory
19
19
  # it { is_expected.to have_service_internal(:processed_data).type(Hash) }
20
20
  # it { is_expected.to have_service_internal(:items).type(Array).consists_of(Item) }
21
21
  # it { is_expected.to have_service_internal(:config).schema({ key: String }) }
22
+ # it { is_expected.to have_service_internal(:items).type(Array).message(/must be an Array/) }
23
+ # it { is_expected.to have_service_internal(:total).must(be_positive: "Total must be positive") }
24
+ # it { is_expected.to have_service_internal(:total).must(:be_positive).message(:default) }
22
25
  # end
23
26
  # ```
24
27
  #
@@ -28,9 +31,11 @@ module Servactory
28
31
  # - `.consists_of(Class)` - for Array/Hash element types
29
32
  # - `.schema(Hash)` - expected schema definition
30
33
  # - `.inclusion(Array)` - expected inclusion values
31
- # - `.must(Array)` - custom validation rules
34
+ # - `.must(Array)` / `.must(*names, **messages)` - custom validation rules,
35
+ # optionally with the expected message of each rule
32
36
  # - `.target(value, name:)` - target validation
33
- # - `.message(String)` - expected error message (after other chain)
37
+ # - `.message(String | Regexp | matcher | :default)` - expected error message (after other chain);
38
+ # after `.must(:rule)` it is the expected message of that rule
34
39
  #
35
40
  # ## Architecture
36
41
  #
@@ -72,7 +77,9 @@ module Servactory
72
77
 
73
78
  register_submatcher :must,
74
79
  class_name: "Shared::MustSubmatcher",
75
- transform_args: ->(args, _kwargs = {}) { [Array(args).flatten] }
80
+ transform_args: (lambda do |args, _kwargs = {}|
81
+ Submatchers::Shared::MustSubmatcher.arguments_from(args)
82
+ end)
76
83
 
77
84
  register_submatcher :message,
78
85
  class_name: "Shared::MessageSubmatcher",