feat: initial release v0.3.0
This commit is contained in:
65
tests/unit/billing/cost-error-branches.test.ts
Normal file
65
tests/unit/billing/cost-error-branches.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lookupMock = vi.hoisted(() => ({
|
||||
resolveBuiltinPricing: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/model-pricing/lookup', () => ({
|
||||
resolveBuiltinPricing: lookupMock.resolveBuiltinPricing,
|
||||
}))
|
||||
|
||||
import { calcImage, calcText, calcVideo, calcVoice } from '@/lib/billing/cost'
|
||||
|
||||
describe('billing/cost error branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('throws ambiguous pricing error when catalog has multiple candidates', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockReturnValue({
|
||||
status: 'ambiguous_model',
|
||||
apiType: 'image',
|
||||
modelId: 'shared-model',
|
||||
candidates: [
|
||||
{
|
||||
apiType: 'image',
|
||||
provider: 'p1',
|
||||
modelId: 'shared-model',
|
||||
pricing: { mode: 'flat', flatAmount: 1 },
|
||||
},
|
||||
{
|
||||
apiType: 'image',
|
||||
provider: 'p2',
|
||||
modelId: 'shared-model',
|
||||
pricing: { mode: 'flat', flatAmount: 1 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(() => calcImage('shared-model', 1)).toThrow('Ambiguous image pricing modelId')
|
||||
})
|
||||
|
||||
it('throws unknown model when catalog returns not_configured', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockReturnValue({
|
||||
status: 'not_configured',
|
||||
})
|
||||
|
||||
expect(() => calcImage('provider::missing-image-model', 1)).toThrow('Unknown image model pricing')
|
||||
})
|
||||
|
||||
it('normalizes invalid numeric inputs to zero before pricing', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockImplementation(
|
||||
(input: { selections?: { tokenType?: 'input' | 'output' } }) => {
|
||||
if (input.selections?.tokenType === 'input') return { status: 'resolved', amount: 2 }
|
||||
if (input.selections?.tokenType === 'output') return { status: 'resolved', amount: 4 }
|
||||
return { status: 'resolved', amount: 3 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(calcText('text-model', Number.NaN, 1_000_000)).toBeCloseTo(4, 8)
|
||||
expect(calcText('text-model', 1_000_000, Number.NaN)).toBeCloseTo(2, 8)
|
||||
expect(calcImage('image-model', Number.NaN)).toBe(0)
|
||||
expect(calcVideo('video-model', '720p', Number.NaN)).toBe(0)
|
||||
expect(calcVoice(Number.NaN)).toBe(0)
|
||||
})
|
||||
})
|
||||
208
tests/unit/billing/cost.test.ts
Normal file
208
tests/unit/billing/cost.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
USD_TO_CNY,
|
||||
calcImage,
|
||||
calcLipSync,
|
||||
calcText,
|
||||
calcVideo,
|
||||
calcVoice,
|
||||
calcVoiceDesign,
|
||||
} from '@/lib/billing/cost'
|
||||
|
||||
describe('billing/cost', () => {
|
||||
it('calculates text cost by known model price table', () => {
|
||||
const cost = calcText('anthropic/claude-sonnet-4', 1_000_000, 1_000_000)
|
||||
expect(cost).toBeCloseTo((3 + 15) * USD_TO_CNY, 8)
|
||||
})
|
||||
|
||||
it('throws when text model pricing is unknown', () => {
|
||||
expect(() => calcText('unknown-model', 500_000, 250_000)).toThrow('Unknown text model pricing')
|
||||
})
|
||||
|
||||
it('throws when image model pricing is unknown', () => {
|
||||
expect(() => calcImage('missing-image-model', 3)).toThrow('Unknown image model pricing')
|
||||
})
|
||||
|
||||
it('supports resolution-aware video pricing', () => {
|
||||
const cost720 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '720p', 2)
|
||||
const cost1080 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '1080p', 2)
|
||||
expect(cost720).toBeCloseTo(0.86, 8)
|
||||
expect(cost1080).toBeCloseTo(2.06, 8)
|
||||
expect(() => calcVideo('doubao-seedance-1-0-pro-fast-251015', '2k', 1)).toThrow('Unsupported video resolution pricing')
|
||||
expect(() => calcVideo('unknown-video-model', '720p', 1)).toThrow('Unknown video model pricing')
|
||||
})
|
||||
|
||||
it('scales ark video pricing by selected duration when tiers omit duration', () => {
|
||||
const shortDuration = calcVideo('doubao-seedance-1-0-pro-250528', '480p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '480p',
|
||||
duration: 2,
|
||||
})
|
||||
const longDuration = calcVideo('doubao-seedance-1-0-pro-250528', '1080p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '1080p',
|
||||
duration: 12,
|
||||
})
|
||||
|
||||
expect(shortDuration).toBeCloseTo(0.292, 8)
|
||||
expect(longDuration).toBeCloseTo(8.808, 8)
|
||||
})
|
||||
|
||||
it('uses Ark 1.5 official default generateAudio=true when audio is omitted', () => {
|
||||
const defaultAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
})
|
||||
const muteAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
generateAudio: false,
|
||||
})
|
||||
|
||||
expect(defaultAudio).toBeCloseTo(1.73, 8)
|
||||
expect(muteAudio).toBeCloseTo(0.86, 8)
|
||||
})
|
||||
|
||||
it('supports Ark Seedance 1.0 Lite i2v pricing and duration scaling', () => {
|
||||
const shortDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '480p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '480p',
|
||||
duration: 2,
|
||||
})
|
||||
const longDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '1080p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '1080p',
|
||||
duration: 12,
|
||||
})
|
||||
|
||||
expect(shortDuration).toBeCloseTo(0.196, 8)
|
||||
expect(longDuration).toBeCloseTo(5.88, 8)
|
||||
})
|
||||
|
||||
it('rejects unsupported Ark capability values before pricing', () => {
|
||||
expect(() => calcVideo('doubao-seedance-1-0-lite-i2v-250428', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
duration: 1,
|
||||
})).toThrow('Unsupported video capability pricing')
|
||||
})
|
||||
|
||||
it('supports minimax capability-aware video pricing', () => {
|
||||
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '768p',
|
||||
duration: 6,
|
||||
})
|
||||
const hailuoFirstLast = calcVideo('minimax-hailuo-02', '768p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '768p',
|
||||
duration: 10,
|
||||
})
|
||||
const t2v = calcVideo('t2v-01', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
duration: 6,
|
||||
})
|
||||
|
||||
expect(hailuoNormal).toBeCloseTo(2.0, 8)
|
||||
expect(hailuoFirstLast).toBeCloseTo(4.0, 8)
|
||||
expect(t2v).toBeCloseTo(3.0, 8)
|
||||
expect(() => calcVideo('minimax-hailuo-02', '512p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '512p',
|
||||
duration: 6,
|
||||
})).toThrow('Unsupported video capability pricing')
|
||||
})
|
||||
|
||||
it('prefers builtin image pricing over custom pricing when builtin exists', () => {
|
||||
const builtin = calcImage('banana', 1)
|
||||
const withCustom = calcImage('banana', 1, undefined, {
|
||||
image: {
|
||||
basePrice: 99,
|
||||
},
|
||||
})
|
||||
expect(withCustom).toBeCloseTo(builtin, 8)
|
||||
})
|
||||
|
||||
it('uses custom image option pricing for unknown models', () => {
|
||||
const cost = calcImage(
|
||||
'openai-compatible:oa-1::gpt-image-1',
|
||||
2,
|
||||
{
|
||||
resolution: '1024x1024',
|
||||
quality: 'high',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
basePrice: 0.2,
|
||||
optionPrices: {
|
||||
resolution: {
|
||||
'1024x1024': 0.05,
|
||||
},
|
||||
quality: {
|
||||
high: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(cost).toBeCloseTo((0.2 + 0.05 + 0.1) * 2, 8)
|
||||
})
|
||||
|
||||
it('uses custom video option pricing for unknown models', () => {
|
||||
const cost = calcVideo(
|
||||
'openai-compatible:oa-1::sora-2',
|
||||
'720p',
|
||||
1,
|
||||
{
|
||||
resolution: '720x1280',
|
||||
duration: 8,
|
||||
},
|
||||
{
|
||||
video: {
|
||||
basePrice: 0.8,
|
||||
optionPrices: {
|
||||
resolution: {
|
||||
'720x1280': 0.2,
|
||||
},
|
||||
duration: {
|
||||
'8': 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(cost).toBeCloseTo(1.4, 8)
|
||||
})
|
||||
|
||||
it('fails explicitly when selected custom option price is missing', () => {
|
||||
expect(() => calcVideo(
|
||||
'openai-compatible:oa-1::sora-2',
|
||||
'720p',
|
||||
1,
|
||||
{
|
||||
resolution: '1792x1024',
|
||||
},
|
||||
{
|
||||
video: {
|
||||
optionPrices: {
|
||||
resolution: {
|
||||
'720x1280': 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)).toThrow('No custom video price matched')
|
||||
})
|
||||
|
||||
it('returns deterministic fixed costs for call-based APIs', () => {
|
||||
expect(calcVoiceDesign()).toBeGreaterThan(0)
|
||||
expect(calcLipSync()).toBeGreaterThan(0)
|
||||
expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)
|
||||
expect(calcLipSync('bailian::videoretalk')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calculates voice costs from quantities', () => {
|
||||
expect(calcVoice(30)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
135
tests/unit/billing/ledger-extra.test.ts
Normal file
135
tests/unit/billing/ledger-extra.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}))
|
||||
|
||||
import { addBalance, recordShadowUsage } from '@/lib/billing/ledger'
|
||||
|
||||
function buildTxStub() {
|
||||
return {
|
||||
userBalance: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
balanceTransaction: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('billing/ledger extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false when addBalance amount is invalid', async () => {
|
||||
const result = await addBalance('u1', 0)
|
||||
expect(result).toBe(false)
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds recharge balance with string reason', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.userBalance.upsert.mockResolvedValue({ balance: 8.5 })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await addBalance('u1', 5, 'manual recharge')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.findFirst).not.toHaveBeenCalled()
|
||||
expect(tx.userBalance.upsert).toHaveBeenCalledTimes(1)
|
||||
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'u1',
|
||||
type: 'recharge',
|
||||
amount: 5,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('supports idempotent addBalance and short-circuits duplicate key', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.balanceTransaction.findFirst.mockResolvedValue({ id: 'existing_tx' })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await addBalance('u1', 3, {
|
||||
type: 'adjust',
|
||||
reason: 'admin adjust',
|
||||
idempotencyKey: 'idem_1',
|
||||
operatorId: 'op_1',
|
||||
externalOrderId: 'order_1',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.findFirst).toHaveBeenCalledTimes(1)
|
||||
expect(tx.userBalance.upsert).not.toHaveBeenCalled()
|
||||
expect(tx.balanceTransaction.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when transaction throws in addBalance', async () => {
|
||||
prismaMock.$transaction.mockRejectedValue(new Error('db error'))
|
||||
|
||||
const result = await addBalance('u1', 2, 'x')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('records shadow usage consume log on success', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.userBalance.upsert.mockResolvedValue({ balance: 11.2 })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await recordShadowUsage('u1', {
|
||||
projectId: 'p1',
|
||||
action: 'analyze',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
metadata: { trace: 'abc' },
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'u1',
|
||||
type: 'shadow_consume',
|
||||
amount: 0,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns false when recordShadowUsage transaction fails', async () => {
|
||||
prismaMock.$transaction.mockRejectedValue(new Error('shadow failed'))
|
||||
|
||||
const result = await recordShadowUsage('u1', {
|
||||
projectId: 'p1',
|
||||
action: 'analyze',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
22
tests/unit/billing/mode.test.ts
Normal file
22
tests/unit/billing/mode.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getBillingMode, getBootBillingEnabled } from '@/lib/billing/mode'
|
||||
|
||||
describe('billing/mode', () => {
|
||||
it('falls back to OFF when env is missing', async () => {
|
||||
delete process.env.BILLING_MODE
|
||||
await expect(getBillingMode()).resolves.toBe('OFF')
|
||||
expect(getBootBillingEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes lower-case env mode', async () => {
|
||||
process.env.BILLING_MODE = 'enforce'
|
||||
await expect(getBillingMode()).resolves.toBe('ENFORCE')
|
||||
expect(getBootBillingEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to OFF when env mode is invalid', async () => {
|
||||
process.env.BILLING_MODE = 'invalid'
|
||||
await expect(getBillingMode()).resolves.toBe('OFF')
|
||||
expect(getBootBillingEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
79
tests/unit/billing/runtime-usage.test.ts
Normal file
79
tests/unit/billing/runtime-usage.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { recordTextUsage, withTextUsageCollection } from '@/lib/billing/runtime-usage'
|
||||
|
||||
describe('billing/runtime-usage', () => {
|
||||
it('ignores records outside of collection scope', () => {
|
||||
expect(() => {
|
||||
recordTextUsage({
|
||||
model: 'm',
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('collects and normalizes token usage', async () => {
|
||||
const { textUsage } = await withTextUsageCollection(async () => {
|
||||
recordTextUsage({
|
||||
model: 'test-model',
|
||||
inputTokens: 10.9,
|
||||
outputTokens: -2,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
expect(textUsage).toEqual([
|
||||
{
|
||||
model: 'test-model',
|
||||
inputTokens: 10,
|
||||
outputTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty usage when store is unavailable at read time', async () => {
|
||||
const getStoreSpy = vi.spyOn(AsyncLocalStorage.prototype, 'getStore')
|
||||
getStoreSpy.mockReturnValueOnce(undefined as never)
|
||||
|
||||
const payload = await withTextUsageCollection(async () => ({ ok: true }))
|
||||
|
||||
expect(payload).toEqual({ result: { ok: true }, textUsage: [] })
|
||||
getStoreSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('normalizes NaN and zero token values to zero', async () => {
|
||||
const { textUsage } = await withTextUsageCollection(async () => {
|
||||
recordTextUsage({
|
||||
model: 'nan-model',
|
||||
inputTokens: Number.NaN,
|
||||
outputTokens: 0,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
expect(textUsage).toEqual([
|
||||
{
|
||||
model: 'nan-model',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('isolates concurrent async local storage contexts', async () => {
|
||||
const [left, right] = await Promise.all([
|
||||
withTextUsageCollection(async () => {
|
||||
recordTextUsage({ model: 'left', inputTokens: 1, outputTokens: 2 })
|
||||
return 'left'
|
||||
}),
|
||||
withTextUsageCollection(async () => {
|
||||
recordTextUsage({ model: 'right', inputTokens: 3, outputTokens: 4 })
|
||||
return 'right'
|
||||
}),
|
||||
])
|
||||
|
||||
expect(left.textUsage).toEqual([{ model: 'left', inputTokens: 1, outputTokens: 2 }])
|
||||
expect(right.textUsage).toEqual([{ model: 'right', inputTokens: 3, outputTokens: 4 }])
|
||||
})
|
||||
})
|
||||
518
tests/unit/billing/service.test.ts
Normal file
518
tests/unit/billing/service.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { calcText, calcVoice } from '@/lib/billing/cost'
|
||||
import type { TaskBillingInfo } from '@/lib/task/types'
|
||||
|
||||
const ledgerMock = vi.hoisted(() => ({
|
||||
confirmChargeWithRecord: vi.fn(),
|
||||
freezeBalance: vi.fn(),
|
||||
getBalance: vi.fn(),
|
||||
getFreezeByIdempotencyKey: vi.fn(),
|
||||
increasePendingFreezeAmount: vi.fn(),
|
||||
recordShadowUsage: vi.fn(),
|
||||
rollbackFreeze: vi.fn(),
|
||||
}))
|
||||
|
||||
const modeMock = vi.hoisted(() => ({
|
||||
getBillingMode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/ledger', () => ledgerMock)
|
||||
vi.mock('@/lib/billing/mode', () => modeMock)
|
||||
|
||||
import { BillingOperationError, InsufficientBalanceError } from '@/lib/billing/errors'
|
||||
import {
|
||||
handleBillingError,
|
||||
prepareTaskBilling,
|
||||
rollbackTaskBilling,
|
||||
settleTaskBilling,
|
||||
withTextBilling,
|
||||
withVoiceBilling,
|
||||
} from '@/lib/billing/service'
|
||||
|
||||
describe('billing/service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValue(true)
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_1')
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0 })
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue(null)
|
||||
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(true)
|
||||
ledgerMock.recordShadowUsage.mockResolvedValue(true)
|
||||
ledgerMock.rollbackFreeze.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('returns raw execution result in OFF mode', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('OFF')
|
||||
|
||||
const result = await withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.confirmChargeWithRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records shadow usage in SHADOW mode without freezing', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('SHADOW')
|
||||
|
||||
const result = await withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.recordShadowUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue(null)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.01 })
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
})
|
||||
|
||||
it('rolls back freeze when execution throws', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_rollback')
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_rollback')
|
||||
})
|
||||
|
||||
it('expands freeze and charges actual voice usage when actual exceeds quoted', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice')
|
||||
|
||||
await withVoiceBilling(
|
||||
'u1',
|
||||
5,
|
||||
{ projectId: 'p1', action: 'voice_gen' },
|
||||
async () => ({ actualDurationSeconds: 50 }),
|
||||
)
|
||||
|
||||
const confirmCall = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)
|
||||
expect(confirmCall).toBeTruthy()
|
||||
const chargedAmount = confirmCall?.[2]?.chargedAmount as number
|
||||
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
|
||||
expect(chargedAmount).toBeCloseTo(calcVoice(50), 8)
|
||||
})
|
||||
|
||||
it('fails and rolls back when overage freeze expansion cannot be covered', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice_low_balance')
|
||||
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(false)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
|
||||
|
||||
await expect(
|
||||
withVoiceBilling(
|
||||
'u1',
|
||||
5,
|
||||
{ projectId: 'p1', action: 'voice_gen' },
|
||||
async () => ({ actualDurationSeconds: 50 }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
|
||||
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_voice_low_balance')
|
||||
})
|
||||
|
||||
it('rejects duplicate sync billing key when freeze is already confirmed', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
|
||||
id: 'freeze_confirmed',
|
||||
userId: 'u1',
|
||||
amount: 0.5,
|
||||
status: 'confirmed',
|
||||
})
|
||||
const execute = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-1' },
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow('duplicate billing request already confirmed')
|
||||
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate sync billing key when freeze is pending', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
|
||||
id: 'freeze_pending',
|
||||
userId: 'u1',
|
||||
amount: 0.5,
|
||||
status: 'pending',
|
||||
})
|
||||
const execute = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-2' },
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow('duplicate billing request is already in progress')
|
||||
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps insufficient balance error to 402 response payload', async () => {
|
||||
const response = handleBillingError(new InsufficientBalanceError(1.2, 0.3))
|
||||
expect(response).toBeTruthy()
|
||||
expect(response?.status).toBe(402)
|
||||
const body = await response?.json()
|
||||
expect(body?.code).toBe('INSUFFICIENT_BALANCE')
|
||||
expect(body?.required).toBeCloseTo(1.2, 8)
|
||||
expect(body?.available).toBeCloseTo(0.3, 8)
|
||||
})
|
||||
|
||||
it('returns null for non-billing errors', () => {
|
||||
expect(handleBillingError(new Error('x'))).toBeNull()
|
||||
expect(handleBillingError('x')).toBeNull()
|
||||
})
|
||||
|
||||
describe('task billing lifecycle helpers', () => {
|
||||
function buildTaskInfo(overrides: Partial<Extract<TaskBillingInfo, { billable: true }>> = {}): Extract<TaskBillingInfo, { billable: true }> {
|
||||
return {
|
||||
billable: true,
|
||||
source: 'task',
|
||||
taskType: 'voice_line',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 5,
|
||||
unit: 'second',
|
||||
maxFrozenCost: calcVoice(5),
|
||||
action: 'voice_line_generate',
|
||||
metadata: { foo: 'bar' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
|
||||
const off = await prepareTaskBilling({
|
||||
id: 'task_off',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
expect((off as Extract<TaskBillingInfo, { billable: true }>).status).toBe('skipped')
|
||||
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
|
||||
const shadow = await prepareTaskBilling({
|
||||
id: 'task_shadow',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
expect((shadow as Extract<TaskBillingInfo, { billable: true }>).status).toBe('quoted')
|
||||
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValueOnce('freeze_task_1')
|
||||
const enforce = await prepareTaskBilling({
|
||||
id: 'task_enforce',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
const enforceInfo = enforce as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(enforceInfo.status).toBe('frozen')
|
||||
expect(enforceInfo.freezeId).toBe('freeze_task_1')
|
||||
})
|
||||
|
||||
it('prepareTaskBilling tolerates unknown text model pricing in SHADOW mode', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
|
||||
const unknownTextInfo = buildTaskInfo({
|
||||
taskType: 'story_to_script_run',
|
||||
apiType: 'text',
|
||||
model: 'gpt-5.2',
|
||||
quantity: 2400,
|
||||
unit: 'token',
|
||||
maxFrozenCost: 0,
|
||||
action: 'story_to_script_run',
|
||||
})
|
||||
|
||||
const shadow = await prepareTaskBilling({
|
||||
id: 'task_shadow_unknown_text_model',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: unknownTextInfo,
|
||||
})
|
||||
|
||||
const shadowInfo = shadow as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(shadowInfo.status).toBe('skipped')
|
||||
expect(shadowInfo.maxFrozenCost).toBe(0)
|
||||
})
|
||||
|
||||
it('prepareTaskBilling throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue(null)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
|
||||
|
||||
await expect(
|
||||
prepareTaskBilling({
|
||||
id: 'task_no_balance',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
})
|
||||
|
||||
it('settleTaskBilling handles SHADOW and non-ENFORCE snapshots', async () => {
|
||||
const shadowSettled = await settleTaskBilling({
|
||||
id: 'task_shadow_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'SHADOW', status: 'quoted' }),
|
||||
})
|
||||
const shadowInfo = shadowSettled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(shadowInfo.status).toBe('settled')
|
||||
expect(shadowInfo.chargedCost).toBe(0)
|
||||
expect(ledgerMock.recordShadowUsage).toHaveBeenCalled()
|
||||
|
||||
const offSettled = await settleTaskBilling({
|
||||
id: 'task_off_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'OFF', status: 'quoted' }),
|
||||
})
|
||||
const offInfo = offSettled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(offInfo.status).toBe('settled')
|
||||
expect(offInfo.chargedCost).toBe(0)
|
||||
})
|
||||
|
||||
it('settleTaskBilling does not fail OFF snapshot when text usage model pricing is unknown', async () => {
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_off_unknown_usage_model',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({
|
||||
taskType: 'story_to_script_run',
|
||||
apiType: 'text',
|
||||
model: 'gpt-5.2',
|
||||
quantity: 2400,
|
||||
unit: 'token',
|
||||
maxFrozenCost: 0,
|
||||
action: 'story_to_script_run',
|
||||
modeSnapshot: 'OFF',
|
||||
status: 'quoted',
|
||||
}),
|
||||
}, {
|
||||
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
|
||||
})
|
||||
|
||||
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(settledInfo.status).toBe('settled')
|
||||
expect(settledInfo.chargedCost).toBe(0)
|
||||
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settleTaskBilling skips SHADOW settlement when text model pricing is unknown', async () => {
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_shadow_unknown_usage_model',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({
|
||||
taskType: 'story_to_script_run',
|
||||
apiType: 'text',
|
||||
model: 'gpt-5.2',
|
||||
quantity: 2400,
|
||||
unit: 'token',
|
||||
maxFrozenCost: 0,
|
||||
action: 'story_to_script_run',
|
||||
modeSnapshot: 'SHADOW',
|
||||
status: 'quoted',
|
||||
}),
|
||||
}, {
|
||||
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
|
||||
})
|
||||
|
||||
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(settledInfo.status).toBe('settled')
|
||||
expect(settledInfo.chargedCost).toBe(0)
|
||||
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settleTaskBilling handles ENFORCE success/failure branches', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_enforce_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),
|
||||
})
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
|
||||
|
||||
const missingFreeze = await settleTaskBilling({
|
||||
id: 'task_enforce_no_freeze',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),
|
||||
})
|
||||
expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
|
||||
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
|
||||
await expect(
|
||||
settleTaskBilling({
|
||||
id: 'task_enforce_confirm_fail',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),
|
||||
}),
|
||||
).rejects.toThrow('confirm failed')
|
||||
})
|
||||
|
||||
it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
|
||||
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
|
||||
|
||||
await expect(
|
||||
settleTaskBilling({
|
||||
id: 'task_confirm_and_rollback_fail',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'BillingOperationError',
|
||||
code: 'BILLING_CONFIRM_FAILED',
|
||||
})
|
||||
})
|
||||
|
||||
it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(
|
||||
new BillingOperationError(
|
||||
'BILLING_INVALID_FREEZE',
|
||||
'invalid freeze',
|
||||
{ reason: 'status_mismatch' },
|
||||
),
|
||||
)
|
||||
|
||||
let thrown: unknown = null
|
||||
try {
|
||||
await settleTaskBilling({
|
||||
id: 'task_confirm_billing_error',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(BillingOperationError)
|
||||
const billingError = thrown as BillingOperationError
|
||||
expect(billingError.code).toBe('BILLING_INVALID_FREEZE')
|
||||
expect(billingError.details).toMatchObject({
|
||||
reason: 'status_mismatch',
|
||||
taskId: 'task_confirm_billing_error',
|
||||
freezeId: 'freeze_billing_error',
|
||||
})
|
||||
})
|
||||
|
||||
it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_enforce_overage',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),
|
||||
}, {
|
||||
result: { actualDurationSeconds: 50 },
|
||||
})
|
||||
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
|
||||
expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
|
||||
})
|
||||
|
||||
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
|
||||
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
|
||||
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
|
||||
billable: true,
|
||||
source: 'task',
|
||||
taskType: 'analyze_novel',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
maxFrozenCost: quoted,
|
||||
action: 'analyze_novel',
|
||||
modeSnapshot: 'ENFORCE',
|
||||
status: 'frozen',
|
||||
freezeId: 'freeze_text_zero',
|
||||
}
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_text_zero_usage',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: textBillingInfo,
|
||||
}, {
|
||||
textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],
|
||||
})
|
||||
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)
|
||||
const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }
|
||||
expect(recordParams.model).toBe('openai/gpt-5')
|
||||
})
|
||||
|
||||
it('rollbackTaskBilling handles success and fallback branches', async () => {
|
||||
const rolledBack = await rollbackTaskBilling({
|
||||
id: 'task_rb_ok',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),
|
||||
})
|
||||
expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
|
||||
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
|
||||
const rollbackFailed = await rollbackTaskBilling({
|
||||
id: 'task_rb_fail',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),
|
||||
})
|
||||
expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
82
tests/unit/billing/task-policy.test.ts
Normal file
82
tests/unit/billing/task-policy.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing/task-policy'
|
||||
import type { TaskBillingInfo } from '@/lib/task/types'
|
||||
|
||||
function expectBillableInfo(info: TaskBillingInfo | null): Extract<TaskBillingInfo, { billable: true }> {
|
||||
expect(info).toBeTruthy()
|
||||
expect(info?.billable).toBe(true)
|
||||
if (!info || !info.billable) {
|
||||
throw new Error('Expected billable task billing info')
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
describe('billing/task-policy', () => {
|
||||
const billingPayload = {
|
||||
analysisModel: 'anthropic/claude-sonnet-4',
|
||||
imageModel: 'seedream',
|
||||
videoModel: 'doubao-seedance-1-5-pro-251215',
|
||||
} as const
|
||||
|
||||
it('builds TaskBillingInfo for every billable task type', () => {
|
||||
for (const taskType of Object.values(TASK_TYPE)) {
|
||||
if (!isBillableTaskType(taskType)) continue
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(taskType, billingPayload))
|
||||
expect(info.taskType).toBe(taskType)
|
||||
expect(info.maxFrozenCost).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for a non-billable task type', () => {
|
||||
const fake = 'not_billable' as unknown as (typeof TASK_TYPE)[keyof typeof TASK_TYPE]
|
||||
expect(isBillableTaskType(fake)).toBe(false)
|
||||
expect(buildDefaultTaskBillingInfo(fake, {})).toBeNull()
|
||||
})
|
||||
|
||||
it('builds text billing info from explicit model payload', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
analysisModel: 'anthropic/claude-sonnet-4',
|
||||
}))
|
||||
expect(info.apiType).toBe('text')
|
||||
expect(info.model).toBe('anthropic/claude-sonnet-4')
|
||||
expect(info.quantity).toBe(4200)
|
||||
})
|
||||
|
||||
it('returns null for missing required models in text/image/video tasks', () => {
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {})).toBeNull()
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {})).toBeNull()
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {})).toBeNull()
|
||||
})
|
||||
|
||||
it('honors candidateCount/count for image tasks', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {
|
||||
candidateCount: 4,
|
||||
imageModel: 'seedream4',
|
||||
}))
|
||||
expect(info.apiType).toBe('image')
|
||||
expect(info.quantity).toBe(4)
|
||||
expect(info.model).toBe('seedream4')
|
||||
})
|
||||
|
||||
it('builds video billing info from firstLastFrame.flModel', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {
|
||||
firstLastFrame: {
|
||||
flModel: 'doubao-seedance-1-0-pro-250528',
|
||||
},
|
||||
duration: 8,
|
||||
}))
|
||||
expect(info.apiType).toBe('video')
|
||||
expect(info.model).toBe('doubao-seedance-1-0-pro-250528')
|
||||
expect(info.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('uses explicit lip sync model from payload', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, {
|
||||
lipSyncModel: 'vidu::vidu-lipsync',
|
||||
}))
|
||||
expect(info.apiType).toBe('lip-sync')
|
||||
expect(info.model).toBe('vidu::vidu-lipsync')
|
||||
expect(info.quantity).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user