cmdx-rspec 1.4.0 → 2.1.0

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.DS_Store +0 -0
  3. data/CHANGELOG.md +25 -0
  4. data/README.md +337 -169
  5. data/lib/cmdx/rspec/helpers.rb +453 -291
  6. data/lib/cmdx/rspec/matchers/be_complete.rb +20 -0
  7. data/lib/cmdx/rspec/matchers/be_deprecated.rb +14 -52
  8. data/lib/cmdx/rspec/matchers/be_interrupted.rb +20 -0
  9. data/lib/cmdx/rspec/matchers/be_ko.rb +19 -0
  10. data/lib/cmdx/rspec/matchers/be_ok.rb +19 -0
  11. data/lib/cmdx/rspec/matchers/be_successful.rb +8 -20
  12. data/lib/cmdx/rspec/matchers/have_been_retried.rb +41 -0
  13. data/lib/cmdx/rspec/matchers/have_been_rolled_back.rb +23 -0
  14. data/lib/cmdx/rspec/matchers/have_callback.rb +46 -0
  15. data/lib/cmdx/rspec/matchers/have_chain_root.rb +30 -0
  16. data/lib/cmdx/rspec/matchers/have_chain_size.rb +29 -0
  17. data/lib/cmdx/rspec/matchers/have_duration.rb +30 -0
  18. data/lib/cmdx/rspec/matchers/have_empty_context.rb +4 -16
  19. data/lib/cmdx/rspec/matchers/have_empty_metadata.rb +3 -9
  20. data/lib/cmdx/rspec/matchers/have_errors_on.rb +50 -0
  21. data/lib/cmdx/rspec/matchers/have_failed.rb +7 -24
  22. data/lib/cmdx/rspec/matchers/have_input.rb +41 -0
  23. data/lib/cmdx/rspec/matchers/have_matching_context.rb +5 -20
  24. data/lib/cmdx/rspec/matchers/have_matching_metadata.rb +5 -17
  25. data/lib/cmdx/rspec/matchers/have_middleware.rb +29 -0
  26. data/lib/cmdx/rspec/matchers/have_no_errors.rb +30 -0
  27. data/lib/cmdx/rspec/matchers/have_output.rb +46 -0
  28. data/lib/cmdx/rspec/matchers/have_pipeline_tasks.rb +27 -0
  29. data/lib/cmdx/rspec/matchers/have_retry_on.rb +36 -0
  30. data/lib/cmdx/rspec/matchers/have_skipped.rb +7 -24
  31. data/lib/cmdx/rspec/matchers/have_tag.rb +30 -0
  32. data/lib/cmdx/rspec/matchers/raise_cmdx_fault.rb +74 -0
  33. data/lib/cmdx/rspec/version.rb +2 -1
  34. data/lib/cmdx/rspec.rb +22 -3
  35. metadata +25 -5
@@ -2,373 +2,442 @@
2
2
 
3
3
  module CMDx
4
4
  module RSpec
5
- # Helper methods for setting up RSpec stubs and expectations on CMDx command execution.
5
+ # RSpec helpers for stubbing and asserting Task execution. Each helper
6
+ # builds a frozen {CMDx::Result} carrying the requested {CMDx::Signal}
7
+ # and wires it into a fresh {CMDx::Chain} so callers see realistic
8
+ # execution output without invoking the Task's `work`.
9
+ #
10
+ # Mix into example groups via `config.include CMDx::RSpec::Helpers`.
6
11
  module Helpers
7
12
 
8
- # Sets up a stub that allows a command to receive :execute and return a successful result.
9
- #
10
- # @param command [Class] The command class to stub execution on
11
- # @param metadata [Hash] Optional metadata to pass to the result
12
- # @param context [Hash] Optional keyword arguments to pass to the command
13
- #
14
- # @return [CMDx::Result] The successful result object
15
- #
16
- # @example Stubbing successful execution with context
17
- # stub_task_success(MyCommand, user_id: 123, role: "admin")
18
- #
19
- # result = MyCommand.execute(user_id: 123, role: "admin")
20
- # expect(result).to be_successful
21
- #
22
- # @example Stubbing successful execution without context
23
- # stub_task_success(MyCommand)
24
- #
25
- # result = MyCommand.execute
26
- # expect(result).to be_successful
27
- def stub_task_success(command, metadata: {}, **context)
28
- task = command.new(context)
29
- result = task.result
13
+ # Anonymous task used by result builders when no +task+ is supplied.
14
+ STUB_TASK = Class.new(CMDx::Task) do
15
+ def self.name
16
+ "CMDx::RSpec::StubTask"
17
+ end
18
+ end
19
+ private_constant :STUB_TASK
20
+
21
+ # Builds a frozen {CMDx::Result} without stubbing task execution.
22
+ # Use when a collaborator must return a result, when seeding a chain,
23
+ # or anywhere a real result is needed outside `stub_task_*`.
24
+ #
25
+ # @param status [Symbol, CMDx::Signal] `:success`, `:skipped`, `:failed`,
26
+ # `:echo`, or a pre-built signal
27
+ # @param reason [String, nil] human-readable outcome reason
28
+ # @param cause [Exception, nil] originating exception on failure
29
+ # @param metadata [Hash] signal metadata payload
30
+ # @param upstream_result [CMDx::Result] required for `:echo`; must be failed
31
+ # @param task [Class] task class for `result.task` (defaults to an internal stub)
32
+ # @param chain [CMDx::Chain, nil] chain to join (a fresh one is created when omitted)
33
+ # @param context [Hash, CMDx::Context, nil] task context; other keyword args
34
+ # are forwarded to `task.new` when +context+ is omitted
35
+ # @param root [Boolean] whether this result is the chain root
36
+ # @param strict [Boolean] whether the result was produced via `execute!`
37
+ # @param deprecated [Boolean] whether the result is flagged deprecated
38
+ # @param retries [Integer] retry count beyond the first attempt
39
+ # @param duration [Float, nil] execution duration in milliseconds
40
+ # @param rolled_back [Boolean] whether rollback ran for this result
41
+ # @param tid [String, nil] execution identifier (defaults to a uuid_v7)
42
+ # @return [CMDx::Result]
43
+ # @raise [ArgumentError] for unknown statuses or invalid echo input
44
+ # @example
45
+ # result = build_result(:success, snapshot:)
46
+ # allow(FetchSnapshot).to receive(:execute).and_return(result)
47
+ def build_result(status, **options)
48
+ signal = resolve_result_signal(
49
+ status,
50
+ reason: options[:reason],
51
+ cause: options[:cause],
52
+ metadata: options.fetch(:metadata, {}),
53
+ upstream_result: options[:upstream_result]
54
+ )
55
+
56
+ assemble_result(signal, **options.except(:reason, :cause, :metadata, :upstream_result))
57
+ end
30
58
 
31
- result.metadata.merge!(metadata)
32
- result.executing!
33
- result.executed!
59
+ # @return [CMDx::Result] a successful result (`state: complete`, `status: success`)
60
+ # @see #build_result
61
+ def build_successful_result(metadata: {}, **context)
62
+ build_result(:success, metadata:, **context)
63
+ end
34
64
 
35
- allow(command).to receive(:execute).and_return(result)
65
+ # @return [CMDx::Result] a skipped result (`state: interrupted`, `status: skipped`)
66
+ # @see #build_result
67
+ def build_skipped_result(reason: nil, cause: nil, metadata: {}, **context)
68
+ build_result(:skipped, reason:, cause:, metadata:, **context)
69
+ end
36
70
 
37
- result
71
+ # @return [CMDx::Result] a failed result (`state: interrupted`, `status: failed`)
72
+ # @see #build_result
73
+ def build_failed_result(reason: nil, cause: nil, metadata: {}, **context)
74
+ build_result(:failed, reason:, cause:, metadata:, **context)
38
75
  end
39
76
 
40
- # Sets up a stub that allows a command to receive :execute! and return a successful result.
41
- #
42
- # @param command [Class] The command class to stub execution on
43
- # @param metadata [Hash] Optional metadata to pass to the result
44
- # @param context [Hash] Optional keyword arguments to pass to the command
45
- #
46
- # @return [CMDx::Result] The successful result object
47
- #
48
- # @example Stubbing successful execution with context
49
- # stub_task_success!(MyCommand, user_id: 123, role: "admin")
50
- #
51
- # result = MyCommand.execute!(user_id: 123, role: "admin")
52
- # expect(result).to be_successful
77
+ # @param upstream_result [CMDx::Result] the failed result being propagated
78
+ # @return [CMDx::Result] a failed result echoing +upstream_result+
79
+ # @see #build_result
80
+ def build_echoed_result(upstream_result, metadata: {}, **context)
81
+ build_result(:echo, upstream_result:, metadata:, **context)
82
+ end
83
+
84
+ # Stubs `command.execute` to return a frozen successful Result.
53
85
  #
54
- # @example Stubbing successful execution without context
55
- # stub_task_success!(MyCommand)
86
+ # @param command [Class] the Task class to stub
87
+ # @param metadata [Hash] payload exposed via `result.metadata`
88
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
89
+ # @return [CMDx::Result] the frozen Result installed on the stub
90
+ # @example
91
+ # stub_task_success(SomeTask, metadata: { id: 1 })
92
+ def stub_task_success(command, metadata: {}, **context)
93
+ build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context)
94
+ end
95
+ alias stub_task stub_task_success
96
+
97
+ # Stubs `command.execute!` (bang variant) to return a frozen successful Result.
56
98
  #
57
- # result = MyCommand.execute!
58
- # expect(result).to be_successful
99
+ # @param command [Class] the Task class to stub
100
+ # @param metadata [Hash] payload exposed via `result.metadata`
101
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
102
+ # @return [CMDx::Result] the frozen Result installed on the stub
59
103
  def stub_task_success!(command, metadata: {}, **context)
60
- task = command.new(context)
61
- result = task.result
62
-
63
- result.metadata.merge!(metadata)
64
- result.executing!
65
- result.executed!
66
-
67
- allow(command).to receive(:execute!).and_return(result)
68
-
69
- result
104
+ build_stub(command, :execute!, CMDx::Signal.success(nil, metadata:), context, strict: true)
70
105
  end
106
+ alias stub_task! stub_task_success!
71
107
 
72
- # Sets up a stub that allows a command to receive :execute and return a skipped result.
108
+ # Stubs `command.execute` to return a frozen skipped Result.
73
109
  #
74
- # @param command [Class] The command class to stub execution on
75
- # @param reason [String, nil] Optional reason for skipping
76
- # @param cause [CMDx::Fault, nil] Optional cause for skipping
77
- # @param metadata [Hash] Optional metadata to pass to the result
78
- # @param context [Hash] Optional keyword arguments to pass to the command
79
- #
80
- # @return [CMDx::Result] The skipped result object
81
- #
82
- # @example Stubbing skipped execution with context
83
- # stub_task_skip(MyCommand, foo: "bar")
84
- #
85
- # result = MyCommand.execute(foo: "bar")
86
- # expect(result).to have_skipped
87
- #
88
- # @example Stubbing skipped execution with reason
89
- # stub_task_skip(MyCommand, reason: "Skipped for testing", foo: "bar")
90
- #
91
- # result = MyCommand.execute(foo: "bar")
92
- # expect(result).to have_skipped(reason: "Skipped for testing")
110
+ # @param command [Class] the Task class to stub
111
+ # @param reason [String, nil] human-readable skip reason
112
+ # @param cause [Exception, nil] originating cause attached to the signal
113
+ # @param metadata [Hash] payload exposed via `result.metadata`
114
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
115
+ # @return [CMDx::Result] the frozen Result installed on the stub
93
116
  def stub_task_skip(command, reason: nil, cause: nil, metadata: {}, **context)
94
- task = command.new(context)
95
- result = task.result
96
- cause ||= CMDx::SkipFault.new(result)
97
-
98
- result.executing!
99
- result.skip!(reason, halt: false, cause:, **metadata)
100
-
101
- allow(command).to receive(:execute).and_return(result)
102
-
103
- result
117
+ build_stub(command, :execute, CMDx::Signal.skipped(reason, metadata:, cause:), context)
104
118
  end
105
119
 
106
- # Sets up a stub that allows a command to receive :execute! and return a skipped result.
120
+ # Stubs `command.execute!` (bang variant) to return a frozen skipped Result.
107
121
  #
108
- # @param command [Class] The command class to stub execution on
109
- # @param reason [String, nil] Optional reason for skipping
110
- # @param cause [CMDx::Fault, nil] Optional cause for skipping
111
- # @param metadata [Hash] Optional metadata to pass to the result
112
- # @param context [Hash] Optional keyword arguments to pass to the command
113
- #
114
- # @return [CMDx::Result] The skipped result object
115
- #
116
- # @example Stubbing skipped execution with context
117
- # stub_task_skip!(MyCommand, foo: "bar")
118
- #
119
- # result = MyCommand.execute!(foo: "bar")
120
- # expect(result).to have_skipped
121
- #
122
- # @example Stubbing skipped execution with reason
123
- # stub_task_skip!(MyCommand, reason: "Skipped for testing", foo: "bar")
124
- #
125
- # result = MyCommand.execute!(foo: "bar")
126
- # expect(result).to have_skipped(reason: "Skipped for testing")
122
+ # @param command [Class] the Task class to stub
123
+ # @param reason [String, nil] human-readable skip reason
124
+ # @param cause [Exception, nil] originating cause attached to the signal
125
+ # @param metadata [Hash] payload exposed via `result.metadata`
126
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
127
+ # @return [CMDx::Result] the frozen Result installed on the stub
127
128
  def stub_task_skip!(command, reason: nil, cause: nil, metadata: {}, **context)
128
- task = command.new(context)
129
- result = task.result
130
- cause ||= CMDx::SkipFault.new(result)
131
-
132
- result.executing!
133
- result.skip!(reason, halt: false, cause:, **metadata)
134
-
135
- allow(command).to receive(:execute!).and_return(result)
136
-
137
- result
129
+ build_stub(command, :execute!, CMDx::Signal.skipped(reason, metadata:, cause:), context, strict: true)
138
130
  end
139
131
 
140
- # Sets up a stub that allows a command to receive :execute and return a failed result.
132
+ # Stubs `command.execute` to return a frozen failed Result.
141
133
  #
142
- # @param command [Class] The command class to stub execution on
143
- # @param reason [String, nil] Optional reason for failure
144
- # @param cause [CMDx::Fault, nil] Optional cause for failure
145
- # @param metadata [Hash] Optional metadata to pass to the result
146
- # @param context [Hash] Optional keyword arguments to pass to the command
147
- #
148
- # @return [CMDx::Result] The failed result object
149
- #
150
- # @example Stubbing failed execution with context
151
- # stub_task_fail(MyCommand, foo: "bar")
152
- #
153
- # result = MyCommand.execute(foo: "bar")
154
- # expect(result).to have_failed
155
- #
156
- # @example Stubbing failed execution with reason
157
- # stub_task_fail(MyCommand, reason: "Failed for testing", foo: "bar")
158
- #
159
- # result = MyCommand.execute(foo: "bar")
160
- # expect(result).to have_failed(reason: "Failed for testing")
134
+ # @param command [Class] the Task class to stub
135
+ # @param reason [String, nil] human-readable failure reason
136
+ # @param cause [Exception, nil] originating cause attached to the signal
137
+ # @param metadata [Hash] payload exposed via `result.metadata`
138
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
139
+ # @return [CMDx::Result] the frozen Result installed on the stub
161
140
  def stub_task_fail(command, reason: nil, cause: nil, metadata: {}, **context)
162
- task = command.new(context)
163
- result = task.result
164
- cause ||= CMDx::FailFault.new(result)
141
+ build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause:), context)
142
+ end
165
143
 
166
- result.executing!
167
- result.fail!(reason, halt: false, cause:, **metadata)
144
+ # Stubs `command.execute!` so a call raises like real strict execution:
145
+ # {CMDx::Fault} for signal failures, or the original exception when
146
+ # +cause+ is set (mirrors {CMDx::Runtime#raise_signal!}).
147
+ #
148
+ # @param command [Class] the Task class to stub
149
+ # @param reason [String, nil] human-readable failure reason
150
+ # @param cause [Exception, nil] originating cause attached to the signal
151
+ # @param metadata [Hash] payload exposed via `result.metadata`
152
+ # @param context [Hash{Symbol => Object}] context overrides forwarded to `command.new`
153
+ # @return [CMDx::Result] the frozen failed Result backing the stub (for inspection)
154
+ def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
155
+ build_stub(command, :execute!, CMDx::Signal.failed(reason, metadata:, cause:), context, strict: true)
156
+ end
168
157
 
169
- allow(command).to receive(:execute).and_return(result)
158
+ # Stubs `command.execute` to return a frozen failed Result whose
159
+ # `cause` is an instance of +exception+. Models the "rescued
160
+ # StandardError -> failed signal" path that Runtime takes when a
161
+ # task's `work` raises something other than a Fault.
162
+ #
163
+ # @param command [Class] the Task class to stub
164
+ # @param exception [Class<StandardError>, StandardError] the cause
165
+ # @param message [String, nil] message used when constructing the
166
+ # exception (when +exception+ is a Class) and to derive the reason
167
+ # @param metadata [Hash]
168
+ # @param context [Hash{Symbol => Object}]
169
+ # @return [CMDx::Result] the frozen Result installed on the stub
170
+ # @example
171
+ # stub_task_error(MyCommand, Net::OpenTimeout, "boom")
172
+ def stub_task_error(command, exception, message = nil, metadata: {}, **context)
173
+ ex = exception.is_a?(Class) ? exception.new(message || "stubbed") : exception
174
+ reason = "[#{ex.class}] #{ex.message}"
175
+ build_stub(command, :execute, CMDx::Signal.failed(reason, metadata:, cause: ex), context)
176
+ end
170
177
 
171
- result
178
+ # Stubs `command.execute` to return a frozen failed Result that
179
+ # echoes +upstream_result+. Models the `throw!`-then-propagate path
180
+ # used by nested tasks/workflows.
181
+ #
182
+ # @param command [Class] the Task class to stub
183
+ # @param upstream_result [CMDx::Result] the originating failure
184
+ # @param metadata [Hash]
185
+ # @param context [Hash{Symbol => Object}]
186
+ # @return [CMDx::Result] the frozen Result installed on the stub
187
+ def stub_task_throw(command, upstream_result, metadata: {}, **context)
188
+ validate_echo_upstream!(upstream_result)
189
+ build_stub(command, :execute, CMDx::Signal.echoed(upstream_result, metadata:), context)
172
190
  end
173
191
 
174
- # Sets up a stub that allows a command to receive :execute! and return a failed result.
175
- #
176
- # @param command [Class] The command class to stub execution on
177
- # @param reason [String, nil] Optional reason for failure
178
- # @param cause [CMDx::Fault, nil] Optional cause for failure
179
- # @param metadata [Hash] Optional metadata to pass to the result
180
- # @param context [Hash] Optional keyword arguments to pass to the command
181
- #
182
- # @return [CMDx::Result] The failed result object
192
+ # Stubs `command.execute` to return a successful Result flagged as
193
+ # `deprecated?`. Useful when asserting deprecation surfaces without
194
+ # triggering the real `Deprecation` action.
183
195
  #
184
- # @example Stubbing failed execution with context
185
- # stub_task_fail!(MyCommand, foo: "bar")
186
- #
187
- # result = MyCommand.execute!(foo: "bar")
188
- # expect(result).to have_failed
189
- #
190
- # @example Stubbing failed execution with reason
191
- # stub_task_fail!(MyCommand, reason: "Failed for testing", foo: "bar")
192
- #
193
- # result = MyCommand.execute!(foo: "bar")
194
- # expect(result).to have_failed(reason: "Failed for testing")
195
- def stub_task_fail!(command, reason: nil, cause: nil, metadata: {}, **context)
196
- task = command.new(context)
197
- result = task.result
198
- cause ||= CMDx::FailFault.new(result)
196
+ # @param command [Class] the Task class to stub
197
+ # @param metadata [Hash]
198
+ # @param context [Hash{Symbol => Object}]
199
+ # @return [CMDx::Result]
200
+ def stub_task_deprecated(command, metadata: {}, **context)
201
+ build_stub(command, :execute, CMDx::Signal.success(nil, metadata:), context, deprecated: true)
202
+ end
199
203
 
200
- result.executing!
201
- result.fail!(reason, halt: false, cause:, **metadata)
204
+ # Captures lines written to a temporary CMDx logger for the duration
205
+ # of the block. Restores the previous logger on exit.
206
+ #
207
+ # @yield runs the block with `CMDx.configuration.logger` swapped
208
+ # @return [Array<String>] captured log lines
209
+ # @example
210
+ # logs = capture_cmdx_logs { MyCommand.execute }
211
+ # expect(logs.join).to include("status=success")
212
+ def capture_cmdx_logs(&)
213
+ raise ArgumentError, "block required" unless block_given?
214
+
215
+ io = StringIO.new
216
+ previous = CMDx.configuration.logger
217
+ formatter = previous&.formatter || CMDx::LogFormatters::Line.new
218
+
219
+ CMDx.configuration.logger = Logger.new(io, formatter:)
220
+ yield
221
+ io.string.lines.map(&:chomp)
222
+ ensure
223
+ CMDx.configuration.logger = previous if previous
224
+ end
202
225
 
203
- allow(command).to receive(:execute!).and_return(result)
226
+ # Subscribes to telemetry events on +command+'s telemetry registry
227
+ # for the duration of the block. Captures every emitted event.
228
+ # Tasks subclassing +command+ also fire (telemetry is cloned at
229
+ # class definition; the registry array is shared by reference until
230
+ # +dup+).
231
+ #
232
+ # @param command [Class] the Task class whose telemetry to listen on
233
+ # @param events [Array<Symbol>] event names to subscribe to;
234
+ # defaults to all of {CMDx::Telemetry::EVENTS}
235
+ # @yield runs the block with subscribers attached
236
+ # @return [Array<CMDx::Telemetry::Event>] captured events in emission order
237
+ # @example
238
+ # events = subscribe_telemetry(MyCommand, :task_executed) { MyCommand.execute }
239
+ # expect(events.map(&:name)).to eq([:task_executed])
240
+ def subscribe_telemetry(command, *events, &)
241
+ raise ArgumentError, "block required" unless block_given?
242
+
243
+ events = CMDx::Telemetry::EVENTS if events.empty?
244
+ captured = []
245
+ telemetry = command.telemetry
246
+ listener = ->(event) { captured << event }
247
+
248
+ begin
249
+ events.each { |e| telemetry.subscribe(e, listener) }
250
+ yield
251
+ ensure
252
+ events.each { |e| telemetry.unsubscribe(e, listener) }
253
+ end
204
254
 
205
- result
255
+ captured
206
256
  end
207
257
 
208
- # Unstubs a command's :execute method.
258
+ # Captures the {CMDx::Chain} produced by +command+'s execution
259
+ # within the block. Subscribes to +command+'s `:task_executed`
260
+ # telemetry to grab the chain reference before Runtime teardown
261
+ # clears it.
209
262
  #
210
- # @param command [Class] The command class to unstub execution on
211
- # @param context [Hash] Optional keyword arguments to match against
263
+ # @param command [Class] the Task class whose chain to capture
264
+ # @yield the block to execute
265
+ # @return [CMDx::Chain, nil] the chain (frozen by Runtime), or nil
266
+ # when +command+ didn't run as a root during the block
267
+ # @example
268
+ # chain = with_cmdx_chain(MyWorkflow) { MyWorkflow.execute }
269
+ # expect(chain.size).to be > 1
270
+ def with_cmdx_chain(command)
271
+ raise ArgumentError, "block required" unless block_given?
272
+
273
+ captured = nil
274
+ telemetry = command.telemetry
275
+ listener = ->(event) { captured ||= event.payload[:result].chain if event.root }
276
+
277
+ begin
278
+ telemetry.subscribe(:task_executed, listener)
279
+ yield
280
+ ensure
281
+ telemetry.unsubscribe(:task_executed, listener)
282
+ end
283
+
284
+ captured
285
+ end
286
+
287
+ # Restores `command.execute` to its original implementation.
288
+ # When `context` is supplied, only the matching argument signature is unstubbed.
212
289
  #
290
+ # @param command [Class] the Task class to unstub
291
+ # @param context [Hash{Symbol => Object}] argument signature whose stub to release
213
292
  # @return [void]
214
- #
215
- # @example Unstubbing execute with context
216
- # unstub_task(MyCommand, foo: "bar")
217
- #
218
- # MyCommand.execute(foo: "bar")
219
- #
220
- # @example Unstubbing execute without context
221
- # unstub_task(MyCommand)
222
- #
223
- # MyCommand.execute
224
- def unstub_task(command, **context)
225
- if context.empty?
293
+ def unstub_task(command, *args, **context)
294
+ if args.empty? && context.empty?
226
295
  allow(command).to receive(:execute).and_call_original
227
296
  else
228
- allow(command).to receive(:execute).with(**context).and_call_original
297
+ allow(command).to receive(:execute).with(*args, **context).and_call_original
229
298
  end
230
299
  end
231
300
 
232
- # Unstubs a command's :execute! method.
233
- #
234
- # @param command [Class] The command class to unstub execution on
235
- # @param context [Hash] Optional keyword arguments to match against
301
+ # Restores `command.execute!` to its original implementation.
302
+ # When `context` is supplied, only the matching argument signature is unstubbed.
236
303
  #
304
+ # @param command [Class] the Task class to unstub
305
+ # @param context [Hash{Symbol => Object}] argument signature whose stub to release
237
306
  # @return [void]
238
- #
239
- # @example Unstubbing execute!
240
- # unstub_task!(MyCommand, foo: "bar")
241
- #
242
- # MyCommand.execute!(foo: "bar")
243
- #
244
- # @example Unstubbing execute! without context
245
- # unstub_task!(MyCommand)
246
- #
247
- # MyCommand.execute!
248
- def unstub_task!(command, **context)
249
- if context.empty?
307
+ def unstub_task!(command, *args, **context)
308
+ if args.empty? && context.empty?
250
309
  allow(command).to receive(:execute!).and_call_original
251
310
  else
252
- allow(command).to receive(:execute!).with(**context).and_call_original
311
+ allow(command).to receive(:execute!).with(*args, **context).and_call_original
253
312
  end
254
313
  end
255
314
 
256
- # Sets up an expectation that a command will receive :execute with the given context.
257
- #
258
- # @param command [Class] The command class to expect execution on
259
- # @param context [Hash] Optional keyword arguments to match against
260
- #
261
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
262
- #
263
- # @example Expecting execution with context
264
- # expect_task_execution(MyCommand, user_id: 123, role: "admin")
315
+ # Sets a positive message expectation that `command.execute` is invoked.
316
+ # When `context` is supplied, the expectation is constrained to that signature.
265
317
  #
266
- # MyCommand.execute(user_id: 123, role: "admin")
267
- #
268
- # @example Expecting execution without context
269
- # expect_task_execution(MyCommand)
270
- #
271
- # MyCommand.execute
272
- def expect_task_execution(command, **context)
273
- if context.empty?
318
+ # @param command [Class] the Task class to expect
319
+ # @param context [Hash{Symbol => Object}] argument signature to match
320
+ # @return [RSpec::Mocks::MessageExpectation]
321
+ def expect_task_execution(command, *args, **context)
322
+ if args.empty? && context.empty?
274
323
  expect(command).to receive(:execute)
275
324
  else
276
- expect(command).to receive(:execute).with(**context)
325
+ expect(command).to receive(:execute).with(*args, **context)
277
326
  end
278
327
  end
279
328
 
280
- # Sets up an expectation that a command will receive :execute! with the given context.
281
- #
282
- # @param command [Class] The command class to expect execution on
283
- # @param context [Hash] Optional keyword arguments to match against
284
- #
285
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
329
+ # Sets a positive message expectation that `command.execute!` is invoked.
330
+ # When `context` is supplied, the expectation is constrained to that signature.
286
331
  #
287
- # @example Expecting execution with context
288
- # expect_task_execution!(MyCommand, user_id: 123, role: "admin")
289
- #
290
- # MyCommand.execute!(user_id: 123, role: "admin")
291
- #
292
- # @example Expecting execution without context
293
- # expect_task_execution!(MyCommand)
294
- #
295
- # MyCommand.execute!
296
- def expect_task_execution!(command, **context)
297
- if context.empty?
332
+ # @param command [Class] the Task class to expect
333
+ # @param context [Hash{Symbol => Object}] argument signature to match
334
+ # @return [RSpec::Mocks::MessageExpectation]
335
+ def expect_task_execution!(command, *args, **context)
336
+ if args.empty? && context.empty?
298
337
  expect(command).to receive(:execute!)
299
338
  else
300
- expect(command).to receive(:execute!).with(**context)
339
+ expect(command).to receive(:execute!).with(*args, **context)
301
340
  end
302
341
  end
303
342
 
304
- # Sets up an expectation that a command will not receive :execute.
305
- #
306
- # @param command [Class] The command class to expect execution on
307
- # @param context [Hash] Optional keyword arguments to match against
308
- #
309
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
343
+ # Sets a negative message expectation that `command.execute` is not invoked.
344
+ # When `context` is supplied, only the matching signature is forbidden.
310
345
  #
311
- # @example Expecting no execution with context
312
- # expect_no_task_execution(MyCommand, foo: "bar")
313
- #
314
- # MyCommand.execute(foo: "bar")
315
- #
316
- # @example Expecting no execution with empty context
317
- # expect_no_task_execution(MyCommand)
318
- #
319
- # MyCommand.execute
320
- def expect_no_task_execution(command, **context)
321
- if context.empty?
346
+ # @param command [Class] the Task class to guard
347
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
348
+ # @return [RSpec::Mocks::MessageExpectation]
349
+ def expect_no_task_execution(command, *args, **context)
350
+ if args.empty? && context.empty?
322
351
  expect(command).not_to receive(:execute)
323
352
  else
324
- expect(command).not_to receive(:execute).with(**context)
353
+ expect(command).not_to receive(:execute).with(*args, **context)
325
354
  end
326
355
  end
327
356
 
328
- # Sets up an expectation that a command will not receive :execute!.
329
- #
330
- # @param command [Class] The command class to expect execution on
331
- # @param context [Hash] Optional keyword arguments to match against
357
+ # Sets a negative message expectation that `command.execute!` is not invoked.
358
+ # When `context` is supplied, only the matching signature is forbidden.
332
359
  #
333
- # @return [RSpec::Mocks::MessageExpectation] The RSpec expectation object
334
- #
335
- # @example Expecting no execution! with context
336
- # expect_no_task_execution!(MyCommand, foo: "bar")
337
- #
338
- # MyCommand.execute!(foo: "bar")
360
+ # @param command [Class] the Task class to guard
361
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
362
+ # @return [RSpec::Mocks::MessageExpectation]
363
+ def expect_no_task_execution!(command, *args, **context)
364
+ if args.empty? && context.empty?
365
+ expect(command).not_to receive(:execute!)
366
+ else
367
+ expect(command).not_to receive(:execute!).with(*args, **context)
368
+ end
369
+ end
370
+
371
+ # Asserts that `command.execute` was invoked (after the fact). Requires a
372
+ # prior spy, e.g. `allow(command).to receive(:execute).and_call_original`
373
+ # or a `stub_task_*` helper. When `context` is supplied, the assertion is
374
+ # constrained to that signature.
375
+ #
376
+ # @param command [Class] the Task class to assert against
377
+ # @param context [Hash{Symbol => Object}] argument signature to match
378
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
379
+ def expected_task_execution(command, *args, **context)
380
+ if args.empty? && context.empty?
381
+ expect(command).to have_received(:execute)
382
+ else
383
+ expect(command).to have_received(:execute).with(*args, **context)
384
+ end
385
+ end
386
+
387
+ # Asserts that `command.execute!` was invoked (after the fact). Requires a
388
+ # prior spy on `execute!`. When `context` is supplied, the assertion is
389
+ # constrained to that signature.
339
390
  #
340
- # @example Expecting no execution! with empty context
341
- # expect_no_task_execution!(MyCommand)
391
+ # @param command [Class] the Task class to assert against
392
+ # @param context [Hash{Symbol => Object}] argument signature to match
393
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
394
+ def expected_task_execution!(command, *args, **context)
395
+ if args.empty? && context.empty?
396
+ expect(command).to have_received(:execute!)
397
+ else
398
+ expect(command).to have_received(:execute!).with(*args, **context)
399
+ end
400
+ end
401
+
402
+ # Asserts that `command.execute` was not invoked (after the fact). Requires
403
+ # a prior spy on `execute`. When `context` is supplied, only the matching
404
+ # signature is checked.
342
405
  #
343
- # MyCommand.execute!
344
- def expect_no_task_execution!(command, **context)
345
- if context.empty?
346
- expect(command).not_to receive(:execute!)
406
+ # @param command [Class] the Task class to assert against
407
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
408
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
409
+ def expected_no_task_execution(command, *args, **context)
410
+ if args.empty? && context.empty?
411
+ expect(command).not_to have_received(:execute)
347
412
  else
348
- expect(command).not_to receive(:execute!).with(**context)
413
+ expect(command).not_to have_received(:execute).with(*args, **context)
349
414
  end
350
415
  end
351
416
 
352
- # Yields each unique pipeline task class so workflow specs can stub tasks in one place.
417
+ # Asserts that `command.execute!` was not invoked (after the fact). Requires
418
+ # a prior spy on `execute!`. When `context` is supplied, only the matching
419
+ # signature is checked.
353
420
  #
354
- # @param command [Class] Class including {CMDx::Workflow}
355
- # @yield [Class] Distinct task class per pipeline stage (first-seen order)
356
- # @return [Array<Class>] Deduplicated task list ({#each} return value)
357
- # @raise [ArgumentError] if no block is given
358
- # @raise [ArgumentError] if +command+ does not include {CMDx::Workflow}
421
+ # @param command [Class] the Task class to assert against
422
+ # @param context [Hash{Symbol => Object}] argument signature to forbid
423
+ # @return [RSpec::Mocks::Matchers::HaveReceived]
424
+ def expected_no_task_execution!(command, *args, **context)
425
+ if args.empty? && context.empty?
426
+ expect(command).not_to have_received(:execute!)
427
+ else
428
+ expect(command).not_to have_received(:execute!).with(*args, **context)
429
+ end
430
+ end
431
+
432
+ # Yields each distinct Task class reachable from a Workflow's pipeline,
433
+ # in first-seen order, so callers can stub them in a single block.
359
434
  #
435
+ # @param command [Class] a Workflow class (must include `CMDx::Workflow`)
436
+ # @yieldparam task [Class] a Task class referenced by the workflow pipeline
437
+ # @return [void]
438
+ # @raise [ArgumentError] when no block is given or `command` is not a Workflow
360
439
  # @example
361
- # stub_workflow_tasks(MyWorkflow) do |t|
362
- # if t == TaskB
363
- # stub_task_success(t)
364
- # elsif t == TaskC
365
- # stub_task_skip(t)
366
- # else
367
- # stub_task_success(t)
368
- # end
369
- # end
370
- #
371
- # MyWorkflow.execute
440
+ # stub_workflow_tasks(MyWorkflow) { |t| stub_task_success(t) }
372
441
  def stub_workflow_tasks(command, &)
373
442
  if !block_given?
374
443
  raise ArgumentError, "block required"
@@ -379,6 +448,99 @@ module CMDx
379
448
  command.pipeline.flat_map(&:tasks).uniq.each(&)
380
449
  end
381
450
 
451
+ private
452
+
453
+ # @api private
454
+ def resolve_result_signal(status, reason:, cause:, metadata:, upstream_result:)
455
+ case status
456
+ when CMDx::Signal
457
+ status
458
+ when :success, :successful
459
+ CMDx::Signal.success(reason, metadata:, cause:)
460
+ when :skip, :skipped
461
+ CMDx::Signal.skipped(reason, metadata:, cause:)
462
+ when :fail, :failed, :failure
463
+ CMDx::Signal.failed(reason, metadata:, cause:)
464
+ when :echo, :echoed
465
+ validate_echo_upstream!(upstream_result)
466
+ CMDx::Signal.echoed(upstream_result, metadata:, cause:)
467
+ else
468
+ raise ArgumentError, "unknown result status #{status.inspect}"
469
+ end
470
+ end
471
+
472
+ # @api private
473
+ def validate_echo_upstream!(upstream_result)
474
+ return if upstream_result.is_a?(CMDx::Result) && upstream_result.failed?
475
+
476
+ raise ArgumentError, "upstream result must be a failed CMDx::Result"
477
+ end
478
+
479
+ # Constructs a frozen {CMDx::Result} from +signal+ and wires it into a
480
+ # {CMDx::Chain} so callers observe the same shape as a real execution.
481
+ #
482
+ # @api private
483
+ # @return [CMDx::Result]
484
+ LIFECYCLE_OPTIONS = %i[root strict deprecated retries duration rolled_back tid].freeze
485
+ private_constant :LIFECYCLE_OPTIONS
486
+
487
+ def assemble_result(signal, task: nil, chain: nil, context: nil, **options)
488
+ context_data = options.except(*LIFECYCLE_OPTIONS)
489
+ task_class = task || STUB_TASK
490
+ context_input = context || context_data
491
+ task_instance = task_class.new(context_input)
492
+ chain ||= CMDx::Chain.new
493
+ result = CMDx::Result.new(
494
+ chain,
495
+ task_instance,
496
+ signal,
497
+ root: options.fetch(:root, true),
498
+ strict: options.fetch(:strict, false),
499
+ deprecated: options.fetch(:deprecated, false),
500
+ retries: options.fetch(:retries, 0),
501
+ duration: options.fetch(:duration, nil),
502
+ rolled_back: options.fetch(:rolled_back, false),
503
+ tid: options.fetch(:tid, SecureRandom.uuid_v7)
504
+ )
505
+
506
+ chain.unshift(result)
507
+
508
+ result
509
+ end
510
+
511
+ # Constructs a frozen {CMDx::Result} from `signal` and stubs `command.method`
512
+ # to return it.
513
+ #
514
+ # @api private
515
+ # @param command [Class] the Task class being stubbed
516
+ # @param method [Symbol] the message to stub (`:execute` or `:execute!`)
517
+ # @param signal [CMDx::Signal] outcome payload to wrap
518
+ # @param context [Hash] context overrides for `command.new`
519
+ # @return [CMDx::Result] the frozen Result installed on the stub
520
+ def build_stub(command, method, signal, context, **options)
521
+ raise ArgumentError, "command must be a Task class" unless command <= CMDx::Task
522
+
523
+ result = assemble_result(signal, **options, task: command, context:)
524
+
525
+ if options.fetch(:strict, false) && result.failed?
526
+ allow(command).to receive(method) { raise_strict_failure!(result) }
527
+ else
528
+ allow(command).to receive(method).and_return(result)
529
+ end
530
+
531
+ result
532
+ end
533
+
534
+ # @api private
535
+ # @param result [CMDx::Result] a failed strict result
536
+ # @raise [CMDx::Fault, StandardError]
537
+ def raise_strict_failure!(result)
538
+ cause = result.cause
539
+ raise(cause) if cause && !cause.is_a?(CMDx::Fault)
540
+
541
+ raise(CMDx::Fault, result.caused_failure, cause:)
542
+ end
543
+
382
544
  end
383
545
  end
384
546
  end