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.
- checksums.yaml +4 -4
- data/README.md +5 -0
- data/lib/generators/README.md +1 -1
- 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/configurable.rb +1 -1
- data/lib/servactory/configuration/factory.rb +30 -3
- data/lib/servactory/configuration/hash_mode/class_names_collection.rb +2 -1
- data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +150 -37
- 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/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 +46 -15
- data/lib/servactory/maintenance/options/option.rb +3 -12
- data/lib/servactory/maintenance/options/registrar.rb +2 -12
- data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
- 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 -129
- 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 +110 -45
- data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +26 -8
- 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 +29 -8
- data/lib/servactory/tool_kit/dynamic_options/format.rb +2 -3
- data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +1 -1
- data/lib/servactory/tool_kit/dynamic_options/max.rb +1 -1
- data/lib/servactory/tool_kit/dynamic_options/min.rb +1 -1
- data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +33 -5
- data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
- data/lib/servactory/tool_kit/dynamic_options/schema.rb +34 -21
- data/lib/servactory/tool_kit/dynamic_options/target.rb +1 -1
- data/lib/servactory/utils.rb +13 -3
- data/lib/servactory/version.rb +1 -1
- data/lib/servactory.rb +3 -0
- metadata +10 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servactory
|
|
4
|
+
module TestKit
|
|
5
|
+
module Rspec
|
|
6
|
+
module Helpers
|
|
7
|
+
# Validates the exception of a mock configuration.
|
|
8
|
+
#
|
|
9
|
+
# ## Purpose
|
|
10
|
+
#
|
|
11
|
+
# Ensures that failure mocks have an exception and that the exception
|
|
12
|
+
# type matches the service configuration. Called automatically by
|
|
13
|
+
# MockExecutor for every config before stubbing.
|
|
14
|
+
#
|
|
15
|
+
# ## Usage
|
|
16
|
+
#
|
|
17
|
+
# ```ruby
|
|
18
|
+
# ExceptionValidator.validate!(config)
|
|
19
|
+
# ```
|
|
20
|
+
#
|
|
21
|
+
# ## Validation Strategy
|
|
22
|
+
#
|
|
23
|
+
# - `.call` - accepts any Servactory::Exceptions::Failure subclass
|
|
24
|
+
# - `.call!` - requires the service's configured failure_class or its subclass
|
|
25
|
+
class ExceptionValidator
|
|
26
|
+
include Concerns::ErrorMessages
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Validates a config and raises on failure.
|
|
30
|
+
#
|
|
31
|
+
# @param config [ServiceMockConfig] The config to validate
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [ArgumentError] If config is invalid
|
|
34
|
+
def validate!(config)
|
|
35
|
+
new(config).validate!
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Creates a new validator instance.
|
|
40
|
+
#
|
|
41
|
+
# @param config [ServiceMockConfig] The config to validate
|
|
42
|
+
# @return [ExceptionValidator] New validator
|
|
43
|
+
def initialize(config)
|
|
44
|
+
@config = config
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validates a single configuration.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [ArgumentError] If config is invalid
|
|
51
|
+
def validate!
|
|
52
|
+
validate_failure_has_exception!
|
|
53
|
+
validate_exception_type!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Validates that failure configs have an exception.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @raise [ArgumentError] If failure config is missing exception
|
|
62
|
+
def validate_failure_has_exception!
|
|
63
|
+
return unless @config.failure? && @config.exception.nil?
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, missing_exception_for_failure_message(@config.service_class)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validates that exception is the correct type for the service.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
# @raise [ArgumentError] If exception type is wrong
|
|
72
|
+
def validate_exception_type!
|
|
73
|
+
return if @config.exception.nil?
|
|
74
|
+
return if valid_exception_type?
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, invalid_exception_type_message(
|
|
77
|
+
service_class: @config.service_class,
|
|
78
|
+
expected_class: failure_class,
|
|
79
|
+
actual_class: @config.exception.class
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Checks if exception is the correct type.
|
|
84
|
+
#
|
|
85
|
+
# Uses different validation strategies based on method type:
|
|
86
|
+
# - For `.call` (non-bang): Relaxed validation - accepts any Servactory::Exceptions::Failure subclass
|
|
87
|
+
# because the exception is wrapped in Result and never raised, so type doesn't matter.
|
|
88
|
+
# - For `.call!` (bang): Strict validation - requires the service's configured failure_class
|
|
89
|
+
# because the exception IS raised and type matters for rescue clauses.
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] True if exception is valid type
|
|
92
|
+
def valid_exception_type?
|
|
93
|
+
if @config.bang_method?
|
|
94
|
+
# Strict validation for call! - exception will be raised
|
|
95
|
+
@config.exception.is_a?(failure_class)
|
|
96
|
+
else
|
|
97
|
+
# Relaxed validation for call - exception is only wrapped in Result
|
|
98
|
+
@config.exception.is_a?(Servactory::Exceptions::Failure)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the expected failure class for the service.
|
|
103
|
+
#
|
|
104
|
+
# @return [Class] The service's failure class
|
|
105
|
+
def failure_class
|
|
106
|
+
@config.service_class.config.failure_class
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -133,7 +133,9 @@ module Servactory
|
|
|
133
133
|
# @raise [ValidationError] If service has required inputs
|
|
134
134
|
# @return [void]
|
|
135
135
|
def validate_service_has_no_required_inputs!
|
|
136
|
-
required_inputs = @service_class.info.inputs.
|
|
136
|
+
required_inputs = @service_class.info.inputs.filter_map do |input_name, input|
|
|
137
|
+
input_name if input.fetch(:actor).required?
|
|
138
|
+
end
|
|
137
139
|
|
|
138
140
|
return if required_inputs.empty?
|
|
139
141
|
|
|
@@ -11,6 +11,9 @@ module Servactory
|
|
|
11
11
|
# MockExecutor translates ServiceMockConfig objects into actual RSpec
|
|
12
12
|
# stub setups using `allow(...).to receive(...)`. It handles single
|
|
13
13
|
# and sequential call scenarios, applying appropriate return behaviors.
|
|
14
|
+
# A stub returned by a previous execution can be passed back to be
|
|
15
|
+
# reconfigured in place instead of registering another stub, as long
|
|
16
|
+
# as it has not been invoked.
|
|
14
17
|
#
|
|
15
18
|
# ## Usage
|
|
16
19
|
#
|
|
@@ -22,12 +25,28 @@ module Servactory
|
|
|
22
25
|
# configs: [config1, config2],
|
|
23
26
|
# rspec_context: self
|
|
24
27
|
# )
|
|
25
|
-
# executor.execute
|
|
28
|
+
# stub = executor.execute
|
|
29
|
+
#
|
|
30
|
+
# # After configs change, while the stub has not been invoked
|
|
31
|
+
# executor.execute(stub)
|
|
32
|
+
# executor.update_arguments(stub)
|
|
26
33
|
# ```
|
|
27
34
|
#
|
|
35
|
+
# ## Argument Matching
|
|
36
|
+
#
|
|
37
|
+
# - **With `.with()`** - RSpec matches the arguments against the configured matcher
|
|
38
|
+
# - **Without `.with()`** - RSpec accepts any arguments, and ServiceInputsGuard
|
|
39
|
+
# verifies them against the service inputs before the stub responds
|
|
40
|
+
#
|
|
41
|
+
# RSpec responds with the most recently registered stub that matches a call.
|
|
42
|
+
# A stub without `.with()` matches every call, so it handles every later
|
|
43
|
+
# call of the stubbed method, and ServiceInputsGuard rejects invalid inputs
|
|
44
|
+
# instead of deferring to stubs registered before it.
|
|
45
|
+
#
|
|
28
46
|
# ## Execution Strategies
|
|
29
47
|
#
|
|
30
48
|
# - **Single Config** - uses `and_return` or `and_raise` directly
|
|
49
|
+
# - **Pass-Through** - uses `and_wrap_original` to call the original or the wrap block
|
|
31
50
|
# - **Sequential Returns** - uses `and_return(*values)` for multiple values
|
|
32
51
|
# - **Sequential with Raises** - uses `and_invoke(*callables)` for mixed behavior
|
|
33
52
|
#
|
|
@@ -36,9 +55,33 @@ module Servactory
|
|
|
36
55
|
# Works with:
|
|
37
56
|
# - ServiceMockConfig - provides configuration for each stub
|
|
38
57
|
# - ServiceMockBuilder - creates executor with configs
|
|
58
|
+
# - ExceptionValidator - validates exceptions of the configs
|
|
59
|
+
# - ServiceInputsGuard - verifies inputs of calls accepted with any arguments
|
|
60
|
+
# - WrapBlockAdapter - adapts wrap blocks to the arguments of service calls
|
|
39
61
|
# - RSpec Context - provides allow/receive/etc. methods
|
|
40
|
-
class MockExecutor
|
|
41
|
-
|
|
62
|
+
class MockExecutor
|
|
63
|
+
# Registered RSpec stub and the guard verifying the inputs it receives.
|
|
64
|
+
#
|
|
65
|
+
# @!attribute [r] message_expectation
|
|
66
|
+
# @return [RSpec::Mocks::MessageExpectation] The RSpec stub
|
|
67
|
+
# @!attribute [r] inputs_guard
|
|
68
|
+
# @return [ServiceInputsGuard] The guard called by the stub implementation
|
|
69
|
+
Stub = Data.define(:message_expectation, :inputs_guard) do
|
|
70
|
+
# Checks whether the stub has received a call.
|
|
71
|
+
#
|
|
72
|
+
# RSpec does not allow modifying an invoked stub.
|
|
73
|
+
#
|
|
74
|
+
# @return [Boolean] True if the stub has been invoked
|
|
75
|
+
def invoked?
|
|
76
|
+
inputs_guard.invoked?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
CALL_ORIGINAL = lambda do |original, *arguments, &block|
|
|
81
|
+
original.call(*arguments, &block)
|
|
82
|
+
end.ruby2_keywords
|
|
83
|
+
|
|
84
|
+
private_constant :CALL_ORIGINAL
|
|
42
85
|
|
|
43
86
|
# Creates a new mock executor.
|
|
44
87
|
#
|
|
@@ -51,21 +94,37 @@ module Servactory
|
|
|
51
94
|
@rspec_context = rspec_context
|
|
52
95
|
end
|
|
53
96
|
|
|
54
|
-
#
|
|
97
|
+
# Registers the stub, or reconfigures the stub from a previous execution.
|
|
55
98
|
#
|
|
56
|
-
# Validates all configs first, then applies
|
|
57
|
-
#
|
|
99
|
+
# Validates all configs first, then applies the argument matcher and
|
|
100
|
+
# the appropriate return behavior (single or sequential).
|
|
58
101
|
#
|
|
59
|
-
# @
|
|
102
|
+
# @param stub [Stub, nil] Stub to reconfigure, which must not have been invoked
|
|
103
|
+
# @return [Stub] The configured stub
|
|
60
104
|
# @raise [ArgumentError] If any config is invalid
|
|
61
|
-
def execute
|
|
105
|
+
def execute(stub = nil)
|
|
62
106
|
validate_configs!
|
|
63
107
|
|
|
108
|
+
stub = stub.nil? ? register_stub : update_arguments(stub)
|
|
109
|
+
|
|
64
110
|
if sequential?
|
|
65
|
-
|
|
111
|
+
apply_sequential_behavior(stub.message_expectation)
|
|
66
112
|
else
|
|
67
|
-
|
|
113
|
+
apply_return_behavior(stub, @configs.first)
|
|
68
114
|
end
|
|
115
|
+
|
|
116
|
+
stub
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Replaces the argument matcher of a registered stub
|
|
120
|
+
# and the inputs matcher of its guard.
|
|
121
|
+
#
|
|
122
|
+
# @param stub [Stub] Stub to update, which must not have been invoked
|
|
123
|
+
# @return [Stub] The updated stub
|
|
124
|
+
def update_arguments(stub)
|
|
125
|
+
stub.message_expectation.with(argument_matcher)
|
|
126
|
+
stub.inputs_guard.inputs_matcher = @configs.first.build_inputs_matcher
|
|
127
|
+
stub
|
|
69
128
|
end
|
|
70
129
|
|
|
71
130
|
private
|
|
@@ -77,35 +136,50 @@ module Servactory
|
|
|
77
136
|
@configs.size > 1
|
|
78
137
|
end
|
|
79
138
|
|
|
80
|
-
#
|
|
139
|
+
# Registers a new stub constrained by the argument matcher.
|
|
81
140
|
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
141
|
+
# Return behaviors run after the guard, which is registered as the stub
|
|
142
|
+
# implementation. Pass-through behaviors replace the stub implementation,
|
|
143
|
+
# so they call the guard themselves.
|
|
144
|
+
#
|
|
145
|
+
# @return [Stub] The registered stub
|
|
146
|
+
def register_stub
|
|
147
|
+
inputs_guard = ServiceInputsGuard.new(service_class: @service_class, method_type:)
|
|
148
|
+
implementation = ->(*arguments) { inputs_guard.verify!(arguments) } unless @configs.first.pass_through?
|
|
87
149
|
|
|
88
150
|
message_expectation = @rspec_context.allow(@service_class).to(
|
|
89
|
-
@rspec_context.receive(
|
|
151
|
+
@rspec_context.receive(method_type, &implementation)
|
|
90
152
|
)
|
|
91
153
|
|
|
92
|
-
|
|
154
|
+
update_arguments(Stub.new(message_expectation:, inputs_guard:))
|
|
93
155
|
end
|
|
94
156
|
|
|
95
|
-
#
|
|
157
|
+
# Returns the stubbed method shared by all configs.
|
|
96
158
|
#
|
|
97
|
-
#
|
|
98
|
-
|
|
159
|
+
# @return [Symbol] :call or :call!
|
|
160
|
+
def method_type
|
|
161
|
+
@configs.first.method_type
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds the argument matcher shared by all configs.
|
|
99
165
|
#
|
|
100
|
-
# @return [
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
166
|
+
# @return [Object] RSpec argument matcher
|
|
167
|
+
def argument_matcher
|
|
168
|
+
@configs.first.build_argument_matcher(@rspec_context)
|
|
169
|
+
end
|
|
104
170
|
|
|
171
|
+
# Applies sequential return behavior to a message expectation.
|
|
172
|
+
#
|
|
173
|
+
# Uses and_return for simple returns and and_invoke when
|
|
174
|
+
# exceptions need to be raised.
|
|
175
|
+
#
|
|
176
|
+
# @param message_expectation [RSpec::Mocks::MessageExpectation] The stub to configure
|
|
177
|
+
# @return [void]
|
|
178
|
+
def apply_sequential_behavior(message_expectation)
|
|
105
179
|
if all_returns?
|
|
106
|
-
|
|
180
|
+
message_expectation.and_return(*@configs.map(&:build_result))
|
|
107
181
|
else
|
|
108
|
-
|
|
182
|
+
message_expectation.and_invoke(*@configs.map { |config| build_callable(config) })
|
|
109
183
|
end
|
|
110
184
|
end
|
|
111
185
|
|
|
@@ -119,38 +193,6 @@ module Servactory
|
|
|
119
193
|
@configs.none? { |config| config.failure? && config.bang_method? }
|
|
120
194
|
end
|
|
121
195
|
|
|
122
|
-
# Executes sequential stub with and_return for all values.
|
|
123
|
-
#
|
|
124
|
-
# @param method_name [Symbol] The method being stubbed
|
|
125
|
-
# @param arg_matcher [Object] RSpec argument matcher
|
|
126
|
-
# @return [void]
|
|
127
|
-
def execute_sequential_returns(method_name, arg_matcher)
|
|
128
|
-
returns = @configs.map(&:build_result)
|
|
129
|
-
|
|
130
|
-
@rspec_context.allow(@service_class).to(
|
|
131
|
-
@rspec_context.receive(method_name)
|
|
132
|
-
.with(arg_matcher)
|
|
133
|
-
.and_return(*returns)
|
|
134
|
-
)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Executes sequential stub with and_invoke for mixed behavior.
|
|
138
|
-
#
|
|
139
|
-
# Uses callables to handle both returns and raises.
|
|
140
|
-
#
|
|
141
|
-
# @param method_name [Symbol] The method being stubbed
|
|
142
|
-
# @param arg_matcher [Object] RSpec argument matcher
|
|
143
|
-
# @return [void]
|
|
144
|
-
def execute_sequential_invoke(method_name, arg_matcher)
|
|
145
|
-
callables = @configs.map { |config| build_callable(config) }
|
|
146
|
-
|
|
147
|
-
@rspec_context.allow(@service_class).to(
|
|
148
|
-
@rspec_context.receive(method_name)
|
|
149
|
-
.with(arg_matcher)
|
|
150
|
-
.and_invoke(*callables)
|
|
151
|
-
)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
196
|
# Builds a callable lambda for and_invoke.
|
|
155
197
|
#
|
|
156
198
|
# @param config [ServiceMockConfig] The config to build callable for
|
|
@@ -164,16 +206,16 @@ module Servactory
|
|
|
164
206
|
end
|
|
165
207
|
end
|
|
166
208
|
|
|
167
|
-
# Applies return or
|
|
209
|
+
# Applies return, raise, or pass-through behavior to a stub.
|
|
168
210
|
#
|
|
169
|
-
# @param
|
|
211
|
+
# @param stub [Stub] The stub to configure
|
|
170
212
|
# @param config [ServiceMockConfig] Configuration with result/exception
|
|
171
213
|
# @return [void]
|
|
172
|
-
def apply_return_behavior(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
message_expectation.and_wrap_original(&config
|
|
214
|
+
def apply_return_behavior(stub, config)
|
|
215
|
+
message_expectation = stub.message_expectation
|
|
216
|
+
|
|
217
|
+
if config.pass_through?
|
|
218
|
+
message_expectation.and_wrap_original(&build_pass_through(stub.inputs_guard, config))
|
|
177
219
|
elsif config.failure? && config.bang_method?
|
|
178
220
|
message_expectation.and_raise(config.exception)
|
|
179
221
|
else
|
|
@@ -181,77 +223,31 @@ module Servactory
|
|
|
181
223
|
end
|
|
182
224
|
end
|
|
183
225
|
|
|
184
|
-
#
|
|
226
|
+
# Builds the and_wrap_original block for a pass-through config.
|
|
185
227
|
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
def validate_configs!
|
|
189
|
-
@configs.each { |config| validate_config!(config) }
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Validates a single configuration.
|
|
228
|
+
# Verifies the received inputs, then delegates to the original method
|
|
229
|
+
# or to the wrap block.
|
|
193
230
|
#
|
|
194
|
-
# @param
|
|
195
|
-
# @
|
|
196
|
-
# @
|
|
197
|
-
def
|
|
198
|
-
|
|
199
|
-
validate_exception_type!(config)
|
|
200
|
-
end
|
|
231
|
+
# @param inputs_guard [ServiceInputsGuard] Guard of the stub
|
|
232
|
+
# @param config [ServiceMockConfig] Pass-through configuration
|
|
233
|
+
# @return [Proc] Block for RSpec's and_wrap_original
|
|
234
|
+
def build_pass_through(inputs_guard, config)
|
|
235
|
+
delegate = config.wrap_original? ? WrapBlockAdapter.adapt(config.wrap_block) : CALL_ORIGINAL
|
|
201
236
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
# @raise [ArgumentError] If failure config is missing exception
|
|
207
|
-
def validate_failure_has_exception!(config)
|
|
208
|
-
return unless config.failure? && config.exception.nil?
|
|
237
|
+
pass_through = lambda do |original, *arguments, &block|
|
|
238
|
+
inputs_guard.verify!(arguments)
|
|
239
|
+
delegate.call(original, *arguments, &block)
|
|
240
|
+
end
|
|
209
241
|
|
|
210
|
-
|
|
242
|
+
pass_through.ruby2_keywords
|
|
211
243
|
end
|
|
212
244
|
|
|
213
|
-
# Validates
|
|
245
|
+
# Validates all configurations before executing.
|
|
214
246
|
#
|
|
215
|
-
# @param config [ServiceMockConfig] The config to validate
|
|
216
247
|
# @return [void]
|
|
217
|
-
# @raise [ArgumentError] If
|
|
218
|
-
def
|
|
219
|
-
|
|
220
|
-
return if valid_exception_type?(config)
|
|
221
|
-
|
|
222
|
-
raise ArgumentError, invalid_exception_type_message(
|
|
223
|
-
service_class: config.service_class,
|
|
224
|
-
expected_class: failure_class_for(config),
|
|
225
|
-
actual_class: config.exception.class
|
|
226
|
-
)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Checks if exception is the correct type.
|
|
230
|
-
#
|
|
231
|
-
# Uses different validation strategies based on method type:
|
|
232
|
-
# - For `.call` (non-bang): Relaxed validation - accepts any Servactory::Exceptions::Failure subclass
|
|
233
|
-
# because the exception is wrapped in Result and never raised, so type doesn't matter.
|
|
234
|
-
# - For `.call!` (bang): Strict validation - requires the service's configured failure_class
|
|
235
|
-
# because the exception IS raised and type matters for rescue clauses.
|
|
236
|
-
#
|
|
237
|
-
# @param config [ServiceMockConfig] The config to check
|
|
238
|
-
# @return [Boolean] True if exception is valid type
|
|
239
|
-
def valid_exception_type?(config)
|
|
240
|
-
if config.bang_method?
|
|
241
|
-
# Strict validation for call! - exception will be raised
|
|
242
|
-
config.exception.is_a?(failure_class_for(config))
|
|
243
|
-
else
|
|
244
|
-
# Relaxed validation for call - exception is only wrapped in Result
|
|
245
|
-
config.exception.is_a?(Servactory::Exceptions::Failure)
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Returns the expected failure class for a service.
|
|
250
|
-
#
|
|
251
|
-
# @param config [ServiceMockConfig] The config with service class
|
|
252
|
-
# @return [Class] The service's failure class
|
|
253
|
-
def failure_class_for(config)
|
|
254
|
-
config.service_class.config.failure_class
|
|
248
|
+
# @raise [ArgumentError] If any config is invalid
|
|
249
|
+
def validate_configs!
|
|
250
|
+
@configs.each { |config| ExceptionValidator.validate!(config) }
|
|
255
251
|
end
|
|
256
252
|
end
|
|
257
253
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servactory
|
|
4
|
+
module TestKit
|
|
5
|
+
module Rspec
|
|
6
|
+
module Helpers
|
|
7
|
+
# Verifies the inputs of calls received by a service mock.
|
|
8
|
+
#
|
|
9
|
+
# ## Purpose
|
|
10
|
+
#
|
|
11
|
+
# RSpec compares the number of arguments before applying an argument
|
|
12
|
+
# matcher, so a stub constrained by ServiceInputsMatcher would reject
|
|
13
|
+
# a call without arguments. Service mocks configured without `.with()`
|
|
14
|
+
# therefore accept any arguments on the RSpec level, and the guard
|
|
15
|
+
# verifies them inside the stub implementation before the mock responds.
|
|
16
|
+
# Calls whose inputs the service would not accept raise the same error
|
|
17
|
+
# as an RSpec argument mismatch.
|
|
18
|
+
#
|
|
19
|
+
# Verification is skipped while no inputs matcher is set, which is the
|
|
20
|
+
# case for mocks constrained with `.with()`.
|
|
21
|
+
#
|
|
22
|
+
# The stub calls the guard on every call it receives, so the guard also
|
|
23
|
+
# tells whether the stub has been invoked. RSpec does not allow modifying
|
|
24
|
+
# an invoked stub.
|
|
25
|
+
#
|
|
26
|
+
# ## Usage
|
|
27
|
+
#
|
|
28
|
+
# Used internally by MockExecutor:
|
|
29
|
+
#
|
|
30
|
+
# ```ruby
|
|
31
|
+
# guard = ServiceInputsGuard.new(service_class: MyService, method_type: :call)
|
|
32
|
+
# guard.inputs_matcher = ServiceInputsMatcher.new(MyService.info.inputs)
|
|
33
|
+
#
|
|
34
|
+
# guard.verify!([{ user_id: 1 }])
|
|
35
|
+
# ```
|
|
36
|
+
class ServiceInputsGuard
|
|
37
|
+
# @return [ServiceInputsMatcher, nil] Matcher applied to received arguments, nil skips verification
|
|
38
|
+
attr_accessor :inputs_matcher
|
|
39
|
+
|
|
40
|
+
# Creates a guard without an inputs matcher.
|
|
41
|
+
#
|
|
42
|
+
# @param service_class [Class] The mocked service class
|
|
43
|
+
# @param method_type [Symbol] The mocked method (:call or :call!)
|
|
44
|
+
def initialize(service_class:, method_type:)
|
|
45
|
+
@service_class = service_class
|
|
46
|
+
@method_type = method_type
|
|
47
|
+
@inputs_matcher = nil
|
|
48
|
+
@invoked = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Checks whether the guarded stub has received a call.
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean] True if #verify! was called
|
|
54
|
+
def invoked?
|
|
55
|
+
@invoked
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Records the call and verifies the arguments received by the mock.
|
|
59
|
+
#
|
|
60
|
+
# @param arguments [Array<Object>] The arguments the service was called with
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [RSpec::Mocks::MockExpectationError] If the arguments do not match the inputs matcher
|
|
63
|
+
def verify!(arguments)
|
|
64
|
+
@invoked = true
|
|
65
|
+
|
|
66
|
+
return if @inputs_matcher.nil? || @inputs_matcher.args_match?(*arguments)
|
|
67
|
+
|
|
68
|
+
raise RSpec::Mocks::MockExpectationError, unexpected_arguments_message(arguments)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Builds the message of RSpec's unexpected arguments error.
|
|
74
|
+
#
|
|
75
|
+
# @param arguments [Array<Object>] The arguments the service was called with
|
|
76
|
+
# @return [String] Error message with expected and received arguments
|
|
77
|
+
def unexpected_arguments_message(arguments)
|
|
78
|
+
<<~MESSAGE.chomp
|
|
79
|
+
#<#{@service_class.inspect} (class)> received #{@method_type.inspect} with unexpected arguments
|
|
80
|
+
expected: (#{@inputs_matcher.description})
|
|
81
|
+
got: #{format_arguments(arguments)}
|
|
82
|
+
MESSAGE
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Formats arguments the way RSpec does in failure messages.
|
|
86
|
+
#
|
|
87
|
+
# @param arguments [Array<Object>] The arguments the service was called with
|
|
88
|
+
# @return [String] Formatted argument list
|
|
89
|
+
def format_arguments(arguments)
|
|
90
|
+
return "(no args)" if arguments.empty?
|
|
91
|
+
|
|
92
|
+
"(#{arguments.map { |argument| RSpec::Support::ObjectFormatter.format(argument) }.join(', ')})"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servactory
|
|
4
|
+
module TestKit
|
|
5
|
+
module Rspec
|
|
6
|
+
module Helpers
|
|
7
|
+
# Argument list matcher for inputs accepted by a service.
|
|
8
|
+
#
|
|
9
|
+
# ## Purpose
|
|
10
|
+
#
|
|
11
|
+
# Default argument matcher for service mocks configured without `.with()`.
|
|
12
|
+
# A call matches when every required input is present and no undeclared
|
|
13
|
+
# inputs are passed. Optional inputs may be omitted, so a call without
|
|
14
|
+
# arguments matches when the service has no required inputs. Input values
|
|
15
|
+
# are not constrained.
|
|
16
|
+
#
|
|
17
|
+
# ## Usage
|
|
18
|
+
#
|
|
19
|
+
# Built internally by ServiceMockConfig when no argument matcher is set
|
|
20
|
+
# and applied to every call by ServiceInputsGuard:
|
|
21
|
+
#
|
|
22
|
+
# ```ruby
|
|
23
|
+
# allow_service(MyService).succeeds(result: "ok")
|
|
24
|
+
#
|
|
25
|
+
# MyService.call(user_id: 1)
|
|
26
|
+
# MyService.call({ user_id: 1, locale: "en" })
|
|
27
|
+
# ```
|
|
28
|
+
#
|
|
29
|
+
# Keyword arguments, a positional Hash and a Datory object are matched,
|
|
30
|
+
# as the service accepts them. Keys are symbolized the way the service
|
|
31
|
+
# does it, so String keys and HashWithIndifferentAccess are accepted
|
|
32
|
+
# as well.
|
|
33
|
+
class ServiceInputsMatcher
|
|
34
|
+
# Creates a matcher from service input definitions.
|
|
35
|
+
#
|
|
36
|
+
# @param inputs [Hash{Symbol => Hash}] Input definitions from `service_class.info.inputs`
|
|
37
|
+
def initialize(inputs)
|
|
38
|
+
@input_names = inputs.keys
|
|
39
|
+
@required_input_names = inputs.filter_map do |input_name, input|
|
|
40
|
+
input_name if input.fetch(:actor).required?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checks whether the call arguments contain acceptable service inputs.
|
|
45
|
+
#
|
|
46
|
+
# The inputs are passed as a single argument accepted by the service,
|
|
47
|
+
# a Hash or a Datory object. Input names are compared after converting
|
|
48
|
+
# it with Servactory::Utils.adapt.
|
|
49
|
+
#
|
|
50
|
+
# @param arguments [Array<Object>] The arguments the service was called with
|
|
51
|
+
# @return [Boolean] True if all required inputs are present and no unknown inputs are passed
|
|
52
|
+
def args_match?(*arguments)
|
|
53
|
+
return @required_input_names.empty? if arguments.empty?
|
|
54
|
+
return false unless arguments.one? && Servactory::Utils.adaptable?(arguments.first)
|
|
55
|
+
|
|
56
|
+
inputs = Servactory::Utils.adapt(arguments.first)
|
|
57
|
+
|
|
58
|
+
@required_input_names.all? { |input_name| inputs.key?(input_name) } &&
|
|
59
|
+
inputs.each_key.all? { |input_name| @input_names.include?(input_name) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Describes the matcher for RSpec failure messages.
|
|
63
|
+
#
|
|
64
|
+
# @return [String] Matcher description
|
|
65
|
+
def description
|
|
66
|
+
"service_inputs(required: #{@required_input_names.inspect}, " \
|
|
67
|
+
"optional: #{(@input_names - @required_input_names).inspect})"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
alias inspect description
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|