geneva_drive 0.6.0 → 0.7.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.
@@ -0,0 +1,581 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The receiver side of the rendezvous, in both arrival orders: the gate that
6
+ # parks or attaches, and the dispatch that wakes a parked execution.
7
+ class SignalRendezvousTest < ActiveSupport::TestCase
8
+ include GenevaDrive::TestHelpers
9
+ include ActiveJob::TestHelper
10
+
11
+ class MinimumAmountMatcher
12
+ def initialize(min_cents:)
13
+ @min_cents = min_cents
14
+ end
15
+
16
+ def matches?(signal)
17
+ signal.name == "payment_confirmed" && signal.payload[:amount_cents].to_i >= @min_cents
18
+ end
19
+ end
20
+
21
+ class InvoiceWorkflow < GenevaDrive::Workflow
22
+ step :prepare do
23
+ Thread.current[:rendezvous_log] << :prepared
24
+ end
25
+
26
+ step :await_payment, wait_for: :payment_confirmed do
27
+ Thread.current[:rendezvous_log] << [:captured, received_signal.payload[:amount_cents]]
28
+ end
29
+
30
+ step :issue_receipt do
31
+ Thread.current[:rendezvous_log] << :receipt
32
+ end
33
+ end
34
+
35
+ class FirstStepWaitsWorkflow < GenevaDrive::Workflow
36
+ step :await_approval, wait_for: :approved do
37
+ Thread.current[:rendezvous_log] << :approved
38
+ end
39
+ end
40
+
41
+ class NarrowedWorkflow < GenevaDrive::Workflow
42
+ # A matcher is a plain object, so it can live in a constant and be shared
43
+ FOR_THIS_HERO = GenevaDrive::SignalMatcher.new(:payment_confirmed) do |payload|
44
+ payload[:email] == hero.email
45
+ end
46
+
47
+ step :await_payment, wait_for: FOR_THIS_HERO do
48
+ Thread.current[:rendezvous_log] << :matched
49
+ end
50
+ end
51
+
52
+ class MatcherObjectWorkflow < GenevaDrive::Workflow
53
+ step :await_payment, wait_for: MinimumAmountMatcher.new(min_cents: 1000) do
54
+ Thread.current[:rendezvous_log] << :big_enough
55
+ end
56
+ end
57
+
58
+ class IdentityWorkflow < GenevaDrive::Workflow
59
+ # The block runs on signal.workflow - record which object that was
60
+ RECORDS_ITS_CONTEXT = GenevaDrive::SignalMatcher.new(:ping) do |_payload|
61
+ (Thread.current[:rendezvous_workflows] ||= []) << self
62
+ true
63
+ end
64
+
65
+ step :await_ping, wait_for: RECORDS_ITS_CONTEXT do
66
+ end
67
+
68
+ step :second_await_ping, wait_for: RECORDS_ITS_CONTEXT do
69
+ end
70
+ end
71
+
72
+ class DelayedWaitWorkflow < GenevaDrive::Workflow
73
+ step :await_payment, wait: 2.days, wait_for: :payment_confirmed do
74
+ Thread.current[:rendezvous_log] << :captured
75
+ end
76
+ end
77
+
78
+ class TwoWaitersWorkflow < GenevaDrive::Workflow
79
+ step :first_wait, wait_for: :ping do
80
+ Thread.current[:rendezvous_log] << [:first, received_signal.id]
81
+ end
82
+
83
+ step :second_wait, wait_for: :ping do
84
+ Thread.current[:rendezvous_log] << [:second, received_signal.id]
85
+ end
86
+ end
87
+
88
+ class SkippableWaitWorkflow < GenevaDrive::Workflow
89
+ step :await_signature, wait_for: :signed, skip_if: -> { Thread.current[:rendezvous_presigned] } do
90
+ Thread.current[:rendezvous_log] << :signed
91
+ end
92
+
93
+ step :archive do
94
+ Thread.current[:rendezvous_log] << :archived
95
+ end
96
+ end
97
+
98
+ class FinishingWaitWorkflow < GenevaDrive::Workflow
99
+ step :await_decision, wait_for: :decided do
100
+ finished!
101
+ end
102
+
103
+ step :never_reached do
104
+ Thread.current[:rendezvous_log] << :never
105
+ end
106
+ end
107
+
108
+ class FlakyWaitWorkflow < GenevaDrive::Workflow
109
+ step :await_payment, wait_for: :payment_confirmed do
110
+ Thread.current[:rendezvous_log] << [:attempt, received_signal.payload[:amount_cents]]
111
+ raise "gateway unavailable" unless Thread.current[:rendezvous_healed]
112
+ end
113
+ end
114
+
115
+ class ResumableWaitWorkflow < GenevaDrive::Workflow
116
+ resumable_step :process_items, wait_for: :batch_ready, max_iterations: 2 do |iter|
117
+ iter.iterate_over([1, 2, 3, 4]) do |item|
118
+ Thread.current[:rendezvous_log] << [:item, item, received_signal.payload[:batch_id]]
119
+ end
120
+ end
121
+ end
122
+
123
+ class MatcherRaisingWorkflow < GenevaDrive::Workflow
124
+ step :await_payment, wait_for: GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| raise "matcher blew up" } do
125
+ Thread.current[:rendezvous_log] << :never
126
+ end
127
+ end
128
+
129
+ setup do
130
+ @user = create_user
131
+ Thread.current[:rendezvous_log] = []
132
+ Thread.current[:rendezvous_presigned] = nil
133
+ Thread.current[:rendezvous_healed] = nil
134
+ Thread.current[:rendezvous_workflows] = nil
135
+ end
136
+
137
+ teardown do
138
+ Thread.current[:rendezvous_log] = nil
139
+ Thread.current[:rendezvous_presigned] = nil
140
+ Thread.current[:rendezvous_healed] = nil
141
+ Thread.current[:rendezvous_workflows] = nil
142
+ end
143
+
144
+ # --- Receiver arrives first (park, then wake) ---
145
+
146
+ test "a waiting step parks instead of running, and holds no queue slot" do
147
+ workflow = InvoiceWorkflow.create!(hero: @user)
148
+ perform_next_step(workflow)
149
+
150
+ execution = workflow.current_execution
151
+ assert_equal "await_payment", execution.step_name
152
+ assert_equal "scheduled", execution.state
153
+
154
+ execution.execute!
155
+ execution.reload
156
+
157
+ assert_equal "waiting", execution.state
158
+ assert_not_nil execution.waiting_since
159
+ assert_nil execution.signal_id
160
+ assert_waiting_for_signal(workflow, :payment_confirmed)
161
+ assert_equal [:prepared], Thread.current[:rendezvous_log]
162
+ assert_equal "ready", workflow.reload.state
163
+ end
164
+
165
+ test "dispatch wakes the parked execution and the step sees the payload" do
166
+ workflow = InvoiceWorkflow.create!(hero: @user)
167
+ perform_next_step(workflow)
168
+ workflow.current_execution.execute!
169
+
170
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
171
+
172
+ execution = workflow.reload.current_execution
173
+ assert_equal "scheduled", execution.state
174
+ assert_equal signal.id, execution.signal_id
175
+ assert_nil execution.waiting_since
176
+ assert_equal "claimed", signal.reload.state
177
+ assert_not_nil signal.claimed_at
178
+
179
+ perform_next_step(workflow)
180
+
181
+ assert_equal [:prepared, [:captured, 12_500]], Thread.current[:rendezvous_log]
182
+ assert_equal "consumed", signal.reload.state
183
+ assert_not_nil signal.consumed_at
184
+ end
185
+
186
+ test "dispatch enqueues a job for the woken execution" do
187
+ workflow = InvoiceWorkflow.create!(hero: @user)
188
+ perform_next_step(workflow)
189
+ workflow.current_execution.execute!
190
+
191
+ assert_enqueued_with(job: GenevaDrive::PerformStepJob) do
192
+ workflow.signal!(:payment_confirmed)
193
+ end
194
+ end
195
+
196
+ # --- Sender arrives first (buffer, then attach at the gate) ---
197
+
198
+ test "a signal arriving before the waiting step is buffered and claimed at the gate" do
199
+ workflow = InvoiceWorkflow.create!(hero: @user)
200
+
201
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 700})
202
+ assert_equal "pending", signal.reload.state
203
+
204
+ speedrun_workflow(workflow)
205
+
206
+ assert_equal [:prepared, [:captured, 700], :receipt], Thread.current[:rendezvous_log]
207
+ assert_equal "consumed", signal.reload.state
208
+ assert_equal "finished", workflow.state
209
+ end
210
+
211
+ test "the first step of a workflow can wait for a signal" do
212
+ workflow = FirstStepWaitsWorkflow.create!(hero: @user)
213
+ workflow.current_execution.execute!
214
+
215
+ assert_waiting_for_signal(workflow, :approved)
216
+
217
+ workflow.signal!(:approved)
218
+ speedrun_workflow(workflow)
219
+
220
+ assert_equal [:approved], Thread.current[:rendezvous_log]
221
+ assert_equal "finished", workflow.state
222
+ end
223
+
224
+ # --- Matching ---
225
+
226
+ test "a non-matching name leaves the execution parked" do
227
+ workflow = InvoiceWorkflow.create!(hero: @user)
228
+ perform_next_step(workflow)
229
+ workflow.current_execution.execute!
230
+
231
+ workflow.signal!(:something_else)
232
+
233
+ assert_equal "waiting", workflow.reload.current_execution.state
234
+ assert_equal "pending", workflow.signals.last.reload.state
235
+ end
236
+
237
+ test "a matcher block narrows by payload and is evaluated on the workflow" do
238
+ workflow = NarrowedWorkflow.create!(hero: @user)
239
+ workflow.current_execution.execute!
240
+
241
+ workflow.signal!(:payment_confirmed, payload: {email: "nobody@example.com"})
242
+ assert_equal "waiting", workflow.reload.current_execution.state
243
+
244
+ workflow.signal!(:payment_confirmed, payload: {email: @user.email})
245
+ assert_equal "scheduled", workflow.reload.current_execution.state
246
+
247
+ speedrun_workflow(workflow)
248
+ assert_equal [:matched], Thread.current[:rendezvous_log]
249
+ end
250
+
251
+ test "a matcher block runs against the live workflow instance, not a reloaded copy" do
252
+ workflow = IdentityWorkflow.create!(hero: @user)
253
+
254
+ # Gate side: the executor's own workflow object
255
+ execution = workflow.current_execution
256
+ executor_workflow = execution.workflow
257
+ workflow.signal!(:ping)
258
+ Thread.current[:rendezvous_workflows] = []
259
+ execution.execute!
260
+
261
+ assert_equal 1, Thread.current[:rendezvous_workflows].size
262
+ assert_same executor_workflow, Thread.current[:rendezvous_workflows].first
263
+
264
+ # Dispatch side: the object signal! was called on
265
+ workflow.reload.current_execution.execute!
266
+ assert_waiting_for_signal(workflow, :ping)
267
+ Thread.current[:rendezvous_workflows] = []
268
+ workflow.signal!(:ping)
269
+
270
+ assert_equal 1, Thread.current[:rendezvous_workflows].size
271
+ assert_same workflow, Thread.current[:rendezvous_workflows].first
272
+ end
273
+
274
+ test "a matcher object owns the whole predicate" do
275
+ workflow = MatcherObjectWorkflow.create!(hero: @user)
276
+ workflow.current_execution.execute!
277
+
278
+ workflow.signal!(:payment_confirmed, payload: {amount_cents: 100})
279
+ assert_equal "waiting", workflow.reload.current_execution.state
280
+
281
+ workflow.signal!(:payment_confirmed, payload: {amount_cents: 5000})
282
+ speedrun_workflow(workflow)
283
+
284
+ assert_equal [:big_enough], Thread.current[:rendezvous_log]
285
+ end
286
+
287
+ test "the oldest matching signal wins at the gate" do
288
+ workflow = InvoiceWorkflow.create!(hero: @user)
289
+ perform_next_step(workflow)
290
+
291
+ first = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
292
+ workflow.signal!(:payment_confirmed, payload: {amount_cents: 2})
293
+
294
+ perform_next_step(workflow)
295
+
296
+ assert_equal [:prepared, [:captured, 1]], Thread.current[:rendezvous_log]
297
+ assert_equal first.id, workflow.step_executions.find_by(step_name: "await_payment").signal_id
298
+ end
299
+
300
+ test "a matcher raising at the gate goes through the exception machinery" do
301
+ workflow = MatcherRaisingWorkflow.create!(hero: @user)
302
+ workflow.signal!(:payment_confirmed)
303
+
304
+ assert_raises(RuntimeError) { perform_next_step(workflow) }
305
+
306
+ assert_equal "paused", workflow.reload.state
307
+ assert_equal [], Thread.current[:rendezvous_log]
308
+ end
309
+
310
+ test "a matcher raising at dispatch raises to the sender and rolls the delivery back" do
311
+ workflow = MatcherRaisingWorkflow.create!(hero: @user)
312
+ workflow.current_execution.execute!
313
+ assert_waiting_for_signal(workflow)
314
+
315
+ assert_raises(RuntimeError) { workflow.signal!(:payment_confirmed) }
316
+
317
+ # Delivery is all-or-nothing: nothing was written, so the sender's retry
318
+ # re-delivers rather than finding a half-delivered event
319
+ assert_equal 0, workflow.signals.count
320
+ assert_equal "waiting", workflow.reload.current_execution.state
321
+ end
322
+
323
+ # --- Composition with other step options ---
324
+
325
+ test "wait: and wait_for: compose" do
326
+ workflow = DelayedWaitWorkflow.create!(hero: @user)
327
+ execution = workflow.current_execution
328
+
329
+ assert execution.scheduled_for > 1.day.from_now
330
+
331
+ # A signal arriving during the delay is simply buffered
332
+ workflow.signal!(:payment_confirmed)
333
+ assert_equal "pending", workflow.signals.last.reload.state
334
+
335
+ execution.execute!
336
+
337
+ assert_equal [:captured], Thread.current[:rendezvous_log]
338
+ assert_equal "consumed", workflow.signals.last.reload.state
339
+ end
340
+
341
+ test "skip_if beats waiting - a skippable step never parks" do
342
+ Thread.current[:rendezvous_presigned] = true
343
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
344
+
345
+ speedrun_workflow(workflow)
346
+
347
+ assert_equal [:archived], Thread.current[:rendezvous_log]
348
+ assert_step_executed(workflow, :await_signature, state: "skipped")
349
+ assert_equal "finished", workflow.state
350
+ end
351
+
352
+ test "skip_if is re-evaluated when the signal wakes the step" do
353
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
354
+ workflow.current_execution.execute!
355
+ assert_waiting_for_signal(workflow, :signed)
356
+
357
+ Thread.current[:rendezvous_presigned] = true
358
+ workflow.signal!(:signed)
359
+ speedrun_workflow(workflow)
360
+
361
+ assert_equal [:archived], Thread.current[:rendezvous_log]
362
+ assert_step_executed(workflow, :await_signature, state: "skipped")
363
+ end
364
+
365
+ test "two sequential steps waiting on the same name consume one signal each" do
366
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
367
+ first_signal = workflow.signal!(:ping)
368
+
369
+ perform_next_step(workflow)
370
+ assert_equal [[:first, first_signal.id]], Thread.current[:rendezvous_log]
371
+ assert_equal "consumed", first_signal.reload.state
372
+
373
+ # The second step parks: the only signal so far has been consumed
374
+ workflow.current_execution.execute!
375
+ assert_waiting_for_signal(workflow, :ping)
376
+
377
+ second_signal = workflow.signal!(:ping)
378
+ perform_next_step(workflow)
379
+
380
+ assert_equal [[:first, first_signal.id], [:second, second_signal.id]], Thread.current[:rendezvous_log]
381
+ assert_equal "consumed", second_signal.reload.state
382
+ end
383
+
384
+ test "finished! from a waiting step consumes its signal" do
385
+ workflow = FinishingWaitWorkflow.create!(hero: @user)
386
+ signal = workflow.signal!(:decided)
387
+
388
+ perform_next_step(workflow)
389
+
390
+ assert_equal "finished", workflow.reload.state
391
+ assert_equal "consumed", signal.reload.state
392
+ assert_equal [], Thread.current[:rendezvous_log]
393
+ end
394
+
395
+ # --- Redelivery across attempts ---
396
+
397
+ test "a reattempt re-reads the same claimed signal" do
398
+ workflow = FlakyWaitWorkflow.create!(hero: @user)
399
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 42})
400
+
401
+ assert_raises(RuntimeError) { perform_next_step(workflow) }
402
+ assert_equal "paused", workflow.reload.state
403
+ assert_equal "claimed", signal.reload.state
404
+
405
+ Thread.current[:rendezvous_healed] = true
406
+ workflow.resume!
407
+ speedrun_workflow(workflow)
408
+
409
+ assert_equal [[:attempt, 42], [:attempt, 42]], Thread.current[:rendezvous_log]
410
+ assert_equal "consumed", signal.reload.state
411
+ assert_equal "finished", workflow.state
412
+ end
413
+
414
+ test "signals delivered while paused are dispatched on resume" do
415
+ workflow = InvoiceWorkflow.create!(hero: @user)
416
+ perform_next_step(workflow)
417
+ workflow.current_execution.execute!
418
+ assert_waiting_for_signal(workflow, :payment_confirmed)
419
+
420
+ workflow.pause!
421
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 99})
422
+
423
+ assert_equal "pending", signal.reload.state
424
+ assert_equal "waiting", workflow.reload.current_execution.state
425
+
426
+ workflow.resume!
427
+
428
+ execution = workflow.reload.current_execution
429
+ assert_equal "scheduled", execution.state
430
+ assert_equal signal.id, execution.signal_id
431
+
432
+ speedrun_workflow(workflow)
433
+ assert_equal [:prepared, [:captured, 99], :receipt], Thread.current[:rendezvous_log]
434
+ end
435
+
436
+ test "resume! leaves a parked execution parked when nothing matches" do
437
+ workflow = InvoiceWorkflow.create!(hero: @user)
438
+ perform_next_step(workflow)
439
+ workflow.current_execution.execute!
440
+
441
+ workflow.pause!
442
+ workflow.resume!
443
+
444
+ assert_equal "ready", workflow.reload.state
445
+ assert_equal "waiting", workflow.current_execution.state
446
+ end
447
+
448
+ test "a workflow paused between dispatch and execution re-attaches on resume" do
449
+ workflow = InvoiceWorkflow.create!(hero: @user)
450
+ perform_next_step(workflow)
451
+ workflow.current_execution.execute!
452
+
453
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 5})
454
+ workflow.pause!
455
+
456
+ # The job runs while paused: prepare cancels the execution
457
+ workflow.step_executions.find_by(state: "scheduled").execute!
458
+ assert_equal "claimed", signal.reload.state
459
+
460
+ workflow.reload.resume!
461
+ speedrun_workflow(workflow)
462
+
463
+ assert_equal [:prepared, [:captured, 5], :receipt], Thread.current[:rendezvous_log]
464
+ assert_equal "consumed", signal.reload.state
465
+ end
466
+
467
+ # --- External verbs ---
468
+
469
+ test "cancel! cancels a parked execution and leaves the signal claimed" do
470
+ workflow = InvoiceWorkflow.create!(hero: @user)
471
+ perform_next_step(workflow)
472
+ workflow.current_execution.execute!
473
+
474
+ workflow.cancel!
475
+
476
+ assert_equal "canceled", workflow.reload.state
477
+ assert_equal "canceled", workflow.step_executions.find_by(step_name: "await_payment").state
478
+ end
479
+
480
+ test "skip! is the operator escape hatch for a parked step" do
481
+ workflow = InvoiceWorkflow.create!(hero: @user)
482
+ perform_next_step(workflow)
483
+ workflow.current_execution.execute!
484
+ assert_waiting_for_signal(workflow)
485
+
486
+ workflow.skip!
487
+
488
+ assert_step_executed(workflow, :await_payment, state: "skipped")
489
+ speedrun_workflow(workflow)
490
+
491
+ assert_equal [:prepared, :receipt], Thread.current[:rendezvous_log]
492
+ assert_equal "finished", workflow.state
493
+ end
494
+
495
+ test "a parked execution is swept when a new execution is created past it" do
496
+ workflow = InvoiceWorkflow.create!(hero: @user)
497
+ perform_next_step(workflow)
498
+ parked = workflow.current_execution
499
+ parked.execute!
500
+ assert_equal "waiting", parked.reload.state
501
+
502
+ workflow.reschedule_current_step!
503
+
504
+ assert_equal "canceled", parked.reload.state
505
+ assert_equal 1, workflow.step_executions.where(state: "scheduled").count
506
+ end
507
+
508
+ # --- Resumable chains ---
509
+
510
+ test "a resumable step gates once and carries the pin across successors" do
511
+ workflow = ResumableWaitWorkflow.create!(hero: @user)
512
+ signal = workflow.signal!(:batch_ready, payload: {batch_id: "b1"})
513
+
514
+ # Interrupt after two items so the step continues through a successor
515
+ run_iterations(workflow, count: 2)
516
+ successor = workflow.reload.current_execution
517
+ assert_equal signal.id, successor.signal_id
518
+ assert_equal "scheduled", successor.state
519
+
520
+ speedrun_current_step(workflow)
521
+
522
+ logged_batches = Thread.current[:rendezvous_log].map(&:last).uniq
523
+ assert_equal ["b1"], logged_batches
524
+ assert_equal 4, Thread.current[:rendezvous_log].size
525
+
526
+ executions = workflow.step_executions.where(step_name: "process_items").order(:id)
527
+ assert executions.count > 1, "expected a chain of executions"
528
+ assert executions.all? { |execution| execution.signal_id == signal.id }
529
+ assert_equal "consumed", signal.reload.state
530
+ end
531
+
532
+ # --- Duplicates ---
533
+
534
+ test "a duplicate signal delivered while one is claimed just buffers" do
535
+ workflow = InvoiceWorkflow.create!(hero: @user)
536
+ perform_next_step(workflow)
537
+ workflow.current_execution.execute!
538
+
539
+ claimed = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
540
+ buffered = workflow.signal!(:payment_confirmed, payload: {amount_cents: 2})
541
+
542
+ assert_equal "claimed", claimed.reload.state
543
+ assert_equal "pending", buffered.reload.state
544
+
545
+ perform_next_step(workflow)
546
+
547
+ assert_equal [:prepared, [:captured, 1]], Thread.current[:rendezvous_log]
548
+ assert_equal "consumed", claimed.reload.state
549
+ assert_equal "pending", buffered.reload.state
550
+ end
551
+
552
+ test "a deduplicated redelivery does not dispatch twice" do
553
+ workflow = InvoiceWorkflow.create!(hero: @user)
554
+ perform_next_step(workflow)
555
+ workflow.current_execution.execute!
556
+
557
+ workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
558
+ execution_id = workflow.reload.current_execution.id
559
+
560
+ duplicate = workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
561
+
562
+ assert duplicate.duplicate_delivery?
563
+ assert_equal execution_id, workflow.reload.current_execution.id
564
+ assert_equal 1, workflow.signals.count
565
+ end
566
+
567
+ # --- Test helper guardrails ---
568
+
569
+ test "step-driving helpers refuse to spin on a parked execution" do
570
+ workflow = InvoiceWorkflow.create!(hero: @user)
571
+ perform_next_step(workflow)
572
+ workflow.current_execution.execute!
573
+
574
+ error = assert_raises(RuntimeError) { speedrun_workflow(workflow) }
575
+ assert_match(/parked/, error.message)
576
+ assert_match(/payment_confirmed/, error.message)
577
+
578
+ error = assert_raises(RuntimeError) { perform_next_step(workflow) }
579
+ assert_match(/parked/, error.message)
580
+ end
581
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "minitest/mock"
5
+
6
+ # Deployments usually ship gem updates before running migrations. Everything
7
+ # except actually waiting for a signal must keep working when the signal_id
8
+ # and waiting_since columns are absent (simulated here by stubbing the column
9
+ # detection, like the resumable step tests do).
10
+ class SignalsWithoutMigrationTest < ActiveSupport::TestCase
11
+ include GenevaDrive::TestHelpers
12
+
13
+ class PlainWorkflow < GenevaDrive::Workflow
14
+ step :one do
15
+ Thread.current[:no_sig_one_ran] = true
16
+ end
17
+
18
+ step :two do
19
+ Thread.current[:no_sig_two_ran] = true
20
+ end
21
+ end
22
+
23
+ class WaitingWorkflow < GenevaDrive::Workflow
24
+ step :await_payment, wait_for: :payment_confirmed do
25
+ Thread.current[:no_sig_waited] = true
26
+ end
27
+ end
28
+
29
+ setup do
30
+ @user = create_user
31
+ reset_thread_tracking!
32
+ end
33
+
34
+ teardown do
35
+ reset_thread_tracking!
36
+ end
37
+
38
+ def reset_thread_tracking!
39
+ %i[no_sig_one_ran no_sig_two_ran no_sig_waited].each { |key| Thread.current[key] = nil }
40
+ end
41
+
42
+ test "regular workflows run to completion without the signal columns" do
43
+ GenevaDrive::StepExecution.stub(:signal_columns?, false) do
44
+ workflow = PlainWorkflow.create!(hero: @user)
45
+ speedrun_workflow(workflow)
46
+
47
+ assert_equal "finished", workflow.state
48
+ assert Thread.current[:no_sig_one_ran]
49
+ assert Thread.current[:no_sig_two_ran]
50
+ end
51
+ end
52
+
53
+ test "executing a waiting step without the columns fails with a clear error" do
54
+ GenevaDrive::StepExecution.stub(:signal_columns?, false) do
55
+ workflow = WaitingWorkflow.create!(hero: @user)
56
+
57
+ error = assert_raises(GenevaDrive::StepConfigurationError) { perform_next_step(workflow) }
58
+ assert_match(/geneva_drive:install/, error.message)
59
+
60
+ workflow.reload
61
+ assert_equal "paused", workflow.state
62
+ failed = workflow.step_executions.find_by(state: "failed")
63
+ assert_not_nil failed
64
+ assert_match(/wait_for:/, failed.error_message)
65
+ assert_not Thread.current[:no_sig_waited]
66
+ end
67
+ end
68
+
69
+ test "signal! still records the event without the columns, it just cannot dispatch" do
70
+ workflow = WaitingWorkflow.create!(hero: @user)
71
+
72
+ GenevaDrive::StepExecution.stub(:signal_columns?, false) do
73
+ signal = workflow.signal!(:payment_confirmed, payload: {amount_cents: 1})
74
+
75
+ assert signal.persisted?
76
+ assert_equal "pending", signal.reload.state
77
+ end
78
+ end
79
+
80
+ test "housekeeping skips the waiting gauges without the columns" do
81
+ GenevaDrive::StepExecution.stub(:signal_columns?, false) do
82
+ assert_nothing_raised { GenevaDrive::HousekeepingJob.perform_now }
83
+ end
84
+ end
85
+
86
+ test "housekeeping skips the signal delete pass without the signals table" do
87
+ original = GenevaDrive.delete_completed_workflows_after
88
+ GenevaDrive.delete_completed_workflows_after = 30.days
89
+
90
+ GenevaDrive::Signal.stub(:table_available?, false) do
91
+ results = GenevaDrive::HousekeepingJob.perform_now
92
+ assert_equal 0, results[:signals_cleaned_up]
93
+ end
94
+ ensure
95
+ GenevaDrive.delete_completed_workflows_after = original
96
+ end
97
+ end