ruby_reactor 0.3.0 → 0.3.2

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. metadata +16 -3
@@ -0,0 +1,994 @@
1
+ # Testing with RSpec
2
+
3
+ RubyReactor provides a comprehensive testing framework designed to make testing reactors intuitive and powerful. The testing utilities include a `TestSubject` class for reactor execution and introspection, along with custom RSpec matchers for expressive assertions.
4
+
5
+ ## Setup
6
+
7
+ Add the RubyReactor RSpec configuration to your `spec_helper.rb` or `rails_helper.rb`:
8
+
9
+ ```ruby
10
+ require 'ruby_reactor/rspec'
11
+
12
+ RSpec.configure do |config|
13
+ RubyReactor::RSpec.configure(config)
14
+ end
15
+ ```
16
+
17
+ This will give you access to the `test_reactor` helper method and all custom matchers.
18
+
19
+ > For reactors that use `with_lock`, `with_semaphore`, `with_rate_limit`, or `with_period`, see [Testing Coordination Primitives](#testing-coordination-primitives) — it covers the `be_skipped`, `be_locked`, `have_available_tokens`, `have_held_tokens`, `have_rate_limit_count`, and `be_period_marked` matchers, plus patterns for testing async snooze and escalation.
20
+
21
+ ## Basic Usage
22
+
23
+ ### The `test_reactor` Helper
24
+
25
+ The primary interface for testing reactors is the `test_reactor` helper, which returns a `TestSubject` instance:
26
+
27
+ ```ruby
28
+ RSpec.describe MyReactor do
29
+ it "processes successfully" do
30
+ subject = test_reactor(MyReactor, email: "test@example.com")
31
+
32
+ expect(subject).to be_success
33
+ end
34
+ end
35
+ ```
36
+
37
+ ### TestSubject Configuration
38
+
39
+ The `test_reactor` helper accepts the following options:
40
+
41
+ | Option | Type | Description |
42
+ |--------|------|-------------|
43
+ | `inputs` | Hash | The inputs to pass to the reactor |
44
+ | `context` | Hash | Optional context data for the execution |
45
+ | `async` | Boolean | Force async (`true`) or sync (`false`) execution |
46
+ | `process_jobs` | Boolean | Whether to automatically process Sidekiq jobs (default: `true`) |
47
+
48
+ ```ruby
49
+ # Force synchronous execution
50
+ subject = test_reactor(MyReactor, { user_id: 1 }, async: false)
51
+
52
+ # With additional context
53
+ subject = test_reactor(MyReactor, { user_id: 1 }, context: { tenant_id: 42 })
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Execution Control
59
+
60
+ ### Running the Reactor
61
+
62
+ The `TestSubject` automatically runs the reactor when you access introspection methods. You can also run it explicitly:
63
+
64
+ ```ruby
65
+ subject = test_reactor(MyReactor, params)
66
+ subject.run # Explicit execution
67
+
68
+ # Chaining is supported
69
+ subject.run_async(false).run
70
+ ```
71
+
72
+ ### Async Mode Control
73
+
74
+ Control whether the reactor runs asynchronously:
75
+
76
+ ```ruby
77
+ # Force synchronous execution (useful for step-by-step debugging)
78
+ subject = test_reactor(MyReactor, params, async: false)
79
+
80
+ # Or use the fluent API
81
+ subject = test_reactor(MyReactor, params).run_async(false)
82
+ ```
83
+
84
+ ### Sidekiq Job Processing
85
+
86
+ By default, `TestSubject` automatically processes Sidekiq jobs in fake mode. This ensures that async steps complete during the test:
87
+
88
+ ```ruby
89
+ # Jobs are processed automatically
90
+ subject = test_reactor(AsyncReactor, params)
91
+ expect(subject).to be_success # All async steps completed
92
+
93
+ # Disable automatic job processing
94
+ subject = test_reactor(AsyncReactor, params, process_jobs: false)
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Introspection
100
+
101
+ ### Accessing Results
102
+
103
+ Once executed, you can inspect the reactor's result:
104
+
105
+ ```ruby
106
+ subject = test_reactor(MyReactor, params)
107
+
108
+ # Check overall status
109
+ subject.success? # => true/false
110
+ subject.failure? # => true/false
111
+
112
+ # Get the final result object
113
+ subject.result # => Success or Failure
114
+
115
+ # Get any error that occurred
116
+ subject.error # => error message or nil
117
+ ```
118
+
119
+ ### Step Results
120
+
121
+ Access the result of individual steps:
122
+
123
+ ```ruby
124
+ subject = test_reactor(OrderReactor, order_id: 123)
125
+
126
+ # Get a specific step's result
127
+ user = subject.step_result(:validate_user)
128
+ order = subject.step_result(:fetch_order)
129
+ ```
130
+
131
+ ### Reactor Instance
132
+
133
+ Access the underlying reactor instance for advanced introspection:
134
+
135
+ ```ruby
136
+ subject = test_reactor(MyReactor, params)
137
+
138
+ # Access the reactor instance
139
+ subject.reactor_instance
140
+
141
+ # Access context data
142
+ subject.reactor_instance.context.execution_trace
143
+ subject.reactor_instance.context.intermediate_results
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Step Mocking
149
+
150
+ ### Basic Step Mocking
151
+
152
+ Use `mock_step` to intercept and replace step implementations:
153
+
154
+ ```ruby
155
+ subject = test_reactor(PaymentReactor, order_id: 123)
156
+ .mock_step(:charge_card) do |args, context|
157
+ # Custom implementation
158
+ Success({ transaction_id: "test-123" })
159
+ end
160
+
161
+ expect(subject).to be_success
162
+ expect(subject.step_result(:charge_card)).to eq({ transaction_id: "test-123" })
163
+ ```
164
+
165
+ ### Chaining Multiple Mocks
166
+
167
+ You can chain multiple `mock_step` calls to mock several steps in a single fluent expression. This is useful when you need to isolate multiple external service calls:
168
+
169
+ ```ruby
170
+ RSpec.describe MultipleRequestsReactor do
171
+ subject(:reactor) do
172
+ test_reactor(described_class, request_id: 1)
173
+ .mock_step(:call_service_1) { |args| Success(args[:request_id]) }
174
+ .mock_step(:call_service_2) { |args| Success(args[:request_id]) }
175
+ .mock_step(:call_service_3) { |args| Success(args[:request_id]) }
176
+ end
177
+
178
+ it "processes successfully with all mocked services" do
179
+ expect(reactor).to be_success
180
+ end
181
+ end
182
+ ```
183
+
184
+ Each `mock_step` call returns the `TestSubject`, allowing you to chain as many mocks as needed. The mocks are applied in the order specified, and each mocked step will use the provided block instead of its original implementation.
185
+
186
+ ### Wrapping Original Implementations
187
+
188
+ The mock block receives a third parameter that allows calling the original implementation:
189
+
190
+ ```ruby
191
+ subject = test_reactor(MyReactor, params)
192
+ .mock_step(:some_step) do |args, context, original|
193
+ # Modify args before calling original
194
+ modified_args = args.merge(extra: "value")
195
+ result = original.call(modified_args, context)
196
+
197
+ # Post-process the result
198
+ result
199
+ end
200
+ ```
201
+
202
+ ### Simulating Failures
203
+
204
+ #### Using `failing_at`
205
+
206
+ The `failing_at` method provides a simple way to simulate step failures:
207
+
208
+ ```ruby
209
+ subject = test_reactor(OrderReactor, params)
210
+ .failing_at(:process_payment)
211
+
212
+ expect(subject).to be_failure
213
+ ```
214
+
215
+ #### Raising Exceptions in Mocks
216
+
217
+ For more control, raise exceptions within mock blocks:
218
+
219
+ ```ruby
220
+ subject = test_reactor(PaymentReactor, params)
221
+ .mock_step(:charge_card) do |args, context|
222
+ raise PaymentDeclinedError, "Card was declined"
223
+ end
224
+
225
+ expect(subject).to be_failure
226
+ expect(subject.error).to include("Card was declined")
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Nested Reactor Testing
232
+
233
+ ### Testing Composed Reactors
234
+
235
+ When testing reactors that use `compose`, you can mock steps within the composed reactor:
236
+
237
+ ```ruby
238
+ # Parent reactor composes ChildReactor at :process_child step
239
+ subject = test_reactor(ParentReactor, params)
240
+ .composed(:process_child)
241
+ .mock_step(:inner_step) do |args, context|
242
+ Success("mocked inner result")
243
+ end
244
+
245
+ expect(subject).to be_success
246
+ ```
247
+
248
+ ### Traversing Composed Results
249
+
250
+ After execution, traverse into composed reactor results:
251
+
252
+ ```ruby
253
+ subject = test_reactor(ParentReactor, params)
254
+ subject.run
255
+
256
+ # Get the composed reactor's TestSubject
257
+ child_subject = subject.composed(:process_child)
258
+ expect(child_subject.step_result(:inner_step)).to eq("expected value")
259
+ ```
260
+
261
+ ### Testing Map Steps
262
+
263
+ For reactors using `map`, you can access individual element results:
264
+
265
+ ```ruby
266
+ subject = test_reactor(BatchProcessor, items: [1, 2, 3])
267
+ subject.run
268
+
269
+ # Access all map element subjects
270
+ elements = subject.map_elements(:process_items)
271
+ expect(elements.length).to eq(3)
272
+
273
+ # Access a specific element by index
274
+ first_element = subject.map_element(:process_items, index: 0)
275
+ expect(first_element).to be_success
276
+
277
+ # Mock within map elements
278
+ subject = test_reactor(BatchProcessor, items: [1, 2, 3])
279
+ .map(:process_items)
280
+ .mock_step(:transform) do |args, context|
281
+ Success(args[:item] * 2)
282
+ end
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Custom RSpec Matchers
288
+
289
+ RubyReactor provides expressive matchers for common assertions:
290
+
291
+ ### Success and Failure Matchers
292
+
293
+ ```ruby
294
+ # Check if reactor succeeded
295
+ expect(subject).to be_success
296
+
297
+ # Check if reactor failed
298
+ expect(subject).to be_failure
299
+ ```
300
+
301
+ The `be_success` matcher provides detailed failure messages when a reactor fails:
302
+
303
+ ```ruby
304
+ # Example failure output:
305
+ # Error: PaymentDeclinedError
306
+ # Card was declined
307
+ # Step: :charge_card
308
+ # File: /app/reactors/payment_reactor.rb:45
309
+ #
310
+ # def charge_card
311
+ # --> PaymentGateway.charge(amount, token)
312
+ # end
313
+ #
314
+ # Backtrace:
315
+ # - /app/reactors/payment_reactor.rb:45
316
+ # - /app/services/payment_gateway.rb:12
317
+ ```
318
+
319
+ ### Step Execution Matchers
320
+
321
+ #### `have_run_step`
322
+
323
+ Verify that a specific step was executed:
324
+
325
+ ```ruby
326
+ expect(subject).to have_run_step(:validate_user)
327
+ ```
328
+
329
+ #### With Return Value
330
+
331
+ ```ruby
332
+ expect(subject).to have_run_step(:validate_user).returning({ id: 1, name: "Alice" })
333
+
334
+ # Works with regex for partial matching
335
+ expect(subject).to have_run_step(:generate_token).returning(/^token_/)
336
+ ```
337
+
338
+ #### With Execution Order
339
+
340
+ ```ruby
341
+ expect(subject).to have_run_step(:send_email).after(:create_user)
342
+ ```
343
+
344
+ #### Combined Assertions
345
+
346
+ ```ruby
347
+ expect(subject).to have_run_step(:process_payment)
348
+ .returning({ status: "completed" })
349
+ .after(:validate_order)
350
+ ```
351
+
352
+ ### Retry Matchers
353
+
354
+ #### `have_retried_step`
355
+
356
+ Verify that a step was retried:
357
+
358
+ ```ruby
359
+ expect(subject).to have_retried_step(:flaky_api_call)
360
+
361
+ # With specific retry count
362
+ expect(subject).to have_retried_step(:flaky_api_call).times(3)
363
+ ```
364
+
365
+ ### Validation Matchers
366
+
367
+ #### `have_validation_error`
368
+
369
+ Check for input validation errors:
370
+
371
+ ```ruby
372
+ subject = test_reactor(UserReactor, email: "invalid", age: 10)
373
+
374
+ expect(subject).to be_failure
375
+ expect(subject).to have_validation_error(:email)
376
+ expect(subject).to have_validation_error(:age)
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Testing Interrupts
382
+
383
+ RubyReactor provides comprehensive test helpers for testing reactors that use the `interrupt` DSL for pause/resume workflows.
384
+
385
+ ### Interrupt State Introspection
386
+
387
+ #### Checking Paused State
388
+
389
+ ```ruby
390
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
391
+
392
+ # Check if reactor is paused
393
+ subject.paused? # => true/false
394
+
395
+ # Get the current interrupt step name
396
+ subject.current_step # => :wait_for_approval (Symbol) or nil
397
+ ```
398
+
399
+ #### Getting Ready Interrupt Steps
400
+
401
+ When a reactor has multiple concurrent interrupts (e.g., parallel approvals), use `ready_interrupt_steps` to see all steps that are ready to be resumed:
402
+
403
+ ```ruby
404
+ subject = test_reactor(MultiApprovalWorkflow, params)
405
+
406
+ # Get all ready interrupt steps
407
+ subject.ready_interrupt_steps # => [:manager_approval, :director_approval]
408
+ ```
409
+
410
+ ### Resuming Paused Reactors
411
+
412
+ Use `resume` to continue execution with a payload:
413
+
414
+ ```ruby
415
+ # Single interrupt - step is automatically detected
416
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
417
+ expect(subject).to be_paused
418
+
419
+ subject.resume(payload: { approved: true, approver: "manager" })
420
+
421
+ expect(subject).to be_success
422
+ ```
423
+
424
+ #### Multiple Concurrent Interrupts
425
+
426
+ When multiple interrupts are ready, you **must** specify which step to resume:
427
+
428
+ ```ruby
429
+ subject = test_reactor(MultiApprovalWorkflow, params)
430
+
431
+ # This will raise an error - ambiguous which interrupt to resume
432
+ # subject.resume(payload: { status: "approved" }) # => Error!
433
+
434
+ # Specify the step explicitly
435
+ subject.resume(step: :manager_approval, payload: { status: "approved" })
436
+ subject.resume(step: :director_approval, payload: { status: "approved" })
437
+
438
+ expect(subject).to be_success
439
+ ```
440
+
441
+ ### Interrupt Matchers
442
+
443
+ #### `be_paused`
444
+
445
+ Assert that a reactor is in paused state:
446
+
447
+ ```ruby
448
+ expect(subject).to be_paused
449
+ expect(subject).not_to be_paused
450
+ ```
451
+
452
+ #### `be_paused_at`
453
+
454
+ Assert that a reactor is paused with specific interrupt(s) ready:
455
+
456
+ ```ruby
457
+ # Single interrupt
458
+ expect(subject).to be_paused_at(:wait_for_approval)
459
+
460
+ # Check if specific interrupt is among the ready ones
461
+ expect(subject).to be_paused_at(:manager_approval)
462
+
463
+ # Check multiple interrupts are ready
464
+ expect(subject).to be_paused_at(:manager_approval, :director_approval)
465
+ ```
466
+
467
+ #### `have_ready_interrupts`
468
+
469
+ Assert the exact set of ready interrupt steps:
470
+
471
+ ```ruby
472
+ # Assert exactly these interrupts are ready
473
+ expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
474
+ ```
475
+
476
+ ### Complete Interrupt Testing Example
477
+
478
+ ```ruby
479
+ RSpec.describe ApprovalWorkflow do
480
+ describe "single approval" do
481
+ it "pauses at approval step and resumes" do
482
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
483
+
484
+ # Verify paused state
485
+ expect(subject).to be_paused
486
+ expect(subject).to be_paused_at(:wait_for_approval)
487
+ expect(subject.current_step).to eq(:wait_for_approval)
488
+
489
+ # Step results before interrupt should be available
490
+ expect(subject.step_result(:prepare_request)).to be_present
491
+
492
+ # Resume with approval payload
493
+ subject.resume(payload: { approved: true, approver: "manager" })
494
+
495
+ # Should complete after resume
496
+ expect(subject).to be_success
497
+ expect(subject.step_result(:finalize)).to include(approved: true)
498
+ end
499
+
500
+ it "stays paused with invalid payload" do
501
+ subject = test_reactor(ApprovalWorkflow, request_id: 123)
502
+
503
+ # Resume with invalid payload (fails validation)
504
+ expect {
505
+ subject.resume(payload: { invalid: "data" })
506
+ }.to raise_error(RubyReactor::Error::ValidationError)
507
+ end
508
+ end
509
+
510
+ describe "multiple approvals" do
511
+ it "handles concurrent approval requirements" do
512
+ subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
513
+
514
+ # Verify multiple interrupts are ready
515
+ expect(subject).to be_paused
516
+ expect(subject).to have_ready_interrupts(:manager_approval, :director_approval)
517
+
518
+ # Resume first approval
519
+ subject.resume(step: :manager_approval, payload: { status: "approved" })
520
+
521
+ # Still paused, waiting for second approval
522
+ expect(subject).to be_paused
523
+ expect(subject.ready_interrupt_steps).to eq([:director_approval])
524
+
525
+ # Complete second approval
526
+ subject.resume(step: :director_approval, payload: { status: "approved" })
527
+
528
+ expect(subject).to be_success
529
+ end
530
+
531
+ it "requires step specification for multiple interrupts" do
532
+ subject = test_reactor(MultiApprovalWorkflow, request_id: 456)
533
+
534
+ # Without step: parameter, raises error
535
+ expect {
536
+ subject.resume(payload: { status: "approved" })
537
+ }.to raise_error(
538
+ RubyReactor::Error::ValidationError,
539
+ /multiple interrupt steps are ready/
540
+ )
541
+ end
542
+ end
543
+ end
544
+ ```
545
+
546
+ ---
547
+
548
+ ## Testing Coordination Primitives
549
+
550
+ Reactors that declare `with_lock`, `with_semaphore`, `with_rate_limit`, or `with_period` can be tested with both vanilla execution assertions and dedicated state matchers that read the live Redis state via the configured storage adapter.
551
+
552
+ ### Test environment requirements
553
+
554
+ The matchers ship with the standard test setup — once `RubyReactor::RSpec.configure(config)` runs in your `spec_helper.rb`, they're available. They require:
555
+
556
+ - A real Redis (the in-memory test mode does not back the primitives).
557
+ - A clean Redis between tests — typically `redis.flushdb` in a `before` block — so leftover lock owners, semaphore tokens, rate-limit counters and period markers don't leak across examples.
558
+
559
+ ### The `Skipped` result
560
+
561
+ `RubyReactor::Skipped` is a `Success` subclass returned in two cases: a `with_period` bucket has already been claimed, or a step explicitly returns `RubyReactor.Skipped(reason: "...")` to halt cleanly (no compensation runs). Use `be_skipped` to distinguish it from a plain `Success`:
562
+
563
+ ```ruby
564
+ result = MonthlyReportReactor.run(org_id: 7)
565
+ expect(result).to be_skipped # any Skipped
566
+ expect(result).to be_skipped.because(:period) # gate hit
567
+ expect(result).to be_skipped.at_step(:second) # step return
568
+ ```
569
+
570
+ `Skipped` still satisfies `success?`, so legacy `if result.success?` callers continue to work; `result.skipped?` discriminates.
571
+
572
+ ### Asserting lock state
573
+
574
+ ```ruby
575
+ it "releases the lock after a successful run" do
576
+ RefundOrderReactor.run(order_id: 42)
577
+
578
+ expect("order:42").not_to be_locked
579
+ end
580
+
581
+ it "holds the lock for the duration of a long step" do
582
+ thread = Thread.new { LongRefundReactor.run(order_id: 42) }
583
+ # Give the executor a moment to acquire
584
+ sleep 0.05
585
+
586
+ expect("order:42").to be_locked
587
+ expect("order:42").to be_locked.by(thread.value.context.context_id)
588
+ end
589
+
590
+ it "raises when contention is hit inline" do
591
+ redis.hset("lock:order:42", "owner", "someone_else")
592
+ redis.hset("lock:order:42", "count", "1")
593
+
594
+ expect { RefundOrderReactor.run(order_id: 42) }
595
+ .to raise_error(RubyReactor::Lock::AcquisitionError)
596
+ end
597
+ ```
598
+
599
+ The `be_locked` matcher takes the **user-provided lock key** (without the internal `lock:` prefix). Use the `.by(owner)` chain to assert ownership — typically the `context_id` of the top-level execution.
600
+
601
+ ### Asserting semaphore state
602
+
603
+ ```ruby
604
+ it "returns the token to the pool on success" do
605
+ 3.times { ApiCallReactor.run }
606
+
607
+ expect("api_limit").to have_available_tokens(5)
608
+ expect("api_limit").to have_held_tokens(0)
609
+ end
610
+
611
+ it "exhausts capacity when held externally" do
612
+ s = RubyReactor::Semaphore.new("api_limit", limit: 2)
613
+ 2.times { s.acquire }
614
+
615
+ expect("api_limit").to have_available_tokens(0)
616
+ expect("api_limit").to have_held_tokens(2)
617
+
618
+ expect { ApiCallReactor.run }.to raise_error(RubyReactor::Semaphore::AcquisitionError)
619
+ end
620
+ ```
621
+
622
+ Both matchers take the **user-provided semaphore name** (without the `semaphore:` prefix).
623
+
624
+ ### Asserting rate-limit state
625
+
626
+ ```ruby
627
+ it "counts each call against the per-second window" do
628
+ 3.times { ChargeReactor.run(account_id: 42) }
629
+
630
+ expect("stripe:42").to have_rate_limit_count(3).for(:second)
631
+ end
632
+
633
+ it "snoozes inline once the window is full" do
634
+ 3.times { ChargeReactor.run(account_id: 42) }
635
+
636
+ expect { ChargeReactor.run(account_id: 42) }
637
+ .to raise_error(RubyReactor::RateLimit::ExceededError) do |e|
638
+ expect(e.period_name).to eq("second")
639
+ expect(e.retry_after_seconds).to be_between(1, 1)
640
+ end
641
+ end
642
+ ```
643
+
644
+ `have_rate_limit_count(n).for(period)` looks at the **current** bucket for the given `period` (use the same symbol or integer seconds you passed to `with_rate_limit`). For multi-window limits, assert each window separately:
645
+
646
+ ```ruby
647
+ expect("stripe:42").to have_rate_limit_count(3).for(:second)
648
+ expect("stripe:42").to have_rate_limit_count(3).for(:minute)
649
+ ```
650
+
651
+ ### Asserting period markers
652
+
653
+ ```ruby
654
+ it "marks the bucket after the first successful run" do
655
+ MonthlyReportReactor.run(org_id: 7)
656
+ expect("monthly_report:7").to be_period_marked.for(:month)
657
+ end
658
+
659
+ it "does not mark the bucket when the run fails" do
660
+ FailingMonthlyReactor.run(org_id: 7)
661
+ expect("monthly_report:7").not_to be_period_marked.for(:month)
662
+ end
663
+
664
+ it "skips a second call in the same bucket" do
665
+ MonthlyReportReactor.run(org_id: 7)
666
+ result = MonthlyReportReactor.run(org_id: 7)
667
+
668
+ expect(result).to be_skipped.because(:period)
669
+ end
670
+ ```
671
+
672
+ `be_period_marked.for(period)` checks the marker at the **current** bucket. To verify the marker's TTL behavior, drop to direct Redis: `redis.ttl(RubyReactor::Period.key("monthly_report:7", :month))`.
673
+
674
+ ### Testing async snooze behavior
675
+
676
+ The Sidekiq worker rescues `Lock::AcquisitionError`, `Semaphore::AcquisitionError`, and `RateLimit::ExceededError` and reschedules via `perform_in`. To test that wiring without spinning Sidekiq:
677
+
678
+ ```ruby
679
+ it "reschedules with retry_after on rate limit hits" do
680
+ redis.set("rate:stripe:42:second:#{Time.now.to_i}", "999")
681
+
682
+ serialized = RubyReactor::ContextSerializer.serialize(
683
+ RubyReactor::Context.new({ account_id: 42 }, ChargeReactor)
684
+ )
685
+
686
+ expect(RubyReactor::SidekiqWorkers::Worker)
687
+ .to receive(:perform_in)
688
+ .with(a_value_between(1.0, 2.0), serialized, "ChargeReactor", 1)
689
+
690
+ RubyReactor::SidekiqWorkers::Worker.new.perform(serialized, "ChargeReactor")
691
+ end
692
+ ```
693
+
694
+ For lock/semaphore the same pattern works — the `perform_in` delay is `lock_snooze_base_delay + rand(0..lock_snooze_jitter)`. Pin those knobs to deterministic values (`jitter = 0`) in tests that assert on the exact delay.
695
+
696
+ ### Testing snooze escalation
697
+
698
+ When the snooze cap (`lock_snooze_max_attempts`) is reached, the worker stops rescheduling and marks the context as failed:
699
+
700
+ ```ruby
701
+ it "marks the context as failed after the snooze cap" do
702
+ RubyReactor.configuration.lock_snooze_max_attempts = 3
703
+
704
+ redis.hset("lock:order:42", "owner", "other")
705
+ redis.hset("lock:order:42", "count", "1")
706
+
707
+ context = RubyReactor::Context.new({ order_id: 42 }, RefundOrderReactor)
708
+ serialized = RubyReactor::ContextSerializer.serialize(context)
709
+
710
+ expect(RubyReactor::SidekiqWorkers::Worker).not_to receive(:perform_in)
711
+
712
+ RubyReactor::SidekiqWorkers::Worker.new
713
+ .perform(serialized, "RefundOrderReactor", 3)
714
+ end
715
+ ```
716
+
717
+ ## Complete Examples
718
+
719
+ ### Testing a Payment Workflow
720
+
721
+ ```ruby
722
+ RSpec.describe PaymentWorkflow do
723
+ describe "successful payment" do
724
+ it "processes payment and creates invoice" do
725
+ subject = test_reactor(PaymentWorkflow,
726
+ order_id: 123,
727
+ amount: 99.99,
728
+ card_token: "tok_visa"
729
+ )
730
+
731
+ expect(subject).to be_success
732
+ expect(subject).to have_run_step(:validate_order)
733
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
734
+ expect(subject).to have_run_step(:create_invoice).after(:charge_card)
735
+
736
+ expect(subject.step_result(:charge_card)).to include(
737
+ status: "succeeded"
738
+ )
739
+ end
740
+ end
741
+
742
+ describe "payment failure" do
743
+ it "handles declined cards gracefully" do
744
+ subject = test_reactor(PaymentWorkflow,
745
+ order_id: 123,
746
+ amount: 99.99,
747
+ card_token: "tok_declined"
748
+ ).mock_step(:charge_card) do |args, context|
749
+ Failure("Card declined", code: "card_declined")
750
+ end
751
+
752
+ expect(subject).to be_failure
753
+ expect(subject.error).to include("Card declined")
754
+ end
755
+ end
756
+
757
+ describe "with retries" do
758
+ it "retries on transient failures" do
759
+ attempt = 0
760
+
761
+ subject = test_reactor(PaymentWorkflow, params)
762
+ .mock_step(:charge_card) do |args, context, original|
763
+ attempt += 1
764
+ if attempt < 3
765
+ raise NetworkError, "Connection timeout"
766
+ end
767
+ original.call(args, context)
768
+ end
769
+
770
+ expect(subject).to be_success
771
+ expect(subject).to have_retried_step(:charge_card).times(2)
772
+ end
773
+ end
774
+ end
775
+ ```
776
+
777
+ ### Testing Composed Reactors
778
+
779
+ ```ruby
780
+ RSpec.describe OrderProcessor do
781
+ it "processes order through composed payment reactor" do
782
+ subject = test_reactor(OrderProcessor, order_id: 123)
783
+ .composed(:process_payment)
784
+ .mock_step(:validate_card) do |args, context|
785
+ Success({ valid: true })
786
+ end
787
+
788
+ expect(subject).to be_success
789
+
790
+ # Inspect the composed reactor
791
+ payment = subject.composed(:process_payment)
792
+ expect(payment.step_result(:validate_card)).to eq({ valid: true })
793
+ end
794
+ end
795
+ ```
796
+
797
+ ### Testing Map Operations
798
+
799
+ ```ruby
800
+ RSpec.describe BatchEmailSender do
801
+ it "sends emails to all recipients" do
802
+ recipients = ["a@example.com", "b@example.com", "c@example.com"]
803
+
804
+ subject = test_reactor(BatchEmailSender, recipients: recipients)
805
+ .map(:send_emails)
806
+ .mock_step(:deliver) do |args, context|
807
+ Success({ sent_to: args[:recipient] })
808
+ end
809
+
810
+ expect(subject).to be_success
811
+
812
+ elements = subject.map_elements(:send_emails)
813
+ expect(elements.length).to eq(3)
814
+ expect(elements).to all(be_success)
815
+ end
816
+
817
+ it "handles partial failures" do
818
+ recipients = ["good@example.com", "bad@example.com"]
819
+
820
+ subject = test_reactor(BatchEmailSender, recipients: recipients)
821
+ .map(:send_emails)
822
+ .mock_step(:deliver) do |args, context|
823
+ if args[:recipient].include?("bad")
824
+ Failure("Invalid email")
825
+ else
826
+ Success({ sent_to: args[:recipient] })
827
+ end
828
+ end
829
+
830
+ # Inspect individual results
831
+ elements = subject.map_elements(:send_emails)
832
+ expect(elements.first).to be_success
833
+ expect(elements.last).to be_failure
834
+ end
835
+ end
836
+ ```
837
+
838
+ ### Testing Input Validation
839
+
840
+ ```ruby
841
+ RSpec.describe UserRegistration do
842
+ context "with valid inputs" do
843
+ it "creates the user" do
844
+ subject = test_reactor(UserRegistration,
845
+ email: "valid@example.com",
846
+ password: "secure123",
847
+ age: 25
848
+ )
849
+
850
+ expect(subject).to be_success
851
+ end
852
+ end
853
+
854
+ context "with invalid inputs" do
855
+ it "fails with validation errors" do
856
+ subject = test_reactor(UserRegistration,
857
+ email: "",
858
+ password: "x",
859
+ age: 10
860
+ )
861
+
862
+ expect(subject).to be_failure
863
+ expect(subject).to have_validation_error(:email)
864
+ expect(subject).to have_validation_error(:password)
865
+ expect(subject).to have_validation_error(:age)
866
+ end
867
+ end
868
+ end
869
+ ```
870
+
871
+ ---
872
+
873
+ ## Best Practices
874
+
875
+ ### 1. Isolate External Dependencies
876
+
877
+ Mock steps that interact with external services:
878
+
879
+ ```ruby
880
+ # Good: Mock external service calls
881
+ subject = test_reactor(PaymentReactor, params)
882
+ .mock_step(:call_stripe_api) { Success(mock_response) }
883
+
884
+ # Avoid: Letting tests hit real APIs
885
+ ```
886
+
887
+ ### 2. Test Compensation Logic
888
+
889
+ Verify that compensation runs correctly on failure:
890
+
891
+ ```ruby
892
+ it "refunds payment when shipping fails" do
893
+ subject = test_reactor(OrderReactor, params)
894
+ .failing_at(:create_shipment)
895
+
896
+ expect(subject).to be_failure
897
+ # Verify compensation occurred through side effects or mocks
898
+ end
899
+ ```
900
+
901
+ ### 3. Use Descriptive Assertions
902
+
903
+ Combine matchers for clear, intention-revealing tests:
904
+
905
+ ```ruby
906
+ # Good: Clear intent
907
+ expect(subject).to have_run_step(:notify_user).after(:create_account)
908
+
909
+ # Less clear: Manual inspection
910
+ trace = subject.reactor_instance.context.execution_trace
911
+ expect(trace.any? { |t| t[:step] == :notify_user }).to be true
912
+ ```
913
+
914
+ ### 4. Force Synchronous Execution for Debugging
915
+
916
+ When debugging async issues, force synchronous execution:
917
+
918
+ ```ruby
919
+ subject = test_reactor(AsyncReactor, params, async: false)
920
+ ```
921
+
922
+ ### 5. Keep Tests Focused
923
+
924
+ Test one aspect per test case:
925
+
926
+ ```ruby
927
+ # Good: Focused tests
928
+ it "validates the order" do
929
+ expect(subject).to have_run_step(:validate_order)
930
+ end
931
+
932
+ it "charges the card after validation" do
933
+ expect(subject).to have_run_step(:charge_card).after(:validate_order)
934
+ end
935
+
936
+ # Avoid: Testing everything in one spec
937
+ it "does the entire workflow correctly" do
938
+ # 50 lines of assertions...
939
+ end
940
+ ```
941
+
942
+ ---
943
+
944
+ ## API Reference
945
+
946
+ ### TestSubject Methods
947
+
948
+ | Method | Description |
949
+ |--------|-------------|
950
+ | `run` | Execute the reactor (auto-called by introspection methods) |
951
+ | `run_async(bool)` | Set async execution mode |
952
+ | `mock_step(name, *nested, &block)` | Mock a step's implementation |
953
+ | `failing_at(name, *nested)` | Simulate failure at a step |
954
+ | `map(step_name)` | Get proxy for mocking map step internals |
955
+ | `composed(step_name)` | Get proxy or traverse composed reactor |
956
+ | `result` | Get the final result (Success/Failure/InterruptResult) |
957
+ | `success?` | Check if reactor succeeded |
958
+ | `failure?` | Check if reactor failed |
959
+ | `paused?` | Check if reactor is paused at an interrupt |
960
+ | `current_step` | Get the current interrupt step name (Symbol or nil) |
961
+ | `ready_interrupt_steps` | Get all ready interrupt step names (Array of Symbols) |
962
+ | `resume(payload:, step:)` | Resume a paused reactor with payload; `step:` required for multiple interrupts |
963
+ | `step_result(name)` | Get a specific step's result |
964
+ | `error` | Get the error message if failed |
965
+ | `map_elements(step_name)` | Get all map element subjects |
966
+ | `map_element(step_name, index:)` | Get specific map element subject |
967
+ | `reactor_instance` | Access the underlying reactor instance |
968
+
969
+ ### RSpec Matchers
970
+
971
+ | Matcher | Description |
972
+ |---------|-------------|
973
+ | `be_success` | Assert reactor completed successfully |
974
+ | `be_failure` | Assert reactor failed |
975
+ | `be_paused` | Assert reactor is paused at an interrupt |
976
+ | `be_paused_at(*steps)` | Assert reactor is paused with specific interrupt(s) ready |
977
+ | `have_ready_interrupts(*steps)` | Assert exact set of ready interrupt steps |
978
+ | `have_run_step(name)` | Assert step was executed |
979
+ | `.returning(value)` | Chain: assert step returned value |
980
+ | `.after(step)` | Chain: assert step ran after another |
981
+ | `have_retried_step(name)` | Assert step was retried |
982
+ | `.times(count)` | Chain: assert retry count |
983
+ | `have_validation_error(field)` | Assert input validation error on field |
984
+ | `be_skipped` | Assert result is a `RubyReactor::Skipped` (period gate or step return) |
985
+ | `.because(reason)` | Chain: assert the skip reason matches |
986
+ | `.at_step(name)` | Chain: assert the halting step (step-returned Skipped only) |
987
+ | `be_locked` | Assert an exclusive lock is currently held for the given key |
988
+ | `.by(owner)` | Chain: assert the lock owner (typically a `context_id`) |
989
+ | `have_available_tokens(n)` | Assert `n` semaphore tokens are still in the pool |
990
+ | `have_held_tokens(n)` | Assert `n` semaphore tokens are currently checked out |
991
+ | `have_rate_limit_count(n)` | Assert the current rate-limit bucket count |
992
+ | `.for(period)` | Chain (required): which window to check (`:second`, `:minute`, …, or integer seconds) |
993
+ | `be_period_marked` | Assert a `with_period` bucket has been marked |
994
+ | `.for(period)` | Chain (required): which bucket granularity to check |