servactory 2.16.1 → 3.0.0.rc1

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -9
  3. data/config/locales/de.yml +134 -0
  4. data/config/locales/en.yml +15 -0
  5. data/config/locales/es.yml +134 -0
  6. data/config/locales/fr.yml +134 -0
  7. data/config/locales/it.yml +134 -0
  8. data/config/locales/ru.yml +15 -0
  9. data/lib/generators/README.md +45 -0
  10. data/lib/generators/servactory/base.rb +82 -0
  11. data/lib/generators/servactory/extension/USAGE +54 -0
  12. data/lib/generators/servactory/extension/extension_generator.rb +41 -0
  13. data/lib/generators/servactory/extension/templates/extension.rb.tt +62 -0
  14. data/lib/generators/servactory/install/USAGE +27 -0
  15. data/lib/generators/servactory/install/install_generator.rb +94 -0
  16. data/lib/generators/servactory/{templates/services/application_service/base.rb → install/templates/application_service/base.rb.tt} +29 -19
  17. data/lib/generators/servactory/{templates/services/application_service/exceptions.rb → install/templates/application_service/exceptions.rb.tt} +1 -1
  18. data/lib/generators/servactory/{templates/services/application_service/result.rb → install/templates/application_service/result.rb.tt} +1 -1
  19. data/lib/generators/servactory/rspec/USAGE +46 -0
  20. data/lib/generators/servactory/rspec/rspec_generator.rb +95 -0
  21. data/lib/generators/servactory/rspec/templates/service_spec.rb.tt +58 -0
  22. data/lib/generators/servactory/service/USAGE +51 -0
  23. data/lib/generators/servactory/service/service_generator.rb +56 -0
  24. data/lib/generators/servactory/service/templates/service.rb.tt +22 -0
  25. data/lib/servactory/actions/collection.rb +56 -1
  26. data/lib/servactory/actions/dsl.rb +11 -11
  27. data/lib/servactory/actions/tools/rules.rb +1 -1
  28. data/lib/servactory/actions/tools/runner.rb +111 -28
  29. data/lib/servactory/base.rb +1 -7
  30. data/lib/servactory/configuration/actions/aliases/collection.rb +5 -0
  31. data/lib/servactory/configuration/actions/rescue_handlers/collection.rb +5 -0
  32. data/lib/servactory/configuration/actions/shortcuts/collection.rb +5 -0
  33. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +5 -0
  34. data/lib/servactory/configuration/config.rb +36 -0
  35. data/lib/servactory/configuration/configurable.rb +95 -0
  36. data/lib/servactory/configuration/dsl.rb +3 -27
  37. data/lib/servactory/configuration/factory.rb +20 -20
  38. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -0
  39. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +5 -0
  40. data/lib/servactory/context/warehouse/inputs.rb +2 -2
  41. data/lib/servactory/context/workspace/inputs.rb +2 -2
  42. data/lib/servactory/context/workspace/internals.rb +2 -2
  43. data/lib/servactory/context/workspace/outputs.rb +2 -2
  44. data/lib/servactory/context/workspace.rb +11 -7
  45. data/lib/servactory/dsl.rb +10 -8
  46. data/lib/servactory/maintenance/attributes/tools/validation.rb +1 -1
  47. data/lib/servactory/result.rb +2 -2
  48. data/lib/servactory/stroma/dsl.rb +118 -0
  49. data/lib/servactory/stroma/entry.rb +32 -0
  50. data/lib/servactory/stroma/exceptions/base.rb +45 -0
  51. data/lib/servactory/stroma/exceptions/invalid_hook_type.rb +29 -0
  52. data/lib/servactory/stroma/exceptions/key_already_registered.rb +32 -0
  53. data/lib/servactory/stroma/exceptions/registry_frozen.rb +33 -0
  54. data/lib/servactory/stroma/exceptions/registry_not_finalized.rb +33 -0
  55. data/lib/servactory/stroma/exceptions/unknown_hook_target.rb +39 -0
  56. data/lib/servactory/stroma/hooks/applier.rb +63 -0
  57. data/lib/servactory/stroma/hooks/collection.rb +103 -0
  58. data/lib/servactory/stroma/hooks/factory.rb +80 -0
  59. data/lib/servactory/stroma/hooks/hook.rb +74 -0
  60. data/lib/servactory/stroma/registry.rb +94 -0
  61. data/lib/servactory/stroma/settings/collection.rb +90 -0
  62. data/lib/servactory/stroma/settings/registry_settings.rb +88 -0
  63. data/lib/servactory/stroma/settings/setting.rb +113 -0
  64. data/lib/servactory/stroma/state.rb +59 -0
  65. data/lib/servactory/test_kit/fake_type.rb +23 -0
  66. data/lib/servactory/test_kit/result.rb +45 -0
  67. data/lib/servactory/test_kit/rspec/helpers/argument_matchers.rb +97 -0
  68. data/lib/servactory/test_kit/rspec/helpers/concerns/error_messages.rb +179 -0
  69. data/lib/servactory/test_kit/rspec/helpers/concerns/service_class_validation.rb +74 -0
  70. data/lib/servactory/test_kit/rspec/helpers/fluent.rb +110 -0
  71. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +149 -0
  72. data/lib/servactory/test_kit/rspec/helpers/legacy.rb +228 -0
  73. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +256 -0
  74. data/lib/servactory/test_kit/rspec/helpers/output_validator.rb +121 -0
  75. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +422 -0
  76. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +129 -0
  77. data/lib/servactory/test_kit/rspec/helpers.rb +51 -84
  78. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +324 -0
  79. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +133 -0
  80. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_context.rb +101 -0
  81. data/lib/servactory/test_kit/rspec/matchers/base/submatcher_registry.rb +205 -0
  82. data/lib/servactory/test_kit/rspec/matchers/concerns/attribute_data_access.rb +100 -0
  83. data/lib/servactory/test_kit/rspec/matchers/concerns/error_message_builder.rb +106 -0
  84. data/lib/servactory/test_kit/rspec/matchers/concerns/value_comparison.rb +97 -0
  85. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +89 -219
  86. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +74 -166
  87. data/lib/servactory/test_kit/rspec/matchers/have_service_output_matcher.rb +238 -0
  88. data/lib/servactory/test_kit/rspec/matchers/result/be_failure_service_matcher.rb +257 -0
  89. data/lib/servactory/test_kit/rspec/matchers/result/be_success_service_matcher.rb +185 -0
  90. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/default_submatcher.rb +81 -0
  91. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/optional_submatcher.rb +62 -0
  92. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +93 -0
  93. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +271 -0
  94. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/consists_of_submatcher.rb +85 -0
  95. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/inclusion_submatcher.rb +120 -0
  96. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +115 -0
  97. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +82 -0
  98. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/schema_submatcher.rb +102 -0
  99. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +125 -0
  100. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +77 -0
  101. data/lib/servactory/test_kit/rspec/matchers.rb +126 -285
  102. data/lib/servactory/test_kit/utils/faker.rb +62 -2
  103. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +166 -0
  104. data/lib/servactory/tool_kit/dynamic_options/format.rb +195 -8
  105. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +219 -17
  106. data/lib/servactory/tool_kit/dynamic_options/max.rb +143 -0
  107. data/lib/servactory/tool_kit/dynamic_options/min.rb +143 -0
  108. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +157 -8
  109. data/lib/servactory/tool_kit/dynamic_options/must.rb +194 -0
  110. data/lib/servactory/tool_kit/dynamic_options/schema.rb +226 -2
  111. data/lib/servactory/tool_kit/dynamic_options/target.rb +252 -0
  112. data/lib/servactory/version.rb +4 -4
  113. data/lib/servactory.rb +4 -0
  114. metadata +73 -19
  115. data/lib/generators/servactory/install_generator.rb +0 -21
  116. data/lib/generators/servactory/rspec_generator.rb +0 -88
  117. data/lib/generators/servactory/service_generator.rb +0 -49
  118. data/lib/servactory/configuration/setup.rb +0 -97
  119. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/consists_of_matcher.rb +0 -68
  120. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/inclusion_matcher.rb +0 -73
  121. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/message_matcher.rb +0 -91
  122. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/must_matcher.rb +0 -72
  123. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/schema_matcher.rb +0 -92
  124. data/lib/servactory/test_kit/rspec/matchers/have_service_attribute_matchers/types_matcher.rb +0 -72
  125. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/default_matcher.rb +0 -69
  126. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/optional_matcher.rb +0 -63
  127. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/required_matcher.rb +0 -81
  128. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matchers/valid_with_matcher.rb +0 -199
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Helpers
7
+ # Backward-compatible API for mocking Servactory services in RSpec tests.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # Provides legacy helper methods for mocking Servactory service calls.
12
+ # These methods are maintained for backward compatibility with existing tests.
13
+ # For new tests, consider using the fluent API via {Fluent} module.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # Include in RSpec configuration via the main Helpers module:
18
+ #
19
+ # ```ruby
20
+ # RSpec.configure do |config|
21
+ # config.include Servactory::TestKit::Rspec::Helpers, type: :service
22
+ # end
23
+ # ```
24
+ #
25
+ # ## Available Methods
26
+ #
27
+ # - `allow_service_as_success!` - mock `.call!` method for success
28
+ # - `allow_service_as_success` - mock `.call` method for success
29
+ # - `allow_service_as_failure!` - mock `.call!` method for failure
30
+ # - `allow_service_as_failure` - mock `.call` method for failure
31
+ #
32
+ # ## Examples
33
+ #
34
+ # ```ruby
35
+ # # Mock call method (returns Result)
36
+ # allow_service_as_success(PaymentService) do
37
+ # { transaction_id: "txn_123" }
38
+ # end
39
+ #
40
+ # # Mock call! method (raises on failure)
41
+ # allow_service_as_success!(PaymentService, with: { amount: 100 }) do
42
+ # { transaction_id: "txn_123" }
43
+ # end
44
+ #
45
+ # # Mock failure
46
+ # allow_service_as_failure(PaymentService) do
47
+ # {
48
+ # exception: ApplicationService::Exceptions::Failure.new(
49
+ # type: :base,
50
+ # message: "Failed"
51
+ # )
52
+ # }
53
+ # end
54
+ # ```
55
+ #
56
+ # @see Fluent for the recommended fluent API
57
+ module Legacy
58
+ # Mock the `call!` method to return a successful result.
59
+ #
60
+ # @param service_class [Class] The service class to mock
61
+ # @param with [Hash, nil] Expected arguments matcher
62
+ # @yield Block returning Hash of output attributes
63
+ # @return [void]
64
+ #
65
+ # @example Basic success mock
66
+ # allow_service_as_success!(PaymentService) do
67
+ # { transaction_id: "txn_123" }
68
+ # end
69
+ #
70
+ # @example With argument matcher
71
+ # allow_service_as_success!(PaymentService, with: { amount: 100 }) do
72
+ # { transaction_id: "txn_123" }
73
+ # end
74
+ #
75
+ def allow_service_as_success!(service_class, with: nil, &block)
76
+ allow_service_legacy!(service_class, :as_success, with:, &block)
77
+ end
78
+
79
+ # Mock the `call` method to return a successful result.
80
+ #
81
+ # @param service_class [Class] The service class to mock
82
+ # @param with [Hash, nil] Expected arguments matcher
83
+ # @yield Block returning Hash of output attributes
84
+ # @return [void]
85
+ #
86
+ # @example Basic success mock
87
+ # allow_service_as_success(PaymentService) do
88
+ # { transaction_id: "txn_123" }
89
+ # end
90
+ #
91
+ def allow_service_as_success(service_class, with: nil, &block)
92
+ allow_service_legacy(service_class, :as_success, with:, &block)
93
+ end
94
+
95
+ # Mock the `call!` method to raise a failure exception.
96
+ #
97
+ # @param service_class [Class] The service class to mock
98
+ # @param with [Hash, nil] Expected arguments matcher
99
+ # @yield Block returning an exception or Hash with `:exception` key
100
+ # @return [void]
101
+ #
102
+ # @example Failure mock
103
+ # allow_service_as_failure!(PaymentService) do
104
+ # ApplicationService::Exceptions::Failure.new(
105
+ # type: :payment_declined,
106
+ # message: "Card declined"
107
+ # )
108
+ # end
109
+ #
110
+ def allow_service_as_failure!(service_class, with: nil, &block)
111
+ allow_service_legacy!(service_class, :as_failure, with:, &block)
112
+ end
113
+
114
+ # Mock the `call` method to return a failure result.
115
+ #
116
+ # @param service_class [Class] The service class to mock
117
+ # @param with [Hash, nil] Expected arguments matcher
118
+ # @yield Block returning an exception or Hash with `:exception` key
119
+ # @return [void]
120
+ #
121
+ # @example Failure mock with exception in hash
122
+ # allow_service_as_failure(PaymentService) do
123
+ # {
124
+ # exception: ApplicationService::Exceptions::Failure.new(
125
+ # type: :base,
126
+ # message: "Failed"
127
+ # )
128
+ # }
129
+ # end
130
+ #
131
+ def allow_service_as_failure(service_class, with: nil, &block)
132
+ allow_service_legacy(service_class, :as_failure, with:, &block)
133
+ end
134
+
135
+ private
136
+
137
+ # Builds legacy mock for .call! method.
138
+ #
139
+ # @param service_class [Class] The service class to mock
140
+ # @param result_type [Symbol] :as_success or :as_failure
141
+ # @param with [Hash, nil] Argument matcher
142
+ # @return [void]
143
+ def allow_service_legacy!(service_class, result_type, with: nil, &block)
144
+ allow_servactory_legacy(service_class, :call!, result_type, with:, &block)
145
+ end
146
+
147
+ # Builds legacy mock for .call method.
148
+ #
149
+ # @param service_class [Class] The service class to mock
150
+ # @param result_type [Symbol] :as_success or :as_failure
151
+ # @param with [Hash, nil] Argument matcher
152
+ # @return [void]
153
+ def allow_service_legacy(service_class, result_type, with: nil, &block)
154
+ allow_servactory_legacy(service_class, :call, result_type, with:, &block)
155
+ end
156
+
157
+ # Core legacy mock implementation.
158
+ #
159
+ # @param service_class [Class] The service class to mock
160
+ # @param method_call [Symbol] :call or :call!
161
+ # @param result_type [Symbol] :as_success or :as_failure
162
+ # @param with [Hash, nil] Argument matcher
163
+ # @return [void]
164
+ def allow_servactory_legacy(service_class, method_call, result_type, with: nil)
165
+ config = build_legacy_config(service_class, method_call, result_type, with, block_given? ? yield : nil)
166
+
167
+ Helpers::MockExecutor.new(
168
+ service_class:,
169
+ configs: [config],
170
+ rspec_context: self
171
+ ).execute
172
+ end
173
+
174
+ # Builds ServiceMockConfig from legacy parameters.
175
+ #
176
+ # @param service_class [Class] The service class
177
+ # @param method_call [Symbol] :call or :call!
178
+ # @param result_type [Symbol] :as_success or :as_failure
179
+ # @param with_arg [Hash, nil] Argument matcher
180
+ # @param block_result [Hash, Object, nil] Block return value
181
+ # @return [ServiceMockConfig] Configured mock config
182
+ def build_legacy_config(service_class, method_call, result_type, with_arg, block_result)
183
+ config = Helpers::ServiceMockConfig.new(service_class:)
184
+ config.method_type = method_call.to_sym
185
+ config.result_type = result_type == :as_success ? :success : :failure
186
+ config.argument_matcher = with_arg
187
+
188
+ process_legacy_block_result(config, block_result) if block_result
189
+
190
+ config
191
+ end
192
+
193
+ # Processes block result into config outputs/exception.
194
+ #
195
+ # @param config [ServiceMockConfig] The config to update
196
+ # @param block_result [Hash, Object] Block return value
197
+ # @return [void]
198
+ def process_legacy_block_result(config, block_result) # rubocop:disable Metrics/MethodLength
199
+ validate_legacy_block_result!(block_result, config.success?)
200
+
201
+ if block_result.is_a?(Hash)
202
+ if config.success?
203
+ config.outputs = block_result
204
+ else
205
+ config.exception = block_result[:exception] if block_result[:exception]
206
+ config.outputs = block_result.except(:exception)
207
+ end
208
+ else
209
+ config.exception = block_result
210
+ end
211
+ end
212
+
213
+ # Validates block result format for legacy API.
214
+ #
215
+ # @param block_result [Object] The block return value
216
+ # @param is_success [Boolean] Whether this is a success mock
217
+ # @raise [ArgumentError] If success mock block doesn't return Hash
218
+ # @return [void]
219
+ def validate_legacy_block_result!(block_result, is_success)
220
+ return unless is_success && !block_result.is_a?(Hash)
221
+
222
+ raise ArgumentError, "Invalid value for block. Must be a Hash with attributes."
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Helpers
7
+ # Executes RSpec stubbing based on mock configurations.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # MockExecutor translates ServiceMockConfig objects into actual RSpec
12
+ # stub setups using `allow(...).to receive(...)`. It handles single
13
+ # and sequential call scenarios, applying appropriate return behaviors.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # Typically used internally by ServiceMockBuilder:
18
+ #
19
+ # ```ruby
20
+ # executor = MockExecutor.new(
21
+ # service_class: MyService,
22
+ # configs: [config1, config2],
23
+ # rspec_context: self
24
+ # )
25
+ # executor.execute
26
+ # ```
27
+ #
28
+ # ## Execution Strategies
29
+ #
30
+ # - **Single Config** - uses `and_return` or `and_raise` directly
31
+ # - **Sequential Returns** - uses `and_return(*values)` for multiple values
32
+ # - **Sequential with Raises** - uses `and_invoke(*callables)` for mixed behavior
33
+ #
34
+ # ## Architecture
35
+ #
36
+ # Works with:
37
+ # - ServiceMockConfig - provides configuration for each stub
38
+ # - ServiceMockBuilder - creates executor with configs
39
+ # - RSpec Context - provides allow/receive/etc. methods
40
+ class MockExecutor
41
+ include Concerns::ErrorMessages
42
+
43
+ # Creates a new mock executor.
44
+ #
45
+ # @param service_class [Class] The Servactory service class to stub
46
+ # @param configs [Array<ServiceMockConfig>] Configurations for each call
47
+ # @param rspec_context [Object] RSpec example context with stubbing methods
48
+ def initialize(service_class:, configs:, rspec_context:)
49
+ @service_class = service_class
50
+ @configs = configs
51
+ @rspec_context = rspec_context
52
+ end
53
+
54
+ # Executes the stub setup based on configurations.
55
+ #
56
+ # Validates all configs first, then applies appropriate stubbing
57
+ # strategy (single or sequential).
58
+ #
59
+ # @return [void]
60
+ # @raise [ArgumentError] If any config is invalid
61
+ def execute
62
+ validate_configs!
63
+
64
+ if sequential?
65
+ execute_sequential
66
+ else
67
+ execute_single
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Checks if this is a sequential mock (multiple configs).
74
+ #
75
+ # @return [Boolean] True if more than one config
76
+ def sequential?
77
+ @configs.size > 1
78
+ end
79
+
80
+ # Executes a single-config stub.
81
+ #
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)
87
+
88
+ message_expectation = @rspec_context.allow(@service_class).to(
89
+ @rspec_context.receive(method_name).with(arg_matcher)
90
+ )
91
+
92
+ apply_return_behavior(message_expectation, config)
93
+ end
94
+
95
+ # Executes a multi-config sequential stub.
96
+ #
97
+ # Chooses between and_return (for simple returns) and and_invoke
98
+ # (when exceptions need to be raised).
99
+ #
100
+ # @return [void]
101
+ def execute_sequential
102
+ method_name = @configs.first.method_type
103
+ arg_matcher = @configs.first.build_argument_matcher(@rspec_context)
104
+
105
+ if all_returns?
106
+ execute_sequential_returns(method_name, arg_matcher)
107
+ else
108
+ execute_sequential_invoke(method_name, arg_matcher)
109
+ end
110
+ end
111
+
112
+ # Checks if all configs can use simple and_return.
113
+ #
114
+ # Returns false if any config is a failure with bang method,
115
+ # which requires raising an exception.
116
+ #
117
+ # @return [Boolean] True if all configs are simple returns
118
+ def all_returns?
119
+ @configs.none? { |config| config.failure? && config.bang_method? }
120
+ end
121
+
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
+ # Builds a callable lambda for and_invoke.
155
+ #
156
+ # @param config [ServiceMockConfig] The config to build callable for
157
+ # @return [Proc] Lambda that returns result or raises exception
158
+ def build_callable(config)
159
+ if config.failure? && config.bang_method?
160
+ ->(*_args) { raise config.exception }
161
+ else
162
+ result = config.build_result
163
+ ->(*_args) { result }
164
+ end
165
+ end
166
+
167
+ # Applies return or raise behavior to a message expectation.
168
+ #
169
+ # @param message_expectation [Object] RSpec message expectation
170
+ # @param config [ServiceMockConfig] Configuration with result/exception
171
+ # @return [void]
172
+ def apply_return_behavior(message_expectation, config)
173
+ if config.failure? && config.bang_method?
174
+ message_expectation.and_raise(config.exception)
175
+ else
176
+ message_expectation.and_return(config.build_result)
177
+ end
178
+ end
179
+
180
+ # Validates all configurations before executing.
181
+ #
182
+ # @return [void]
183
+ # @raise [ArgumentError] If any config is invalid
184
+ def validate_configs!
185
+ @configs.each { |config| validate_config!(config) }
186
+ end
187
+
188
+ # Validates a single configuration.
189
+ #
190
+ # @param config [ServiceMockConfig] The config to validate
191
+ # @return [void]
192
+ # @raise [ArgumentError] If config is invalid
193
+ def validate_config!(config)
194
+ validate_failure_has_exception!(config)
195
+ validate_exception_type!(config)
196
+ end
197
+
198
+ # Validates that failure configs have an exception.
199
+ #
200
+ # @param config [ServiceMockConfig] The config to validate
201
+ # @return [void]
202
+ # @raise [ArgumentError] If failure config is missing exception
203
+ def validate_failure_has_exception!(config)
204
+ return unless config.failure? && config.exception.nil?
205
+
206
+ raise ArgumentError, missing_exception_for_failure_message(config.service_class)
207
+ end
208
+
209
+ # Validates that exception is the correct type for the service.
210
+ #
211
+ # @param config [ServiceMockConfig] The config to validate
212
+ # @return [void]
213
+ # @raise [ArgumentError] If exception type is wrong
214
+ def validate_exception_type!(config)
215
+ return if config.exception.nil?
216
+ return if valid_exception_type?(config)
217
+
218
+ raise ArgumentError, invalid_exception_type_message(
219
+ service_class: config.service_class,
220
+ expected_class: failure_class_for(config),
221
+ actual_class: config.exception.class
222
+ )
223
+ end
224
+
225
+ # Checks if exception is the correct type.
226
+ #
227
+ # Uses different validation strategies based on method type:
228
+ # - For `.call` (non-bang): Relaxed validation - accepts any Servactory::Exceptions::Failure subclass
229
+ # because the exception is wrapped in Result and never raised, so type doesn't matter.
230
+ # - For `.call!` (bang): Strict validation - requires the service's configured failure_class
231
+ # because the exception IS raised and type matters for rescue clauses.
232
+ #
233
+ # @param config [ServiceMockConfig] The config to check
234
+ # @return [Boolean] True if exception is valid type
235
+ def valid_exception_type?(config)
236
+ if config.bang_method?
237
+ # Strict validation for call! - exception will be raised
238
+ config.exception.is_a?(failure_class_for(config))
239
+ else
240
+ # Relaxed validation for call - exception is only wrapped in Result
241
+ config.exception.is_a?(Servactory::Exceptions::Failure)
242
+ end
243
+ end
244
+
245
+ # Returns the expected failure class for a service.
246
+ #
247
+ # @param config [ServiceMockConfig] The config with service class
248
+ # @return [Class] The service's failure class
249
+ def failure_class_for(config)
250
+ config.service_class.config.failure_class
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servactory
4
+ module TestKit
5
+ module Rspec
6
+ module Helpers
7
+ # Validates mock output values against service definitions.
8
+ #
9
+ # ## Purpose
10
+ #
11
+ # Ensures that mocked output values match the service's output
12
+ # definitions in terms of names and types. Helps catch configuration
13
+ # errors early in tests.
14
+ #
15
+ # ## Usage
16
+ #
17
+ # Called automatically when `succeeds()` is used on builder:
18
+ #
19
+ # ```ruby
20
+ # allow_service(MyService)
21
+ # .succeeds(user: user)
22
+ # ```
23
+ #
24
+ # Can also be called directly:
25
+ #
26
+ # ```ruby
27
+ # OutputValidator.validate!(
28
+ # service_class: MyService,
29
+ # outputs: { user: user }
30
+ # )
31
+ # ```
32
+ #
33
+ # ## Validations
34
+ #
35
+ # 1. **Output names** - all provided outputs must be defined in service
36
+ # 2. **Output types** - values must match expected types (if defined)
37
+ class OutputValidator
38
+ include Concerns::ErrorMessages
39
+
40
+ # Error raised when validation fails
41
+ class ValidationError < StandardError; end
42
+
43
+ class << self
44
+ # Validates outputs and raises on failure.
45
+ #
46
+ # @param service_class [Class] The service class
47
+ # @param outputs [Hash] Output values to validate
48
+ # @raise [ValidationError] If validation fails
49
+ # @return [void]
50
+ def validate!(service_class:, outputs:)
51
+ new(service_class:, outputs:).validate!
52
+ end
53
+ end
54
+
55
+ # Creates a new validator instance.
56
+ #
57
+ # @param service_class [Class] The service class
58
+ # @param outputs [Hash] Output values to validate
59
+ # @return [OutputValidator] New validator
60
+ def initialize(service_class:, outputs:)
61
+ @service_class = service_class
62
+ @outputs = outputs
63
+ end
64
+
65
+ # Runs all validations.
66
+ #
67
+ # @raise [ValidationError] If any validation fails
68
+ # @return [void]
69
+ def validate!
70
+ validate_output_names!
71
+ validate_output_types!
72
+ end
73
+
74
+ private
75
+
76
+ # Validates all output names are defined in service.
77
+ #
78
+ # @raise [ValidationError] If unknown output names provided
79
+ # @return [void]
80
+ def validate_output_names!
81
+ defined_outputs = @service_class.info.outputs.keys
82
+ provided_outputs = @outputs.keys
83
+
84
+ unknown_outputs = provided_outputs - defined_outputs
85
+
86
+ return if unknown_outputs.empty?
87
+
88
+ raise ValidationError, unknown_outputs_message(
89
+ service_class: @service_class,
90
+ unknown_outputs:,
91
+ defined_outputs:
92
+ )
93
+ end
94
+
95
+ # Validates output values match expected types.
96
+ #
97
+ # @raise [ValidationError] If type mismatch found
98
+ # @return [void]
99
+ def validate_output_types! # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
100
+ @outputs.each do |name, value|
101
+ output_info = @service_class.info.outputs[name]
102
+ next unless output_info
103
+
104
+ expected_types = output_info[:types]
105
+ next if value.nil?
106
+ next if expected_types.nil? || expected_types.empty?
107
+ next if expected_types.any? { |type| value.is_a?(type) }
108
+
109
+ raise ValidationError, type_mismatch_message(
110
+ service_class: @service_class,
111
+ output_name: name,
112
+ expected_types:,
113
+ actual_value: value
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end