feat: initial release v0.3.0
This commit is contained in:
86
tests/integration/billing/api-contract.integration.test.ts
Normal file
86
tests/integration/billing/api-contract.integration.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
183
tests/integration/billing/ledger.integration.test.ts
Normal file
183
tests/integration/billing/ledger.integration.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
confirmChargeWithRecord,
|
||||
freezeBalance,
|
||||
getBalance,
|
||||
recordShadowUsage,
|
||||
rollbackFreeze,
|
||||
} from '@/lib/billing/ledger'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
describe('billing/ledger integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('freezes balance when enough funds exist', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_ok' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(7, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(3, 8)
|
||||
})
|
||||
|
||||
it('returns null freeze id when balance is insufficient', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 1)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_no_money' })
|
||||
expect(freezeId).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses same freeze record with the same idempotency key', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const first = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
|
||||
const second = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
|
||||
|
||||
expect(first).toBeTruthy()
|
||||
expect(second).toBe(first)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(8, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(2, 8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('supports partial confirmation and refunds difference', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'confirm_partial' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const confirmed = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 2,
|
||||
unit: 'second',
|
||||
},
|
||||
{ chargedAmount: 2 },
|
||||
)
|
||||
expect(confirmed).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(8, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(2, 8)
|
||||
expect(await prisma.usageCost.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('is idempotent when confirm is called repeatedly', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'confirm_idem' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const first = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'image',
|
||||
model: 'seedream',
|
||||
quantity: 1,
|
||||
unit: 'image',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
const second = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'image',
|
||||
model: 'seedream',
|
||||
quantity: 1,
|
||||
unit: 'image',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
|
||||
expect(first).toBe(true)
|
||||
expect(second).toBe(true)
|
||||
expect(await prisma.balanceTransaction.count({ where: { freezeId: freezeId! } })).toBe(1)
|
||||
})
|
||||
|
||||
it('rolls back pending freeze and restores funds', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 4, { idempotencyKey: 'rollback_ok' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const rolled = await rollbackFreeze(freezeId!)
|
||||
expect(rolled).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(10, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
|
||||
it('returns false when trying to rollback a non-pending freeze', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'rollback_after_confirm' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 5,
|
||||
unit: 'second',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
|
||||
const rolled = await rollbackFreeze(freezeId!)
|
||||
expect(rolled).toBe(false)
|
||||
})
|
||||
|
||||
it('records shadow usage as audit transaction without balance change', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 5)
|
||||
|
||||
const ok = await recordShadowUsage(user.id, {
|
||||
projectId: 'asset-hub',
|
||||
action: 'shadow_test',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1200,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(5, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(0, 8)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
|
||||
})
|
||||
})
|
||||
137
tests/integration/billing/service.integration.test.ts
Normal file
137
tests/integration/billing/service.integration.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { calcVoice } from '@/lib/billing/cost'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { prepareTaskBilling, rollbackTaskBilling, settleTaskBilling } from '@/lib/billing/service'
|
||||
import { TASK_TYPE, type TaskBillingInfo } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
function expectBillableInfo(info: TaskBillingInfo | null | undefined): Extract<TaskBillingInfo, { billable: true }> {
|
||||
expect(info?.billable).toBe(true)
|
||||
if (!info || !info.billable) {
|
||||
throw new Error('Expected billable task billing info')
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
describe('billing/service integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('marks task billing as skipped in OFF mode', async () => {
|
||||
process.env.BILLING_MODE = 'OFF'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const result = await prepareTaskBilling({
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
})
|
||||
|
||||
expect(result?.billable).toBe(true)
|
||||
expect((result as TaskBillingInfo & { status: string }).status).toBe('skipped')
|
||||
})
|
||||
|
||||
it('records shadow audit in SHADOW mode and does not consume balance', async () => {
|
||||
process.env.BILLING_MODE = 'SHADOW'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
expect(prepared.status).toBe('quoted')
|
||||
|
||||
const settled = expectBillableInfo(await settleTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: prepared,
|
||||
}, {
|
||||
result: { actualDurationSeconds: 2 },
|
||||
}))
|
||||
|
||||
expect(settled.status).toBe('settled')
|
||||
expect(settled.chargedCost).toBe(0)
|
||||
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||
expect(balance?.totalSpent).toBeCloseTo(0, 8)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
|
||||
})
|
||||
|
||||
it('freezes and settles in ENFORCE mode with actual usage', async () => {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
expect(prepared.status).toBe('frozen')
|
||||
expect(prepared.freezeId).toBeTruthy()
|
||||
|
||||
const settled = expectBillableInfo(await settleTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: prepared,
|
||||
}, {
|
||||
result: { actualDurationSeconds: 2 },
|
||||
}))
|
||||
|
||||
expect(settled.status).toBe('settled')
|
||||
expect(settled.chargedCost).toBeCloseTo(calcVoice(2), 8)
|
||||
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.totalSpent).toBeCloseTo(calcVoice(2), 8)
|
||||
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
|
||||
it('rolls back frozen billing in ENFORCE mode', async () => {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
const rolled = expectBillableInfo(await rollbackTaskBilling({
|
||||
id: taskId,
|
||||
billingInfo: prepared,
|
||||
}))
|
||||
|
||||
expect(rolled.status).toBe('rolled_back')
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
})
|
||||
130
tests/integration/billing/submitter.integration.test.ts
Normal file
130
tests/integration/billing/submitter.integration.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ApiError } from '@/lib/api-errors'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { submitTask } from '@/lib/task/submitter'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
vi.mock('@/lib/task/queues', () => ({
|
||||
addTaskJob: vi.fn(async () => ({ id: 'mock-job' })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
describe('billing/submitter integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('builds billing info server-side for billable task submission', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const result = await submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-a',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-a',
|
||||
payload: { maxSeconds: 5 },
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const task = await prisma.task.findUnique({ where: { id: result.taskId } })
|
||||
expect(task).toBeTruthy()
|
||||
const billing = task?.billingInfo as { billable?: boolean; source?: string } | null
|
||||
expect(billing?.billable).toBe(true)
|
||||
expect(billing?.source).toBe('task')
|
||||
})
|
||||
|
||||
it('marks task as failed when balance is insufficient', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 0)
|
||||
|
||||
const billingInfo = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 10 })
|
||||
expect(billingInfo?.billable).toBe(true)
|
||||
|
||||
await expect(
|
||||
submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-b',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-b',
|
||||
payload: { maxSeconds: 10 },
|
||||
billingInfo,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' } satisfies Pick<ApiError, 'code'>)
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
expect(task).toBeTruthy()
|
||||
expect(task?.status).toBe('failed')
|
||||
expect(task?.errorCode).toBe('INSUFFICIENT_BALANCE')
|
||||
})
|
||||
|
||||
it('allows billable task submission without computed billingInfo in OFF mode (regression)', async () => {
|
||||
process.env.BILLING_MODE = 'OFF'
|
||||
const user = await createTestUser()
|
||||
|
||||
const result = await submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-c',
|
||||
type: TASK_TYPE.IMAGE_CHARACTER,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-c',
|
||||
payload: {},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const task = await prisma.task.findUnique({ where: { id: result.taskId } })
|
||||
expect(task).toBeTruthy()
|
||||
expect(task?.errorCode).toBeNull()
|
||||
expect(task?.billingInfo).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps strict billingInfo validation in ENFORCE mode (regression)', async () => {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
await expect(
|
||||
submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-d',
|
||||
type: TASK_TYPE.IMAGE_CHARACTER,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-d',
|
||||
payload: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'INVALID_PARAMS' } satisfies Pick<ApiError, 'code'>)
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: TASK_TYPE.IMAGE_CHARACTER,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
expect(task).toBeTruthy()
|
||||
expect(task?.status).toBe('failed')
|
||||
expect(task?.errorCode).toBe('INVALID_PARAMS')
|
||||
expect(task?.errorMessage).toContain('missing server-generated billingInfo')
|
||||
})
|
||||
})
|
||||
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal file
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Job } from 'bullmq'
|
||||
import { UnrecoverableError } from 'bullmq'
|
||||
import { prepareTaskBilling } from '@/lib/billing/service'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { TaskTerminatedError } from '@/lib/task/errors'
|
||||
import { withTaskLifecycle } from '@/lib/workers/shared'
|
||||
import { TASK_TYPE, type TaskBillingInfo, type TaskJobData } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createQueuedTask, createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
async function createPreparedVoiceTask() {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const taskId = randomUUID()
|
||||
const raw = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })
|
||||
if (!raw || !raw.billable) {
|
||||
throw new Error('failed to build billing info fixture')
|
||||
}
|
||||
const prepared = await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: raw,
|
||||
})
|
||||
|
||||
const billingInfo = prepared as TaskBillingInfo
|
||||
await createQueuedTask({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-1',
|
||||
billingInfo,
|
||||
})
|
||||
|
||||
const jobData: TaskJobData = {
|
||||
taskId,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'en',
|
||||
projectId: project.id,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-1',
|
||||
billingInfo,
|
||||
userId: user.id,
|
||||
payload: {},
|
||||
}
|
||||
|
||||
const job = {
|
||||
data: jobData,
|
||||
queueName: 'voice',
|
||||
opts: {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2_000,
|
||||
},
|
||||
},
|
||||
attemptsMade: 0,
|
||||
} as unknown as Job<TaskJobData>
|
||||
|
||||
return { taskId, user, project, job }
|
||||
}
|
||||
|
||||
describe('billing/worker lifecycle integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('settles billing and marks task completed on success', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await withTaskLifecycle(fixture.job, async () => ({ actualDurationSeconds: 2 }))
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('completed')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect(billing?.billable).toBe(true)
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
|
||||
})
|
||||
|
||||
it('rolls back billing and marks task failed on error', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new Error('worker failed')
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnrecoverableError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('failed')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
})
|
||||
|
||||
it('keeps task active for queue retry on retryable worker error', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new TypeError('terminated')
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TypeError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('processing')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('frozen')
|
||||
})
|
||||
|
||||
it('rolls back billing on cancellation path', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new TaskTerminatedError(fixture.taskId)
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnrecoverableError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
expect(task?.status).not.toBe('failed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user