@xenterprises/fastify-xstripe 1.0.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.
- package/.dockerignore +62 -0
- package/.env.example +116 -0
- package/API.md +574 -0
- package/CHANGELOG.md +96 -0
- package/EXAMPLES.md +883 -0
- package/LICENSE +15 -0
- package/MIGRATION.md +374 -0
- package/QUICK_START.md +179 -0
- package/README.md +331 -0
- package/SECURITY.md +465 -0
- package/TESTING.md +357 -0
- package/index.d.ts +309 -0
- package/package.json +53 -0
- package/server/app.js +557 -0
- package/src/handlers/defaultHandlers.js +355 -0
- package/src/handlers/exampleHandlers.js +278 -0
- package/src/handlers/index.js +8 -0
- package/src/index.js +10 -0
- package/src/utils/helpers.js +220 -0
- package/src/webhooks/webhooks.js +72 -0
- package/src/xStripe.js +45 -0
- package/test/handlers.test.js +959 -0
- package/test/xStripe.integration.test.js +409 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
// test/handlers.test.js
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Example tests for webhook handlers
|
|
7
|
+
* Run with: node --test test/handlers.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Mock event data
|
|
11
|
+
const mockSubscriptionCreatedEvent = {
|
|
12
|
+
type: 'customer.subscription.created',
|
|
13
|
+
data: {
|
|
14
|
+
object: {
|
|
15
|
+
id: 'sub_123',
|
|
16
|
+
customer: 'cus_123',
|
|
17
|
+
status: 'active',
|
|
18
|
+
current_period_start: 1234567890,
|
|
19
|
+
current_period_end: 1234567890,
|
|
20
|
+
items: {
|
|
21
|
+
data: [{
|
|
22
|
+
price: {
|
|
23
|
+
id: 'price_123',
|
|
24
|
+
product: 'prod_123',
|
|
25
|
+
unit_amount: 2000,
|
|
26
|
+
currency: 'usd',
|
|
27
|
+
},
|
|
28
|
+
quantity: 1,
|
|
29
|
+
}],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockPaymentFailedEvent = {
|
|
36
|
+
type: 'invoice.payment_failed',
|
|
37
|
+
data: {
|
|
38
|
+
object: {
|
|
39
|
+
id: 'in_123',
|
|
40
|
+
customer: 'cus_123',
|
|
41
|
+
subscription: 'sub_123',
|
|
42
|
+
amount_due: 2000,
|
|
43
|
+
attempt_count: 2,
|
|
44
|
+
currency: 'usd',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Test subscription created handler
|
|
50
|
+
test('subscription.created handler should log correctly', async () => {
|
|
51
|
+
let loggedInfo = null;
|
|
52
|
+
|
|
53
|
+
const mockFastify = {
|
|
54
|
+
log: {
|
|
55
|
+
info: (data) => { loggedInfo = data; },
|
|
56
|
+
error: () => {},
|
|
57
|
+
warn: () => {},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const mockStripe = {};
|
|
62
|
+
|
|
63
|
+
// Import and test default handler
|
|
64
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
65
|
+
const handler = defaultHandlers['customer.subscription.created'];
|
|
66
|
+
|
|
67
|
+
await handler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
68
|
+
|
|
69
|
+
assert.ok(loggedInfo, 'Should have logged info');
|
|
70
|
+
assert.equal(loggedInfo.subscriptionId, 'sub_123');
|
|
71
|
+
assert.equal(loggedInfo.customerId, 'cus_123');
|
|
72
|
+
assert.equal(loggedInfo.status, 'active');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Test custom handler with database update
|
|
76
|
+
test('custom subscription handler should update database', async () => {
|
|
77
|
+
let updatedData = null;
|
|
78
|
+
|
|
79
|
+
const customHandler = async (event, fastify, stripe) => {
|
|
80
|
+
const subscription = event.data.object;
|
|
81
|
+
|
|
82
|
+
await fastify.prisma.user.update({
|
|
83
|
+
where: { stripeCustomerId: subscription.customer },
|
|
84
|
+
data: {
|
|
85
|
+
subscriptionId: subscription.id,
|
|
86
|
+
subscriptionStatus: subscription.status,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mockFastify = {
|
|
92
|
+
log: {
|
|
93
|
+
info: () => {},
|
|
94
|
+
error: () => {},
|
|
95
|
+
warn: () => {},
|
|
96
|
+
},
|
|
97
|
+
prisma: {
|
|
98
|
+
user: {
|
|
99
|
+
update: async (data) => {
|
|
100
|
+
updatedData = data;
|
|
101
|
+
return { id: 1 };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const mockStripe = {};
|
|
108
|
+
|
|
109
|
+
await customHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
110
|
+
|
|
111
|
+
assert.ok(updatedData, 'Should have updated database');
|
|
112
|
+
assert.equal(updatedData.where.stripeCustomerId, 'cus_123');
|
|
113
|
+
assert.equal(updatedData.data.subscriptionId, 'sub_123');
|
|
114
|
+
assert.equal(updatedData.data.subscriptionStatus, 'active');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Test payment failure handler
|
|
118
|
+
test('payment failure handler should send email', async () => {
|
|
119
|
+
let emailSent = null;
|
|
120
|
+
|
|
121
|
+
const customHandler = async (event, fastify, stripe) => {
|
|
122
|
+
const invoice = event.data.object;
|
|
123
|
+
const customer = await stripe.customers.retrieve(invoice.customer);
|
|
124
|
+
|
|
125
|
+
await fastify.email.send(
|
|
126
|
+
customer.email,
|
|
127
|
+
'Payment Failed',
|
|
128
|
+
'<p>Please update your payment method.</p>'
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const mockFastify = {
|
|
133
|
+
log: {
|
|
134
|
+
info: () => {},
|
|
135
|
+
error: () => {},
|
|
136
|
+
warn: () => {},
|
|
137
|
+
},
|
|
138
|
+
email: {
|
|
139
|
+
send: async (to, subject, body) => {
|
|
140
|
+
emailSent = { to, subject, body };
|
|
141
|
+
return { success: true };
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const mockStripe = {
|
|
147
|
+
customers: {
|
|
148
|
+
retrieve: async (id) => ({
|
|
149
|
+
id,
|
|
150
|
+
email: 'test@example.com',
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await customHandler(mockPaymentFailedEvent, mockFastify, mockStripe);
|
|
156
|
+
|
|
157
|
+
assert.ok(emailSent, 'Should have sent email');
|
|
158
|
+
assert.equal(emailSent.to, 'test@example.com');
|
|
159
|
+
assert.equal(emailSent.subject, 'Payment Failed');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Test error handling
|
|
163
|
+
test('handler errors should not throw', async () => {
|
|
164
|
+
const failingHandler = async (event, fastify, stripe) => {
|
|
165
|
+
throw new Error('Database connection failed');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const mockFastify = {
|
|
169
|
+
log: {
|
|
170
|
+
info: () => {},
|
|
171
|
+
error: () => {},
|
|
172
|
+
warn: () => {},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const mockStripe = {};
|
|
177
|
+
|
|
178
|
+
// Should not throw - errors are caught by webhook handler
|
|
179
|
+
try {
|
|
180
|
+
await failingHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
181
|
+
assert.fail('Should have thrown an error');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
assert.equal(error.message, 'Database connection failed');
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Test helper functions
|
|
188
|
+
test('helper functions work correctly', async () => {
|
|
189
|
+
const { formatAmount, getPlanName, isActiveSubscription } = await import('../src/utils/helpers.js');
|
|
190
|
+
|
|
191
|
+
// Test formatAmount
|
|
192
|
+
const formatted = formatAmount(2000, 'USD');
|
|
193
|
+
assert.equal(formatted, '$20.00');
|
|
194
|
+
|
|
195
|
+
// Test getPlanName
|
|
196
|
+
const subscription = mockSubscriptionCreatedEvent.data.object;
|
|
197
|
+
const planName = getPlanName(subscription);
|
|
198
|
+
assert.ok(planName);
|
|
199
|
+
|
|
200
|
+
// Test isActiveSubscription
|
|
201
|
+
assert.equal(isActiveSubscription(subscription), true);
|
|
202
|
+
|
|
203
|
+
const canceledSub = { ...subscription, status: 'canceled' };
|
|
204
|
+
assert.equal(isActiveSubscription(canceledSub), false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Test idempotency
|
|
208
|
+
test('handlers should be idempotent', async () => {
|
|
209
|
+
let callCount = 0;
|
|
210
|
+
|
|
211
|
+
const idempotentHandler = async (event, fastify, stripe) => {
|
|
212
|
+
const subscription = event.data.object;
|
|
213
|
+
|
|
214
|
+
// Upsert pattern - safe to call multiple times
|
|
215
|
+
await fastify.prisma.user.upsert({
|
|
216
|
+
where: { stripeCustomerId: subscription.customer },
|
|
217
|
+
update: { subscriptionStatus: subscription.status },
|
|
218
|
+
create: {
|
|
219
|
+
stripeCustomerId: subscription.customer,
|
|
220
|
+
subscriptionStatus: subscription.status,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
callCount++;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const mockFastify = {
|
|
228
|
+
log: {
|
|
229
|
+
info: () => {},
|
|
230
|
+
error: () => {},
|
|
231
|
+
warn: () => {},
|
|
232
|
+
},
|
|
233
|
+
prisma: {
|
|
234
|
+
user: {
|
|
235
|
+
upsert: async () => ({ id: 1 }),
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const mockStripe = {};
|
|
241
|
+
|
|
242
|
+
// Call handler multiple times
|
|
243
|
+
await idempotentHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
244
|
+
await idempotentHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
245
|
+
await idempotentHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
246
|
+
|
|
247
|
+
assert.equal(callCount, 3, 'Should have been called 3 times');
|
|
248
|
+
// In real scenario, database state should be same after each call
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// COMPREHENSIVE EVENT HANDLER TESTS
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
test('customer.updated handler should process customer updates', async () => {
|
|
256
|
+
const mockEvent = {
|
|
257
|
+
type: 'customer.updated',
|
|
258
|
+
data: {
|
|
259
|
+
object: {
|
|
260
|
+
id: 'cus_123',
|
|
261
|
+
email: 'updated@example.com',
|
|
262
|
+
name: 'John Updated',
|
|
263
|
+
metadata: { userId: '456' },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
let loggedData = null;
|
|
269
|
+
|
|
270
|
+
const mockFastify = {
|
|
271
|
+
log: {
|
|
272
|
+
info: (data) => { loggedData = data; },
|
|
273
|
+
error: () => {},
|
|
274
|
+
warn: () => {},
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
279
|
+
const handler = defaultHandlers['customer.updated'];
|
|
280
|
+
|
|
281
|
+
await handler(mockEvent, mockFastify, {});
|
|
282
|
+
|
|
283
|
+
assert.ok(loggedData);
|
|
284
|
+
assert.equal(loggedData.customerId, 'cus_123');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('customer.deleted handler should process customer deletion', async () => {
|
|
288
|
+
const mockEvent = {
|
|
289
|
+
type: 'customer.deleted',
|
|
290
|
+
data: {
|
|
291
|
+
object: {
|
|
292
|
+
id: 'cus_123',
|
|
293
|
+
email: 'deleted@example.com',
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
let loggedData = null;
|
|
299
|
+
|
|
300
|
+
const mockFastify = {
|
|
301
|
+
log: {
|
|
302
|
+
info: (data) => { loggedData = data; },
|
|
303
|
+
error: () => {},
|
|
304
|
+
warn: () => {},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
309
|
+
const handler = defaultHandlers['customer.deleted'];
|
|
310
|
+
|
|
311
|
+
await handler(mockEvent, mockFastify, {});
|
|
312
|
+
|
|
313
|
+
assert.ok(loggedData);
|
|
314
|
+
assert.equal(loggedData.customerId, 'cus_123');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('invoice.created handler should process invoice creation', async () => {
|
|
318
|
+
const mockEvent = {
|
|
319
|
+
type: 'invoice.created',
|
|
320
|
+
data: {
|
|
321
|
+
object: {
|
|
322
|
+
id: 'in_123',
|
|
323
|
+
customer: 'cus_123',
|
|
324
|
+
subscription: 'sub_123',
|
|
325
|
+
amount_due: 5000,
|
|
326
|
+
currency: 'usd',
|
|
327
|
+
status: 'draft',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let loggedData = null;
|
|
333
|
+
|
|
334
|
+
const mockFastify = {
|
|
335
|
+
log: {
|
|
336
|
+
info: (data) => { loggedData = data; },
|
|
337
|
+
error: () => {},
|
|
338
|
+
warn: () => {},
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
343
|
+
const handler = defaultHandlers['invoice.created'];
|
|
344
|
+
|
|
345
|
+
await handler(mockEvent, mockFastify, {});
|
|
346
|
+
|
|
347
|
+
assert.ok(loggedData);
|
|
348
|
+
assert.equal(loggedData.invoiceId, 'in_123');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('invoice.finalized handler should process invoice finalization', async () => {
|
|
352
|
+
const mockEvent = {
|
|
353
|
+
type: 'invoice.finalized',
|
|
354
|
+
data: {
|
|
355
|
+
object: {
|
|
356
|
+
id: 'in_123',
|
|
357
|
+
customer: 'cus_123',
|
|
358
|
+
amount_due: 5000,
|
|
359
|
+
status: 'open',
|
|
360
|
+
hosted_invoice_url: 'https://stripe.com/invoice',
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
let loggedData = null;
|
|
366
|
+
|
|
367
|
+
const mockFastify = {
|
|
368
|
+
log: {
|
|
369
|
+
info: (data) => { loggedData = data; },
|
|
370
|
+
error: () => {},
|
|
371
|
+
warn: () => {},
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
376
|
+
const handler = defaultHandlers['invoice.finalized'];
|
|
377
|
+
|
|
378
|
+
await handler(mockEvent, mockFastify, {});
|
|
379
|
+
|
|
380
|
+
assert.ok(loggedData);
|
|
381
|
+
assert.equal(loggedData.invoiceId, 'in_123');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('invoice.paid handler should process successful invoice payments', async () => {
|
|
385
|
+
const mockEvent = {
|
|
386
|
+
type: 'invoice.paid',
|
|
387
|
+
data: {
|
|
388
|
+
object: {
|
|
389
|
+
id: 'in_123',
|
|
390
|
+
customer: 'cus_123',
|
|
391
|
+
amount_paid: 5000,
|
|
392
|
+
paid: true,
|
|
393
|
+
status: 'paid',
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
let loggedData = null;
|
|
399
|
+
|
|
400
|
+
const mockFastify = {
|
|
401
|
+
log: {
|
|
402
|
+
info: (data) => { loggedData = data; },
|
|
403
|
+
error: () => {},
|
|
404
|
+
warn: () => {},
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
409
|
+
const handler = defaultHandlers['invoice.paid'];
|
|
410
|
+
|
|
411
|
+
await handler(mockEvent, mockFastify, {});
|
|
412
|
+
|
|
413
|
+
assert.ok(loggedData);
|
|
414
|
+
assert.equal(loggedData.invoiceId, 'in_123');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('charge.succeeded handler should process successful charges', async () => {
|
|
418
|
+
const mockEvent = {
|
|
419
|
+
type: 'charge.succeeded',
|
|
420
|
+
data: {
|
|
421
|
+
object: {
|
|
422
|
+
id: 'ch_123',
|
|
423
|
+
customer: 'cus_123',
|
|
424
|
+
amount: 5000,
|
|
425
|
+
currency: 'usd',
|
|
426
|
+
status: 'succeeded',
|
|
427
|
+
payment_method_details: { type: 'card' },
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
let loggedData = null;
|
|
433
|
+
|
|
434
|
+
const mockFastify = {
|
|
435
|
+
log: {
|
|
436
|
+
info: (data) => { loggedData = data; },
|
|
437
|
+
error: () => {},
|
|
438
|
+
warn: () => {},
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
443
|
+
const handler = defaultHandlers['charge.succeeded'];
|
|
444
|
+
|
|
445
|
+
await handler(mockEvent, mockFastify, {});
|
|
446
|
+
|
|
447
|
+
assert.ok(loggedData);
|
|
448
|
+
assert.equal(loggedData.chargeId, 'ch_123');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('charge.failed handler should process failed charges', async () => {
|
|
452
|
+
const mockEvent = {
|
|
453
|
+
type: 'charge.failed',
|
|
454
|
+
data: {
|
|
455
|
+
object: {
|
|
456
|
+
id: 'ch_123',
|
|
457
|
+
customer: 'cus_123',
|
|
458
|
+
amount: 5000,
|
|
459
|
+
currency: 'usd',
|
|
460
|
+
failure_code: 'card_declined',
|
|
461
|
+
failure_message: 'Your card was declined',
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
let loggedData = null;
|
|
467
|
+
|
|
468
|
+
const mockFastify = {
|
|
469
|
+
log: {
|
|
470
|
+
info: () => {},
|
|
471
|
+
error: (data) => { loggedData = data; },
|
|
472
|
+
warn: () => {},
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
477
|
+
const handler = defaultHandlers['charge.failed'];
|
|
478
|
+
|
|
479
|
+
await handler(mockEvent, mockFastify, {});
|
|
480
|
+
|
|
481
|
+
assert.ok(loggedData);
|
|
482
|
+
assert.equal(loggedData.chargeId, 'ch_123');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('charge.refunded handler should process charge refunds', async () => {
|
|
486
|
+
const mockEvent = {
|
|
487
|
+
type: 'charge.refunded',
|
|
488
|
+
data: {
|
|
489
|
+
object: {
|
|
490
|
+
id: 'ch_123',
|
|
491
|
+
customer: 'cus_123',
|
|
492
|
+
amount_refunded: 5000,
|
|
493
|
+
refunded: true,
|
|
494
|
+
refunds: {
|
|
495
|
+
data: [{ id: 're_123', amount: 5000 }],
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
let loggedData = null;
|
|
502
|
+
|
|
503
|
+
const mockFastify = {
|
|
504
|
+
log: {
|
|
505
|
+
info: (data) => { loggedData = data; },
|
|
506
|
+
error: () => {},
|
|
507
|
+
warn: () => {},
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
512
|
+
const handler = defaultHandlers['charge.refunded'];
|
|
513
|
+
|
|
514
|
+
await handler(mockEvent, mockFastify, {});
|
|
515
|
+
|
|
516
|
+
assert.ok(loggedData);
|
|
517
|
+
assert.equal(loggedData.chargeId, 'ch_123');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('payment_method.attached handler should process payment method attachments', async () => {
|
|
521
|
+
const mockEvent = {
|
|
522
|
+
type: 'payment_method.attached',
|
|
523
|
+
data: {
|
|
524
|
+
object: {
|
|
525
|
+
id: 'pm_123',
|
|
526
|
+
customer: 'cus_123',
|
|
527
|
+
type: 'card',
|
|
528
|
+
card: {
|
|
529
|
+
brand: 'visa',
|
|
530
|
+
last4: '4242',
|
|
531
|
+
exp_month: 12,
|
|
532
|
+
exp_year: 2025,
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
let loggedData = null;
|
|
539
|
+
|
|
540
|
+
const mockFastify = {
|
|
541
|
+
log: {
|
|
542
|
+
info: (data) => { loggedData = data; },
|
|
543
|
+
error: () => {},
|
|
544
|
+
warn: () => {},
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
549
|
+
const handler = defaultHandlers['payment_method.attached'];
|
|
550
|
+
|
|
551
|
+
await handler(mockEvent, mockFastify, {});
|
|
552
|
+
|
|
553
|
+
assert.ok(loggedData);
|
|
554
|
+
assert.equal(loggedData.paymentMethodId, 'pm_123');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('payment_method.detached handler should process payment method detachments', async () => {
|
|
558
|
+
const mockEvent = {
|
|
559
|
+
type: 'payment_method.detached',
|
|
560
|
+
data: {
|
|
561
|
+
object: {
|
|
562
|
+
id: 'pm_123',
|
|
563
|
+
customer: null,
|
|
564
|
+
type: 'card',
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
let loggedData = null;
|
|
570
|
+
|
|
571
|
+
const mockFastify = {
|
|
572
|
+
log: {
|
|
573
|
+
info: (data) => { loggedData = data; },
|
|
574
|
+
error: () => {},
|
|
575
|
+
warn: () => {},
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
580
|
+
const handler = defaultHandlers['payment_method.detached'];
|
|
581
|
+
|
|
582
|
+
await handler(mockEvent, mockFastify, {});
|
|
583
|
+
|
|
584
|
+
assert.ok(loggedData);
|
|
585
|
+
assert.equal(loggedData.paymentMethodId, 'pm_123');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// ============================================================================
|
|
589
|
+
// SUBSCRIPTION LIFECYCLE TESTS
|
|
590
|
+
// ============================================================================
|
|
591
|
+
|
|
592
|
+
test('subscription.updated handler should process subscription changes', async () => {
|
|
593
|
+
const mockEvent = {
|
|
594
|
+
type: 'customer.subscription.updated',
|
|
595
|
+
data: {
|
|
596
|
+
object: {
|
|
597
|
+
id: 'sub_123',
|
|
598
|
+
customer: 'cus_123',
|
|
599
|
+
status: 'active',
|
|
600
|
+
current_period_end: 1234567890,
|
|
601
|
+
},
|
|
602
|
+
previous_attributes: {
|
|
603
|
+
status: 'trialing',
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
let loggedData = null;
|
|
609
|
+
|
|
610
|
+
const mockFastify = {
|
|
611
|
+
log: {
|
|
612
|
+
info: (data) => { loggedData = data; },
|
|
613
|
+
error: () => {},
|
|
614
|
+
warn: () => {},
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
619
|
+
const handler = defaultHandlers['customer.subscription.updated'];
|
|
620
|
+
|
|
621
|
+
await handler(mockEvent, mockFastify, {});
|
|
622
|
+
|
|
623
|
+
assert.ok(loggedData);
|
|
624
|
+
assert.equal(loggedData.subscriptionId, 'sub_123');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('subscription.deleted handler should process subscription cancellations', async () => {
|
|
628
|
+
const mockEvent = {
|
|
629
|
+
type: 'customer.subscription.deleted',
|
|
630
|
+
data: {
|
|
631
|
+
object: {
|
|
632
|
+
id: 'sub_123',
|
|
633
|
+
customer: 'cus_123',
|
|
634
|
+
status: 'canceled',
|
|
635
|
+
canceled_at: 1234567890,
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
let loggedData = null;
|
|
641
|
+
|
|
642
|
+
const mockFastify = {
|
|
643
|
+
log: {
|
|
644
|
+
info: (data) => { loggedData = data; },
|
|
645
|
+
error: () => {},
|
|
646
|
+
warn: () => {},
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
651
|
+
const handler = defaultHandlers['customer.subscription.deleted'];
|
|
652
|
+
|
|
653
|
+
await handler(mockEvent, mockFastify, {});
|
|
654
|
+
|
|
655
|
+
assert.ok(loggedData);
|
|
656
|
+
assert.equal(loggedData.subscriptionId, 'sub_123');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('subscription.trial_will_end handler should process upcoming trial endings', async () => {
|
|
660
|
+
const mockEvent = {
|
|
661
|
+
type: 'customer.subscription.trial_will_end',
|
|
662
|
+
data: {
|
|
663
|
+
object: {
|
|
664
|
+
id: 'sub_123',
|
|
665
|
+
customer: 'cus_123',
|
|
666
|
+
trial_end: 1234567890,
|
|
667
|
+
status: 'trialing',
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
let loggedData = null;
|
|
673
|
+
|
|
674
|
+
const mockFastify = {
|
|
675
|
+
log: {
|
|
676
|
+
info: (data) => { loggedData = data; },
|
|
677
|
+
error: () => {},
|
|
678
|
+
warn: () => {},
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
683
|
+
const handler = defaultHandlers['customer.subscription.trial_will_end'];
|
|
684
|
+
|
|
685
|
+
await handler(mockEvent, mockFastify, {});
|
|
686
|
+
|
|
687
|
+
assert.ok(loggedData);
|
|
688
|
+
assert.equal(loggedData.subscriptionId, 'sub_123');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// ============================================================================
|
|
692
|
+
// HANDLER REGISTRY AND CUSTOMIZATION TESTS
|
|
693
|
+
// ============================================================================
|
|
694
|
+
|
|
695
|
+
test('can override default handlers', async () => {
|
|
696
|
+
let customHandlerCalled = false;
|
|
697
|
+
|
|
698
|
+
const customHandler = async () => {
|
|
699
|
+
customHandlerCalled = true;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const mockFastify = {
|
|
703
|
+
log: {
|
|
704
|
+
info: () => {},
|
|
705
|
+
error: () => {},
|
|
706
|
+
warn: () => {},
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// Store original handler, replace with custom
|
|
711
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
712
|
+
const originalHandler = defaultHandlers['customer.subscription.created'];
|
|
713
|
+
|
|
714
|
+
defaultHandlers['customer.subscription.created'] = customHandler;
|
|
715
|
+
|
|
716
|
+
// Call custom handler
|
|
717
|
+
await defaultHandlers['customer.subscription.created'](mockSubscriptionCreatedEvent, mockFastify, {});
|
|
718
|
+
|
|
719
|
+
assert.ok(customHandlerCalled, 'Custom handler should have been called');
|
|
720
|
+
|
|
721
|
+
// Restore original handler
|
|
722
|
+
defaultHandlers['customer.subscription.created'] = originalHandler;
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('unknown event types can be handled with fallback', async () => {
|
|
726
|
+
const unknownEvent = {
|
|
727
|
+
type: 'unknown.event.type',
|
|
728
|
+
data: { object: { id: 'test' } },
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
let loggedInfo = null;
|
|
732
|
+
|
|
733
|
+
const mockFastify = {
|
|
734
|
+
log: {
|
|
735
|
+
info: () => {},
|
|
736
|
+
error: () => {},
|
|
737
|
+
warn: (info) => { loggedInfo = info; },
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const fallbackHandler = async (event, fastify) => {
|
|
742
|
+
fastify.log.warn(`Unhandled event type: ${event.type}`);
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
await fallbackHandler(unknownEvent, mockFastify);
|
|
746
|
+
|
|
747
|
+
assert.ok(loggedInfo, 'Should have warned about unknown event');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test('handler receives correct Stripe instance', async () => {
|
|
751
|
+
let stripeInstance = null;
|
|
752
|
+
|
|
753
|
+
const mockStripe = {
|
|
754
|
+
customers: { retrieve: async () => ({ id: 'cus_123' }) },
|
|
755
|
+
subscriptions: { retrieve: async () => ({ id: 'sub_123' }) },
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const testHandler = async (event, fastify, stripe) => {
|
|
759
|
+
stripeInstance = stripe;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const mockFastify = {
|
|
763
|
+
log: { info: () => {}, error: () => {}, warn: () => {} },
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
await testHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe);
|
|
767
|
+
|
|
768
|
+
assert.strictEqual(stripeInstance, mockStripe);
|
|
769
|
+
assert.ok(stripeInstance.customers);
|
|
770
|
+
assert.ok(stripeInstance.subscriptions);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ============================================================================
|
|
774
|
+
// EVENT DATA VALIDATION TESTS
|
|
775
|
+
// ============================================================================
|
|
776
|
+
|
|
777
|
+
test('handler processes events with complex nested data', async () => {
|
|
778
|
+
const complexEvent = {
|
|
779
|
+
type: 'customer.subscription.created',
|
|
780
|
+
data: {
|
|
781
|
+
object: {
|
|
782
|
+
id: 'sub_complex',
|
|
783
|
+
customer: 'cus_complex',
|
|
784
|
+
status: 'active',
|
|
785
|
+
items: {
|
|
786
|
+
data: [
|
|
787
|
+
{
|
|
788
|
+
id: 'si_1',
|
|
789
|
+
price: {
|
|
790
|
+
id: 'price_1',
|
|
791
|
+
product: 'prod_1',
|
|
792
|
+
unit_amount: 1000,
|
|
793
|
+
currency: 'usd',
|
|
794
|
+
recurring: { interval: 'month', interval_count: 1 },
|
|
795
|
+
},
|
|
796
|
+
quantity: 1,
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
id: 'si_2',
|
|
800
|
+
price: {
|
|
801
|
+
id: 'price_2',
|
|
802
|
+
product: 'prod_2',
|
|
803
|
+
unit_amount: 2000,
|
|
804
|
+
currency: 'usd',
|
|
805
|
+
recurring: { interval: 'year', interval_count: 1 },
|
|
806
|
+
},
|
|
807
|
+
quantity: 2,
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
},
|
|
811
|
+
billing_cycle_anchor: 1234567890,
|
|
812
|
+
current_period_start: 1234567890,
|
|
813
|
+
current_period_end: 1234567999,
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
let loggedData = null;
|
|
819
|
+
|
|
820
|
+
const mockFastify = {
|
|
821
|
+
log: {
|
|
822
|
+
info: (data) => { loggedData = data; },
|
|
823
|
+
error: () => {},
|
|
824
|
+
warn: () => {},
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
829
|
+
const handler = defaultHandlers['customer.subscription.created'];
|
|
830
|
+
|
|
831
|
+
await handler(complexEvent, mockFastify, {});
|
|
832
|
+
|
|
833
|
+
assert.ok(loggedData);
|
|
834
|
+
assert.equal(loggedData.subscriptionId, 'sub_complex');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test('handler tolerates missing optional fields', async () => {
|
|
838
|
+
const minimalEvent = {
|
|
839
|
+
type: 'customer.subscription.created',
|
|
840
|
+
data: {
|
|
841
|
+
object: {
|
|
842
|
+
id: 'sub_minimal',
|
|
843
|
+
customer: 'cus_minimal',
|
|
844
|
+
status: 'active',
|
|
845
|
+
items: { data: [{ price: { id: 'price_minimal' } }] },
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
let errorThrown = false;
|
|
851
|
+
|
|
852
|
+
const mockFastify = {
|
|
853
|
+
log: {
|
|
854
|
+
info: () => {},
|
|
855
|
+
error: () => {},
|
|
856
|
+
warn: () => {},
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
861
|
+
const handler = defaultHandlers['customer.subscription.created'];
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
await handler(minimalEvent, mockFastify, {});
|
|
865
|
+
} catch (error) {
|
|
866
|
+
errorThrown = true;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
assert.equal(errorThrown, false, 'Should handle minimal event data');
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// ============================================================================
|
|
873
|
+
// CONCURRENCY AND PERFORMANCE TESTS
|
|
874
|
+
// ============================================================================
|
|
875
|
+
|
|
876
|
+
test('handlers can execute concurrently', async () => {
|
|
877
|
+
let executionCount = 0;
|
|
878
|
+
|
|
879
|
+
const concurrentHandler = async (event, fastify, stripe) => {
|
|
880
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
881
|
+
executionCount++;
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const mockFastify = {
|
|
885
|
+
log: { info: () => {}, error: () => {}, warn: () => {} },
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
const mockStripe = {};
|
|
889
|
+
|
|
890
|
+
// Execute 5 handlers concurrently
|
|
891
|
+
const promises = Array(5)
|
|
892
|
+
.fill(null)
|
|
893
|
+
.map(() => concurrentHandler(mockSubscriptionCreatedEvent, mockFastify, mockStripe));
|
|
894
|
+
|
|
895
|
+
await Promise.all(promises);
|
|
896
|
+
|
|
897
|
+
assert.equal(executionCount, 5, 'All 5 concurrent handlers should execute');
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('multiple event types can be handled in sequence', async () => {
|
|
901
|
+
const mockSubscriptionEvent = {
|
|
902
|
+
type: 'customer.subscription.created',
|
|
903
|
+
data: {
|
|
904
|
+
object: {
|
|
905
|
+
id: 'sub_123',
|
|
906
|
+
customer: 'cus_123',
|
|
907
|
+
status: 'active',
|
|
908
|
+
items: {
|
|
909
|
+
data: [{
|
|
910
|
+
price: {
|
|
911
|
+
id: 'price_123',
|
|
912
|
+
product: 'prod_123',
|
|
913
|
+
unit_amount: 2000,
|
|
914
|
+
currency: 'usd',
|
|
915
|
+
},
|
|
916
|
+
quantity: 1,
|
|
917
|
+
}],
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const mockChargeSucceededEvent = {
|
|
924
|
+
type: 'charge.succeeded',
|
|
925
|
+
data: {
|
|
926
|
+
object: {
|
|
927
|
+
id: 'ch_123',
|
|
928
|
+
customer: 'cus_123',
|
|
929
|
+
amount: 5000,
|
|
930
|
+
currency: 'usd',
|
|
931
|
+
status: 'succeeded',
|
|
932
|
+
payment_method_details: { type: 'card' },
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const results = [];
|
|
938
|
+
|
|
939
|
+
const mockFastify = {
|
|
940
|
+
log: {
|
|
941
|
+
info: (data) => { results.push(data); },
|
|
942
|
+
error: () => {},
|
|
943
|
+
warn: (data) => { results.push(data); },
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const { defaultHandlers } = await import('../src/handlers/defaultHandlers.js');
|
|
948
|
+
|
|
949
|
+
const events = [mockSubscriptionEvent, mockChargeSucceededEvent];
|
|
950
|
+
|
|
951
|
+
for (const event of events) {
|
|
952
|
+
const handler = defaultHandlers[event.type];
|
|
953
|
+
if (handler) {
|
|
954
|
+
await handler(event, mockFastify, {});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
assert.equal(results.length, 2, 'Should have processed 2 events');
|
|
959
|
+
});
|