test: comprehensive billing route tests (checkout, success, webhook, portal, recover)
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

This commit is contained in:
Hoid 2026-03-03 12:37:26 +01:00
parent 5137b80a2a
commit e240d9e30d

View file

@ -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()
// 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'
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/)
})
const response = await request(app)
.post('/v1/billing/portal')
.send({ email: 'user@example.com' })
it('returns checkout URL for valid starter plan', async () => {
mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_abc' })
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'
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 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).toEqual({
error: 'No subscription found for this email address. Please contact support if you believe this is an error.'
})
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 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 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 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'
})
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 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')
})
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')
})
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 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).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 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/)
})
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')
})
})