busybee 0.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.
data/docs/testing.md ADDED
@@ -0,0 +1,680 @@
1
+ # Testing BPMN Workflows with Busybee
2
+
3
+ Busybee provides RSpec helpers and matchers for testing BPMN workflows against Zeebe. This testing module makes it easy to write integration tests that deploy processes, create instances, activate jobs, and verify workflow behavior.
4
+
5
+ ## Installation
6
+
7
+ Add busybee to your `Gemfile` test group:
8
+
9
+ ```ruby
10
+ group :test do
11
+ gem "busybee"
12
+ end
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ In your `spec/spec_helper.rb` or `rails_helper.rb`, require the testing module after RSpec:
24
+
25
+ ```ruby
26
+ require "rspec"
27
+ require "busybee/testing"
28
+ ```
29
+
30
+ The testing module will automatically include helper methods in all RSpec examples.
31
+
32
+ ## Configuration
33
+
34
+ Configure the Zeebe connection. Busybee reads from environment variables by default, or you can configure explicitly:
35
+
36
+ ```ruby
37
+ # Use environment variables (recommended)
38
+ # ZEEBE_ADDRESS=localhost:26500
39
+ # ZEEBE_USERNAME=demo
40
+ # ZEEBE_PASSWORD=demo
41
+
42
+ # Or configure explicitly
43
+ Busybee::Testing.configure do |config|
44
+ config.address = "localhost:26500"
45
+ config.username = "demo"
46
+ config.password = "demo"
47
+ config.activate_request_timeout = 2000 # milliseconds, default: 1000
48
+ end
49
+ ```
50
+
51
+ ### Configuration Options
52
+
53
+ | Option | Environment Variable | Default | Description |
54
+ |--------|---------------------|---------|-------------|
55
+ | `address` | `ZEEBE_ADDRESS` | `"localhost:26500"` | Zeebe gateway gRPC address |
56
+ | `username` | `ZEEBE_USERNAME` | `"demo"` | Zeebe authentication username |
57
+ | `password` | `ZEEBE_PASSWORD` | `"demo"` | Zeebe authentication password |
58
+ | `activate_request_timeout` | - | `1000` | Timeout in milliseconds for job activation requests |
59
+
60
+ ## Helper Methods
61
+
62
+ ### Process Deployment
63
+
64
+ #### `deploy_process(path, uniquify: nil)`
65
+
66
+ Deploys a BPMN file to Zeebe.
67
+
68
+ **Parameters:**
69
+ - `path` (String) - Path to BPMN file
70
+ - `uniquify` (nil, true, String) - Uniquification strategy:
71
+ - `nil` (default) - Deploy with original process ID from BPMN
72
+ - `true` - Auto-generate unique process ID like `test-process-abc123`
73
+ - String - Use custom process ID
74
+
75
+ **Returns:** Hash with `:process` (GRPC metadata) and `:process_id` (String)
76
+
77
+ **Examples:**
78
+
79
+ ```ruby
80
+ # Deploy with original process ID (most common)
81
+ result = deploy_process("spec/fixtures/order_process.bpmn")
82
+ result[:process_id] #=> "order-fulfillment" (from BPMN file)
83
+
84
+ # Deploy with auto-generated unique ID (for test isolation)
85
+ result = deploy_process("spec/fixtures/order_process.bpmn", uniquify: true)
86
+ result[:process_id] #=> "test-process-a1b2c3d4"
87
+
88
+ # Deploy with custom ID
89
+ result = deploy_process("spec/fixtures/order_process.bpmn", uniquify: "my-test-order-process")
90
+ result[:process_id] #=> "my-test-order-process"
91
+ ```
92
+
93
+ ### Process Instance Management
94
+
95
+ #### `with_process_instance(process_id, variables = {})`
96
+
97
+ Creates a process instance, yields its key, and automatically cancels it when the block exits. This ensures cleanup even if tests fail.
98
+
99
+ **Parameters:**
100
+ - `process_id` (String) - BPMN process ID
101
+ - `variables` (Hash) - Initial process variables (optional)
102
+
103
+ **Yields:** Integer process instance key
104
+
105
+ **Examples:**
106
+
107
+ ```ruby
108
+ with_process_instance("order-fulfillment") do |key|
109
+ # Test process behavior
110
+ # Instance is automatically cancelled after block
111
+ end
112
+
113
+ # With initial variables
114
+ with_process_instance("order-fulfillment", order_id: "12345", items: 3) do |key|
115
+ job = activate_job("prepare-shipment")
116
+ expect(job.variables["order_id"]).to eq("12345")
117
+ end
118
+ ```
119
+
120
+ #### `process_instance_key`
121
+
122
+ Returns the current process instance key within a `with_process_instance` block.
123
+
124
+ **Returns:** Integer or nil
125
+
126
+ ```ruby
127
+ with_process_instance("my-process") do
128
+ puts process_instance_key #=> 2251799813685255
129
+ end
130
+ ```
131
+
132
+ #### `last_process_instance_key`
133
+
134
+ Returns the process instance key from the most recently completed `with_process_instance` call. Useful for debugging test failures by correlating with ElasticSearch/Operate data.
135
+
136
+ **Returns:** Integer or nil
137
+
138
+ ```ruby
139
+ it "processes order" do
140
+ with_process_instance("order-fulfillment") { ... }
141
+ end
142
+
143
+ after do
144
+ puts "Failed process instance: #{last_process_instance_key}" if last_process_instance_key
145
+ end
146
+ ```
147
+
148
+ ### Job Activation
149
+
150
+ #### `activate_job(type)`
151
+
152
+ Activates a single job of the specified type. Raises `Busybee::Testing::NoJobAvailable` if no job is available.
153
+
154
+ **Parameters:**
155
+ - `type` (String) - Job type to activate
156
+
157
+ **Returns:** `ActivatedJob` instance
158
+
159
+ **Raises:** `NoJobAvailable` if no matching job found
160
+
161
+ **Example:**
162
+
163
+ ```ruby
164
+ job = activate_job("process-payment")
165
+ expect(job.variables["amount"]).to eq(99.99)
166
+ job.mark_completed(payment_status: "success")
167
+ ```
168
+
169
+ #### `activate_jobs(type, max_jobs:)`
170
+
171
+ Activates multiple jobs of the specified type.
172
+
173
+ **Parameters:**
174
+ - `type` (String) - Job type to activate
175
+ - `max_jobs` (Integer) - Maximum number of jobs to activate
176
+
177
+ **Returns:** Enumerator of `ActivatedJob` instances
178
+
179
+ **Example:**
180
+
181
+ ```ruby
182
+ jobs = activate_jobs("send-notification", max_jobs: 5)
183
+ jobs.each do |job|
184
+ recipient = job.variables["email"]
185
+ job.mark_completed(sent_at: Time.now.iso8601)
186
+ end
187
+ ```
188
+
189
+ ### Message Publishing
190
+
191
+ #### `publish_message(name, correlation_key:, variables: {}, ttl_ms: 5000)`
192
+
193
+ Publishes a message to Zeebe to trigger message intermediate catch events or message start events.
194
+
195
+ **Parameters:**
196
+ - `name` (String) - Message name matching BPMN definition
197
+ - `correlation_key` (String) - Key to correlate message with process instance
198
+ - `variables` (Hash) - Message payload variables (optional, default: `{}`)
199
+ - `ttl_ms` (Integer) - Message time-to-live in milliseconds (optional, default: `5000`)
200
+
201
+ **Example:**
202
+
203
+ ```ruby
204
+ # Process waiting for message with correlation
205
+ with_process_instance("approval-workflow", request_id: "req-123") do
206
+ publish_message(
207
+ "approval-granted",
208
+ correlation_key: "req-123",
209
+ variables: { approved_by: "manager", approved_at: Time.now.iso8601 }
210
+ )
211
+
212
+ assert_process_completed!
213
+ end
214
+ ```
215
+
216
+ ### Variable Management
217
+
218
+ #### `set_variables(scope_key, variables, local: true)`
219
+
220
+ Sets variables on a process scope (process instance or element instance).
221
+
222
+ **Parameters:**
223
+ - `scope_key` (Integer) - Element instance key
224
+ - `variables` (Hash) - Variables to set
225
+ - `local` (Boolean) - Whether variables are local to scope (default: `true`)
226
+
227
+ **Example:**
228
+
229
+ ```ruby
230
+ with_process_instance("data-processing") do |key|
231
+ set_variables(key, { processed_count: 100, status: "in_progress" })
232
+
233
+ job = activate_job("validate-data")
234
+ expect(job.variables["processed_count"]).to eq(100)
235
+ end
236
+ ```
237
+
238
+ ### Process Completion
239
+
240
+ #### `assert_process_completed!(wait: 0.25)`
241
+
242
+ Asserts that the current process instance has completed. Useful for verifying end-to-end workflow execution.
243
+
244
+ **Parameters:**
245
+ - `wait` (Float) - Seconds to wait before checking (default: `0.25`)
246
+
247
+ **Raises:** RuntimeError if process is still running
248
+
249
+ **Example:**
250
+
251
+ ```ruby
252
+ with_process_instance("simple-workflow") do
253
+ job = activate_job("single-task")
254
+ job.mark_completed
255
+
256
+ assert_process_completed! # Verifies workflow reached end event
257
+ end
258
+ ```
259
+
260
+ ### Zeebe Availability
261
+
262
+ #### `zeebe_available?(timeout: 5)`
263
+
264
+ Checks if Zeebe is available and responsive.
265
+
266
+ **Parameters:**
267
+ - `timeout` (Integer) - Timeout in seconds (default: `5`)
268
+
269
+ **Returns:** Boolean
270
+
271
+ **Example:**
272
+
273
+ ```ruby
274
+ before(:all) do
275
+ skip "Zeebe not running" unless zeebe_available?
276
+ end
277
+ ```
278
+
279
+ ## ActivatedJob API
280
+
281
+ The `ActivatedJob` class wraps Zeebe's GRPC job response with a fluent API for testing.
282
+
283
+ ### Accessors
284
+
285
+ ```ruby
286
+ job = activate_job("my-task")
287
+
288
+ job.key #=> 2251799813685263 (job key)
289
+ job.process_instance_key #=> 2251799813685255 (process instance key)
290
+ job.variables #=> {"order_id" => "123", "total" => 99.99}
291
+ job.headers #=> {"priority" => "high"}
292
+ job.retries #=> 3
293
+ ```
294
+
295
+ ### Expectation Methods
296
+
297
+ These methods verify job state and return `self` for chaining:
298
+
299
+ #### `expect_variables(expected)`
300
+
301
+ Asserts that job variables include the expected key-value pairs.
302
+
303
+ **Parameters:**
304
+ - `expected` (Hash) - Expected variable subset (symbol or string keys)
305
+
306
+ **Returns:** self
307
+
308
+ **Raises:** `RSpec::Expectations::ExpectationNotMetError` if not matched
309
+
310
+ ```ruby
311
+ job.expect_variables(order_id: "123", total: 99.99)
312
+ .and_complete
313
+ ```
314
+
315
+ #### `expect_headers(expected)`
316
+
317
+ Asserts that job headers include the expected key-value pairs.
318
+
319
+ **Parameters:**
320
+ - `expected` (Hash) - Expected header subset (symbol or string keys)
321
+
322
+ **Returns:** self
323
+
324
+ **Raises:** `RSpec::Expectations::ExpectationNotMetError` if not matched
325
+
326
+ ```ruby
327
+ job.expect_headers(priority: "high", batch_id: "batch-42")
328
+ .and_complete
329
+ ```
330
+
331
+ ### Terminal Methods
332
+
333
+ These methods complete the job lifecycle. All return `self` for chaining.
334
+
335
+ #### `mark_completed(variables = {})`
336
+
337
+ Completes the job successfully with optional output variables.
338
+
339
+ **Alias:** `and_complete`
340
+
341
+ **Parameters:**
342
+ - `variables` (Hash) - Output variables to merge into process state
343
+
344
+ **Example:**
345
+
346
+ ```ruby
347
+ job.mark_completed(payment_id: "pay-789", charged_amount: 99.99)
348
+
349
+ # Fluent chaining style
350
+ activate_job("process-payment")
351
+ .expect_variables(amount: 99.99)
352
+ .and_complete(payment_id: "pay-789")
353
+ ```
354
+
355
+ #### `mark_failed(message = nil, retries: 0)`
356
+
357
+ Fails the job with an error message and retry count.
358
+
359
+ **Alias:** `and_fail`
360
+
361
+ **Parameters:**
362
+ - `message` (String, nil) - Error message
363
+ - `retries` (Integer) - Number of retries remaining (default: `0`)
364
+
365
+ **Example:**
366
+
367
+ ```ruby
368
+ job.mark_failed("Payment gateway timeout", retries: 2)
369
+
370
+ # Fluent style
371
+ activate_job("external-api-call")
372
+ .and_fail("Service unavailable", retries: 3)
373
+ ```
374
+
375
+ #### `throw_error_event(code, message = nil)`
376
+
377
+ Throws a BPMN error event that can be caught by error boundary events.
378
+
379
+ **Alias:** `and_throw_error_event`
380
+
381
+ **Parameters:**
382
+ - `code` (String) - BPMN error code
383
+ - `message` (String, nil) - Error message
384
+
385
+ **Example:**
386
+
387
+ ```ruby
388
+ job.throw_error_event("VALIDATION_FAILED", "Invalid order data")
389
+
390
+ # Fluent style
391
+ activate_job("validate-order")
392
+ .and_throw_error_event("INVALID_ITEMS", "Item count mismatch")
393
+ ```
394
+
395
+ #### `update_retries(count)`
396
+
397
+ Updates the job's retry count without completing or failing it.
398
+
399
+ **Parameters:**
400
+ - `count` (Integer) - New retry count
401
+
402
+ **Example:**
403
+
404
+ ```ruby
405
+ job.update_retries(5)
406
+ ```
407
+
408
+ ## RSpec Matchers
409
+
410
+ ### `have_received_variables`
411
+
412
+ Matches activated jobs with expected variable values.
413
+
414
+ **Example:**
415
+
416
+ ```ruby
417
+ job = activate_job("my-task")
418
+ expect(job).to have_received_variables(order_id: "123")
419
+ expect(job).to have_received_variables("order_id" => "123", "total" => 99.99)
420
+ ```
421
+
422
+ ### `have_received_headers`
423
+
424
+ Matches activated jobs with expected header values.
425
+
426
+ **Example:**
427
+
428
+ ```ruby
429
+ job = activate_job("my-task")
430
+ expect(job).to have_received_headers(priority: "high")
431
+ expect(job).to have_received_headers("workflow_version" => "2")
432
+ ```
433
+
434
+ ### `have_activated`
435
+
436
+ Flexible matcher supporting chained expectations and terminal actions. Can be used standalone or with chains.
437
+
438
+ **Chains:**
439
+ - `.with_variables(hash)` - Assert expected variables
440
+ - `.with_headers(hash)` - Assert expected headers
441
+ - `.and_complete(vars)` - Complete job with output
442
+ - `.and_fail(message, retries:)` - Fail job
443
+ - `.and_throw_error_event(code, message)` - Throw error
444
+
445
+ **Examples:**
446
+
447
+ ```ruby
448
+ # Basic activation check
449
+ job = activate_job("my-task")
450
+ expect(job).to have_activated
451
+
452
+ # With variable assertions
453
+ expect(job).to have_activated.with_variables(order_id: "123")
454
+
455
+ # Complete workflow with chaining
456
+ expect(activate_job("process-order"))
457
+ .to have_activated
458
+ .with_variables(order_id: "123", total: 99.99)
459
+ .with_headers(priority: "high")
460
+ .and_complete(processed: true, processed_at: Time.now.iso8601)
461
+ ```
462
+
463
+ ## Complete Workflow Example
464
+
465
+ Here's a complete example testing an order fulfillment workflow:
466
+
467
+ ```ruby
468
+ # spec/workflows/order_fulfillment_spec.rb
469
+ require "spec_helper"
470
+
471
+ RSpec.describe "Order Fulfillment Workflow" do
472
+ let(:bpmn_path) { File.expand_path("../fixtures/order_fulfillment.bpmn", __dir__) }
473
+ let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
474
+ let(:order_id) { SecureRandom.uuid }
475
+
476
+ context "when order is approved" do
477
+ it "processes payment and ships order" do
478
+ with_process_instance(process_id, order_id: order_id, items_count: 3) do
479
+ # Verify payment processing job
480
+ expect(activate_job("process-payment"))
481
+ .to have_activated
482
+ .with_variables(order_id: order_id, items_count: 3)
483
+ .and_complete(payment_id: "pay-#{SecureRandom.hex(4)}", amount_charged: 149.99)
484
+
485
+ # Verify shipment preparation
486
+ expect(activate_job("prepare-shipment"))
487
+ .to have_activated
488
+ .with_variables(order_id: order_id, payment_id: /^pay-/)
489
+ .and_complete(tracking_number: "TRACK123", carrier: "FedEx")
490
+
491
+ # Verify notification sent
492
+ notification_job = activate_job("send-confirmation-email")
493
+ expect(notification_job).to have_received_variables(
494
+ order_id: order_id,
495
+ tracking_number: "TRACK123"
496
+ )
497
+ notification_job.mark_completed(email_sent: true)
498
+
499
+ # Assert workflow completed
500
+ assert_process_completed!
501
+ end
502
+ end
503
+ end
504
+
505
+ context "when payment fails" do
506
+ it "handles payment error and notifies customer" do
507
+ with_process_instance(process_id, order_id: order_id, items_count: 2) do
508
+ # Payment fails with error event
509
+ activate_job("process-payment")
510
+ .expect_variables(order_id: order_id)
511
+ .and_throw_error_event("PAYMENT_DECLINED", "Insufficient funds")
512
+
513
+ # Error boundary catches and triggers notification
514
+ expect(activate_job("send-payment-failed-email"))
515
+ .to have_activated
516
+ .with_variables(order_id: order_id)
517
+ .and_complete
518
+
519
+ assert_process_completed!
520
+ end
521
+ end
522
+ end
523
+
524
+ context "when shipment needs approval" do
525
+ it "waits for approval message" do
526
+ correlation_key = "approval-#{order_id}"
527
+
528
+ with_process_instance(process_id, order_id: order_id, correlation_id: correlation_key) do
529
+ # Complete initial jobs
530
+ activate_job("process-payment").and_complete(payment_id: "pay-123")
531
+ activate_job("check-shipment-requirements")
532
+ .and_complete(requires_approval: true)
533
+
534
+ # Process waits at message intermediate catch event
535
+ # Publish approval message
536
+ publish_message(
537
+ "shipment-approved",
538
+ correlation_key: correlation_key,
539
+ variables: { approved_by: "manager@example.com", approved_at: Time.now.iso8601 }
540
+ )
541
+
542
+ # Verify shipment proceeds
543
+ expect(activate_job("prepare-shipment"))
544
+ .to have_activated
545
+ .with_variables(
546
+ approved_by: "manager@example.com",
547
+ requires_approval: true
548
+ )
549
+ .and_complete(tracking_number: "TRACK456")
550
+
551
+ activate_job("send-confirmation-email").and_complete
552
+
553
+ assert_process_completed!
554
+ end
555
+ end
556
+ end
557
+ end
558
+ ```
559
+
560
+ ## Composing Shared Workflow Contexts
561
+
562
+ For complex workflows with many test scenarios, extract common setup into shared contexts:
563
+
564
+ ```ruby
565
+ # spec/support/workflow_contexts.rb
566
+ RSpec.shared_context "deployed order workflow" do
567
+ let(:bpmn_path) { File.expand_path("../../integration/fixtures/order_fulfillment.bpmn", __dir__) }
568
+ let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
569
+ let(:order_id) { SecureRandom.uuid }
570
+ let(:base_variables) { { order_id: order_id, customer_id: "cust-123" } }
571
+
572
+ def complete_payment_step(payment_status: "success")
573
+ activate_job("process-payment")
574
+ .expect_variables(order_id: order_id)
575
+ .and_complete(
576
+ payment_id: "pay-#{SecureRandom.hex(4)}",
577
+ payment_status: payment_status
578
+ )
579
+ end
580
+
581
+ def complete_shipment_step
582
+ activate_job("prepare-shipment")
583
+ .and_complete(tracking_number: "TRACK#{rand(1000..9999)}")
584
+ end
585
+ end
586
+
587
+ # Use in specs
588
+ RSpec.describe "Order edge cases" do
589
+ include_context "deployed order workflow"
590
+
591
+ it "handles partial shipments" do
592
+ with_process_instance(process_id, base_variables.merge(partial_shipment: true)) do
593
+ complete_payment_step
594
+ complete_shipment_step
595
+ # Additional steps...
596
+ assert_process_completed!
597
+ end
598
+ end
599
+ end
600
+ ```
601
+
602
+ ## Testing Best Practices
603
+
604
+ ### 1. Use Unique Process IDs for Isolation
605
+
606
+ Deploy processes with unique IDs when tests might interfere:
607
+
608
+ ```ruby
609
+ # Good: Each test gets isolated process
610
+ let(:process_id) { deploy_process(bpmn_path, uniquify: true)[:process_id] }
611
+
612
+ # Avoid: Shared process might cause cross-test pollution
613
+ before(:all) { @process_id = deploy_process(bpmn_path)[:process_id] }
614
+ ```
615
+
616
+ ### 2. Always Clean Up Process Instances
617
+
618
+ Use `with_process_instance` which automatically cancels instances:
619
+
620
+ ```ruby
621
+ # Good: Automatic cleanup
622
+ with_process_instance(process_id) do |key|
623
+ # test code
624
+ end
625
+
626
+ # Avoid: Manual instance management
627
+ key = create_instance(process_id)
628
+ # ... test code ...
629
+ cancel_instance(key) # Easy to forget in error paths
630
+ ```
631
+
632
+ ### 3. Verify Job Variables Before Completing
633
+
634
+ Assert expected inputs before completing jobs:
635
+
636
+ ```ruby
637
+ # Good: Verify then complete
638
+ job = activate_job("send-email")
639
+ expect(job).to have_received_variables(
640
+ recipient: "user@example.com",
641
+ template: "order_confirmation"
642
+ )
643
+ job.mark_completed(sent_at: Time.now.iso8601)
644
+
645
+ # Also Good: fluent style
646
+ activate_job("send-email")
647
+ .expect_variables(recipient: "user@example.com")
648
+ .and_complete(sent_at: Time.now.iso8601)
649
+ ```
650
+
651
+ ## Troubleshooting
652
+
653
+ If you are running the entire Camunda Platform, you can debug your workflow by checking the Operate UI to inspect process state. The `last_process_instance_key` helper can be used to help you find the process instance in question.
654
+
655
+ ### "Zeebe is not running"
656
+
657
+ Ensure Zeebe is started **and healthy** before running your tests.
658
+
659
+ ### "No job of type 'my-task' available"
660
+
661
+ Common causes:
662
+ - Process instance hasn't reached that task yet
663
+ - Job type name doesn't match BPMN definition
664
+ - Job already activated by another worker
665
+ - Process completed or failed before reaching task
666
+
667
+ ### "Process instance still running"
668
+
669
+ When `assert_process_completed!` fails:
670
+ - Verify all jobs were activated and completed
671
+ - Check for message intermediate catch events waiting for messages
672
+ - Look for timer events that haven't fired
673
+ - Use `last_process_instance_key` to find instance in Operate
674
+
675
+ ### Variables Not Available in Job
676
+
677
+ Ensure variables are:
678
+ - Set in start variables: `with_process_instance(id, my_var: "value")`
679
+ - Returned from previous job: `job.mark_completed(output_var: "value")`
680
+ - Correctly I/O-mapped on each service task