const request = require('supertest'); const express = require('express'); // Set env before loading the module process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; // Mock dependencies jest.mock('../../../services/stripeWebhookService', () => ({ constructEvent: jest.fn(), handleAccountUpdated: jest.fn(), handlePayoutPaid: jest.fn(), handlePayoutFailed: jest.fn(), handlePayoutCanceled: jest.fn(), handleAccountDeauthorized: jest.fn(), })); jest.mock('../../../services/disputeService', () => ({ handleDisputeCreated: jest.fn(), handleDisputeClosed: jest.fn(), })); jest.mock('../../../utils/logger', () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), })); const StripeWebhookService = require('../../../services/stripeWebhookService'); const DisputeService = require('../../../services/disputeService'); const stripeWebhooksRoutes = require('../../../routes/stripeWebhooks'); describe('Stripe Webhooks Routes', () => { let app; beforeEach(() => { app = express(); // Add raw body middleware to capture raw body for signature verification app.use(express.raw({ type: 'application/json' })); app.use((req, res, next) => { req.rawBody = req.body; // Parse body for route handler if (Buffer.isBuffer(req.body)) { try { req.body = JSON.parse(req.body.toString()); } catch (e) { req.body = {}; } } next(); }); app.use('/stripe/webhooks', stripeWebhooksRoutes); jest.clearAllMocks(); }); describe('POST /stripe/webhooks', () => { it('should return 400 when signature is missing', async () => { const response = await request(app) .post('/stripe/webhooks') .send({ type: 'test' }); expect(response.status).toBe(400); expect(response.body.error).toBe('Missing signature'); }); it('should return 400 when signature verification fails', async () => { StripeWebhookService.constructEvent.mockImplementation(() => { throw new Error('Invalid signature'); }); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'invalid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify({ type: 'test' })); expect(response.status).toBe(400); expect(response.body.error).toBe('Invalid signature'); }); it('should handle account.updated event', async () => { const mockEvent = { id: 'evt_123', type: 'account.updated', data: { object: { id: 'acct_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handleAccountUpdated.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(response.body.received).toBe(true); expect(response.body.eventId).toBe('evt_123'); expect(StripeWebhookService.handleAccountUpdated).toHaveBeenCalledWith({ id: 'acct_123' }); }); it('should handle payout.paid event', async () => { const mockEvent = { id: 'evt_123', type: 'payout.paid', data: { object: { id: 'po_123' } }, account: 'acct_456', }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handlePayoutPaid.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(StripeWebhookService.handlePayoutPaid).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456'); }); it('should handle payout.failed event', async () => { const mockEvent = { id: 'evt_123', type: 'payout.failed', data: { object: { id: 'po_123' } }, account: 'acct_456', }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handlePayoutFailed.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(StripeWebhookService.handlePayoutFailed).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456'); }); it('should handle payout.canceled event', async () => { const mockEvent = { id: 'evt_123', type: 'payout.canceled', data: { object: { id: 'po_123' } }, account: 'acct_456', }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handlePayoutCanceled.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(StripeWebhookService.handlePayoutCanceled).toHaveBeenCalledWith({ id: 'po_123' }, 'acct_456'); }); it('should handle account.application.deauthorized event', async () => { const mockEvent = { id: 'evt_123', type: 'account.application.deauthorized', data: { object: {} }, account: 'acct_456', }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handleAccountDeauthorized.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(StripeWebhookService.handleAccountDeauthorized).toHaveBeenCalledWith('acct_456'); }); it('should handle charge.dispute.created event', async () => { const mockEvent = { id: 'evt_123', type: 'charge.dispute.created', data: { object: { id: 'dp_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); DisputeService.handleDisputeCreated.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(DisputeService.handleDisputeCreated).toHaveBeenCalledWith({ id: 'dp_123' }); }); it('should handle charge.dispute.closed event', async () => { const mockEvent = { id: 'evt_123', type: 'charge.dispute.closed', data: { object: { id: 'dp_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); DisputeService.handleDisputeClosed.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(DisputeService.handleDisputeClosed).toHaveBeenCalledWith({ id: 'dp_123' }); }); it('should handle charge.dispute.funds_reinstated event', async () => { const mockEvent = { id: 'evt_123', type: 'charge.dispute.funds_reinstated', data: { object: { id: 'dp_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); DisputeService.handleDisputeClosed.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(DisputeService.handleDisputeClosed).toHaveBeenCalled(); }); it('should handle charge.dispute.funds_withdrawn event', async () => { const mockEvent = { id: 'evt_123', type: 'charge.dispute.funds_withdrawn', data: { object: { id: 'dp_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); DisputeService.handleDisputeClosed.mockResolvedValue(); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(DisputeService.handleDisputeClosed).toHaveBeenCalled(); }); it('should handle unhandled event types gracefully', async () => { const mockEvent = { id: 'evt_123', type: 'customer.created', data: { object: {} }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); expect(response.status).toBe(200); expect(response.body.received).toBe(true); }); it('should return 200 even when handler throws error', async () => { const mockEvent = { id: 'evt_123', type: 'account.updated', data: { object: { id: 'acct_123' } }, }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handleAccountUpdated.mockRejectedValue(new Error('Handler error')); const response = await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); // Should still return 200 to prevent Stripe retries expect(response.status).toBe(200); expect(response.body.received).toBe(true); }); it('should log event with connected account when present', async () => { const mockEvent = { id: 'evt_123', type: 'payout.paid', data: { object: { id: 'po_123' } }, account: 'acct_connected', }; StripeWebhookService.constructEvent.mockReturnValue(mockEvent); StripeWebhookService.handlePayoutPaid.mockResolvedValue(); await request(app) .post('/stripe/webhooks') .set('stripe-signature', 'valid-signature') .set('Content-Type', 'application/json') .send(JSON.stringify(mockEvent)); // Logger should have been called with connected account info const logger = require('../../../utils/logger'); expect(logger.info).toHaveBeenCalledWith( 'Stripe webhook received', expect.objectContaining({ eventId: 'evt_123', eventType: 'payout.paid', connectedAccount: 'acct_connected', }) ); }); }); });