test: comprehensive billing route tests (checkout, success, webhook)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m0s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 12m0s
This commit is contained in:
parent
e240d9e30d
commit
e04d0bb283
1 changed files with 272 additions and 359 deletions
|
|
@ -2,436 +2,349 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
|
||||||
// Mock logger
|
// Hoist mock functions so they're available in vi.mock factories
|
||||||
vi.mock('../../services/logger.js', () => ({
|
const {
|
||||||
default: { info: vi.fn(), error: vi.fn() }
|
mockBillingPortalCreate,
|
||||||
}))
|
mockCheckoutSessionsCreate,
|
||||||
|
mockCheckoutSessionsRetrieve,
|
||||||
// Mock DB functions
|
mockSubscriptionsRetrieve,
|
||||||
vi.mock('../../services/keys.js', () => ({
|
mockWebhooksConstructEvent,
|
||||||
createPaidKey: vi.fn(),
|
mockProductsSearch,
|
||||||
downgradeByCustomer: vi.fn(),
|
mockPricesList,
|
||||||
updateEmailByCustomer: vi.fn(),
|
mockPricesCreate,
|
||||||
getCustomerIdByEmail: vi.fn(),
|
mockPricesRetrieve,
|
||||||
getKeyByEmail: vi.fn()
|
mockProductsCreate,
|
||||||
}))
|
mockStripeInstance,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
// 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 mockBillingPortalCreate = vi.fn()
|
||||||
const mockProductsSearch = vi.fn().mockResolvedValue({ data: [{ id: 'prod_test_123', name: 'test' }] })
|
const mockCheckoutSessionsCreate = vi.fn()
|
||||||
const mockPricesList = vi.fn().mockResolvedValue({ data: [{ id: 'price_test_123', unit_amount: 900 }] })
|
const mockCheckoutSessionsRetrieve = vi.fn()
|
||||||
const mockPricesCreate = vi.fn().mockResolvedValue({ id: 'price_new_123' })
|
const mockSubscriptionsRetrieve = vi.fn()
|
||||||
const mockProductsCreate = vi.fn().mockResolvedValue({ id: 'prod_new_123' })
|
const mockWebhooksConstructEvent = vi.fn()
|
||||||
|
const mockProductsSearch = vi.fn()
|
||||||
|
const mockPricesList = vi.fn()
|
||||||
|
const mockPricesCreate = vi.fn()
|
||||||
const mockPricesRetrieve = vi.fn()
|
const mockPricesRetrieve = vi.fn()
|
||||||
|
const mockProductsCreate = vi.fn()
|
||||||
|
|
||||||
const mockStripe = {
|
const mockStripeInstance = {
|
||||||
checkout: { sessions: { create: mockCheckoutCreate, retrieve: mockCheckoutRetrieve } },
|
|
||||||
subscriptions: { retrieve: mockSubRetrieve },
|
|
||||||
webhooks: { constructEvent: mockConstructEvent },
|
|
||||||
billingPortal: { sessions: { create: mockBillingPortalCreate } },
|
billingPortal: { sessions: { create: mockBillingPortalCreate } },
|
||||||
|
checkout: { sessions: { create: mockCheckoutSessionsCreate, retrieve: mockCheckoutSessionsRetrieve } },
|
||||||
|
subscriptions: { retrieve: mockSubscriptionsRetrieve },
|
||||||
|
webhooks: { constructEvent: mockWebhooksConstructEvent },
|
||||||
products: { search: mockProductsSearch, create: mockProductsCreate },
|
products: { search: mockProductsSearch, create: mockProductsCreate },
|
||||||
prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve },
|
prices: { list: mockPricesList, create: mockPricesCreate, retrieve: mockPricesRetrieve },
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('stripe', () => ({
|
return {
|
||||||
default: vi.fn().mockImplementation(function() { return mockStripe })
|
mockBillingPortalCreate, mockCheckoutSessionsCreate, mockCheckoutSessionsRetrieve,
|
||||||
|
mockSubscriptionsRetrieve, mockWebhooksConstructEvent, mockProductsSearch,
|
||||||
|
mockPricesList, mockPricesCreate, mockPricesRetrieve, mockProductsCreate, mockStripeInstance,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../services/logger.js', () => ({
|
||||||
|
default: { info: vi.fn(), error: vi.fn() }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Stub env BEFORE importing router
|
vi.mock('../../services/keys.js', () => ({
|
||||||
vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_123456789')
|
getCustomerIdByEmail: vi.fn(),
|
||||||
vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_test_secret')
|
getKeyByEmail: vi.fn(),
|
||||||
vi.stubEnv('BASE_URL', 'https://test.snapapi.eu')
|
createPaidKey: vi.fn(),
|
||||||
|
downgradeByCustomer: vi.fn(),
|
||||||
|
updateEmailByCustomer: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('stripe', () => ({
|
||||||
|
default: function Stripe() { return mockStripeInstance },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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 { billingRouter } from '../billing.js'
|
||||||
|
import { getCustomerIdByEmail, getKeyByEmail, createPaidKey, downgradeByCustomer, updateEmailByCustomer } from '../../services/keys.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
app.use('/v1/billing/webhook', express.raw({ type: '*/*' }))
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use('/v1/billing', billingRouter)
|
app.use('/v1/billing', billingRouter)
|
||||||
|
|
||||||
// Default mock returns for getOrCreatePrice chain
|
describe('POST /v1/billing/portal', () => {
|
||||||
function setupPriceMocks() {
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
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(() => {
|
it('should return portal URL when email has stripe customer ID', async () => {
|
||||||
// Clear call history but keep implementations for mocks that need defaults
|
vi.mocked(getCustomerIdByEmail).mockResolvedValue('cus_123456')
|
||||||
mockCheckoutCreate.mockClear()
|
mockBillingPortalCreate.mockResolvedValue({ url: 'https://billing.stripe.com/p/session_123456' })
|
||||||
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 ───
|
const response = await request(app).post('/v1/billing/portal').send({ email: 'user@example.com' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
describe('POST /v1/billing/checkout', () => {
|
expect(response.body).toEqual({ url: 'https://billing.stripe.com/p/session_123456' })
|
||||||
it('returns 400 when plan is missing', async () => {
|
expect(getCustomerIdByEmail).toHaveBeenCalledWith('user@example.com')
|
||||||
const res = await request(app).post('/v1/billing/checkout').send({})
|
expect(mockBillingPortalCreate).toHaveBeenCalledWith(
|
||||||
expect(res.status).toBe(400)
|
expect.objectContaining({ customer: 'cus_123456' })
|
||||||
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' }),
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns checkout URL for pro plan', async () => {
|
it('should return 404 when email has no stripe customer ID', async () => {
|
||||||
mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_pro' })
|
vi.mocked(getCustomerIdByEmail).mockResolvedValue(undefined)
|
||||||
const res = await request(app).post('/v1/billing/checkout').send({ plan: 'pro' })
|
const response = await request(app).post('/v1/billing/portal').send({ email: 'nonexistent@example.com' })
|
||||||
expect(res.status).toBe(200)
|
expect(response.status).toBe(404)
|
||||||
expect(res.body.url).toBe('https://checkout.stripe.com/session_pro')
|
expect(response.body.error).toContain('No subscription found')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns checkout URL for business plan', async () => {
|
it('should return 400 when email is missing', async () => {
|
||||||
mockCheckoutCreate.mockResolvedValue({ url: 'https://checkout.stripe.com/session_biz' })
|
const response = await request(app).post('/v1/billing/portal').send({})
|
||||||
const res = await request(app).post('/v1/billing/checkout').send({ plan: 'business' })
|
expect(response.status).toBe(400)
|
||||||
expect(res.status).toBe(200)
|
expect(response.body).toEqual({ error: 'Email address is required' })
|
||||||
expect(res.body.url).toBe('https://checkout.stripe.com/session_biz')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 500 on Stripe error', async () => {
|
it('should return 400 when email is empty string', async () => {
|
||||||
mockCheckoutCreate.mockRejectedValue(new Error('Stripe down'))
|
const response = await request(app).post('/v1/billing/portal').send({ email: '' })
|
||||||
const res = await request(app).post('/v1/billing/checkout').send({ plan: 'starter' })
|
expect(response.status).toBe(400)
|
||||||
expect(res.status).toBe(500)
|
expect(response.body).toEqual({ error: 'Email address is required' })
|
||||||
expect(res.body.error).toMatch(/Failed to create checkout/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── 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', () => {
|
describe('GET /v1/billing/success', () => {
|
||||||
it('returns 400 when session_id is missing', async () => {
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
const res = await request(app).get('/v1/billing/success')
|
|
||||||
expect(res.status).toBe(400)
|
it('should return 400 for missing session_id', async () => {
|
||||||
expect(res.text).toMatch(/Missing session_id/)
|
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 () => {
|
it('should return HTML with API key for valid session', async () => {
|
||||||
const uniqueSessionId = `cs_test_success_${Date.now()}`
|
mockCheckoutSessionsRetrieve.mockResolvedValue({
|
||||||
mockCheckoutRetrieve.mockResolvedValue({
|
id: 'cs_test_unique_success_1',
|
||||||
id: uniqueSessionId,
|
|
||||||
customer_details: { email: 'buyer@example.com' },
|
customer_details: { email: 'buyer@example.com' },
|
||||||
customer: 'cus_buyer_123',
|
|
||||||
metadata: { plan: 'pro' },
|
metadata: { plan: 'pro' },
|
||||||
|
customer: 'cus_buyer_1',
|
||||||
})
|
})
|
||||||
vi.mocked(createPaidKey).mockResolvedValue({
|
vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_newkey123456' } as any)
|
||||||
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 })
|
const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_unique_success_1' })
|
||||||
expect(res.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(res.text).toContain('snap_testkey1234567890ab')
|
expect(response.headers['content-type']).toMatch(/html/)
|
||||||
expect(res.text).toContain('Welcome to SnapAPI')
|
expect(response.text).toContain('snap_newkey123456')
|
||||||
expect(res.text).toContain('Pro Plan')
|
expect(response.text).toContain('Welcome to SnapAPI')
|
||||||
expect(createPaidKey).toHaveBeenCalledWith('buyer@example.com', 'pro', 'cus_buyer_123')
|
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 () => {
|
it('should handle duplicate session_id (dedup via provisionedSessions)', async () => {
|
||||||
const dupSessionId = `cs_test_dup_${Date.now()}`
|
const sessionId = 'cs_test_dedup_unique_2'
|
||||||
// First call provisions
|
mockCheckoutSessionsRetrieve.mockResolvedValue({
|
||||||
mockCheckoutRetrieve.mockResolvedValue({
|
id: sessionId, customer_details: { email: 'dedup@example.com' },
|
||||||
id: dupSessionId,
|
metadata: { plan: 'starter' }, customer: 'cus_dedup',
|
||||||
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)
|
vi.mocked(createPaidKey).mockResolvedValue({ key: 'snap_first_key' } as any)
|
||||||
|
|
||||||
await request(app).get('/v1/billing/success').query({ session_id: dupSessionId })
|
await request(app).get('/v1/billing/success').query({ session_id: sessionId })
|
||||||
|
|
||||||
// Second call with same ID
|
|
||||||
vi.mocked(createPaidKey).mockClear()
|
vi.mocked(createPaidKey).mockClear()
|
||||||
const res = await request(app).get('/v1/billing/success').query({ session_id: dupSessionId })
|
const response = await request(app).get('/v1/billing/success').query({ session_id: sessionId })
|
||||||
expect(res.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
expect(res.text).toContain('already provisioned')
|
expect(response.text).toContain('already provisioned')
|
||||||
expect(createPaidKey).not.toHaveBeenCalled()
|
expect(createPaidKey).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 500 on Stripe error', async () => {
|
it('should return 500 on Stripe API error', async () => {
|
||||||
mockCheckoutRetrieve.mockRejectedValue(new Error('Stripe error'))
|
mockCheckoutSessionsRetrieve.mockRejectedValue(new Error('Stripe error'))
|
||||||
const res = await request(app).get('/v1/billing/success').query({ session_id: 'cs_fail' })
|
const response = await request(app).get('/v1/billing/success').query({ session_id: 'cs_test_error_3' })
|
||||||
expect(res.status).toBe(500)
|
expect(response.status).toBe(500)
|
||||||
expect(res.text).toMatch(/Something went wrong/)
|
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', () => {
|
describe('POST /v1/billing/webhook', () => {
|
||||||
it('returns 400 when stripe-signature is missing', async () => {
|
beforeEach(() => { vi.clearAllMocks() })
|
||||||
const res = await request(app).post('/v1/billing/webhook').send({})
|
|
||||||
expect(res.status).toBe(400)
|
it('should return 400 for missing stripe-signature header', async () => {
|
||||||
expect(res.text).toMatch(/Missing signature/)
|
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 () => {
|
it('should handle checkout.session.completed — provisions key', async () => {
|
||||||
mockConstructEvent.mockImplementation(() => {
|
const sessionId = 'cs_webhook_unique_4'
|
||||||
throw new Error('Invalid signature')
|
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 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')
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await request(app)
|
it('should handle customer.subscription.updated with canceled status — downgrades', async () => {
|
||||||
.post('/v1/billing/webhook')
|
mockWebhooksConstructEvent.mockReturnValue({
|
||||||
.set('stripe-signature', 'bad_sig')
|
id: 'evt_2', type: 'customer.subscription.updated',
|
||||||
.send({})
|
data: { object: {
|
||||||
expect(res.status).toBe(400)
|
customer: 'cus_cancel_1', status: 'canceled',
|
||||||
expect(res.text).toMatch(/Webhook error/)
|
items: { data: [{ price: { product: 'prod_snap_test' } }] },
|
||||||
|
}}
|
||||||
|
})
|
||||||
|
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 checkout.session.completed and provisions key', async () => {
|
it('should handle customer.subscription.deleted — downgrades', async () => {
|
||||||
const uniqueWebhookSession = `cs_webhook_${Date.now()}`
|
mockWebhooksConstructEvent.mockReturnValue({
|
||||||
mockConstructEvent.mockReturnValue({
|
id: 'evt_3', type: 'customer.subscription.deleted',
|
||||||
type: 'checkout.session.completed',
|
data: { object: {
|
||||||
id: 'evt_checkout_1',
|
customer: 'cus_delete_1',
|
||||||
data: {
|
items: { data: [{ price: { product: 'prod_snap_test' } }] },
|
||||||
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
|
const response = await request(app)
|
||||||
mockSubRetrieve.mockResolvedValue({
|
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||||
items: {
|
expect(response.status).toBe(200)
|
||||||
data: [{
|
expect(downgradeByCustomer).toHaveBeenCalledWith('cus_delete_1')
|
||||||
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 () => {
|
it('should handle customer.updated — updates email', async () => {
|
||||||
mockConstructEvent.mockReturnValue({
|
mockWebhooksConstructEvent.mockReturnValue({
|
||||||
type: 'customer.subscription.deleted',
|
id: 'evt_4', type: 'customer.updated',
|
||||||
id: 'evt_sub_del',
|
data: { object: { id: 'cus_email_update', email: 'newemail@example.com' } }
|
||||||
data: {
|
|
||||||
object: {
|
|
||||||
customer: 'cus_downgrade_123',
|
|
||||||
items: {
|
|
||||||
data: [{
|
|
||||||
price: { product: 'prod_test_123' }
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const response = await request(app)
|
||||||
const res = await request(app)
|
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||||
.post('/v1/billing/webhook')
|
expect(response.status).toBe(200)
|
||||||
.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')
|
expect(updateEmailByCustomer).toHaveBeenCalledWith('cus_email_update', 'newemail@example.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores non-SnapAPI events', async () => {
|
it('should ignore non-SnapAPI events (different product ID)', async () => {
|
||||||
mockConstructEvent.mockReturnValue({
|
mockWebhooksConstructEvent.mockReturnValue({
|
||||||
type: 'customer.subscription.deleted',
|
id: 'evt_5', type: 'customer.subscription.updated',
|
||||||
id: 'evt_other',
|
data: { object: {
|
||||||
data: {
|
customer: 'cus_other', status: 'canceled',
|
||||||
object: {
|
items: { data: [{ price: { product: 'prod_OTHER_not_snapapi' } }] },
|
||||||
customer: 'cus_other',
|
}}
|
||||||
items: {
|
|
||||||
data: [{
|
|
||||||
price: { product: 'prod_DOCFAST_OTHER' }
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const response = await request(app)
|
||||||
const res = await request(app)
|
.post('/v1/billing/webhook').set('stripe-signature', 'sig_valid').send(Buffer.from('{}'))
|
||||||
.post('/v1/billing/webhook')
|
expect(response.status).toBe(200)
|
||||||
.set('stripe-signature', 'valid_sig')
|
expect(response.body).toEqual({ received: true, ignored: true })
|
||||||
.send({})
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(res.body.ignored).toBe(true)
|
|
||||||
expect(downgradeByCustomer).not.toHaveBeenCalled()
|
expect(downgradeByCustomer).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('processes subscription.updated with canceled status', async () => {
|
it('should return 400 for invalid signature', async () => {
|
||||||
mockConstructEvent.mockReturnValue({
|
mockWebhooksConstructEvent.mockImplementation(() => { throw new Error('Invalid signature') })
|
||||||
type: 'customer.subscription.updated',
|
const response = await request(app)
|
||||||
id: 'evt_sub_cancel',
|
.post('/v1/billing/webhook').set('stripe-signature', 'sig_invalid').send(Buffer.from('{}'))
|
||||||
data: {
|
expect(response.status).toBe(400)
|
||||||
object: {
|
expect(response.text).toContain('Webhook error')
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue