Session 7i: Stripe test coverage gaps filled, dual-provider cutover documented (1042 tests)

This commit is contained in:
Kev
2026-06-10 13:55:59 -04:00
parent d4e5e76452
commit b9084408bf
6 changed files with 162 additions and 24 deletions
+64 -22
View File
@@ -12,30 +12,32 @@ jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
}));
// Mock Stripe
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => ({
customers: {
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
// Mock Stripe — singleton handle so tests can override constructEvent
// per-case. The service caches `new Stripe()` once; the mock returns
// the same instance every time so the override applies to the cached
// reference too.
const mockStripeInstance = {
customers: {
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
},
checkout: {
sessions: {
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
},
checkout: {
sessions: {
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
},
},
billingPortal: {
sessions: {
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
},
billingPortal: {
sessions: {
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
},
},
subscriptions: {
list: jest.fn().mockResolvedValue({ data: [] }),
},
webhooks: {
constructEvent: jest.fn(),
},
}));
});
},
subscriptions: {
list: jest.fn().mockResolvedValue({ data: [] }),
},
webhooks: {
constructEvent: jest.fn(),
},
};
jest.mock('stripe', () => jest.fn(() => mockStripeInstance));
jest.mock('axios');
process.env.ODDS_API_KEY = 'test';
@@ -154,4 +156,44 @@ describe('POST /api/stripe/webhook', () => {
.set('Content-Type', 'application/json')
.expect(400);
});
test('returns 400 when signature header is present but invalid', async () => {
// Header present, but constructEvent throws — Stripe's real behavior
// when the signed payload doesn't match the secret. The route must
// surface 400 and never invoke the event-dispatch path.
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => {
throw new Error('No signatures found matching the expected signature');
});
const res = await request(app)
.post('/api/stripe/webhook')
.set('Content-Type', 'application/json')
.set('stripe-signature', 't=1700000000,v1=deadbeef')
.send(Buffer.from('{"id":"evt_forged","type":"checkout.session.completed"}'))
.expect(400);
expect(res.body.error).toMatch(/signature/i);
expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledTimes(1);
});
test('valid signature dispatches to handler and returns 200', async () => {
// Positive case: route → service → 200 with `received: true`. We can't
// observe the supabase write here without entangling with auth setup;
// the unit suite (stripeService.test.js) already proves the dispatch
// wiring per event type. This test pins the route-level contract.
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => ({
id: 'evt_ok',
type: 'invoice.payment_failed', // chosen because it requires no users-table fixture beyond the default
data: { object: { customer: 'cus_test123' } },
}));
const res = await request(app)
.post('/api/stripe/webhook')
.set('Content-Type', 'application/json')
.set('stripe-signature', 't=1700000000,v1=stub')
.send(Buffer.from('{}'))
.expect(200);
expect(res.body.received).toBe(true);
});
});