87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { apiHandler } from '@/lib/api-errors'
|
|
import { calcText } from '@/lib/billing/cost'
|
|
import { withTextBilling } from '@/lib/billing/service'
|
|
import { prisma } from '../../helpers/prisma'
|
|
import { resetBillingState } from '../../helpers/db-reset'
|
|
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
|
|
|
describe('billing/api contract integration', () => {
|
|
beforeEach(async () => {
|
|
await resetBillingState()
|
|
process.env.BILLING_MODE = 'ENFORCE'
|
|
})
|
|
|
|
it('returns 402 payload when balance is insufficient', async () => {
|
|
const user = await createTestUser()
|
|
const project = await createTestProject(user.id)
|
|
await seedBalance(user.id, 0)
|
|
|
|
const route = apiHandler(async () => {
|
|
await withTextBilling(
|
|
user.id,
|
|
'anthropic/claude-sonnet-4',
|
|
1000,
|
|
500,
|
|
{ projectId: project.id, action: 'api_contract_insufficient' },
|
|
async () => ({ ok: true }),
|
|
)
|
|
return NextResponse.json({ ok: true })
|
|
})
|
|
|
|
const req = new NextRequest('http://localhost/api/test', {
|
|
method: 'POST',
|
|
headers: { 'x-request-id': 'req_insufficient' },
|
|
})
|
|
const response = await route(req, { params: Promise.resolve({}) })
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(402)
|
|
expect(body?.error?.code).toBe('INSUFFICIENT_BALANCE')
|
|
expect(typeof body?.required).toBe('number')
|
|
expect(typeof body?.available).toBe('number')
|
|
})
|
|
|
|
it('rejects duplicate retry with same request id and prevents duplicate charge', async () => {
|
|
const user = await createTestUser()
|
|
const project = await createTestProject(user.id)
|
|
await seedBalance(user.id, 5)
|
|
|
|
const route = apiHandler(async () => {
|
|
await withTextBilling(
|
|
user.id,
|
|
'anthropic/claude-sonnet-4',
|
|
1000,
|
|
500,
|
|
{ projectId: project.id, action: 'api_contract_dedupe' },
|
|
async () => ({ ok: true }),
|
|
)
|
|
return NextResponse.json({ ok: true })
|
|
})
|
|
|
|
const req1 = new NextRequest('http://localhost/api/test', {
|
|
method: 'POST',
|
|
headers: { 'x-request-id': 'same_request_id' },
|
|
})
|
|
const req2 = new NextRequest('http://localhost/api/test', {
|
|
method: 'POST',
|
|
headers: { 'x-request-id': 'same_request_id' },
|
|
})
|
|
|
|
const resp1 = await route(req1, { params: Promise.resolve({}) })
|
|
const resp2 = await route(req2, { params: Promise.resolve({}) })
|
|
const body2 = await resp2.json()
|
|
|
|
expect(resp1.status).toBe(200)
|
|
expect(resp2.status).toBe(409)
|
|
expect(body2?.error?.code).toBe('CONFLICT')
|
|
expect(String(body2?.error?.message || '')).toContain('duplicate billing request already confirmed')
|
|
|
|
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
|
const expectedCharge = calcText('anthropic/claude-sonnet-4', 1000, 500)
|
|
expect(balance?.totalSpent).toBeCloseTo(expectedCharge, 8)
|
|
expect(await prisma.balanceFreeze.count()).toBe(1)
|
|
})
|
|
})
|