const request = require('supertest'); // Mock Redis const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); // Mock Supabase const mockSupabaseFrom = jest.fn(); const mockSupabaseAuth = { getUser: jest.fn() }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), })); // 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' }), }, }, billingPortal: { sessions: { create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }), }, }, 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'; process.env.STRIPE_SECRET_KEY = 'sk_test_xxx'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; const app = require('../../src/app'); const MOCK_USER = { id: 'user-1', email: 'test@test.com', tier: 'free', scan_count: 0, stripe_customer_id: null, founder_status: false, }; function setupAuthMocks(user = MOCK_USER) { mockSupabaseAuth.getUser.mockResolvedValue({ data: { user: { id: user.id, email: user.email } }, error: null, }); mockSupabaseFrom.mockImplementation((table) => { if (table === 'users') { return { select: () => ({ eq: () => ({ single: () => Promise.resolve({ data: user, error: null }), }), }), update: () => ({ eq: () => Promise.resolve({ error: null }), }), }; } return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) }; }); } beforeEach(() => { jest.clearAllMocks(); mockRedis.get.mockResolvedValue(null); mockRedis.set.mockResolvedValue('OK'); mockRedis.hset.mockResolvedValue(1); mockRedis.hgetall.mockResolvedValue({}); mockRedis.expire.mockResolvedValue(1); }); describe('POST /api/stripe/checkout', () => { test('creates checkout session and returns URL', async () => { setupAuthMocks(); const res = await request(app) .post('/api/stripe/checkout') .set('Authorization', 'Bearer valid-token') .send({ tier: 'analyst' }) .expect(200); expect(res.body.checkout_url).toBeDefined(); expect(res.body.session_id).toBeDefined(); }); test('returns 400 for invalid tier', async () => { setupAuthMocks(); const res = await request(app) .post('/api/stripe/checkout') .set('Authorization', 'Bearer valid-token') .send({ tier: 'gold' }) .expect(400); expect(res.body.error).toContain('tier'); }); test('returns 401 without auth', async () => { await request(app) .post('/api/stripe/checkout') .send({ tier: 'analyst' }) .expect(401); }); describe('Session 14 — Africa tier checkout', () => { const originalAfricaPrice = process.env.STRIPE_PRICE_AFRICA; afterAll(() => { if (originalAfricaPrice == null) delete process.env.STRIPE_PRICE_AFRICA; else process.env.STRIPE_PRICE_AFRICA = originalAfricaPrice; }); test("'africa' is now an accepted tier (validation passes)", async () => { setupAuthMocks(); process.env.STRIPE_PRICE_AFRICA = 'price_africa_test'; const res = await request(app) .post('/api/stripe/checkout') .set('Authorization', 'Bearer valid-token') .send({ tier: 'africa' }); // We DON'T assert on the status here — the downstream Stripe // mock may produce 200 OR the test's supabase fake may take a // different path. The important assertion: the request didn't // 400 with "tier must be analyst or desk". expect(res.status).not.toBe(400); }); test('returns 503 with code:tier_unconfigured when STRIPE_PRICE_AFRICA is unset', async () => { setupAuthMocks(); delete process.env.STRIPE_PRICE_AFRICA; const res = await request(app) .post('/api/stripe/checkout') .set('Authorization', 'Bearer valid-token') .send({ tier: 'africa' }) .expect(503); expect(res.body.code).toBe('tier_unconfigured'); expect(res.body.error).toMatch(/africa/i); }); test('still rejects unrelated tiers with 400', async () => { setupAuthMocks(); const res = await request(app) .post('/api/stripe/checkout') .set('Authorization', 'Bearer valid-token') .send({ tier: 'gold' }) .expect(400); expect(res.body.error).toMatch(/tier/); }); }); }); describe('POST /api/stripe/portal', () => { test('returns portal URL for existing customer', async () => { setupAuthMocks({ ...MOCK_USER, stripe_customer_id: 'cus_existing' }); const res = await request(app) .post('/api/stripe/portal') .set('Authorization', 'Bearer valid-token') .expect(200); expect(res.body.portal_url).toBeDefined(); }); test('returns 400 when no subscription', async () => { setupAuthMocks(); const res = await request(app) .post('/api/stripe/portal') .set('Authorization', 'Bearer valid-token') .expect(400); expect(res.body.error).toContain('No active subscription'); }); }); describe('GET /api/stripe/status', () => { test('returns tier and subscription info', async () => { setupAuthMocks({ ...MOCK_USER, tier: 'analyst', founder_status: true }); const res = await request(app) .get('/api/stripe/status') .set('Authorization', 'Bearer valid-token') .expect(200); expect(res.body.tier).toBe('analyst'); expect(res.body.is_founder).toBe(true); }); }); describe('POST /api/stripe/webhook', () => { test('returns 400 without signature', async () => { await request(app) .post('/api/stripe/webhook') .send('{}') .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); }); });