Files
rentall-app/backend/tests/unit/routes/stripeWebhooks.test.js
2026-01-18 19:18:35 -05:00

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',
})
);
});
});
});