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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +7 -2
- data/config/locales/ja.yml +134 -0
- data/lib/generators/README.md +2 -2
- data/lib/generators/servactory/extension/USAGE +1 -1
- data/lib/generators/servactory/install/USAGE +2 -2
- data/lib/generators/servactory/rspec/USAGE +4 -4
- data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
- data/lib/generators/servactory/service/USAGE +5 -5
- data/lib/servactory/actions/collection.rb +21 -5
- data/lib/servactory/actions/stages/collection.rb +19 -1
- data/lib/servactory/actions/tools/rules.rb +1 -1
- data/lib/servactory/actions/tools/runner.rb +1 -1
- data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
- data/lib/servactory/configuration/config.rb +16 -0
- data/lib/servactory/configuration/configurable.rb +7 -4
- data/lib/servactory/configuration/factory.rb +30 -3
- data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
- data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
- data/lib/servactory/context/warehouse/inputs.rb +1 -1
- data/lib/servactory/context/workspace/inputs.rb +2 -2
- data/lib/servactory/context/workspace/internals.rb +2 -2
- data/lib/servactory/context/workspace/outputs.rb +2 -2
- data/lib/servactory/context/workspace.rb +7 -7
- data/lib/servactory/info/builder.rb +4 -3
- data/lib/servactory/inputs/dsl.rb +5 -1
- data/lib/servactory/inputs/input.rb +1 -2
- data/lib/servactory/inputs/tools/validation.rb +10 -18
- data/lib/servactory/internals/dsl.rb +7 -1
- data/lib/servactory/maintenance/attributes/base.rb +4 -1
- data/lib/servactory/maintenance/attributes/collection.rb +61 -6
- data/lib/servactory/maintenance/options/collection.rb +71 -30
- data/lib/servactory/maintenance/options/option.rb +3 -12
- data/lib/servactory/maintenance/options/registrar.rb +3 -13
- data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
- data/lib/servactory/maintenance/validations/performer.rb +12 -23
- data/lib/servactory/outputs/dsl.rb +7 -1
- data/lib/servactory/result.rb +21 -8
- data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
- data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
- data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
- data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
- data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
- data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
- data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
- data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
- data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
- data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
- data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
- data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
- data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
- data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
- data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
- data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
- data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
- data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
- data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
- data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
- data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
- data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
- data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
- data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
- data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
- data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
- data/lib/servactory/utils.rb +13 -3
- data/lib/servactory/version.rb +1 -1
- data/lib/servactory.rb +3 -4
- 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
|
-
#
|
|
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
|
-
|
|
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: #{
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
120
|
+
"should return expected message in case of problem:\n\n#{@mismatch.indent(2)}"
|
|
121
|
+
end
|
|
71
122
|
|
|
72
|
-
|
|
73
|
-
should return expected message in case of problem:
|
|
123
|
+
private
|
|
74
124
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
152
|
+
# Compares the option's message with the expected message.
|
|
85
153
|
#
|
|
86
|
-
#
|
|
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 [
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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]
|
|
141
|
+
# @return [String] Failure message with expected vs actual rules and messages
|
|
69
142
|
def build_failure_message
|
|
70
|
-
|
|
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 :
|
|
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
|
|
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 [
|
|
92
|
-
#
|
|
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 [
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|