eb443232fb
Subscription billing: - POST /api/stripe/checkout — creates Stripe Checkout session - POST /api/stripe/webhook — handles lifecycle events (raw body) - POST /api/stripe/portal — customer self-service management - GET /api/stripe/status — current subscription info Founder code system: - Validates codes against env var list with expiry date - Routes to founder Stripe Price IDs (locked rate for life) - 4 price objects: analyst, analyst-founder, desk, desk-founder Webhook events handled: - checkout.session.completed → tier update + founder status - customer.subscription.deleted → revert to free tier - invoice.payment_failed → logged Lazy Stripe init (no API key required at import time). Raw body middleware for webhook signature verification. 16 new tests, 237 total (210 Node.js + 27 Python), all passing. Phase 3 Web MVP COMPLETE. All roadmap features shipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
158 lines
4.5 KiB
JavaScript
158 lines
4.5 KiB
JavaScript
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
|
|
jest.mock('stripe', () => {
|
|
return jest.fn().mockImplementation(() => ({
|
|
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('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('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);
|
|
});
|
|
});
|