diff --git a/src/routes/__tests__/billing.test.ts b/src/routes/__tests__/billing.test.ts index 6a7ffc2..8392613 100644 --- a/src/routes/__tests__/billing.test.ts +++ b/src/routes/__tests__/billing.test.ts @@ -2,436 +2,349 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import request from 'supertest' import express from 'express' -// Mock logger +// Hoist mock functions so they're available in vi.mock factories +const { + mockBillingPortalCreate, + mockCheckoutSessionsCreate, + mockCheckoutSessionsRetrieve, + mockSubscriptionsRetrieve, + mockWebhooksConstructEvent, + mockProductsSearch, + mockPricesList, + mockPricesCreate, + mockPricesRetrieve, + mockProductsCreate, + mockStripeInstance, +} = vi.hoisted(() => { + const mockBillingPortalCreate = vi.fn() + const mockCheckoutSessionsCreate = vi.fn() + const mockCheckoutSessionsRetrieve = vi.fn() + const mockSubscriptionsRetrieve = vi.fn() + const mockWebhooksConstructEvent = vi.fn() + const mockProductsSearch = vi.fn() + const mockPricesList = vi.fn() + const mockPricesCreate = vi.fn() + const mockPricesRetrieve = vi.fn() + const mockProductsCreate = vi.fn() + + const mockStripeInstance = { + billingPortal: { sessions: { create: mockBillingPortalCreate } }, + checkout: { sessions: { create: mockCheckoutSessionsCreate, retrieve: mockCheckoutSessionsRetrieve } }, + subscriptions: { retrieve: mockSubscriptionsRetrieve }, + webhooks: { constructEvent: mockWebhooksConstructEvent }, + products: { search: mockProductsSearch, create: mockProductsCreate }, + prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve }, + } + + return { + mockBillingPortalCreate, mockCheckoutSessionsCreate, mockCheckoutSessionsRetrieve, + mockSubscriptionsRetrieve, mockWebhooksConstructEvent, mockProductsSearch, + mockPricesList, mockPricesCreate, mockPricesRetrieve, mockProductsCreate, mockStripeInstance, + } +}) + vi.mock('../../services/logger.js', () => ({ default: { info: vi.fn(), error: vi.fn() } })) -// Mock DB functions vi.mock('../../services/keys.js', () => ({ + getCustomerIdByEmail: vi.fn(), + getKeyByEmail: vi.fn(), createPaidKey: vi.fn(), downgradeByCustomer: vi.fn(), updateEmailByCustomer: vi.fn(), - getCustomerIdByEmail: vi.fn(), - getKeyByEmail: vi.fn() })) -// 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 = { - 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(function() { return mockStripe }) + default: function Stripe() { return mockStripeInstance }, })) -// 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') +// Set env vars BEFORE import (vi.stubEnv may not work for module-level reads) +process.env.STRIPE_SECRET_KEY = 'sk_test_123456789' +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_secret' +process.env.BASE_URL = 'https://test.snapapi.eu' + +// Setup default mocks for initPrices (runs on import) — prices not found, so create them +mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] }) +mockPricesList.mockImplementation(async () => ({ data: [] })) +mockPricesCreate.mockImplementation(async ({ unit_amount }: any) => ({ id: `price_${unit_amount}` })) -import { createPaidKey, downgradeByCustomer, updateEmailByCustomer, getCustomerIdByEmail, getKeyByEmail } from '../../services/keys.js' import { billingRouter } from '../billing.js' +import { getCustomerIdByEmail, getKeyByEmail, createPaidKey, downgradeByCustomer, updateEmailByCustomer } from '../../services/keys.js' const app = express() +app.use('/v1/billing/webhook', express.raw({ type: '*/*' })) app.use(express.json()) app.use('/v1/billing', billingRouter) -// 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' }) -} +describe('POST /v1/billing/portal', () => { + beforeEach(() => { vi.clearAllMocks() }) -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() -}) + it('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' }) -// ─── 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('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('returns checkout URL for valid starter plan', async () => { - mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' }) - - 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' }), - }) + const response = await request(app).post('/v1/billing/portal').send({ email: 'user@example.com' }) + 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( + expect.objectContaining({ customer: 'cus_123456' }) ) }) - 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 404 when email has no stripe customer ID', async () => { + vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined) + const response = await request(app).post('/v1/billing/portal').send({ email: 'nonexistent@example.com' }) + expect(response.status).toBe(404) + expect(response.body.error).toContain('No subscription found') }) - 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') + 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 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/) + it('should return 400 when email is empty string', async () => { + const response = await request(app).post('/v1/billing/portal').send({ email: '' }) + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'Email address is required' }) }) }) -// ─── GET /v1/billing/success ─── +describe('GET /v1/billing/recover', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('should return success message and 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' + }) + 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_abcd...9012') + }) + + it('should return 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.message).toContain('If an account exists') + expect(response.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) + }) + + 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) + }) + + it('should properly mask API keys with correct format', async () => { + vi.mocked(getKeyByEmail).mockResolvedValue({ + key: 'snap_1234567890abcdef', tier: 'starter', email: 'user@example.com', createdAt: '2024-01-01T00:00:00Z' + }) + const response = await request(app).get('/v1/billing/recover').query({ email: 'user@example.com' }) + expect(response.body.maskedKey).toBe('snap_1234...cdef') + }) +}) + +describe('POST /v1/billing/checkout', () => { + beforeEach(() => { + vi.clearAllMocks() + mockProductsSearch.mockResolvedValue({ data: [{ id: 'prod_snap_test' }] }) + mockPricesList.mockResolvedValue({ data: [{ id: 'price_test', unit_amount: 900 }] }) + }) + + it('should return 400 for missing plan', async () => { + const response = await request(app).post('/v1/billing/checkout').send({}) + expect(response.status).toBe(400) + expect(response.body.error).toContain('Invalid plan') + }) + + it('should return 400 for invalid plan name', async () => { + const response = await request(app).post('/v1/billing/checkout').send({ plan: 'enterprise' }) + expect(response.status).toBe(400) + expect(response.body.error).toContain('Invalid plan') + }) + + it('should return checkout URL for valid plan (starter)', async () => { + mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' }) + const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) + expect(response.status).toBe(200) + expect(response.body).toEqual({ url: 'https://checkout.stripe.com/session_abc' }) + expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith(expect.objectContaining({ + mode: 'subscription', + metadata: { plan: 'starter', service: 'snapapi' }, + })) + }) + + it('should return checkout URL for pro plan', async () => { + mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/pro' }) + const response = await request(app).post('/v1/billing/checkout').send({ plan: 'pro' }) + expect(response.status).toBe(200) + expect(response.body.url).toBe('https://checkout.stripe.com/pro') + }) + + it('should return checkout URL for business plan', async () => { + mockCheckoutSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/biz' }) + const response = await request(app).post('/v1/billing/checkout').send({ plan: 'business' }) + expect(response.status).toBe(200) + expect(response.body.url).toBe('https://checkout.stripe.com/biz') + }) + + it('should return 500 on Stripe API error', async () => { + mockCheckoutSessionsCreate.mockRejectedValue(new Error('Stripe is down')) + const response = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' }) + expect(response.status).toBe(500) + expect(response.body.error).toContain('Failed to create checkout session') + }) +}) 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/) + beforeEach(() => { vi.clearAllMocks() }) + + it('should return 400 for missing session_id', async () => { + const response = await request(app).get('/v1/billing/success') + expect(response.status).toBe(400) + expect(response.text).toBe('Missing session_id') }) - it('returns HTML with API key for valid session', async () => { - const uniqueSessionId = `cs_test_success_${Date.now()}` - mockCheckoutRetrieve.mockResolvedValue({ - id: uniqueSessionId, + it('should return HTML with API key for valid session', async () => { + mockCheckoutSessionsRetrieve.mockResolvedValue({ + id: 'cs_test_unique_success_1', customer_details: { email: 'buyer@example.com' }, - customer: 'cus_buyer_123', metadata: { plan: 'pro' }, + customer: 'cus_buyer_1', }) - vi.mocked(createPaidKey).mockResolvedValue({ - key: 'snap_testkey1234567890ab', - tier: 'pro', - email: 'buyer@example.com', - createdAt: new Date().toISOString(), - } as any) + vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_newkey123456' } 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') + const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_unique_success_1' }) + expect(response.status).toBe(200) + expect(response.headers['content-type']).toMatch(/html/) + expect(response.text).toContain('snap_newkey123456') + expect(response.text).toContain('Welcome to SnapAPI') + expect(response.text).toContain('Pro Plan') + expect(createPaidKey).toHaveBeenCalledWith('buyer@example.com', 'pro', 'cus_buyer_1') }) - 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' }, + it('should handle duplicate session_id (dedup via provisionedSessions)', async () => { + const sessionId = 'cs_test_dedup_unique_2' + mockCheckoutSessionsRetrieve.mockResolvedValue({ + id: sessionId, customer_details: { email: 'dedup@example.com' }, + metadata: { plan: 'starter' }, customer: 'cus_dedup', }) - vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_dupkey123', tier: 'starter', email: 'dup@example.com', createdAt: '' } as any) + vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_first_key' } as any) - await request(app).get('/v1/billing/success').query({ session_id: dupSessionId }) - - // Second call with same ID + await request(app).get('/v1/billing/success').query({ session_id: sessionId }) 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') + const response = await request(app).get('/v1/billing/success').query({ session_id: sessionId }) + expect(response.status).toBe(200) + expect(response.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/) + it('should return 500 on Stripe API error', async () => { + mockCheckoutSessionsRetrieve.mockRejectedValue(new Error('Stripe error')) + const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_error_3' }) + expect(response.status).toBe(500) + expect(response.text).toContain('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 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('returns success message when email does not exist (no info leak)', async () => { - vi.mocked(getKeyByEmail).mockResolvedValue(undefined) - 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('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('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('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/) + beforeEach(() => { vi.clearAllMocks() }) + + it('should return 400 for missing stripe-signature header', async () => { + const response = await request(app).post('/v1/billing/webhook').send(Buffer.from('{}')) + expect(response.status).toBe(400) + expect(response.text).toBe('Missing signature') }) - it('returns 400 on invalid signature', async () => { - mockConstructEvent.mockImplementation(() => { - throw new Error('Invalid signature') + it('should handle checkout.session.completed — provisions key', async () => { + const sessionId = 'cs_webhook_unique_4' + mockWebhooksConstructEvent.mockReturnValue({ + id: 'evt_1', type: 'checkout.session.completed', + data: { object: { + id: sessionId, customer_details: { email: 'webhook@example.com' }, + metadata: { plan: 'pro' }, customer: 'cus_wh_1', subscription: 'sub_123', + }} }) + mockSubscriptionsRetrieve.mockResolvedValue({ + items: { data: [{ price: { product: 'prod_snap_test' } }] } + }) + vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_wh_key' } as any) - 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/) + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) + expect(response.status).toBe(200) + expect(response.body.received).toBe(true) + expect(createPaidKey).toHaveBeenCalledWith('webhook@example.com', 'pro', 'cus_wh_1') }) - 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' - } - } + it('should handle customer.subscription.updated with canceled status — downgrades', async () => { + mockWebhooksConstructEvent.mockReturnValue({ + id: 'evt_2', type: 'customer.subscription.updated', + data: { object: { + customer: 'cus_cancel_1', status: 'canceled', + items: { data: [{ price: { product: 'prod_snap_test' } }] }, + }} }) - // 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') + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) + expect(response.status).toBe(200) + expect(downgradeByCustomer).toHaveBeenCalledWith('cus_cancel_1') }) - 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' } - }] - } - } - } + it('should handle customer.subscription.deleted — downgrades', async () => { + mockWebhooksConstructEvent.mockReturnValue({ + id: 'evt_3', type: 'customer.subscription.deleted', + data: { object: { + customer: 'cus_delete_1', + items: { data: [{ price: { product: 'prod_snap_test' } }] }, + }} }) - - 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') + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) + expect(response.status).toBe(200) + expect(downgradeByCustomer).toHaveBeenCalledWith('cus_delete_1') }) - 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' - } - } + it('should handle customer.updated — updates email', async () => { + mockWebhooksConstructEvent.mockReturnValue({ + id: 'evt_4', type: 'customer.updated', + 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) + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) + expect(response.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' } - }] - } - } - } + it('should ignore non-SnapAPI events (different product ID)', async () => { + mockWebhooksConstructEvent.mockReturnValue({ + id: 'evt_5', type: 'customer.subscription.updated', + data: { object: { + customer: 'cus_other', status: 'canceled', + items: { data: [{ price: { product: 'prod_OTHER_not_snapapi' } }] }, + }} }) - - 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) + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}')) + expect(response.status).toBe(200) + expect(response.body).toEqual({ received: true, ignored: 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') + it('should return 400 for invalid signature', async () => { + mockWebhooksConstructEvent.mockImplementation(() => { throw new Error('Invalid signature') }) + const response = await request(app) + .post('/v1/billing/webhook').set('stripe-signature', 'sig_invalid').send(Buffer.from('{}')) + expect(response.status).toBe(400) + expect(response.text).toContain('Webhook error') }) })