vona-module-test-pay 5.0.2 → 5.0.4
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.
- package/package.json +4 -4
- package/test/paymentSession.test.ts +349 -1
- package/test/paypalLifecycle.test.ts +1259 -0
- package/test/paypalProvider.test.ts +481 -0
- package/test/webhook.test.ts +25 -10
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { ApiError } from '@cabloy/paypal-server-sdk';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import { app } from 'vona-mock';
|
|
5
|
+
|
|
6
|
+
function createOptions(gateway: Record<string, unknown>) {
|
|
7
|
+
const { clientOptions } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
8
|
+
return {
|
|
9
|
+
...clientOptions,
|
|
10
|
+
secretCredential: { clientId: 'client-1', clientSecret: 'secret-1' },
|
|
11
|
+
webhookId: 'webhook-1',
|
|
12
|
+
merchantReference: 'merchant-1',
|
|
13
|
+
gateway,
|
|
14
|
+
} as never;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function orderRecord(
|
|
18
|
+
status: string,
|
|
19
|
+
captureStatus?: string,
|
|
20
|
+
purchaseUnitOverrides: Record<string, unknown> = {},
|
|
21
|
+
) {
|
|
22
|
+
return {
|
|
23
|
+
id: 'order-1',
|
|
24
|
+
status,
|
|
25
|
+
purchaseUnits: [
|
|
26
|
+
{
|
|
27
|
+
customId: 'payment-correlation-reference',
|
|
28
|
+
invoiceId: 'payment-invoice-reference',
|
|
29
|
+
amount: { currencyCode: 'USD', value: '12.99' },
|
|
30
|
+
payee: { merchantId: 'merchant-1' },
|
|
31
|
+
payments: captureStatus
|
|
32
|
+
? {
|
|
33
|
+
captures: [
|
|
34
|
+
{
|
|
35
|
+
id: 'capture-1',
|
|
36
|
+
status: captureStatus,
|
|
37
|
+
amount: { currencyCode: 'USD', value: '12.99' },
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
42
|
+
...purchaseUnitOverrides,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paymentInput = {
|
|
49
|
+
paymentSessionId: 101,
|
|
50
|
+
businessReference: 'business-1',
|
|
51
|
+
providerInvoiceReference: 'payment-invoice-reference',
|
|
52
|
+
providerCorrelationReference: 'payment-correlation-reference',
|
|
53
|
+
idempotencyKey: 'payment-key',
|
|
54
|
+
amountMinor: 1299,
|
|
55
|
+
currency: 'USD',
|
|
56
|
+
providerOrderId: 'order-1',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const refundInput = {
|
|
60
|
+
paymentSessionId: 101,
|
|
61
|
+
refundOperationId: 201,
|
|
62
|
+
businessReference: 'refund-business-1',
|
|
63
|
+
providerInvoiceReference: 'refund-invoice-reference',
|
|
64
|
+
providerCorrelationReference: 'refund-correlation-reference',
|
|
65
|
+
idempotencyKey: 'refund-key',
|
|
66
|
+
amountMinor: 500,
|
|
67
|
+
currency: 'USD',
|
|
68
|
+
providerCaptureId: 'capture-1',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function paypalApiError(statusCode: number) {
|
|
72
|
+
return new ApiError(
|
|
73
|
+
{
|
|
74
|
+
request: {} as never,
|
|
75
|
+
response: { statusCode, headers: {}, body: '' } as never,
|
|
76
|
+
},
|
|
77
|
+
'opaque provider error',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('paypalProvider.test.ts', { concurrency: false }, () => {
|
|
82
|
+
it('resolves the authoritative Sandbox environment and creates an approval order', async () => {
|
|
83
|
+
await app.bean.executor.mockCtx(async () => {
|
|
84
|
+
const calls: Array<{ kind: string; input: unknown; options: unknown }> = [];
|
|
85
|
+
const gateway = {
|
|
86
|
+
async createOrder(options: unknown, input: unknown) {
|
|
87
|
+
calls.push({ kind: 'createOrder', input, options });
|
|
88
|
+
return {
|
|
89
|
+
id: 'order-1',
|
|
90
|
+
links: [
|
|
91
|
+
{ rel: 'approve', href: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1' },
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
97
|
+
const options = createOptions(gateway);
|
|
98
|
+
const payment = await provider.startPayment(
|
|
99
|
+
{
|
|
100
|
+
...paymentInput,
|
|
101
|
+
providerOrderId: undefined,
|
|
102
|
+
returnUrl: 'https://shop.test/pay/return',
|
|
103
|
+
cancelUrl: 'https://shop.test/pay/cancel',
|
|
104
|
+
},
|
|
105
|
+
options,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
assert.equal((options as any).environment, 'sandbox');
|
|
109
|
+
assert.deepEqual(payment, {
|
|
110
|
+
state: 'requires_action',
|
|
111
|
+
providerPaymentId: 'order-1',
|
|
112
|
+
providerOrderId: 'order-1',
|
|
113
|
+
nextAction: {
|
|
114
|
+
kind: 'redirect',
|
|
115
|
+
url: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
assert.deepEqual(calls[0]?.input, {
|
|
119
|
+
paypalRequestId: 'payment-key',
|
|
120
|
+
body: {
|
|
121
|
+
intent: 'CAPTURE',
|
|
122
|
+
applicationContext: {
|
|
123
|
+
returnUrl: 'https://shop.test/pay/return',
|
|
124
|
+
cancelUrl: 'https://shop.test/pay/cancel',
|
|
125
|
+
},
|
|
126
|
+
purchaseUnits: [
|
|
127
|
+
{
|
|
128
|
+
customId: 'payment-correlation-reference',
|
|
129
|
+
invoiceId: 'payment-invoice-reference',
|
|
130
|
+
amount: { currencyCode: 'USD', value: '12.99' },
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
assert.deepEqual(calls[0]?.options, {
|
|
136
|
+
environment: 'sandbox',
|
|
137
|
+
clientId: 'client-1',
|
|
138
|
+
clientSecret: 'secret-1',
|
|
139
|
+
webhookId: 'webhook-1',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects missing callback URLs and incomplete PayPal configuration', async () => {
|
|
145
|
+
await app.bean.executor.mockCtx(async () => {
|
|
146
|
+
const gateway = {
|
|
147
|
+
async createOrder() {
|
|
148
|
+
throw new Error('unexpected createOrder');
|
|
149
|
+
},
|
|
150
|
+
async getOrder() {
|
|
151
|
+
throw new Error('unexpected getOrder');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
155
|
+
const options = createOptions(gateway) as any;
|
|
156
|
+
await assert.rejects(
|
|
157
|
+
provider.startPayment({ ...paymentInput, providerOrderId: undefined }, options),
|
|
158
|
+
{ code: 500, status: 500 },
|
|
159
|
+
);
|
|
160
|
+
await assert.rejects(
|
|
161
|
+
provider.queryPayment(paymentInput, {
|
|
162
|
+
...options,
|
|
163
|
+
secretCredential: { clientId: undefined, clientSecret: undefined },
|
|
164
|
+
}),
|
|
165
|
+
{ status: 503 },
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('requests a complete PayPal capture representation', async () => {
|
|
171
|
+
await app.bean.executor.mockCtx(async () => {
|
|
172
|
+
const calls: unknown[] = [];
|
|
173
|
+
const gateway = {
|
|
174
|
+
async captureOrder(_options: unknown, input: unknown) {
|
|
175
|
+
calls.push(input);
|
|
176
|
+
return orderRecord('COMPLETED', 'COMPLETED');
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
180
|
+
const payment = await provider.confirmPayment(paymentInput, createOptions(gateway));
|
|
181
|
+
|
|
182
|
+
assert.equal(payment.state, 'succeeded');
|
|
183
|
+
assert.equal(payment.providerCaptureId, 'capture-1');
|
|
184
|
+
assert.deepEqual(payment.nextAction, { kind: 'completed' });
|
|
185
|
+
assert.deepEqual(calls, [
|
|
186
|
+
{
|
|
187
|
+
id: 'order-1',
|
|
188
|
+
paypalRequestId: 'payment-key',
|
|
189
|
+
prefer: 'return=representation',
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('maps PayPal capture states and rejects conflicting order facts', async () => {
|
|
196
|
+
await app.bean.executor.mockCtx(async () => {
|
|
197
|
+
let result: unknown = orderRecord('CREATED');
|
|
198
|
+
const gateway = {
|
|
199
|
+
async getOrder() {
|
|
200
|
+
return result;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
204
|
+
const options = createOptions(gateway);
|
|
205
|
+
|
|
206
|
+
for (const [captureStatus, state] of [
|
|
207
|
+
['COMPLETED', 'succeeded'],
|
|
208
|
+
['PENDING', 'processing'],
|
|
209
|
+
['FAILED', 'failed'],
|
|
210
|
+
['DECLINED', 'failed'],
|
|
211
|
+
] as const) {
|
|
212
|
+
result = orderRecord('CREATED', captureStatus);
|
|
213
|
+
const payment = await provider.queryPayment(paymentInput, options);
|
|
214
|
+
assert.equal(payment.state, state);
|
|
215
|
+
}
|
|
216
|
+
result = orderRecord('VOIDED');
|
|
217
|
+
assert.equal((await provider.queryPayment(paymentInput, options)).state, 'cancelled');
|
|
218
|
+
result = {
|
|
219
|
+
...orderRecord('CREATED'),
|
|
220
|
+
links: [
|
|
221
|
+
{ rel: 'approve', href: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1' },
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
assert.deepEqual(await provider.queryPayment(paymentInput, options), {
|
|
225
|
+
state: 'requires_action',
|
|
226
|
+
providerPaymentId: 'order-1',
|
|
227
|
+
providerOrderId: 'order-1',
|
|
228
|
+
nextAction: {
|
|
229
|
+
kind: 'redirect',
|
|
230
|
+
url: 'https://www.sandbox.paypal.com/checkoutnow?token=order-1',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
result = orderRecord('CREATED');
|
|
234
|
+
const missingApproval = await provider.queryPayment(paymentInput, options);
|
|
235
|
+
assert.equal(missingApproval.state, 'requires_action');
|
|
236
|
+
assert.equal(missingApproval.nextAction, undefined);
|
|
237
|
+
|
|
238
|
+
result = orderRecord('CREATED', 'COMPLETED', {
|
|
239
|
+
amount: { currencyCode: 'USD', value: '13.99' },
|
|
240
|
+
});
|
|
241
|
+
await assert.rejects(provider.queryPayment(paymentInput, options), { status: 409 });
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('logs only allowlisted merchant diagnostics before rejecting order and refund conflicts', async () => {
|
|
246
|
+
await app.bean.executor.mockCtx(async () => {
|
|
247
|
+
const warnings: unknown[] = [];
|
|
248
|
+
const gateway = {
|
|
249
|
+
async getOrder() {
|
|
250
|
+
return orderRecord('COMPLETED', 'COMPLETED', {
|
|
251
|
+
payee: { merchantId: 'merchant-order-observed' },
|
|
252
|
+
payer: { merchantId: 'merchant-1' },
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
async refundCapturedPayment() {
|
|
256
|
+
return {
|
|
257
|
+
id: 'refund-1',
|
|
258
|
+
customId: 'refund-correlation-reference',
|
|
259
|
+
invoiceId: 'refund-invoice-reference',
|
|
260
|
+
amount: { currencyCode: 'USD', value: '5.00' },
|
|
261
|
+
status: 'COMPLETED',
|
|
262
|
+
payee: { merchantId: 'merchant-1' },
|
|
263
|
+
payer: { merchantId: 'merchant-refund-observed' },
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
268
|
+
const warn = (provider as any).$logger.warn;
|
|
269
|
+
(provider as any).$logger.warn = (message: unknown) => warnings.push(message);
|
|
270
|
+
try {
|
|
271
|
+
await assert.rejects(provider.queryPayment(paymentInput, createOptions(gateway)), {
|
|
272
|
+
status: 409,
|
|
273
|
+
});
|
|
274
|
+
await assert.rejects(provider.createRefund(refundInput, createOptions(gateway)), {
|
|
275
|
+
status: 409,
|
|
276
|
+
});
|
|
277
|
+
} finally {
|
|
278
|
+
(provider as any).$logger.warn = warn;
|
|
279
|
+
}
|
|
280
|
+
assert.deepEqual(warnings, [
|
|
281
|
+
{
|
|
282
|
+
event: 'paypal.merchant_reference_conflict',
|
|
283
|
+
observedMerchantSource: 'payee.merchant_id',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
event: 'paypal.merchant_reference_conflict',
|
|
287
|
+
observedMerchantSource: 'payer.merchant_id',
|
|
288
|
+
},
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('maps refund statuses and preserves refund request facts', async () => {
|
|
294
|
+
await app.bean.executor.mockCtx(async () => {
|
|
295
|
+
const calls: unknown[] = [];
|
|
296
|
+
let status = 'COMPLETED';
|
|
297
|
+
function refundRecord() {
|
|
298
|
+
return {
|
|
299
|
+
id: 'refund-1',
|
|
300
|
+
customId: 'refund-correlation-reference',
|
|
301
|
+
invoiceId: 'refund-invoice-reference',
|
|
302
|
+
amount: { currencyCode: 'USD', value: '5.00' },
|
|
303
|
+
status,
|
|
304
|
+
payer: { merchantId: 'merchant-1' },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const gateway = {
|
|
308
|
+
async refundCapturedPayment(_options: unknown, input: unknown) {
|
|
309
|
+
calls.push(input);
|
|
310
|
+
return refundRecord();
|
|
311
|
+
},
|
|
312
|
+
async getRefund() {
|
|
313
|
+
return refundRecord();
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
317
|
+
const options = createOptions(gateway);
|
|
318
|
+
for (const [nextStatus, state] of [
|
|
319
|
+
['COMPLETED', 'succeeded'],
|
|
320
|
+
['PENDING', 'pending'],
|
|
321
|
+
['FAILED', 'failed'],
|
|
322
|
+
['CANCELLED', 'cancelled'],
|
|
323
|
+
] as const) {
|
|
324
|
+
status = nextStatus;
|
|
325
|
+
assert.equal((await provider.createRefund(refundInput, options)).state, state);
|
|
326
|
+
assert.equal(
|
|
327
|
+
(await provider.queryRefund!({ ...refundInput, providerRefundId: 'refund-1' }, options))
|
|
328
|
+
.state,
|
|
329
|
+
state,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
assert.deepEqual(calls[0], {
|
|
333
|
+
captureId: 'capture-1',
|
|
334
|
+
paypalRequestId: 'refund-key',
|
|
335
|
+
body: {
|
|
336
|
+
amount: { currencyCode: 'USD', value: '5.00' },
|
|
337
|
+
customId: 'refund-correlation-reference',
|
|
338
|
+
invoiceId: 'refund-invoice-reference',
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('treats a definitive PayPal refund rejection as terminal but preserves ambiguous errors', async () => {
|
|
345
|
+
await app.bean.executor.mockCtx(async () => {
|
|
346
|
+
let error: Error = paypalApiError(422);
|
|
347
|
+
const gateway = {
|
|
348
|
+
async refundCapturedPayment() {
|
|
349
|
+
throw error;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
353
|
+
const options = createOptions(gateway);
|
|
354
|
+
|
|
355
|
+
assert.deepEqual(await provider.createRefund(refundInput, options), { state: 'failed' });
|
|
356
|
+
error = paypalApiError(409);
|
|
357
|
+
await assert.rejects(provider.createRefund(refundInput, options), { statusCode: 409 });
|
|
358
|
+
error = new Error('transport failure');
|
|
359
|
+
await assert.rejects(provider.createRefund(refundInput, options), /transport failure/);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('keeps capture-only refund notifications unresolved without posting another refund', async () => {
|
|
364
|
+
await app.bean.executor.mockCtx(async () => {
|
|
365
|
+
const calls: string[] = [];
|
|
366
|
+
const gateway = {
|
|
367
|
+
async verifyWebhookSignature() {},
|
|
368
|
+
async getCapturedPayment() {
|
|
369
|
+
calls.push('getCapturedPayment');
|
|
370
|
+
return { id: 'capture-1' };
|
|
371
|
+
},
|
|
372
|
+
async refundCapturedPayment() {
|
|
373
|
+
calls.push('refundCapturedPayment');
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
377
|
+
const verified = await provider.verifyWebhook(
|
|
378
|
+
{
|
|
379
|
+
rawBody: '{"id":"event-refunded"}',
|
|
380
|
+
body: {
|
|
381
|
+
id: 'event-refunded',
|
|
382
|
+
event_type: 'PAYMENT.CAPTURE.REFUNDED',
|
|
383
|
+
resource: {
|
|
384
|
+
id: 'capture-1',
|
|
385
|
+
status: 'REFUNDED',
|
|
386
|
+
amount: { currency_code: 'USD', value: '12.99' },
|
|
387
|
+
supplementary_data: { related_ids: { capture_id: 'capture-1' } },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
headers: {},
|
|
391
|
+
},
|
|
392
|
+
createOptions(gateway),
|
|
393
|
+
);
|
|
394
|
+
assert.equal(verified.ignored, true);
|
|
395
|
+
assert.equal(verified.providerCaptureId, 'capture-1');
|
|
396
|
+
assert.equal(verified.refund, undefined);
|
|
397
|
+
assert.equal(verified.refundOperationId, undefined);
|
|
398
|
+
assert.deepEqual(calls, ['getCapturedPayment']);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('resolves an alternate capture-refunded resource ID only with refund metadata', async () => {
|
|
403
|
+
await app.bean.executor.mockCtx(async () => {
|
|
404
|
+
const gateway = {
|
|
405
|
+
async verifyWebhookSignature() {},
|
|
406
|
+
async getCapturedPayment() {
|
|
407
|
+
throw paypalApiError(404);
|
|
408
|
+
},
|
|
409
|
+
async getRefund() {
|
|
410
|
+
return {
|
|
411
|
+
id: 'refund-1',
|
|
412
|
+
customId: 'not-a-local-refund',
|
|
413
|
+
invoiceId: 'refund-invoice-reference',
|
|
414
|
+
amount: { currencyCode: 'USD', value: '5.00' },
|
|
415
|
+
status: 'COMPLETED',
|
|
416
|
+
payer: { merchantId: 'merchant-1' },
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
421
|
+
await assert.rejects(
|
|
422
|
+
provider.verifyWebhook(
|
|
423
|
+
{
|
|
424
|
+
rawBody: '{"id":"event-refunded-alternate"}',
|
|
425
|
+
body: {
|
|
426
|
+
id: 'event-refunded-alternate',
|
|
427
|
+
event_type: 'PAYMENT.CAPTURE.REFUNDED',
|
|
428
|
+
resource: {
|
|
429
|
+
id: 'refund-1',
|
|
430
|
+
status: 'REFUNDED',
|
|
431
|
+
amount: { currency_code: 'USD', value: '5.00' },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
headers: {},
|
|
435
|
+
},
|
|
436
|
+
createOptions(gateway),
|
|
437
|
+
),
|
|
438
|
+
{ status: 400 },
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('passes webhook facts to the gateway and rejects uncorrelated capture events', async () => {
|
|
444
|
+
await app.bean.executor.mockCtx(async () => {
|
|
445
|
+
const calls: unknown[] = [];
|
|
446
|
+
const gateway = {
|
|
447
|
+
async verifyWebhookSignature(options: unknown, input: unknown) {
|
|
448
|
+
calls.push({ options, input });
|
|
449
|
+
},
|
|
450
|
+
async getOrder() {
|
|
451
|
+
return orderRecord('COMPLETED', 'COMPLETED');
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
const { provider } = app.bean.payProvider.resolveByName('pay-paypal:paypal', 'default');
|
|
455
|
+
const options = createOptions(gateway);
|
|
456
|
+
const webhookInput = {
|
|
457
|
+
rawBody: '{"id":"event-1"}',
|
|
458
|
+
body: {
|
|
459
|
+
id: 'event-1',
|
|
460
|
+
event_type: 'PAYMENT.CAPTURE.COMPLETED',
|
|
461
|
+
resource: {
|
|
462
|
+
id: 'capture-1',
|
|
463
|
+
amount: { currency_code: 'USD', value: '12.99' },
|
|
464
|
+
supplementary_data: { related_ids: { order_id: 'order-1' } },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
headers: { 'paypal-transmission-id': 'transmission-1' },
|
|
468
|
+
};
|
|
469
|
+
await assert.rejects(provider.verifyWebhook(webhookInput, options), { status: 400 });
|
|
470
|
+
assert.deepEqual(calls[0], {
|
|
471
|
+
options: {
|
|
472
|
+
environment: 'sandbox',
|
|
473
|
+
clientId: 'client-1',
|
|
474
|
+
clientSecret: 'secret-1',
|
|
475
|
+
webhookId: 'webhook-1',
|
|
476
|
+
},
|
|
477
|
+
input: webhookInput,
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
package/test/webhook.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { acquireTestLock, app } from 'vona-mock';
|
|
|
6
6
|
interface IFixture {
|
|
7
7
|
userId?: number;
|
|
8
8
|
paymentSessionId?: number;
|
|
9
|
+
providerCorrelationReference?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
async function createFixture(suffix: string): Promise<IFixture> {
|
|
@@ -19,7 +20,11 @@ async function createFixture(suffix: string): Promise<IFixture> {
|
|
|
19
20
|
currency: 'USD',
|
|
20
21
|
correlationId: `payment-${suffix}`,
|
|
21
22
|
});
|
|
22
|
-
return {
|
|
23
|
+
return {
|
|
24
|
+
userId: user.id as number,
|
|
25
|
+
paymentSessionId: session.id as number,
|
|
26
|
+
providerCorrelationReference: session.providerCorrelationReference,
|
|
27
|
+
};
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
async function cleanup(fixture: IFixture) {
|
|
@@ -46,7 +51,7 @@ function createRawBody(
|
|
|
46
51
|
state,
|
|
47
52
|
amountMinor: 1299,
|
|
48
53
|
eventType: `payment.${state}`,
|
|
49
|
-
|
|
54
|
+
providerCorrelationReference: fixture.providerCorrelationReference,
|
|
50
55
|
eventId: options?.eventId ?? `mock-${suffix}`,
|
|
51
56
|
providerPaymentId: options?.providerPaymentId ?? `payment-${suffix}`,
|
|
52
57
|
providerCaptureId: options?.providerCaptureId ?? `capture-${suffix}`,
|
|
@@ -54,7 +59,7 @@ function createRawBody(
|
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
function createCommand(rawBody: string) {
|
|
62
|
+
function createCommand(rawBody: string, paymentSessionId: number) {
|
|
58
63
|
const body = JSON.parse(rawBody);
|
|
59
64
|
return {
|
|
60
65
|
providerName: 'pay-mock:mock',
|
|
@@ -64,7 +69,7 @@ function createCommand(rawBody: string) {
|
|
|
64
69
|
verified: {
|
|
65
70
|
eventId: body.eventId,
|
|
66
71
|
eventType: body.eventType,
|
|
67
|
-
paymentSessionId
|
|
72
|
+
paymentSessionId,
|
|
68
73
|
payment: {
|
|
69
74
|
state: body.state,
|
|
70
75
|
providerPaymentId: body.providerPaymentId,
|
|
@@ -202,8 +207,12 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
|
|
|
202
207
|
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
203
208
|
try {
|
|
204
209
|
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
205
|
-
const first = await app
|
|
206
|
-
|
|
210
|
+
const first = await app
|
|
211
|
+
.scope('a-pay')
|
|
212
|
+
.service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
|
|
213
|
+
const replay = await app
|
|
214
|
+
.scope('a-pay')
|
|
215
|
+
.service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
|
|
207
216
|
assert.equal(replay.id, first.id);
|
|
208
217
|
assert.equal(replay.state, 'processed');
|
|
209
218
|
const session = await app
|
|
@@ -231,7 +240,9 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
|
|
|
231
240
|
const rawBody = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
232
241
|
const receive = async () => {
|
|
233
242
|
return await app.bean.executor.mockCtx(async () => {
|
|
234
|
-
return await app
|
|
243
|
+
return await app
|
|
244
|
+
.scope('a-pay')
|
|
245
|
+
.service.webhook.receive(createCommand(rawBody, fixture.paymentSessionId!));
|
|
235
246
|
});
|
|
236
247
|
};
|
|
237
248
|
const results = await Promise.allSettled([receive(), receive()]);
|
|
@@ -244,7 +255,7 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
|
|
|
244
255
|
const failedRawBody = createRawBody(fixture, randomUUID().slice(0, 12), 'failed');
|
|
245
256
|
const ignored = await app
|
|
246
257
|
.scope('a-pay')
|
|
247
|
-
.service.webhook.receive(createCommand(failedRawBody));
|
|
258
|
+
.service.webhook.receive(createCommand(failedRawBody, fixture.paymentSessionId!));
|
|
248
259
|
const session = await app
|
|
249
260
|
.scope('a-pay')
|
|
250
261
|
.model.paymentSession.getById(fixture.paymentSessionId!);
|
|
@@ -267,12 +278,16 @@ describe('webhook.test.ts', { concurrency: false, sequential: true }, () => {
|
|
|
267
278
|
const fixture = await createFixture(randomUUID().slice(0, 12));
|
|
268
279
|
try {
|
|
269
280
|
const first = createRawBody(fixture, randomUUID().slice(0, 12));
|
|
270
|
-
await app
|
|
281
|
+
await app
|
|
282
|
+
.scope('a-pay')
|
|
283
|
+
.service.webhook.receive(createCommand(first, fixture.paymentSessionId!));
|
|
271
284
|
const conflict = createRawBody(fixture, randomUUID().slice(0, 12), 'succeeded', {
|
|
272
285
|
providerCaptureId: `other-capture-${randomUUID().slice(0, 12)}`,
|
|
273
286
|
});
|
|
274
287
|
await assert.rejects(
|
|
275
|
-
app
|
|
288
|
+
app
|
|
289
|
+
.scope('a-pay')
|
|
290
|
+
.service.webhook.receive(createCommand(conflict, fixture.paymentSessionId!)),
|
|
276
291
|
(error: any) => error.code === 409,
|
|
277
292
|
);
|
|
278
293
|
const facts = await countFacts(fixture.paymentSessionId!);
|