Session 7i: Stripe test coverage gaps filled, dual-provider cutover documented (1042 tests)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user