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);
});
});
+25
View File
@@ -127,6 +127,31 @@ describe('stripeService', () => {
expect(profilesUpdate.patch.subscription_status).toBe('grace_period');
});
test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => {
// Plan-change flow (portal-driven upgrade/downgrade). Stripe sends
// the new price on items.data[0].price.id; the service must map it
// back to a tier via PRICE_MAP and reflect that on the user.
const fake = makeFake();
mockSupabaseClient.current = fake;
const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly';
await handleWebhookEvent({
type: 'customer.subscription.updated',
data: {
object: {
customer: 'cus_active',
status: 'active',
items: { data: [{ price: { id: newPriceId } }] },
},
},
});
const usersUpdate = fake.updates.find((u) => u.table === 'users');
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
expect(usersUpdate.patch.tier).toBe('desk');
expect(usersUpdate.patch.grace_period_until).toBeNull();
expect(profilesUpdate.patch.tier).toBe('desk');
expect(profilesUpdate.patch.subscription_status).toBe('active');
});
test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => {
const fake = makeFake();
mockSupabaseClient.current = fake;