336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
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',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|