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,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.reject { |_, v| v[:required] == false }.keys
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 # rubocop:disable Metrics/ClassLength
41
- include Concerns::ErrorMessages
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
- # Executes the stub setup based on configurations.
97
+ # Registers the stub, or reconfigures the stub from a previous execution.
55
98
  #
56
- # Validates all configs first, then applies appropriate stubbing
57
- # strategy (single or sequential).
99
+ # Validates all configs first, then applies the argument matcher and
100
+ # the appropriate return behavior (single or sequential).
58
101
  #
59
- # @return [void]
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
- execute_sequential
111
+ apply_sequential_behavior(stub.message_expectation)
66
112
  else
67
- execute_single
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
- # Executes a single-config stub.
139
+ # Registers a new stub constrained by the argument matcher.
81
140
  #
82
- # @return [void]
83
- def execute_single
84
- config = @configs.first
85
- method_name = config.method_type
86
- arg_matcher = config.build_argument_matcher(@rspec_context)
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(method_name).with(arg_matcher)
151
+ @rspec_context.receive(method_type, &implementation)
90
152
  )
91
153
 
92
- apply_return_behavior(message_expectation, config)
154
+ update_arguments(Stub.new(message_expectation:, inputs_guard:))
93
155
  end
94
156
 
95
- # Executes a multi-config sequential stub.
157
+ # Returns the stubbed method shared by all configs.
96
158
  #
97
- # Chooses between and_return (for simple returns) and and_invoke
98
- # (when exceptions need to be raised).
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 [void]
101
- def execute_sequential
102
- method_name = @configs.first.method_type
103
- arg_matcher = @configs.first.build_argument_matcher(@rspec_context)
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
- execute_sequential_returns(method_name, arg_matcher)
180
+ message_expectation.and_return(*@configs.map(&:build_result))
107
181
  else
108
- execute_sequential_invoke(method_name, arg_matcher)
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 raise behavior to a message expectation.
209
+ # Applies return, raise, or pass-through behavior to a stub.
168
210
  #
169
- # @param message_expectation [Object] RSpec message expectation
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(message_expectation, config)
173
- if config.call_original?
174
- message_expectation.and_call_original
175
- elsif config.wrap_original?
176
- message_expectation.and_wrap_original(&config.wrap_block)
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
- # Validates all configurations before executing.
226
+ # Builds the and_wrap_original block for a pass-through config.
185
227
  #
186
- # @return [void]
187
- # @raise [ArgumentError] If any config is invalid
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 config [ServiceMockConfig] The config to validate
195
- # @return [void]
196
- # @raise [ArgumentError] If config is invalid
197
- def validate_config!(config)
198
- validate_failure_has_exception!(config)
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
- # Validates that failure configs have an exception.
203
- #
204
- # @param config [ServiceMockConfig] The config to validate
205
- # @return [void]
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
- raise ArgumentError, missing_exception_for_failure_message(config.service_class)
242
+ pass_through.ruby2_keywords
211
243
  end
212
244
 
213
- # Validates that exception is the correct type for the service.
245
+ # Validates all configurations before executing.
214
246
  #
215
- # @param config [ServiceMockConfig] The config to validate
216
247
  # @return [void]
217
- # @raise [ArgumentError] If exception type is wrong
218
- def validate_exception_type!(config)
219
- return if config.exception.nil?
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