@open-mercato/checkout 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a

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,363 @@
1
+ /** @jest-environment node */
2
+
3
+ /**
4
+ * Tests for the state-machine guard added to updateTransactionStatusCommand.
5
+ *
6
+ * Exercises:
7
+ * 1. A terminal → non-terminal transition is rejected with a 409 before the
8
+ * nativeUpdate is even called.
9
+ * 2. A non-terminal → terminal transition succeeds (nativeUpdate called with
10
+ * the correct WHERE clause locking the current status).
11
+ * 3. When nativeUpdate returns 0 (concurrent write won the race), the command
12
+ * throws a 409 (concurrent_status_update) and does NOT proceed to emit
13
+ * terminal events.
14
+ * 4. When nativeUpdate returns 1, the command succeeds and emits the correct
15
+ * terminal event.
16
+ */
17
+
18
+ import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
19
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
20
+ import { CheckoutTransaction } from '../../data/entities'
21
+
22
+ // Register the transaction commands
23
+ import '../transactions'
24
+
25
+ jest.mock('../../events', () => ({
26
+ emitCheckoutEvent: jest.fn(async () => undefined),
27
+ }))
28
+
29
+ const { emitCheckoutEvent } = jest.requireMock('../../events') as {
30
+ emitCheckoutEvent: jest.Mock
31
+ }
32
+
33
+ const ORG_ID = '123e4567-e89b-12d3-a456-426614174000'
34
+ const TENANT_ID = '123e4567-e89b-12d3-a456-426614174001'
35
+ const LINK_ID = '123e4567-e89b-12d3-a456-426614174010'
36
+ const TX_ID = '123e4567-e89b-12d3-a456-426614174020'
37
+
38
+ function makeTransaction(status: CheckoutTransaction['status']) {
39
+ return {
40
+ id: TX_ID,
41
+ linkId: LINK_ID,
42
+ organizationId: ORG_ID,
43
+ tenantId: TENANT_ID,
44
+ status,
45
+ paymentStatus: null,
46
+ gatewayTransactionId: null,
47
+ amount: '100.00',
48
+ currencyCode: 'USD',
49
+ createdAt: new Date(),
50
+ updatedAt: new Date(),
51
+ }
52
+ }
53
+
54
+ function makeLink() {
55
+ return {
56
+ id: LINK_ID,
57
+ organizationId: ORG_ID,
58
+ tenantId: TENANT_ID,
59
+ slug: 'test-link',
60
+ templateId: null,
61
+ gatewayProviderKey: 'stripe',
62
+ activeReservationCount: 1,
63
+ completionCount: 0,
64
+ maxCompletions: null,
65
+ isLocked: false,
66
+ deletedAt: null,
67
+ }
68
+ }
69
+
70
+ type MockEm = {
71
+ findOne: jest.Mock
72
+ nativeUpdate: jest.Mock
73
+ flush: jest.Mock
74
+ refresh: jest.Mock
75
+ transactional: (fn: (tx: MockEm) => Promise<unknown>) => Promise<unknown>
76
+ }
77
+
78
+ function makeMockEm(
79
+ transaction: ReturnType<typeof makeTransaction>,
80
+ nativeUpdateResult = 1,
81
+ // When nativeUpdate returns 0, the post-CAS findOne should return a
82
+ // different status to simulate the winning writer's value.
83
+ postCasStatus?: CheckoutTransaction['status'],
84
+ ): MockEm {
85
+ let findOneCallCount = 0
86
+ const mockTx: MockEm = {
87
+ findOne: jest.fn(async (_entity: unknown, filter: Record<string, unknown>) => {
88
+ findOneCallCount++
89
+ if (filter.id === TX_ID) {
90
+ // After a CAS miss (nativeUpdate=0) the command re-reads the row with
91
+ // refresh:true to fetch the winning writer's status. Return the
92
+ // post-CAS status on subsequent calls when one is provided.
93
+ if (findOneCallCount > 1 && postCasStatus !== undefined) {
94
+ return { ...transaction, status: postCasStatus }
95
+ }
96
+ return transaction
97
+ }
98
+ if (filter.id === LINK_ID) return makeLink()
99
+ return null
100
+ }),
101
+ nativeUpdate: jest.fn(async () => nativeUpdateResult),
102
+ flush: jest.fn(async () => undefined),
103
+ refresh: jest.fn(async () => undefined),
104
+ transactional: (fn) => fn(mockTx),
105
+ }
106
+ return mockTx
107
+ }
108
+
109
+ function makeContext(em: MockEm): CommandRuntimeContext {
110
+ return {
111
+ container: {
112
+ resolve: (token: string) => {
113
+ if (token === 'em') return em
114
+ return null
115
+ },
116
+ } as unknown as CommandRuntimeContext['container'],
117
+ auth: null,
118
+ organizationScope: null,
119
+ selectedOrganizationId: ORG_ID,
120
+ organizationIds: [ORG_ID],
121
+ }
122
+ }
123
+
124
+ function baseInput(status: string) {
125
+ return {
126
+ id: TX_ID,
127
+ status,
128
+ paymentStatus: null,
129
+ gatewayTransactionId: null,
130
+ organizationId: ORG_ID,
131
+ tenantId: TENANT_ID,
132
+ }
133
+ }
134
+
135
+ async function runUpdateStatus(em: MockEm, status: string) {
136
+ const handler = commandRegistry.get('checkout.transaction.updateStatus')
137
+ if (!handler) throw new Error('checkout.transaction.updateStatus not registered')
138
+ return handler.execute(baseInput(status), makeContext(em))
139
+ }
140
+
141
+ describe('updateTransactionStatusCommand — state-machine guard', () => {
142
+ beforeEach(() => {
143
+ jest.clearAllMocks()
144
+ })
145
+
146
+ describe('valid transitions', () => {
147
+ it('processing → completed: calls nativeUpdate with status=processing in the WHERE clause', async () => {
148
+ const transaction = makeTransaction('processing')
149
+ const em = makeMockEm(transaction, 1)
150
+
151
+ await runUpdateStatus(em, 'completed')
152
+
153
+ expect(em.nativeUpdate).toHaveBeenCalledWith(
154
+ expect.anything(),
155
+ expect.objectContaining({
156
+ id: TX_ID,
157
+ organizationId: ORG_ID,
158
+ tenantId: TENANT_ID,
159
+ status: 'processing', // pins the current status for atomicity
160
+ }),
161
+ expect.objectContaining({ status: 'completed' }),
162
+ )
163
+ })
164
+
165
+ it('processing → completed: emits checkout.transaction.completed event', async () => {
166
+ const transaction = makeTransaction('processing')
167
+ const em = makeMockEm(transaction, 1)
168
+
169
+ await runUpdateStatus(em, 'completed')
170
+
171
+ expect(emitCheckoutEvent).toHaveBeenCalledWith(
172
+ 'checkout.transaction.completed',
173
+ expect.any(Object),
174
+ )
175
+ })
176
+
177
+ it('pending → processing: nativeUpdate is called with correct WHERE', async () => {
178
+ const transaction = makeTransaction('pending')
179
+ const em = makeMockEm(transaction, 1)
180
+
181
+ await runUpdateStatus(em, 'processing')
182
+
183
+ expect(em.nativeUpdate).toHaveBeenCalledWith(
184
+ expect.anything(),
185
+ expect.objectContaining({ status: 'pending' }),
186
+ expect.objectContaining({ status: 'processing' }),
187
+ )
188
+ })
189
+
190
+ it('pending → processing: does NOT emit a terminal event', async () => {
191
+ const transaction = makeTransaction('pending')
192
+ const em = makeMockEm(transaction, 1)
193
+
194
+ await runUpdateStatus(em, 'processing')
195
+
196
+ expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
197
+ 'checkout.transaction.completed',
198
+ expect.any(Object),
199
+ )
200
+ expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
201
+ 'checkout.transaction.failed',
202
+ expect.any(Object),
203
+ )
204
+ })
205
+ })
206
+
207
+ describe('regression guard — terminal → non-terminal (the race condition)', () => {
208
+ it('completed → processing: throws 409 with code invalid_status_transition', async () => {
209
+ expect.assertions(3)
210
+ const transaction = makeTransaction('completed')
211
+ const em = makeMockEm(transaction, 1)
212
+
213
+ try {
214
+ await runUpdateStatus(em, 'processing')
215
+ } catch (err) {
216
+ const error = err as { status?: number; body?: Record<string, unknown> }
217
+ expect(error.status).toBe(409)
218
+ expect(error.body?.code).toBe('invalid_status_transition')
219
+ expect(em.nativeUpdate).not.toHaveBeenCalled()
220
+ }
221
+ })
222
+
223
+ it('completed → failed: throws 409', async () => {
224
+ expect.assertions(2)
225
+ const transaction = makeTransaction('completed')
226
+ const em = makeMockEm(transaction, 1)
227
+
228
+ try {
229
+ await runUpdateStatus(em, 'failed')
230
+ } catch (err) {
231
+ const error = err as { status?: number; body?: Record<string, unknown> }
232
+ expect(error.status).toBe(409)
233
+ expect(em.nativeUpdate).not.toHaveBeenCalled()
234
+ }
235
+ })
236
+
237
+ it('expired → processing: throws 409', async () => {
238
+ expect.assertions(2)
239
+ const transaction = makeTransaction('expired')
240
+ const em = makeMockEm(transaction, 1)
241
+
242
+ try {
243
+ await runUpdateStatus(em, 'processing')
244
+ } catch (err) {
245
+ const error = err as { status?: number; body?: Record<string, unknown> }
246
+ expect(error.status).toBe(409)
247
+ expect(em.nativeUpdate).not.toHaveBeenCalled()
248
+ }
249
+ })
250
+
251
+ it('failed → completed: throws 409', async () => {
252
+ expect.assertions(2)
253
+ const transaction = makeTransaction('failed')
254
+ const em = makeMockEm(transaction, 1)
255
+
256
+ try {
257
+ await runUpdateStatus(em, 'completed')
258
+ } catch (err) {
259
+ const error = err as { status?: number; body?: Record<string, unknown> }
260
+ expect(error.status).toBe(409)
261
+ expect(em.nativeUpdate).not.toHaveBeenCalled()
262
+ }
263
+ })
264
+ })
265
+
266
+ describe('TOCTOU guard — nativeUpdate returns 0 (concurrent write wins)', () => {
267
+ it('nativeUpdate=0 throws 409 with code concurrent_status_update', async () => {
268
+ expect.assertions(5)
269
+ // Transaction is still processing from our read, but another writer won
270
+ // the race and set status to completed. The post-CAS re-read (refresh:true)
271
+ // should return the winner's status, not the stale identity-mapped value.
272
+ const transaction = makeTransaction('processing')
273
+ const em = makeMockEm(transaction, 0, 'completed')
274
+
275
+ try {
276
+ await runUpdateStatus(em, 'completed')
277
+ } catch (err) {
278
+ const error = err as { status?: number; body?: Record<string, unknown> }
279
+ expect(error.status).toBe(409)
280
+ expect(error.body?.code).toBe('concurrent_status_update')
281
+ expect(error.body?.expectedStatus).toBe('processing')
282
+ // currentStatus must reflect the winning writer's value, not the stale snapshot
283
+ expect(error.body?.currentStatus).toBe('completed')
284
+ // The terminal event must NOT be emitted when the update was a no-op
285
+ expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
286
+ 'checkout.transaction.completed',
287
+ expect.any(Object),
288
+ )
289
+ }
290
+ })
291
+
292
+ it('nativeUpdate=0 does NOT emit any event', async () => {
293
+ expect.assertions(1)
294
+ const transaction = makeTransaction('processing')
295
+ const em = makeMockEm(transaction, 0)
296
+
297
+ try {
298
+ await runUpdateStatus(em, 'completed')
299
+ } catch {
300
+ // Expected 409 — assert no events were emitted
301
+ expect(emitCheckoutEvent).not.toHaveBeenCalled()
302
+ }
303
+ })
304
+ })
305
+
306
+ describe('same-state transitions (idempotency support)', () => {
307
+ it('processing → processing: succeeds and calls nativeUpdate', async () => {
308
+ const transaction = makeTransaction('processing')
309
+ const em = makeMockEm(transaction, 1)
310
+
311
+ await runUpdateStatus(em, 'processing')
312
+
313
+ expect(em.nativeUpdate).toHaveBeenCalledWith(
314
+ expect.anything(),
315
+ expect.objectContaining({ status: 'processing' }),
316
+ expect.objectContaining({ status: 'processing' }),
317
+ )
318
+ })
319
+
320
+ it('completed → completed: succeeds and calls nativeUpdate', async () => {
321
+ const transaction = makeTransaction('completed')
322
+ const em = makeMockEm(transaction, 1)
323
+
324
+ await runUpdateStatus(em, 'completed')
325
+
326
+ expect(em.nativeUpdate).toHaveBeenCalledWith(
327
+ expect.anything(),
328
+ expect.objectContaining({ status: 'completed' }),
329
+ expect.objectContaining({ status: 'completed' }),
330
+ )
331
+ })
332
+
333
+ it('completed → completed: does NOT re-emit checkout.transaction.completed (idempotent redelivery guard)', async () => {
334
+ const transaction = makeTransaction('completed')
335
+ const em = makeMockEm(transaction, 1)
336
+
337
+ await runUpdateStatus(em, 'completed')
338
+
339
+ expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
340
+ 'checkout.transaction.completed',
341
+ expect.any(Object),
342
+ )
343
+ })
344
+
345
+ it('authorized → captured (both → completed): CAS succeeds but terminal event is NOT re-emitted', async () => {
346
+ // Both payment_gateways.payment.authorized and .captured map to 'completed'.
347
+ // The second delivery (captured) finds the transaction already completed;
348
+ // the CAS still succeeds (same-state), but checkout.transaction.completed
349
+ // must NOT fire a second time so the customer does not receive a duplicate notification.
350
+ const transaction = makeTransaction('completed')
351
+ const em = makeMockEm(transaction, 1)
352
+
353
+ await runUpdateStatus(em, 'completed')
354
+
355
+ expect(em.nativeUpdate).toHaveBeenCalledTimes(1)
356
+ expect(emitCheckoutEvent).not.toHaveBeenCalledWith(
357
+ 'checkout.transaction.completed',
358
+ expect.any(Object),
359
+ )
360
+ })
361
+ })
362
+ })
363
+
@@ -12,6 +12,7 @@ import {
12
12
  parseCheckoutInput,
13
13
  toMoneyString,
14
14
  } from '../lib/utils'
15
+ import { assertValidCheckoutStatusTransition } from '../lib/transaction-status-machine'
15
16
 
16
17
  function resolveTransactionScope(input: { tenantId?: string | null; organizationId?: string | null }) {
17
18
  if (!input.organizationId || !input.tenantId) {
@@ -168,11 +169,63 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
168
169
  const previousTerminal = isTerminalCheckoutStatus(previousStatus)
169
170
  const nextTerminal = isTerminalCheckoutStatus(nextStatus)
170
171
 
171
- transaction.status = nextStatus
172
- transaction.paymentStatus = parsed.paymentStatus ?? transaction.paymentStatus ?? null
173
- transaction.gatewayTransactionId = parsed.gatewayTransactionId ?? transaction.gatewayTransactionId ?? null
174
- await tx.flush()
172
+ // State-machine guard: reject any transition not permitted by the
173
+ // VALID_CHECKOUT_TRANSITIONS map (e.g. completed → processing).
174
+ // This runs inside the DB transaction so the check and the write share
175
+ // the same serialisable snapshot.
176
+ assertValidCheckoutStatusTransition(previousStatus, nextStatus)
177
+
178
+ // Resolve the new field values before the write.
179
+ const newPaymentStatus = parsed.paymentStatus ?? transaction.paymentStatus ?? null
180
+ const newGatewayTransactionId = parsed.gatewayTransactionId ?? transaction.gatewayTransactionId ?? null
181
+
182
+ // Atomic compare-and-swap: the WHERE clause pins the current status so
183
+ // a concurrent writer that already advanced the status to a terminal
184
+ // state will cause this update to match 0 rows — eliminating the TOCTOU
185
+ // window between the findOne above and this write.
186
+ const affected = await tx.nativeUpdate(
187
+ CheckoutTransaction,
188
+ {
189
+ id: parsed.id,
190
+ organizationId: scope.organizationId,
191
+ tenantId: scope.tenantId,
192
+ status: previousStatus,
193
+ },
194
+ {
195
+ status: nextStatus,
196
+ paymentStatus: newPaymentStatus,
197
+ gatewayTransactionId: newGatewayTransactionId,
198
+ updatedAt: new Date(),
199
+ },
200
+ )
201
+
202
+ if (affected === 0) {
203
+ // A concurrent writer already advanced the status. Force a DB round-trip
204
+ // (refresh: true bypasses the MikroORM identity map) so the reported
205
+ // currentStatus reflects the winning writer's value, not the stale snapshot.
206
+ const actualTx = await tx.findOne(
207
+ CheckoutTransaction,
208
+ { id: parsed.id, organizationId: scope.organizationId, tenantId: scope.tenantId },
209
+ { fields: ['status'], refresh: true },
210
+ )
211
+ throw new CrudHttpError(409, {
212
+ error: `[internal] Transaction status was already updated by a concurrent process (expected "${previousStatus}", actual "${actualTx?.status ?? 'unknown'}")`,
213
+ code: 'concurrent_status_update',
214
+ expectedStatus: previousStatus,
215
+ currentStatus: actualTx?.status ?? 'unknown',
216
+ requestedStatus: nextStatus,
217
+ })
218
+ }
175
219
 
220
+ // Sync the entity state in the unit of work by refreshing it.
221
+ // This loads the updated values (status, paymentStatus, gatewayTransactionId)
222
+ // from the DB and marks the entity as clean, avoiding a redundant second write
223
+ // during the subsequent tx.flush().
224
+ await tx.refresh(transaction)
225
+
226
+ // Only apply terminal link state and emit the terminal event when the
227
+ // status actually changes — prevents double-notification on idempotent
228
+ // redeliveries (e.g. authorized → captured, both mapping to 'completed').
176
229
  if (!previousTerminal && nextTerminal) {
177
230
  const { usageLimitReached } = applyTerminalTransactionState(link, nextStatus)
178
231
  await tx.flush()
@@ -182,7 +235,7 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
182
235
  usageLimitReachedLinkSlug = link.slug
183
236
  }
184
237
  }
185
- if (nextTerminal) {
238
+ if (nextTerminal && previousStatus !== nextStatus) {
186
239
  terminalEventPayload = {
187
240
  transactionId: transaction.id,
188
241
  linkId: transaction.linkId,
@@ -200,14 +253,16 @@ const updateTransactionStatusCommand: CommandHandler<Record<string, unknown>, {
200
253
  }
201
254
  }
202
255
  })
203
- if (parsed.status === 'completed') {
204
- await emitCheckoutEvent('checkout.transaction.completed', terminalEventPayload ?? {}).catch(() => undefined)
205
- } else if (parsed.status === 'failed') {
206
- await emitCheckoutEvent('checkout.transaction.failed', terminalEventPayload ?? {}).catch(() => undefined)
207
- } else if (parsed.status === 'cancelled') {
208
- await emitCheckoutEvent('checkout.transaction.cancelled', terminalEventPayload ?? {}).catch(() => undefined)
209
- } else if (parsed.status === 'expired') {
210
- await emitCheckoutEvent('checkout.transaction.expired', terminalEventPayload ?? {}).catch(() => undefined)
256
+ if (terminalEventPayload !== null) {
257
+ if (parsed.status === 'completed') {
258
+ await emitCheckoutEvent('checkout.transaction.completed', terminalEventPayload).catch(() => undefined)
259
+ } else if (parsed.status === 'failed') {
260
+ await emitCheckoutEvent('checkout.transaction.failed', terminalEventPayload).catch(() => undefined)
261
+ } else if (parsed.status === 'cancelled') {
262
+ await emitCheckoutEvent('checkout.transaction.cancelled', terminalEventPayload).catch(() => undefined)
263
+ } else if (parsed.status === 'expired') {
264
+ await emitCheckoutEvent('checkout.transaction.expired', terminalEventPayload).catch(() => undefined)
265
+ }
211
266
  }
212
267
  if (emitUsageLimitReached && usageLimitReachedLinkId && usageLimitReachedLinkSlug) {
213
268
  await emitCheckoutEvent('checkout.link.usageLimitReached', {
@@ -0,0 +1,152 @@
1
+ /** @jest-environment node */
2
+
3
+ import {
4
+ VALID_CHECKOUT_TRANSITIONS,
5
+ assertValidCheckoutStatusTransition,
6
+ isValidCheckoutStatusTransition,
7
+ } from '../transaction-status-machine'
8
+
9
+ describe('VALID_CHECKOUT_TRANSITIONS', () => {
10
+ it('pending can transition to processing, completed, failed, cancelled, expired', () => {
11
+ expect(VALID_CHECKOUT_TRANSITIONS.pending).toEqual(
12
+ expect.arrayContaining(['processing', 'completed', 'failed', 'cancelled', 'expired']),
13
+ )
14
+ })
15
+
16
+ it('processing can transition to completed, failed, cancelled, expired', () => {
17
+ expect(VALID_CHECKOUT_TRANSITIONS.processing).toEqual(
18
+ expect.arrayContaining(['completed', 'failed', 'cancelled', 'expired']),
19
+ )
20
+ })
21
+
22
+ it('terminal states have no allowed transitions', () => {
23
+ expect(VALID_CHECKOUT_TRANSITIONS.completed).toHaveLength(0)
24
+ expect(VALID_CHECKOUT_TRANSITIONS.failed).toHaveLength(0)
25
+ expect(VALID_CHECKOUT_TRANSITIONS.cancelled).toHaveLength(0)
26
+ expect(VALID_CHECKOUT_TRANSITIONS.expired).toHaveLength(0)
27
+ })
28
+ })
29
+
30
+ describe('isValidCheckoutStatusTransition', () => {
31
+ it('pending -> processing is valid', () => {
32
+ expect(isValidCheckoutStatusTransition('pending', 'processing')).toBe(true)
33
+ })
34
+
35
+ it('pending -> completed is valid (captured webhook arrives before processing)', () => {
36
+ expect(isValidCheckoutStatusTransition('pending', 'completed')).toBe(true)
37
+ })
38
+
39
+ it('pending -> failed is valid', () => {
40
+ expect(isValidCheckoutStatusTransition('pending', 'failed')).toBe(true)
41
+ })
42
+
43
+ it('pending -> cancelled is valid', () => {
44
+ expect(isValidCheckoutStatusTransition('pending', 'cancelled')).toBe(true)
45
+ })
46
+
47
+ it('pending -> expired is valid', () => {
48
+ expect(isValidCheckoutStatusTransition('pending', 'expired')).toBe(true)
49
+ })
50
+
51
+ it('processing -> completed is valid', () => {
52
+ expect(isValidCheckoutStatusTransition('processing', 'completed')).toBe(true)
53
+ })
54
+
55
+ it('processing -> failed is valid', () => {
56
+ expect(isValidCheckoutStatusTransition('processing', 'failed')).toBe(true)
57
+ })
58
+
59
+ it('processing -> cancelled is valid', () => {
60
+ expect(isValidCheckoutStatusTransition('processing', 'cancelled')).toBe(true)
61
+ })
62
+
63
+ it('processing -> expired is valid', () => {
64
+ expect(isValidCheckoutStatusTransition('processing', 'expired')).toBe(true)
65
+ })
66
+
67
+ it('completed -> processing is INVALID (regression guard)', () => {
68
+ expect(isValidCheckoutStatusTransition('completed', 'processing')).toBe(false)
69
+ })
70
+
71
+ it('completed -> failed is INVALID', () => {
72
+ expect(isValidCheckoutStatusTransition('completed', 'failed')).toBe(false)
73
+ })
74
+
75
+ it('completed -> pending is INVALID', () => {
76
+ expect(isValidCheckoutStatusTransition('completed', 'pending')).toBe(false)
77
+ })
78
+
79
+ it('failed -> processing is INVALID', () => {
80
+ expect(isValidCheckoutStatusTransition('failed', 'processing')).toBe(false)
81
+ })
82
+
83
+ it('failed -> completed is INVALID', () => {
84
+ expect(isValidCheckoutStatusTransition('failed', 'completed')).toBe(false)
85
+ })
86
+
87
+ it('cancelled -> processing is INVALID', () => {
88
+ expect(isValidCheckoutStatusTransition('cancelled', 'processing')).toBe(false)
89
+ })
90
+
91
+ it('expired -> processing is INVALID', () => {
92
+ expect(isValidCheckoutStatusTransition('expired', 'processing')).toBe(false)
93
+ })
94
+
95
+ it('expired -> completed is INVALID', () => {
96
+ expect(isValidCheckoutStatusTransition('expired', 'completed')).toBe(false)
97
+ })
98
+
99
+ it('pending -> pending is valid', () => {
100
+ expect(isValidCheckoutStatusTransition('pending', 'pending')).toBe(true)
101
+ })
102
+
103
+ it('processing -> processing is valid', () => {
104
+ expect(isValidCheckoutStatusTransition('processing', 'processing')).toBe(true)
105
+ })
106
+
107
+ it('completed -> completed is valid', () => {
108
+ expect(isValidCheckoutStatusTransition('completed', 'completed')).toBe(true)
109
+ })
110
+ })
111
+
112
+ describe('assertValidCheckoutStatusTransition', () => {
113
+ it('does not throw for a valid transition (pending -> processing)', () => {
114
+ expect(() => assertValidCheckoutStatusTransition('pending', 'processing')).not.toThrow()
115
+ })
116
+
117
+ it('does not throw for a valid transition (pending -> completed)', () => {
118
+ expect(() => assertValidCheckoutStatusTransition('pending', 'completed')).not.toThrow()
119
+ })
120
+
121
+ it('does not throw for a valid transition (processing -> completed)', () => {
122
+ expect(() => assertValidCheckoutStatusTransition('processing', 'completed')).not.toThrow()
123
+ })
124
+
125
+ it('throws a 409 CrudHttpError for completed -> processing', () => {
126
+ expect.assertions(3)
127
+ try {
128
+ assertValidCheckoutStatusTransition('completed', 'processing')
129
+ } catch (err) {
130
+ const error = err as { status?: number; body?: Record<string, unknown> }
131
+ expect(error.status).toBe(409)
132
+ expect(error.body?.code).toBe('invalid_status_transition')
133
+ expect(error.body?.currentStatus).toBe('completed')
134
+ }
135
+ })
136
+
137
+ it('throws a 409 CrudHttpError for expired -> completed', () => {
138
+ expect.assertions(3)
139
+ try {
140
+ assertValidCheckoutStatusTransition('expired', 'completed')
141
+ } catch (err) {
142
+ const error = err as { status?: number; body?: Record<string, unknown> }
143
+ expect(error.status).toBe(409)
144
+ expect(error.body?.code).toBe('invalid_status_transition')
145
+ expect(error.body?.requestedStatus).toBe('completed')
146
+ }
147
+ })
148
+
149
+ it('does not throw for same-state transition (processing -> processing)', () => {
150
+ expect(() => assertValidCheckoutStatusTransition('processing', 'processing')).not.toThrow()
151
+ })
152
+ })