Skip to content

Commit 98e627d

Browse files
test(billing): add unit tests for webhook service and controller
Cover all webhook event handlers (checkout.session.completed, subscription.updated, subscription.deleted, invoice.payment_failed) and controller endpoint to meet global coverage thresholds.
1 parent fca676f commit 98e627d

2 files changed

Lines changed: 465 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing webhook controller
8+
*/
9+
describe('Billing webhook controller unit tests:', () => {
10+
let BillingWebhookController;
11+
let mockBillingWebhookService;
12+
let mockStripeInstance;
13+
let res;
14+
15+
beforeEach(async () => {
16+
jest.resetModules();
17+
18+
mockBillingWebhookService = {
19+
handleCheckoutCompleted: jest.fn().mockResolvedValue(),
20+
handleSubscriptionUpdated: jest.fn().mockResolvedValue(),
21+
handleSubscriptionDeleted: jest.fn().mockResolvedValue(),
22+
handleInvoicePaymentFailed: jest.fn().mockResolvedValue(),
23+
};
24+
25+
jest.unstable_mockModule('../services/billing.webhook.service.js', () => ({
26+
default: mockBillingWebhookService,
27+
}));
28+
29+
mockStripeInstance = {
30+
webhooks: {
31+
constructEvent: jest.fn(),
32+
},
33+
};
34+
35+
jest.unstable_mockModule('stripe', () => ({
36+
default: jest.fn(() => mockStripeInstance),
37+
}));
38+
39+
res = {
40+
status: jest.fn().mockReturnThis(),
41+
json: jest.fn().mockReturnThis(),
42+
};
43+
});
44+
45+
afterEach(() => {
46+
jest.restoreAllMocks();
47+
});
48+
49+
test('should return 400 when stripe is not configured', async () => {
50+
jest.unstable_mockModule('../../../config/index.js', () => ({
51+
default: { stripe: {} },
52+
}));
53+
54+
const mod = await import('../controllers/billing.webhook.controller.js');
55+
BillingWebhookController = mod.default;
56+
57+
const req = { headers: {}, body: '' };
58+
await BillingWebhookController.handleWebhook(req, res);
59+
60+
expect(res.status).toHaveBeenCalledWith(400);
61+
expect(res.json).toHaveBeenCalledWith({ error: 'Stripe is not configured' });
62+
});
63+
64+
test('should return 400 when signature verification fails', async () => {
65+
jest.unstable_mockModule('../../../config/index.js', () => ({
66+
default: { stripe: { secretKey: 'sk_test_1', webhookSecret: 'whsec_1' } },
67+
}));
68+
69+
mockStripeInstance.webhooks.constructEvent.mockImplementation(() => {
70+
throw new Error('bad signature');
71+
});
72+
73+
const mod = await import('../controllers/billing.webhook.controller.js');
74+
BillingWebhookController = mod.default;
75+
76+
const req = { headers: { 'stripe-signature': 'sig_bad' }, body: 'raw' };
77+
await BillingWebhookController.handleWebhook(req, res);
78+
79+
expect(res.status).toHaveBeenCalledWith(400);
80+
expect(res.json).toHaveBeenCalledWith({ error: 'Webhook signature verification failed: bad signature' });
81+
});
82+
83+
test('should handle checkout.session.completed event', async () => {
84+
jest.unstable_mockModule('../../../config/index.js', () => ({
85+
default: { stripe: { secretKey: 'sk_test_2', webhookSecret: 'whsec_2' } },
86+
}));
87+
88+
const eventData = { type: 'checkout.session.completed', data: { object: { id: 'cs_123' } } };
89+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
90+
91+
const mod = await import('../controllers/billing.webhook.controller.js');
92+
BillingWebhookController = mod.default;
93+
94+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
95+
await BillingWebhookController.handleWebhook(req, res);
96+
97+
expect(mockBillingWebhookService.handleCheckoutCompleted).toHaveBeenCalledWith({ id: 'cs_123' });
98+
expect(res.status).toHaveBeenCalledWith(200);
99+
expect(res.json).toHaveBeenCalledWith({ received: true });
100+
});
101+
102+
test('should handle customer.subscription.updated event', async () => {
103+
jest.unstable_mockModule('../../../config/index.js', () => ({
104+
default: { stripe: { secretKey: 'sk_test_3', webhookSecret: 'whsec_3' } },
105+
}));
106+
107+
const eventData = { type: 'customer.subscription.updated', data: { object: { id: 'sub_123' } } };
108+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
109+
110+
const mod = await import('../controllers/billing.webhook.controller.js');
111+
BillingWebhookController = mod.default;
112+
113+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
114+
await BillingWebhookController.handleWebhook(req, res);
115+
116+
expect(mockBillingWebhookService.handleSubscriptionUpdated).toHaveBeenCalledWith({ id: 'sub_123' });
117+
expect(res.status).toHaveBeenCalledWith(200);
118+
});
119+
120+
test('should handle customer.subscription.deleted event', async () => {
121+
jest.unstable_mockModule('../../../config/index.js', () => ({
122+
default: { stripe: { secretKey: 'sk_test_4', webhookSecret: 'whsec_4' } },
123+
}));
124+
125+
const eventData = { type: 'customer.subscription.deleted', data: { object: { id: 'sub_del' } } };
126+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
127+
128+
const mod = await import('../controllers/billing.webhook.controller.js');
129+
BillingWebhookController = mod.default;
130+
131+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
132+
await BillingWebhookController.handleWebhook(req, res);
133+
134+
expect(mockBillingWebhookService.handleSubscriptionDeleted).toHaveBeenCalledWith({ id: 'sub_del' });
135+
expect(res.status).toHaveBeenCalledWith(200);
136+
});
137+
138+
test('should handle invoice.payment_failed event', async () => {
139+
jest.unstable_mockModule('../../../config/index.js', () => ({
140+
default: { stripe: { secretKey: 'sk_test_5', webhookSecret: 'whsec_5' } },
141+
}));
142+
143+
const eventData = { type: 'invoice.payment_failed', data: { object: { id: 'inv_fail' } } };
144+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
145+
146+
const mod = await import('../controllers/billing.webhook.controller.js');
147+
BillingWebhookController = mod.default;
148+
149+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
150+
await BillingWebhookController.handleWebhook(req, res);
151+
152+
expect(mockBillingWebhookService.handleInvoicePaymentFailed).toHaveBeenCalledWith({ id: 'inv_fail' });
153+
expect(res.status).toHaveBeenCalledWith(200);
154+
});
155+
156+
test('should return 200 for unknown event types', async () => {
157+
jest.unstable_mockModule('../../../config/index.js', () => ({
158+
default: { stripe: { secretKey: 'sk_test_6', webhookSecret: 'whsec_6' } },
159+
}));
160+
161+
const eventData = { type: 'unknown.event', data: { object: {} } };
162+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
163+
164+
const mod = await import('../controllers/billing.webhook.controller.js');
165+
BillingWebhookController = mod.default;
166+
167+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
168+
await BillingWebhookController.handleWebhook(req, res);
169+
170+
expect(res.status).toHaveBeenCalledWith(200);
171+
expect(res.json).toHaveBeenCalledWith({ received: true });
172+
});
173+
174+
test('should return 500 when handler throws', async () => {
175+
jest.unstable_mockModule('../../../config/index.js', () => ({
176+
default: { stripe: { secretKey: 'sk_test_7', webhookSecret: 'whsec_7' } },
177+
}));
178+
179+
const eventData = { type: 'checkout.session.completed', data: { object: { id: 'cs_err' } } };
180+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
181+
mockBillingWebhookService.handleCheckoutCompleted.mockRejectedValue(new Error('DB error'));
182+
183+
const mod = await import('../controllers/billing.webhook.controller.js');
184+
BillingWebhookController = mod.default;
185+
186+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
187+
await BillingWebhookController.handleWebhook(req, res);
188+
189+
expect(res.status).toHaveBeenCalledWith(500);
190+
expect(res.json).toHaveBeenCalledWith({ error: 'Webhook handler failed' });
191+
});
192+
});

0 commit comments

Comments
 (0)