more backend unit test coverage
This commit is contained in:
335
backend/tests/unit/routes/stripeWebhooks.test.js
Normal file
335
backend/tests/unit/routes/stripeWebhooks.test.js
Normal file
@@ -0,0 +1,335 @@
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user