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,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Claim and consumption accounting. A signal stays claimed - and therefore
6
+ # attachable - while any attached execution chain is unresolved, and flips to
7
+ # consumed when the last one resolves cleanly. With a single claimant that is
8
+ # the same moment as the step completing; the counters are what make a
9
+ # one-to-many dispatch legible.
10
+ class SignalConsumptionTest < ActiveSupport::TestCase
11
+ include GenevaDrive::TestHelpers
12
+
13
+ class InvoiceWorkflow < GenevaDrive::Workflow
14
+ step :await_payment, wait_for: :payment_confirmed do
15
+ raise "gateway unavailable" unless Thread.current[:consumption_healed]
16
+ end
17
+
18
+ step :issue_receipt do
19
+ end
20
+ end
21
+
22
+ class SkippingWorkflow < GenevaDrive::Workflow
23
+ step :await_payment, wait_for: :payment_confirmed do
24
+ skip!
25
+ end
26
+ end
27
+
28
+ class TwoWaitersWorkflow < GenevaDrive::Workflow
29
+ step :first_wait, wait_for: :ping do
30
+ end
31
+
32
+ step :second_wait, wait_for: :ping do
33
+ end
34
+ end
35
+
36
+ class ResumableWaitWorkflow < GenevaDrive::Workflow
37
+ resumable_step :process_items, wait_for: :batch_ready, max_iterations: 2 do |iter|
38
+ iter.iterate_over([1, 2, 3, 4]) { |item| }
39
+ end
40
+ end
41
+
42
+ class SkippableWaitWorkflow < GenevaDrive::Workflow
43
+ step :await_payment, wait_for: :payment_confirmed, skip_if: -> { Thread.current[:consumption_presigned] } do
44
+ end
45
+
46
+ step :await_payment_again, wait_for: :payment_confirmed do
47
+ end
48
+ end
49
+
50
+ class FailingWaitWorkflow < GenevaDrive::Workflow
51
+ step :await_payment, wait_for: :payment_confirmed do
52
+ raise "gateway unavailable"
53
+ end
54
+
55
+ step :issue_receipt do
56
+ end
57
+ end
58
+
59
+ setup do
60
+ @user = create_user
61
+ Thread.current[:consumption_healed] = true
62
+ Thread.current[:consumption_presigned] = nil
63
+ end
64
+
65
+ teardown do
66
+ Thread.current[:consumption_healed] = nil
67
+ Thread.current[:consumption_presigned] = nil
68
+ end
69
+
70
+ # Builds a step execution attached to a signal, the way dispatch or the gate
71
+ # would. Used to stage attachment shapes that only the DAG scheduler can
72
+ # produce for real.
73
+ def attach_execution(workflow, signal, step_name:, state:, outcome: nil)
74
+ signal.claim!
75
+ workflow.step_executions.create!(
76
+ step_name: step_name,
77
+ state: state,
78
+ scheduled_for: Time.current,
79
+ waiting_since: (state == "waiting") ? Time.current : nil,
80
+ outcome: outcome,
81
+ signal_id: signal.id
82
+ )
83
+ end
84
+
85
+ test "a clean single-claimant run counts one claim and one consumption" do
86
+ workflow = InvoiceWorkflow.create!(hero: @user)
87
+ signal = workflow.signal!(:payment_confirmed)
88
+
89
+ assert_equal 0, signal.claimed
90
+ assert_equal 0, signal.consumed
91
+
92
+ perform_next_step(workflow)
93
+ signal.reload
94
+
95
+ assert_equal "consumed", signal.state
96
+ assert_equal 1, signal.claimed
97
+ assert_equal 1, signal.consumed
98
+ assert_not_nil signal.claimed_at
99
+ assert_not_nil signal.consumed_at
100
+ end
101
+
102
+ test "a skipping step consumes its signal too" do
103
+ workflow = SkippingWorkflow.create!(hero: @user)
104
+ signal = workflow.signal!(:payment_confirmed)
105
+
106
+ perform_next_step(workflow)
107
+ signal.reload
108
+
109
+ assert_equal "consumed", signal.state
110
+ assert_equal 1, signal.consumed
111
+ end
112
+
113
+ test "a retry re-attaching counts a second claim but only one consumption" do
114
+ Thread.current[:consumption_healed] = false
115
+ workflow = InvoiceWorkflow.create!(hero: @user)
116
+ signal = workflow.signal!(:payment_confirmed)
117
+
118
+ assert_raises(RuntimeError) { perform_next_step(workflow) }
119
+ signal.reload
120
+
121
+ assert_equal "claimed", signal.state
122
+ assert_equal 1, signal.claimed
123
+ assert_equal 0, signal.consumed
124
+ first_claimed_at = signal.claimed_at
125
+
126
+ Thread.current[:consumption_healed] = true
127
+ workflow.resume!
128
+ speedrun_workflow(workflow)
129
+ signal.reload
130
+
131
+ assert_equal "consumed", signal.state
132
+ assert_equal 2, signal.claimed
133
+ assert_equal 1, signal.consumed
134
+ # claimed_at keeps meaning "when this event started being handled"
135
+ assert_equal first_claimed_at.to_i, signal.claimed_at.to_i
136
+ end
137
+
138
+ test "successor executions carry the pin over without counting a new claim" do
139
+ workflow = ResumableWaitWorkflow.create!(hero: @user)
140
+ signal = workflow.signal!(:batch_ready, payload: {batch_id: "b1"})
141
+
142
+ run_iterations(workflow, count: 2)
143
+ assert_equal 1, signal.reload.claimed
144
+ assert_equal 0, signal.consumed
145
+
146
+ speedrun_current_step(workflow)
147
+ signal.reload
148
+
149
+ assert_operator workflow.step_executions.where(signal_id: signal.id).count, :>, 1
150
+ assert_equal 1, signal.claimed
151
+ assert_equal 1, signal.consumed
152
+ assert_equal "consumed", signal.state
153
+ end
154
+
155
+ test "the signal stays claimed while another attached execution is still active" do
156
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
157
+ workflow.current_execution.execute!
158
+ assert_waiting_for_signal(workflow, :ping)
159
+
160
+ signal = workflow.signal!(:ping)
161
+ assert_equal 1, signal.reload.claimed
162
+
163
+ # Stage a second attached execution, as a DAG salvo would produce
164
+ attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
165
+ assert_equal 2, signal.reload.claimed
166
+
167
+ perform_next_step(workflow)
168
+ signal.reload
169
+
170
+ assert_equal "claimed", signal.state
171
+ assert_equal 1, signal.consumed
172
+ assert_nil signal.consumed_at
173
+ end
174
+
175
+ test "the flip happens when the last attached chain resolves" do
176
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
177
+ signal = workflow.signal!(:ping)
178
+
179
+ first = attach_execution(workflow, signal, step_name: "first_wait", state: "waiting")
180
+ second = attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
181
+ assert_equal 2, signal.reload.claimed
182
+
183
+ first.update!(state: "completed", outcome: "success", completed_at: Time.current)
184
+ signal.record_consumption!
185
+
186
+ assert_equal "claimed", signal.state
187
+ assert_equal 1, signal.consumed
188
+
189
+ second.update!(state: "skipped", outcome: "skipped", skipped_at: Time.current)
190
+ signal.record_consumption!
191
+
192
+ assert_equal "consumed", signal.state
193
+ assert_equal 2, signal.consumed
194
+ assert_not_nil signal.consumed_at
195
+ end
196
+
197
+ test "claimed? and consumed? read the counters, not the lifecycle state" do
198
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
199
+ signal = workflow.signal!(:ping)
200
+
201
+ assert_equal "pending", signal.state
202
+ assert_not signal.claimed?
203
+ assert_not signal.consumed?
204
+
205
+ execution = attach_execution(workflow, signal, step_name: "first_wait", state: "waiting")
206
+ signal.reload
207
+
208
+ assert_equal "claimed", signal.state
209
+ assert signal.claimed?
210
+ assert_not signal.consumed?
211
+
212
+ # A second chain keeps the state at claimed, but the first clean
213
+ # resolution already makes consumed? true
214
+ second = attach_execution(workflow, signal, step_name: "second_wait", state: "waiting")
215
+ execution.update!(state: "completed", outcome: "success")
216
+ signal.record_consumption!
217
+
218
+ assert_equal "claimed", signal.state
219
+ assert signal.consumed?
220
+ assert_equal 1, signal.consumed
221
+
222
+ second.update!(state: "completed", outcome: "success")
223
+ signal.record_consumption!
224
+
225
+ assert_equal "consumed", signal.state
226
+ assert signal.claimed?
227
+ assert signal.consumed?
228
+ end
229
+
230
+ test "the state enum generates neither predicates nor scopes" do
231
+ assert_not GenevaDrive::Signal.new.respond_to?(:pending?)
232
+ assert_not GenevaDrive::Signal.respond_to?(:pending)
233
+
234
+ # The names the enum would have taken belong to the counters
235
+ assert_equal 0, GenevaDrive::Signal.new.claimed
236
+ assert_equal 0, GenevaDrive::Signal.new.consumed
237
+ end
238
+
239
+ # === Skips settle the signal they were delivered ===
240
+
241
+ test "skip_if firing at wake consumes the signal that woke the step" do
242
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
243
+ workflow.current_execution.execute!
244
+ assert_waiting_for_signal(workflow, :payment_confirmed)
245
+
246
+ # The condition flips while the step sits parked, then the event arrives
247
+ Thread.current[:consumption_presigned] = true
248
+ signal = workflow.signal!(:payment_confirmed)
249
+ assert_equal "claimed", signal.reload.state
250
+ assert_equal signal.id, workflow.reload.current_execution.signal_id
251
+
252
+ perform_next_step(workflow)
253
+ signal.reload
254
+
255
+ assert_step_executed(workflow, :await_payment, state: "skipped")
256
+ assert_equal "consumed", signal.state
257
+ assert_equal 1, signal.claimed
258
+ assert_equal 1, signal.consumed
259
+ assert_not_nil signal.consumed_at
260
+ end
261
+
262
+ test "a step skipped at wake does not hand its stale event to the next waiter" do
263
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
264
+ workflow.current_execution.execute!
265
+
266
+ Thread.current[:consumption_presigned] = true
267
+ signal = workflow.signal!(:payment_confirmed)
268
+ perform_next_step(workflow)
269
+
270
+ # The second step waits on the same name and must park: the only event so
271
+ # far was settled by the skip
272
+ workflow.reload.current_execution.execute!
273
+
274
+ assert_waiting_for_signal(workflow, :payment_confirmed)
275
+ assert_nil workflow.reload.current_execution.signal_id
276
+ assert_equal 1, signal.reload.consumed
277
+
278
+ # ... and a fresh event moves it along
279
+ fresh = workflow.signal!(:payment_confirmed)
280
+ perform_next_step(workflow)
281
+
282
+ assert_equal "consumed", fresh.reload.state
283
+ assert_equal "finished", workflow.reload.state
284
+ end
285
+
286
+ test "external skip! settles the signal of a dispatched execution" do
287
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
288
+ workflow.current_execution.execute!
289
+ signal = workflow.signal!(:payment_confirmed)
290
+
291
+ # Dispatched, attached, but its job never ran
292
+ assert_equal "scheduled", workflow.reload.current_execution.state
293
+ workflow.skip!
294
+ signal.reload
295
+
296
+ assert_step_executed(workflow, :await_payment, state: "skipped")
297
+ assert_equal "consumed", signal.state
298
+ assert_equal 1, signal.consumed
299
+ end
300
+
301
+ test "external skip! on a paused workflow settles the claim its failed step held" do
302
+ workflow = FailingWaitWorkflow.create!(hero: @user)
303
+ signal = workflow.signal!(:payment_confirmed)
304
+
305
+ assert_raises(RuntimeError) { perform_next_step(workflow) }
306
+ assert_equal "paused", workflow.reload.state
307
+ assert_equal "claimed", signal.reload.state
308
+ assert_equal 1, signal.claimed
309
+ assert_equal 0, signal.consumed
310
+
311
+ workflow.skip!
312
+ signal.reload
313
+
314
+ assert_equal "consumed", signal.state
315
+ assert_equal 1, signal.consumed
316
+ assert_not_nil signal.consumed_at
317
+ assert_equal "issue_receipt", workflow.reload.next_step_name
318
+ assert_equal "scheduled", workflow.current_execution.state
319
+ end
320
+
321
+ test "skipping at park time leaves a buffered signal pending" do
322
+ Thread.current[:consumption_presigned] = true
323
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
324
+ buffered = workflow.signal!(:unrelated_event)
325
+
326
+ # The step never attaches - skip_if is evaluated before the gate
327
+ perform_next_step(workflow)
328
+
329
+ assert_step_executed(workflow, :await_payment, state: "skipped")
330
+ buffered.reload
331
+ assert_equal "pending", buffered.state
332
+ assert_equal 0, buffered.claimed
333
+ assert_equal 0, buffered.consumed
334
+ end
335
+
336
+ test "external skip! of an unattached parked execution touches no buffered signal" do
337
+ workflow = SkippableWaitWorkflow.create!(hero: @user)
338
+ workflow.current_execution.execute!
339
+ assert_waiting_for_signal(workflow, :payment_confirmed)
340
+ buffered = workflow.signal!(:unrelated_event)
341
+
342
+ workflow.skip!
343
+
344
+ buffered.reload
345
+ assert_equal "pending", buffered.state
346
+ assert_equal 0, buffered.consumed
347
+ end
348
+
349
+ test "a signal with no attachments is vacuously resolved" do
350
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
351
+
352
+ assert workflow.signal!(:ping).fully_resolved?
353
+ end
354
+
355
+ test "an unresolved chain keeps the signal from being fully resolved" do
356
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
357
+ signal = workflow.signal!(:ping)
358
+ # Free up the one-active slot so each staged state can occupy it in turn
359
+ workflow.step_executions.update_all(state: "canceled", outcome: "canceled")
360
+
361
+ %w[waiting scheduled in_progress].each_with_index do |state, index|
362
+ execution = attach_execution(workflow, signal, step_name: "node_#{index}", state: state)
363
+ assert_not signal.fully_resolved?, "expected #{state} to count as unresolved"
364
+ execution.update!(state: "completed", outcome: "success")
365
+ end
366
+
367
+ assert signal.fully_resolved?
368
+ end
369
+
370
+ test "a chain whose latest execution failed or was canceled keeps the signal claimed" do
371
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
372
+ signal = workflow.signal!(:ping)
373
+ execution = attach_execution(workflow, signal, step_name: "first_wait", state: "failed", outcome: "failed")
374
+
375
+ assert_not signal.fully_resolved?
376
+
377
+ execution.update!(state: "canceled", outcome: "canceled")
378
+ assert_not signal.fully_resolved?
379
+
380
+ execution.update!(state: "completed", outcome: "success")
381
+ assert signal.fully_resolved?
382
+ end
383
+
384
+ test "only the latest execution of a chain decides whether it resolved" do
385
+ workflow = TwoWaitersWorkflow.create!(hero: @user)
386
+ signal = workflow.signal!(:ping)
387
+
388
+ attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "continued")
389
+ attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "reattempted")
390
+
391
+ assert_not signal.fully_resolved?
392
+
393
+ attach_execution(workflow, signal, step_name: "first_wait", state: "completed", outcome: "success")
394
+
395
+ assert signal.fully_resolved?
396
+ end
397
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # The sender side of the rendezvous: Workflow#signal! and the Signal record
6
+ # itself. The receiver side (gate, park, wake) lives in
7
+ # signal_rendezvous_test.rb.
8
+ class SignalDeliveryTest < ActiveSupport::TestCase
9
+ include GenevaDrive::TestHelpers
10
+
11
+ class InvoiceWorkflow < GenevaDrive::Workflow
12
+ step :await_payment, wait_for: :payment_confirmed do
13
+ Thread.current[:signal_delivery_amount] = received_signal.payload[:amount_cents]
14
+ end
15
+
16
+ step :issue_receipt do
17
+ end
18
+ end
19
+
20
+ class PlainWorkflow < GenevaDrive::Workflow
21
+ step :only_step do
22
+ end
23
+ end
24
+
25
+ setup do
26
+ @user = create_user
27
+ Thread.current[:signal_delivery_amount] = nil
28
+ end
29
+
30
+ teardown do
31
+ Thread.current[:signal_delivery_amount] = nil
32
+ end
33
+
34
+ test "signal! persists a pending signal" do
35
+ workflow = PlainWorkflow.create!(hero: @user)
36
+
37
+ signal = workflow.signal!(:something_happened, payload: {order_id: 42})
38
+
39
+ assert signal.persisted?
40
+ assert_equal "something_happened", signal.name
41
+ assert_equal "pending", signal.state
42
+ assert_nil signal.claimed_at
43
+ assert_nil signal.consumed_at
44
+ assert_not signal.duplicate_delivery?
45
+ assert_equal workflow.id, signal.workflow_id
46
+ end
47
+
48
+ test "payload comes back with indifferent access" do
49
+ workflow = PlainWorkflow.create!(hero: @user)
50
+
51
+ workflow.signal!(:webhook, payload: {"order_id" => 42, :amount_cents => 100})
52
+ signal = workflow.signals.last.reload
53
+
54
+ assert_equal 42, signal.payload[:order_id]
55
+ assert_equal 42, signal.payload["order_id"]
56
+ assert_equal 100, signal.payload[:amount_cents]
57
+ assert_equal 100, signal.payload["amount_cents"]
58
+ end
59
+
60
+ test "payload round-trips types through ActiveJob serialization" do
61
+ workflow = PlainWorkflow.create!(hero: @user)
62
+ moment = Time.current.change(usec: 0)
63
+
64
+ workflow.signal!(:webhook, payload: {at: moment, hero: @user})
65
+ signal = workflow.signals.last.reload
66
+
67
+ assert_equal moment, signal.payload[:at]
68
+ assert_equal @user, signal.payload[:hero]
69
+ end
70
+
71
+ test "non-hash payloads are returned as-is" do
72
+ workflow = PlainWorkflow.create!(hero: @user)
73
+
74
+ workflow.signal!(:webhook, payload: "just a string")
75
+
76
+ assert_equal "just a string", workflow.signals.last.reload.payload
77
+ end
78
+
79
+ test "an empty payload is the default" do
80
+ workflow = PlainWorkflow.create!(hero: @user)
81
+
82
+ signal = workflow.signal!(:webhook)
83
+
84
+ assert_equal({}, signal.reload.payload)
85
+ end
86
+
87
+ test "signal! requires a name" do
88
+ workflow = PlainWorkflow.create!(hero: @user)
89
+
90
+ assert_raises(ArgumentError) { workflow.signal!(nil) }
91
+ assert_raises(ArgumentError) { workflow.signal!("") }
92
+ end
93
+
94
+ test "signal! rejects unknown options so the keyword surface stays usable" do
95
+ workflow = PlainWorkflow.create!(hero: @user)
96
+
97
+ error = assert_raises(ArgumentError) { workflow.signal!(:webhook, timeout: 5.minutes) }
98
+ assert_match(/timeout/, error.message)
99
+ end
100
+
101
+ test "an oversized payload raises before anything is persisted" do
102
+ workflow = PlainWorkflow.create!(hero: @user)
103
+ original = GenevaDrive.max_signal_payload_size
104
+ GenevaDrive.max_signal_payload_size = 64
105
+
106
+ error = assert_raises(GenevaDrive::SignalPayloadTooLargeError) do
107
+ workflow.signal!(:webhook, payload: {blob: "x" * 1024})
108
+ end
109
+
110
+ assert_match(/max_signal_payload_size/, error.message)
111
+ assert_equal 0, workflow.signals.count
112
+ ensure
113
+ GenevaDrive.max_signal_payload_size = original
114
+ end
115
+
116
+ test "the payload bound can be disabled" do
117
+ workflow = PlainWorkflow.create!(hero: @user)
118
+ original = GenevaDrive.max_signal_payload_size
119
+ GenevaDrive.max_signal_payload_size = nil
120
+
121
+ signal = workflow.signal!(:webhook, payload: {blob: "x" * 1024})
122
+
123
+ assert signal.persisted?
124
+ ensure
125
+ GenevaDrive.max_signal_payload_size = original
126
+ end
127
+
128
+ test "redelivery with the same idempotency key returns the original row" do
129
+ workflow = PlainWorkflow.create!(hero: @user)
130
+
131
+ first = workflow.signal!(:webhook, payload: {n: 1}, idempotency_key: "evt_1")
132
+ second = workflow.signal!(:webhook, payload: {n: 2}, idempotency_key: "evt_1")
133
+
134
+ assert_equal first.id, second.id
135
+ assert second.duplicate_delivery?
136
+ assert_not first.duplicate_delivery?
137
+ assert_equal 1, workflow.signals.count
138
+ # The first payload wins - the duplicate is a no-op, not an update
139
+ assert_equal 1, second.payload[:n]
140
+ end
141
+
142
+ test "the same idempotency key under a different name is a different signal" do
143
+ workflow = PlainWorkflow.create!(hero: @user)
144
+
145
+ workflow.signal!(:webhook_a, idempotency_key: "evt_1")
146
+ workflow.signal!(:webhook_b, idempotency_key: "evt_1")
147
+
148
+ assert_equal 2, workflow.signals.count
149
+ end
150
+
151
+ test "the same idempotency key on a different workflow is a different signal" do
152
+ other_user = create_user(email: "other@example.com")
153
+ workflow = PlainWorkflow.create!(hero: @user)
154
+ other_workflow = PlainWorkflow.create!(hero: other_user)
155
+
156
+ workflow.signal!(:webhook, idempotency_key: "evt_1")
157
+ other_workflow.signal!(:webhook, idempotency_key: "evt_1")
158
+
159
+ assert_equal 1, workflow.signals.count
160
+ assert_equal 1, other_workflow.signals.count
161
+ end
162
+
163
+ test "signals without an idempotency key never deduplicate" do
164
+ workflow = PlainWorkflow.create!(hero: @user)
165
+
166
+ workflow.signal!(:webhook)
167
+ workflow.signal!(:webhook)
168
+
169
+ assert_equal 2, workflow.signals.count
170
+ end
171
+
172
+ test "signal! on a finished workflow raises WorkflowNotOngoing" do
173
+ workflow = PlainWorkflow.create!(hero: @user)
174
+ speedrun_workflow(workflow)
175
+ assert_equal "finished", workflow.state
176
+
177
+ error = assert_raises(GenevaDrive::WorkflowNotOngoing) { workflow.signal!(:webhook) }
178
+
179
+ assert_match(/finished workflow/, error.message)
180
+ assert_equal 0, workflow.signals.count
181
+ end
182
+
183
+ test "signal! on a canceled workflow raises WorkflowNotOngoing" do
184
+ workflow = PlainWorkflow.create!(hero: @user)
185
+ workflow.cancel!
186
+
187
+ assert_raises(GenevaDrive::WorkflowNotOngoing) { workflow.signal!(:webhook) }
188
+ end
189
+
190
+ test "redelivery of the event that finished the workflow is a no-op" do
191
+ workflow = InvoiceWorkflow.create!(hero: @user)
192
+ workflow.signal!(:payment_confirmed, payload: {amount_cents: 500}, idempotency_key: "evt_1")
193
+ speedrun_workflow(workflow)
194
+ assert_equal "finished", workflow.state
195
+
196
+ redelivered = workflow.signal!(:payment_confirmed, payload: {amount_cents: 500}, idempotency_key: "evt_1")
197
+
198
+ assert redelivered.duplicate_delivery?
199
+ assert_equal 1, workflow.signals.count
200
+ end
201
+
202
+ test "a new event on a finished workflow still raises even when other signals exist" do
203
+ workflow = InvoiceWorkflow.create!(hero: @user)
204
+ workflow.signal!(:payment_confirmed, idempotency_key: "evt_1")
205
+ speedrun_workflow(workflow)
206
+
207
+ assert_raises(GenevaDrive::WorkflowNotOngoing) do
208
+ workflow.signal!(:payment_confirmed, idempotency_key: "evt_2")
209
+ end
210
+ end
211
+
212
+ test "signal! survives an enclosing transaction thanks to the savepoint" do
213
+ workflow = PlainWorkflow.create!(hero: @user)
214
+ workflow.signal!(:webhook, idempotency_key: "evt_1")
215
+
216
+ GenevaDrive::Workflow.transaction do
217
+ duplicate = workflow.signal!(:webhook, idempotency_key: "evt_1")
218
+ assert duplicate.duplicate_delivery?
219
+
220
+ # The enclosing transaction is still usable
221
+ workflow.signal!(:other_webhook)
222
+ end
223
+
224
+ assert_equal 2, workflow.signals.count
225
+ end
226
+
227
+ test "signals can still be recorded for a workflow whose class was removed" do
228
+ workflow = InvoiceWorkflow.create!(hero: @user)
229
+ perform_next_step(workflow)
230
+ workflow.current_execution.execute!
231
+ assert_waiting_for_signal(workflow, :payment_confirmed)
232
+
233
+ # The class is gone: STI resolution falls back to the base Workflow, which
234
+ # has no step definitions - so no matcher can be resolved and nothing is
235
+ # woken, but the row is still written.
236
+ GenevaDrive::Workflow.where(id: workflow.id).update_all(type: "DeletedInvoiceWorkflow")
237
+ orphan = GenevaDrive::Workflow.find(workflow.id)
238
+ assert_equal GenevaDrive::Workflow, orphan.class
239
+
240
+ signal = orphan.signal!(:payment_confirmed)
241
+
242
+ assert signal.persisted?
243
+ assert_equal "pending", signal.reload.state
244
+ assert_equal "waiting", orphan.current_execution.state
245
+ end
246
+
247
+ test "signals are deleted with their workflow" do
248
+ workflow = PlainWorkflow.create!(hero: @user)
249
+ workflow.signal!(:webhook)
250
+
251
+ assert_difference -> { GenevaDrive::Signal.count }, -1 do
252
+ workflow.destroy!
253
+ end
254
+ end
255
+ end