diff --git a/src/routes/__tests__/billing.test.ts b/src/routes/__tests__/billing.test.ts index f181d2c..6a7ffc2 100644 --- a/src/routes/__tests__/billing.test.ts +++ b/src/routes/__tests__/billing.test.ts @@ -1,187 +1,437 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import request from 'supertest' import express from 'express' -import { billingRouter } from '../billing.js' -// Mock the dependencies +// Mock logger vi.mock('../../services/logger.js', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - } + default: { info: vi.fn(), error: vi.fn() } })) +// Mock DB functions vi.mock('../../services/keys.js', () => ({ + createPaidKey: vi.fn(), + downgradeByCustomer: vi.fn(), + updateEmailByCustomer: vi.fn(), getCustomerIdByEmail: vi.fn(), getKeyByEmail: vi.fn() })) -// Create a mock Stripe instance +// Create mock Stripe methods with default returns so initPrices() at import time works +const mockCheckoutCreate = vi.fn() +const mockCheckoutRetrieve = vi.fn() +const mockSubRetrieve = vi.fn() +const mockConstructEvent = vi.fn() const mockBillingPortalCreate = vi.fn() +const mockProductsSearch = vi.fn().mockResolvedValue({ data: [{ id: 'prod_test_123', name: 'test' }] }) +const mockPricesList = vi.fn().mockResolvedValue({ data: [{ id: 'price_test_123', unit_amount: 900 }] }) +const mockPricesCreate = vi.fn().mockResolvedValue({ id: 'price_new_123' }) +const mockProductsCreate = vi.fn().mockResolvedValue({ id: 'prod_new_123' }) +const mockPricesRetrieve = vi.fn() + const mockStripe = { - billingPortal: { - sessions: { - create: mockBillingPortalCreate - } - } + checkout: { sessions: { create: mockCheckoutCreate, retrieve: mockCheckoutRetrieve } }, + subscriptions: { retrieve: mockSubRetrieve }, + webhooks: { constructEvent: mockConstructEvent }, + billingPortal: { sessions: { create: mockBillingPortalCreate } }, + products: { search: mockProductsSearch, create: mockProductsCreate }, + prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve }, } vi.mock('stripe', () => ({ - default: vi.fn().mockImplementation(() => mockStripe) + default: vi.fn().mockImplementation(function() { return mockStripe }) })) -// Mock the Stripe environment variables -const mockStripeKey = 'sk_test_123456789' -vi.stubEnv('STRIPE_SECRET_KEY', mockStripeKey) +// Stub env BEFORE importing router +vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123456789') +vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_test_secret') vi.stubEnv('BASE_URL', 'https://test.snapapi.eu') -import { getCustomerIdByEmail, getKeyByEmail } from '../../services/keys.js' +import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from '../../services/keys.js' +import { billingRouter } from '../billing.js' const app = express() app.use(express.json()) app.use('/v1/billing', billingRouter) -describe('POST /v1/billing/portal', () => { - beforeEach(() => { - vi.clearAllMocks() - mockBillingPortalCreate.mockClear() +// Default mock returns for getOrCreatePrice chain +function setupPriceMocks() { + mockProductsSearch.mockResolvedValue({ + data: [{ id: 'prod_test_123', name: 'SnapAPI Starter' }] + }) + mockPricesList.mockResolvedValue({ + data: [{ id: 'price_test_123', unit_amount: 900 }] + }) + mockPricesCreate.mockResolvedValue({ id: 'price_new_123' }) + mockProductsCreate.mockResolvedValue({ id: 'prod_new_123' }) +} + +beforeEach(() => { + // Clear call history but keep implementations for mocks that need defaults + mockCheckoutCreate.mockClear() + mockCheckoutRetrieve.mockClear() + mockSubRetrieve.mockClear() + mockConstructEvent.mockClear() + mockBillingPortalCreate.mockClear() + vi.mocked(createPaidKey).mockClear() + vi.mocked(downgradeByCustomer).mockClear() + vi.mocked(updateEmailByCustomer).mockClear() + vi.mocked(getCustomerIdByEmail).mockClear() + vi.mocked(getKeyByEmail).mockClear() + // Re-setup price mocks (getOrCreatePrice needs these if cache is cold) + setupPriceMocks() +}) + +// ─── POST /v1/billing/checkout ─── + +describe('POST /v1/billing/checkout', () => { + it('returns 400 when plan is missing', async () => { + const res = await request(app).post('/v1/billing/checkout').send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Invalid plan/) }) - it.skip('should return portal URL when email has stripe customer ID', async () => { - vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_123456') - mockBillingPortalCreate.mockResolvedValue({ - url: 'https://billing.stripe.com/p/session_123456' - }) - - const response = await request(app) - .post('/v1/billing/portal') - .send({ email: 'user@example.com' }) - - if (response.status !== 200) { - console.log('Response status:', response.status) - console.log('Response body:', response.body) - } - - expect(response.status).toBe(200) - expect(response.body).toEqual({ - url: 'https://billing.stripe.com/p/session_123456' - }) - expect(getCustomerIdByEmail).toHaveBeenCalledWith('user@example.com') - expect(mockBillingPortalCreate).toHaveBeenCalledWith({ - customer: 'cus_123456', - return_url: 'https://test.snapapi.eu/#billing' - }) + it('returns 400 for invalid plan name', async () => { + const res = await request(app).post('/v1/billing/checkout').send({ plan: 'enterprise' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Invalid plan/) }) - it('should return 404 when email has no stripe customer ID', async () => { - vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined) + it('returns checkout URL for valid starter plan', async () => { + mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' }) - const response = await request(app) - .post('/v1/billing/portal') - .send({ email: 'nonexistent@example.com' }) - - expect(response.status).toBe(404) - expect(response.body).toEqual({ - error: 'No subscription found for this email address. Please contact support if you believe this is an error.' - }) + const res = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) + expect(res.status).toBe(200) + expect(res.body.url).toBe('https://checkout.stripe.com/session_abc') + expect(mockCheckoutCreate).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'subscription', + metadata: expect.objectContaining({ plan: 'starter', service: 'snapapi' }), + }) + ) }) - it('should return 400 when email is missing', async () => { - const response = await request(app) - .post('/v1/billing/portal') - .send({}) - - expect(response.status).toBe(400) - expect(response.body).toEqual({ - error: 'Email address is required' - }) + it('returns checkout URL for pro plan', async () => { + mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_pro' }) + const res = await request(app).post('/v1/billing/checkout').send({ plan: 'pro' }) + expect(res.status).toBe(200) + expect(res.body.url).toBe('https://checkout.stripe.com/session_pro') }) - it('should return 400 when email is empty string', async () => { - const response = await request(app) - .post('/v1/billing/portal') - .send({ email: '' }) + it('returns checkout URL for business plan', async () => { + mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_biz' }) + const res = await request(app).post('/v1/billing/checkout').send({ plan: 'business' }) + expect(res.status).toBe(200) + expect(res.body.url).toBe('https://checkout.stripe.com/session_biz') + }) - expect(response.status).toBe(400) - expect(response.body).toEqual({ - error: 'Email address is required' - }) + it('returns 500 on Stripe error', async () => { + mockCheckoutCreate.mockRejectedValue(new Error('Stripe down')) + const res = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) + expect(res.status).toBe(500) + expect(res.body.error).toMatch(/Failed to create checkout/) }) }) -describe('GET /v1/billing/recover', () => { - beforeEach(() => { - vi.clearAllMocks() +// ─── GET /v1/billing/success ─── + +describe('GET /v1/billing/success', () => { + it('returns 400 when session_id is missing', async () => { + const res = await request(app).get('/v1/billing/success') + expect(res.status).toBe(400) + expect(res.text).toMatch(/Missing session_id/) }) - it('should return success message and masked key when email exists', async () => { + it('returns HTML with API key for valid session', async () => { + const uniqueSessionId = `cs_test_success_${Date.now()}` + mockCheckoutRetrieve.mockResolvedValue({ + id: uniqueSessionId, + customer_details: { email: 'buyer@example.com' }, + customer: 'cus_buyer_123', + metadata: { plan: 'pro' }, + }) + vi.mocked(createPaidKey).mockResolvedValue({ + key: 'snap_testkey1234567890ab', + tier: 'pro', + email: 'buyer@example.com', + createdAt: new Date().toISOString(), + } as any) + + const res = await request(app).get('/v1/billing/success').query({ session_id: uniqueSessionId }) + expect(res.status).toBe(200) + expect(res.text).toContain('snap_testkey1234567890ab') + expect(res.text).toContain('Welcome to SnapAPI') + expect(res.text).toContain('Pro Plan') + expect(createPaidKey).toHaveBeenCalledWith('buyer@example.com', 'pro', 'cus_buyer_123') + }) + + it('shows dedup message for already-provisioned session', async () => { + const dupSessionId = `cs_test_dup_${Date.now()}` + // First call provisions + mockCheckoutRetrieve.mockResolvedValue({ + id: dupSessionId, + customer_details: { email: 'dup@example.com' }, + customer: 'cus_dup', + metadata: { plan: 'starter' }, + }) + vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_dupkey123', tier: 'starter', email: 'dup@example.com', createdAt: '' } as any) + + await request(app).get('/v1/billing/success').query({ session_id: dupSessionId }) + + // Second call with same ID + vi.mocked(createPaidKey).mockClear() + const res = await request(app).get('/v1/billing/success').query({ session_id: dupSessionId }) + expect(res.status).toBe(200) + expect(res.text).toContain('already provisioned') + expect(createPaidKey).not.toHaveBeenCalled() + }) + + it('returns 500 on Stripe error', async () => { + mockCheckoutRetrieve.mockRejectedValue(new Error('Stripe error')) + const res = await request(app).get('/v1/billing/success').query({ session_id: 'cs_fail' }) + expect(res.status).toBe(500) + expect(res.text).toMatch(/Something went wrong/) + }) +}) + +// ─── POST /v1/billing/portal ─── + +describe('POST /v1/billing/portal', () => { + it('returns portal URL when email has Stripe customer ID', async () => { + vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_portal_123') + mockBillingPortalCreate.mockResolvedValue({ url: 'https://billing.stripe.com/p/session_portal' }) + + const res = await request(app).post('/v1/billing/portal').send({ email: 'user@example.com' }) + expect(res.status).toBe(200) + expect(res.body.url).toBe('https://billing.stripe.com/p/session_portal') + expect(mockBillingPortalCreate).toHaveBeenCalledWith( + expect.objectContaining({ customer: 'cus_portal_123' }) + ) + }) + + it('returns 404 when email has no Stripe customer', async () => { + vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined) + const res = await request(app).post('/v1/billing/portal').send({ email: 'nobody@example.com' }) + expect(res.status).toBe(404) + expect(res.body.error).toMatch(/No subscription found/) + }) + + it('returns 400 when email is missing', async () => { + const res = await request(app).post('/v1/billing/portal').send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Email address is required/) + }) + + it('returns 400 when email is empty string', async () => { + const res = await request(app).post('/v1/billing/portal').send({ email: '' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Email address is required/) + }) +}) + +// ─── GET /v1/billing/recover ─── + +describe('GET /v1/billing/recover', () => { + it('returns masked key when email exists', async () => { vi.mocked(getKeyByEmail).mockResolvedValue({ key: 'snap_abcd1234efgh5678ijkl9012', tier: 'pro', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z', stripeCustomerId: 'cus_123456' - }) + } as any) - const response = await request(app) - .get('/v1/billing/recover') - .query({ email: 'user@example.com' }) - - expect(response.status).toBe(200) - expect(response.body).toEqual({ - message: 'If an account exists with this email, the API key has been sent.', - maskedKey: 'snap_abcd...9012' - }) - expect(getKeyByEmail).toHaveBeenCalledWith('user@example.com') + const res = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) + expect(res.status).toBe(200) + expect(res.body.message).toMatch(/If an account exists/) + expect(res.body.maskedKey).toBe('snap_abcd...9012') }) - it('should return success message when email does not exist (no info leak)', async () => { + it('returns success message when email does not exist (no info leak)', async () => { vi.mocked(getKeyByEmail).mockResolvedValue(undefined) - - const response = await request(app) - .get('/v1/billing/recover') - .query({ email: 'nonexistent@example.com' }) - - expect(response.status).toBe(200) - expect(response.body).toEqual({ - message: 'If an account exists with this email, the API key has been sent.' - }) + const res = await request(app).get('/v1/billing/recover').query({ email: 'nobody@example.com' }) + expect(res.status).toBe(200) + expect(res.body.message).toMatch(/If an account exists/) + expect(res.body.maskedKey).toBeUndefined() }) - it('should return 400 when email is missing', async () => { - const response = await request(app) - .get('/v1/billing/recover') - - expect(response.status).toBe(400) - expect(response.body).toEqual({ - error: 'Email address is required' - }) + it('returns 400 when email is missing', async () => { + const res = await request(app).get('/v1/billing/recover') + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Email address is required/) }) - it('should return 400 when email is empty string', async () => { - const response = await request(app) - .get('/v1/billing/recover') - .query({ email: '' }) - - expect(response.status).toBe(400) - expect(response.body).toEqual({ - error: 'Email address is required' - }) + it('returns 400 when email is empty string', async () => { + const res = await request(app).get('/v1/billing/recover').query({ email: '' }) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/Email address is required/) }) - it('should properly mask API keys with correct format', async () => { + it('properly masks API keys', async () => { vi.mocked(getKeyByEmail).mockResolvedValue({ key: 'snap_1234567890abcdef', tier: 'starter', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z' + } as any) + + const res = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) + expect(res.status).toBe(200) + expect(res.body.maskedKey).toBe('snap_1234...cdef') + }) +}) + +// ─── POST /v1/billing/webhook ─── + +describe('POST /v1/billing/webhook', () => { + it('returns 400 when stripe-signature is missing', async () => { + const res = await request(app).post('/v1/billing/webhook').send({}) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Missing signature/) + }) + + it('returns 400 on invalid signature', async () => { + mockConstructEvent.mockImplementation(() => { + throw new Error('Invalid signature') }) - const response = await request(app) - .get('/v1/billing/recover') - .query({ email: 'user@example.com' }) - - expect(response.status).toBe(200) - expect(response.body.maskedKey).toBe('snap_1234...cdef') + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'bad_sig') + .send({}) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Webhook error/) }) -}) \ No newline at end of file + + it('processes checkout.session.completed and provisions key', async () => { + const uniqueWebhookSession = `cs_webhook_${Date.now()}` + mockConstructEvent.mockReturnValue({ + type: 'checkout.session.completed', + id: 'evt_checkout_1', + data: { + object: { + id: uniqueWebhookSession, + customer_details: { email: 'webhook@example.com' }, + customer: 'cus_webhook_123', + metadata: { plan: 'pro' }, + subscription: 'sub_123' + } + } + }) + // isSnapAPIEvent needs to resolve the subscription's product + mockSubRetrieve.mockResolvedValue({ + items: { + data: [{ + price: { product: { id: 'prod_test_123' } } + }] + } + }) + vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_wh_key', tier: 'pro', email: 'webhook@example.com', createdAt: '' } as any) + + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'valid_sig') + .send({}) + + expect(res.status).toBe(200) + expect(res.body.received).toBe(true) + expect(createPaidKey).toHaveBeenCalledWith('webhook@example.com', 'pro', 'cus_webhook_123') + }) + + it('processes customer.subscription.deleted and downgrades', async () => { + mockConstructEvent.mockReturnValue({ + type: 'customer.subscription.deleted', + id: 'evt_sub_del', + data: { + object: { + customer: 'cus_downgrade_123', + items: { + data: [{ + price: { product: 'prod_test_123' } + }] + } + } + } + }) + + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'valid_sig') + .send({}) + + expect(res.status).toBe(200) + expect(downgradeByCustomer).toHaveBeenCalledWith('cus_downgrade_123') + }) + + it('processes customer.updated and updates email', async () => { + mockConstructEvent.mockReturnValue({ + type: 'customer.updated', + id: 'evt_cust_upd', + data: { + object: { + id: 'cus_email_update', + email: 'newemail@example.com' + } + } + }) + + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'valid_sig') + .send({}) + + expect(res.status).toBe(200) + expect(updateEmailByCustomer).toHaveBeenCalledWith('cus_email_update', 'newemail@example.com') + }) + + it('ignores non-SnapAPI events', async () => { + mockConstructEvent.mockReturnValue({ + type: 'customer.subscription.deleted', + id: 'evt_other', + data: { + object: { + customer: 'cus_other', + items: { + data: [{ + price: { product: 'prod_DOCFAST_OTHER' } + }] + } + } + } + }) + + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'valid_sig') + .send({}) + + expect(res.status).toBe(200) + expect(res.body.ignored).toBe(true) + expect(downgradeByCustomer).not.toHaveBeenCalled() + }) + + it('processes subscription.updated with canceled status', async () => { + mockConstructEvent.mockReturnValue({ + type: 'customer.subscription.updated', + id: 'evt_sub_cancel', + data: { + object: { + customer: 'cus_cancel_123', + status: 'canceled', + items: { + data: [{ + price: { product: 'prod_test_123' } + }] + } + } + } + }) + + const res = await request(app) + .post('/v1/billing/webhook') + .set('stripe-signature', 'valid_sig') + .send({}) + + expect(res.status).toBe(200) + expect(downgradeByCustomer).toHaveBeenCalledWith('cus_cancel_123') + }) +})