feat: initial release v0.3.0
This commit is contained in:
125
tests/concurrency/billing/ledger.concurrency.test.ts
Normal file
125
tests/concurrency/billing/ledger.concurrency.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { calcText } from '@/lib/billing/cost'
|
||||
import {
|
||||
confirmChargeWithRecord,
|
||||
freezeBalance,
|
||||
getBalance,
|
||||
rollbackFreeze,
|
||||
} from '@/lib/billing/ledger'
|
||||
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'
|
||||
import { expectNoNegativeLedger } from '../../helpers/assertions'
|
||||
|
||||
describe('billing/concurrency', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('does not create negative balance during high-concurrency freezes', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const attempts = Array.from({ length: 40 }, (_, idx) =>
|
||||
freezeBalance(user.id, 1, { idempotencyKey: `concurrency_freeze_${idx}` }))
|
||||
const freezeIds = await Promise.all(attempts)
|
||||
const successCount = freezeIds.filter(Boolean).length
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(successCount).toBeLessThanOrEqual(10)
|
||||
expect(balance.balance).toBeCloseTo(10 - successCount, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(successCount, 8)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
|
||||
it('applies idempotency key correctly under concurrent duplicate requests', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const attempts = Array.from({ length: 20 }, () =>
|
||||
freezeBalance(user.id, 2, { idempotencyKey: 'same_key_concurrency' }))
|
||||
const freezeIds = await Promise.all(attempts)
|
||||
const uniqueIds = new Set(freezeIds.filter(Boolean))
|
||||
|
||||
expect(uniqueIds.size).toBe(1)
|
||||
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('keeps a valid final state when confirm and rollback race', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 5, { idempotencyKey: 'race_key' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const [confirmResult, rollbackResult] = await Promise.allSettled([
|
||||
confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'race_confirm',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 10,
|
||||
unit: 'token',
|
||||
},
|
||||
{ chargedAmount: 3 },
|
||||
),
|
||||
rollbackFreeze(freezeId!),
|
||||
])
|
||||
|
||||
expect(['fulfilled', 'rejected']).toContain(confirmResult.status)
|
||||
expect(['fulfilled', 'rejected']).toContain(rollbackResult.status)
|
||||
expect(confirmResult.status === 'fulfilled' || rollbackResult.status === 'fulfilled').toBe(true)
|
||||
|
||||
const freeze = await prisma.balanceFreeze.findUnique({ where: { id: freezeId! } })
|
||||
expect(['confirmed', 'rolled_back']).toContain(freeze?.status)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
if (freeze?.status === 'confirmed') {
|
||||
expect(balance.balance).toBeCloseTo(7, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(3, 8)
|
||||
} else {
|
||||
expect(balance.balance).toBeCloseTo(10, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(0, 8)
|
||||
}
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
|
||||
it('prevents duplicate consumption on retried sync billing with same requestId', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 5)
|
||||
|
||||
const attempt = () =>
|
||||
withTextBilling(
|
||||
user.id,
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
500,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'retry_no_double_charge',
|
||||
requestId: 'fixed_request_id',
|
||||
},
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled([attempt(), attempt(), attempt()])
|
||||
expect(results.some((item) => item.status === 'fulfilled')).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
const expected = calcText('anthropic/claude-sonnet-4', 1000, 500)
|
||||
expect(balance.totalSpent).toBeLessThanOrEqual(expected + 1e-8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'consume' } })).toBeLessThanOrEqual(1)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
})
|
||||
25
tests/contracts/behavior-test-standard.md
Normal file
25
tests/contracts/behavior-test-standard.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Behavior Test Standard
|
||||
|
||||
## Scope
|
||||
- `tests/integration/api/contract/**/*.test.ts`
|
||||
- `tests/integration/chain/**/*.test.ts`
|
||||
- `tests/unit/worker/**/*.test.ts`
|
||||
|
||||
## Must-have
|
||||
- Assert observable results: response payload/status, persisted fields, or queue/job payload.
|
||||
- Include at least one concrete-value assertion for each key business branch.
|
||||
- Cover at least one failure branch for each critical route/handler.
|
||||
|
||||
## Forbidden patterns
|
||||
- Source-text contract assertions (for example checking route code contains `apiHandler`, `submitTask`, `maybeSubmitLLMTask`).
|
||||
- Using only weak call assertions like `toHaveBeenCalled()` as the primary proof.
|
||||
- Structural tests that pass without executing route/worker logic.
|
||||
|
||||
## Minimum assertion quality
|
||||
- Prefer `toHaveBeenCalledWith(...)` with `objectContaining(...)` on critical fields.
|
||||
- Validate exact business fields (`description`, `imageUrl`, `referenceImages`, `aspectRatio`, `taskId`, `async`).
|
||||
- For async task chains, validate queue selection and job metadata (`jobId`, `priority`, `type`).
|
||||
|
||||
## Regression rule
|
||||
- One historical bug must map to at least one dedicated regression test case.
|
||||
- Bug fix without matching behavior regression test is incomplete.
|
||||
24
tests/contracts/requirements-matrix.test.ts
Normal file
24
tests/contracts/requirements-matrix.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { REQUIREMENTS_MATRIX } from './requirements-matrix'
|
||||
|
||||
function fileExists(repoPath: string) {
|
||||
return fs.existsSync(path.resolve(process.cwd(), repoPath))
|
||||
}
|
||||
|
||||
describe('requirements matrix integrity', () => {
|
||||
it('requirement ids are unique', () => {
|
||||
const ids = REQUIREMENTS_MATRIX.map((entry) => entry.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('all declared test files exist', () => {
|
||||
for (const entry of REQUIREMENTS_MATRIX) {
|
||||
expect(entry.tests.length, entry.id).toBeGreaterThan(0)
|
||||
for (const testPath of entry.tests) {
|
||||
expect(fileExists(testPath), `${entry.id} -> ${testPath}`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
84
tests/contracts/requirements-matrix.ts
Normal file
84
tests/contracts/requirements-matrix.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type RequirementPriority = 'P0' | 'P1' | 'P2'
|
||||
|
||||
export type RequirementCoverageEntry = {
|
||||
id: string
|
||||
feature: string
|
||||
userValue: string
|
||||
risk: string
|
||||
priority: RequirementPriority
|
||||
tests: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
||||
{
|
||||
id: 'REQ-ASSETHUB-CHARACTER-EDIT',
|
||||
feature: 'Asset Hub character edit',
|
||||
userValue: '角色信息编辑后立即可见并正确保存',
|
||||
risk: '字段映射漂移导致保存失败或误写',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-ASSETHUB-REFERENCE-TO-CHARACTER',
|
||||
feature: 'Asset Hub reference-to-character',
|
||||
userValue: '上传参考图后生成角色形象且使用参考图',
|
||||
risk: 'referenceImages 丢失或分支走错',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/unit/helpers/reference-to-character-helpers.test.ts',
|
||||
'tests/unit/worker/reference-to-character.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-GENERATE-IMAGE',
|
||||
feature: 'Novel promotion image generation',
|
||||
userValue: '角色/场景/分镜图可稳定生成并回写',
|
||||
risk: '任务 payload 漂移、worker 写回错误实体',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
'tests/integration/chain/image.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-GENERATE-VIDEO',
|
||||
feature: 'Novel promotion video generation',
|
||||
userValue: '面板视频可生成并可追踪状态',
|
||||
risk: 'panel 定位错误、model 能力判断错误、状态错乱',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'tests/unit/worker/video-worker.test.ts',
|
||||
'tests/integration/chain/video.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-TEXT-ANALYSIS',
|
||||
feature: 'Text analysis and storyboard orchestration',
|
||||
userValue: '文本分析链路稳定并可回放结果',
|
||||
risk: 'step 编排变化导致结果结构损坏',
|
||||
priority: 'P1',
|
||||
tests: [
|
||||
'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||
'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-TASK-STATE-CONSISTENCY',
|
||||
feature: 'Task state and SSE consistency',
|
||||
userValue: '前端状态与任务真实状态一致',
|
||||
risk: 'target-state 与 SSE 失配导致误提示',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/unit/helpers/task-state-service.test.ts',
|
||||
'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||
'tests/unit/optimistic/sse-invalidation.test.ts',
|
||||
],
|
||||
},
|
||||
]
|
||||
50
tests/contracts/route-behavior-matrix.ts
Normal file
50
tests/contracts/route-behavior-matrix.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ROUTE_CATALOG, type RouteCatalogEntry } from './route-catalog'
|
||||
|
||||
export type RouteBehaviorMatrixEntry = {
|
||||
routeFile: string
|
||||
contractGroup: RouteCatalogEntry['contractGroup']
|
||||
caseId: string
|
||||
tests: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string> = {
|
||||
'llm-observe-routes': 'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||
'direct-submit-routes': 'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'crud-asset-hub-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'crud-novel-promotion-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||
'user-project-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'auth-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'infra-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
}
|
||||
|
||||
function resolveChainTest(routeFile: string): string {
|
||||
if (routeFile.includes('/generate-video/') || routeFile.includes('/lip-sync/')) {
|
||||
return 'tests/integration/chain/video.chain.test.ts'
|
||||
}
|
||||
if (routeFile.includes('/voice-') || routeFile.includes('/voice/')) {
|
||||
return 'tests/integration/chain/voice.chain.test.ts'
|
||||
}
|
||||
if (
|
||||
routeFile.includes('/analyze')
|
||||
|| routeFile.includes('/story-to-script')
|
||||
|| routeFile.includes('/script-to-storyboard')
|
||||
|| routeFile.includes('/screenplay-conversion')
|
||||
|| routeFile.includes('/reference-to-character')
|
||||
) {
|
||||
return 'tests/integration/chain/text.chain.test.ts'
|
||||
}
|
||||
return 'tests/integration/chain/image.chain.test.ts'
|
||||
}
|
||||
|
||||
export const ROUTE_BEHAVIOR_MATRIX: ReadonlyArray<RouteBehaviorMatrixEntry> = ROUTE_CATALOG.map((entry) => ({
|
||||
routeFile: entry.routeFile,
|
||||
contractGroup: entry.contractGroup,
|
||||
caseId: `ROUTE:${entry.routeFile.replace(/^src\/app\/api\//, '').replace(/\/route\.ts$/, '')}`,
|
||||
tests: [
|
||||
CONTRACT_TEST_BY_GROUP[entry.contractGroup],
|
||||
resolveChainTest(entry.routeFile),
|
||||
],
|
||||
}))
|
||||
|
||||
export const ROUTE_BEHAVIOR_COUNT = ROUTE_BEHAVIOR_MATRIX.length
|
||||
231
tests/contracts/route-catalog.ts
Normal file
231
tests/contracts/route-catalog.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export type RouteCategory =
|
||||
| 'asset-hub'
|
||||
| 'novel-promotion'
|
||||
| 'projects'
|
||||
| 'tasks'
|
||||
| 'user'
|
||||
| 'auth'
|
||||
| 'infra'
|
||||
| 'system'
|
||||
|
||||
export type RouteContractGroup =
|
||||
| 'llm-observe-routes'
|
||||
| 'direct-submit-routes'
|
||||
| 'crud-asset-hub-routes'
|
||||
| 'crud-novel-promotion-routes'
|
||||
| 'task-infra-routes'
|
||||
| 'user-project-routes'
|
||||
| 'auth-routes'
|
||||
| 'infra-routes'
|
||||
|
||||
export type RouteCatalogEntry = {
|
||||
routeFile: string
|
||||
category: RouteCategory
|
||||
contractGroup: RouteContractGroup
|
||||
}
|
||||
|
||||
const ROUTE_FILES = [
|
||||
'src/app/api/asset-hub/ai-design-character/route.ts',
|
||||
'src/app/api/asset-hub/ai-design-location/route.ts',
|
||||
'src/app/api/asset-hub/ai-modify-character/route.ts',
|
||||
'src/app/api/asset-hub/ai-modify-location/route.ts',
|
||||
'src/app/api/asset-hub/appearances/route.ts',
|
||||
'src/app/api/asset-hub/character-voice/route.ts',
|
||||
'src/app/api/asset-hub/characters/[characterId]/appearances/[appearanceIndex]/route.ts',
|
||||
'src/app/api/asset-hub/characters/[characterId]/route.ts',
|
||||
'src/app/api/asset-hub/characters/route.ts',
|
||||
'src/app/api/asset-hub/folders/[folderId]/route.ts',
|
||||
'src/app/api/asset-hub/folders/route.ts',
|
||||
'src/app/api/asset-hub/generate-image/route.ts',
|
||||
'src/app/api/asset-hub/locations/[locationId]/route.ts',
|
||||
'src/app/api/asset-hub/locations/route.ts',
|
||||
'src/app/api/asset-hub/modify-image/route.ts',
|
||||
'src/app/api/asset-hub/picker/route.ts',
|
||||
'src/app/api/asset-hub/reference-to-character/route.ts',
|
||||
'src/app/api/asset-hub/select-image/route.ts',
|
||||
'src/app/api/asset-hub/undo-image/route.ts',
|
||||
'src/app/api/asset-hub/update-asset-label/route.ts',
|
||||
'src/app/api/asset-hub/upload-image/route.ts',
|
||||
'src/app/api/asset-hub/upload-temp/route.ts',
|
||||
'src/app/api/asset-hub/voice-design/route.ts',
|
||||
'src/app/api/asset-hub/voices/[id]/route.ts',
|
||||
'src/app/api/asset-hub/voices/route.ts',
|
||||
'src/app/api/asset-hub/voices/upload/route.ts',
|
||||
'src/app/api/auth/[...nextauth]/route.ts',
|
||||
'src/app/api/auth/register/route.ts',
|
||||
'src/app/api/cos/image/route.ts',
|
||||
'src/app/api/files/[...path]/route.ts',
|
||||
'src/app/api/storage/sign/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/assets/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-voice/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/confirm-selection/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/cleanup-unselected-images/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/clips/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-images/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-videos/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-voices/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/editor/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/[episodeId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/batch/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/split-by-markers/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/location/confirm-selection/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel-link/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel/select-candidate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/photography-plan/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/select-character-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/select-location-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/speaker-voice/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/storyboard-group/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/storyboards/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-prompt/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/upload-asset-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/video-proxy/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/video-urls/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-lines/route.ts',
|
||||
'src/app/api/projects/[projectId]/assets/route.ts',
|
||||
'src/app/api/projects/[projectId]/costs/route.ts',
|
||||
'src/app/api/projects/[projectId]/data/route.ts',
|
||||
'src/app/api/projects/[projectId]/route.ts',
|
||||
'src/app/api/projects/route.ts',
|
||||
'src/app/api/runs/[runId]/cancel/route.ts',
|
||||
'src/app/api/runs/[runId]/events/route.ts',
|
||||
'src/app/api/runs/[runId]/route.ts',
|
||||
'src/app/api/runs/[runId]/steps/[stepKey]/retry/route.ts',
|
||||
'src/app/api/runs/route.ts',
|
||||
'src/app/api/sse/route.ts',
|
||||
'src/app/api/system/boot-id/route.ts',
|
||||
'src/app/api/task-target-states/route.ts',
|
||||
'src/app/api/tasks/[taskId]/route.ts',
|
||||
'src/app/api/tasks/dismiss/route.ts',
|
||||
'src/app/api/tasks/route.ts',
|
||||
'src/app/api/user-preference/route.ts',
|
||||
'src/app/api/user/api-config/route.ts',
|
||||
'src/app/api/user/assistant/chat/route.ts',
|
||||
'src/app/api/user/api-config/assistant/validate-media-template/route.ts',
|
||||
'src/app/api/user/api-config/assistant/probe-media-template/route.ts',
|
||||
'src/app/api/user/api-config/probe-model-llm-protocol/route.ts',
|
||||
'src/app/api/user/api-config/test-connection/route.ts',
|
||||
'src/app/api/user/api-config/test-provider/route.ts',
|
||||
'src/app/api/user/balance/route.ts',
|
||||
'src/app/api/user/costs/details/route.ts',
|
||||
'src/app/api/user/costs/route.ts',
|
||||
'src/app/api/user/models/route.ts',
|
||||
'src/app/api/user/transactions/route.ts',
|
||||
] as const
|
||||
|
||||
function resolveCategory(routeFile: string): RouteCategory {
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'asset-hub'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'novel-promotion'
|
||||
if (routeFile.startsWith('src/app/api/projects/')) return 'projects'
|
||||
if (
|
||||
routeFile.startsWith('src/app/api/tasks/')
|
||||
|| routeFile.startsWith('src/app/api/runs/')
|
||||
|| routeFile === 'src/app/api/task-target-states/route.ts'
|
||||
) {
|
||||
return 'tasks'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/user/') || routeFile === 'src/app/api/user-preference/route.ts') return 'user'
|
||||
if (routeFile.startsWith('src/app/api/auth/')) return 'auth'
|
||||
if (routeFile.startsWith('src/app/api/system/')) return 'system'
|
||||
return 'infra'
|
||||
}
|
||||
|
||||
function resolveContractGroup(routeFile: string): RouteContractGroup {
|
||||
if (
|
||||
routeFile.includes('/ai-')
|
||||
|| routeFile.includes('/analyze')
|
||||
|| routeFile.includes('/story-to-script-stream/')
|
||||
|| routeFile.includes('/script-to-storyboard-stream/')
|
||||
|| routeFile.includes('/screenplay-conversion/')
|
||||
|| routeFile.includes('/reference-to-character/')
|
||||
|| routeFile.includes('/character-profile/')
|
||||
|| routeFile.endsWith('/clips/route.ts')
|
||||
|| routeFile.endsWith('/episodes/split/route.ts')
|
||||
|| routeFile.endsWith('/voice-analyze/route.ts')
|
||||
) {
|
||||
return 'llm-observe-routes'
|
||||
}
|
||||
if (
|
||||
routeFile.endsWith('/generate-image/route.ts')
|
||||
|| routeFile.endsWith('/generate-video/route.ts')
|
||||
|| routeFile.endsWith('/modify-image/route.ts')
|
||||
|| routeFile.endsWith('/voice-design/route.ts')
|
||||
|| routeFile.endsWith('/insert-panel/route.ts')
|
||||
|| routeFile.endsWith('/lip-sync/route.ts')
|
||||
|| routeFile.endsWith('/modify-asset-image/route.ts')
|
||||
|| routeFile.endsWith('/modify-storyboard-image/route.ts')
|
||||
|| routeFile.endsWith('/panel-variant/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-group/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-panel-image/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-single-image/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-storyboard-text/route.ts')
|
||||
|| routeFile.endsWith('/voice-generate/route.ts')
|
||||
) {
|
||||
return 'direct-submit-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'crud-asset-hub-routes'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'crud-novel-promotion-routes'
|
||||
if (
|
||||
routeFile.startsWith('src/app/api/tasks/')
|
||||
|| routeFile.startsWith('src/app/api/runs/')
|
||||
|| routeFile === 'src/app/api/task-target-states/route.ts'
|
||||
|| routeFile === 'src/app/api/sse/route.ts'
|
||||
) {
|
||||
return 'task-infra-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/projects/') || routeFile.startsWith('src/app/api/user/')) {
|
||||
return 'user-project-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/auth/')) return 'auth-routes'
|
||||
return 'infra-routes'
|
||||
}
|
||||
|
||||
export const ROUTE_CATALOG: ReadonlyArray<RouteCatalogEntry> = ROUTE_FILES.map((routeFile) => ({
|
||||
routeFile,
|
||||
category: resolveCategory(routeFile),
|
||||
contractGroup: resolveContractGroup(routeFile),
|
||||
}))
|
||||
|
||||
export const ROUTE_COUNT = ROUTE_CATALOG.length
|
||||
58
tests/contracts/task-type-catalog.ts
Normal file
58
tests/contracts/task-type-catalog.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
|
||||
export type TaskTestLayer = 'unit-helper' | 'worker-unit' | 'api-contract' | 'chain'
|
||||
|
||||
export type TaskTypeCoverageEntry = {
|
||||
taskType: TaskType
|
||||
owner: string
|
||||
layers: ReadonlyArray<TaskTestLayer>
|
||||
}
|
||||
|
||||
const TASK_TYPE_OWNER_MAP = {
|
||||
[TASK_TYPE.IMAGE_PANEL]: 'tests/unit/worker/panel-image-task-handler.test.ts',
|
||||
[TASK_TYPE.IMAGE_CHARACTER]: 'tests/unit/worker/character-image-task-handler.test.ts',
|
||||
[TASK_TYPE.IMAGE_LOCATION]: 'tests/unit/worker/location-image-task-handler.test.ts',
|
||||
[TASK_TYPE.VIDEO_PANEL]: 'tests/unit/worker/video-worker.test.ts',
|
||||
[TASK_TYPE.LIP_SYNC]: 'tests/unit/worker/video-worker.test.ts',
|
||||
[TASK_TYPE.VOICE_LINE]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.INSERT_PANEL]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.PANEL_VARIANT]: 'tests/unit/worker/panel-variant-task-handler.test.ts',
|
||||
[TASK_TYPE.MODIFY_ASSET_IMAGE]: 'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
[TASK_TYPE.REGENERATE_GROUP]: 'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_IMAGE]: 'tests/unit/worker/asset-hub-image-suffix.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_MODIFY]: 'tests/unit/worker/modify-image-reference-description.test.ts',
|
||||
[TASK_TYPE.ANALYZE_NOVEL]: 'tests/unit/worker/analyze-novel.test.ts',
|
||||
[TASK_TYPE.STORY_TO_SCRIPT_RUN]: 'tests/unit/worker/story-to-script.test.ts',
|
||||
[TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.CLIPS_BUILD]: 'tests/unit/worker/clips-build.test.ts',
|
||||
[TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',
|
||||
[TASK_TYPE.VOICE_ANALYZE]: 'tests/unit/worker/voice-analyze.test.ts',
|
||||
[TASK_TYPE.ANALYZE_GLOBAL]: 'tests/unit/worker/analyze-global.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'tests/unit/worker/shot-ai-prompt-appearance.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'tests/unit/worker/shot-ai-prompt-location.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'tests/unit/worker/shot-ai-prompt-shot.test.ts',
|
||||
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'tests/unit/worker/shot-ai-variants.test.ts',
|
||||
[TASK_TYPE.AI_CREATE_CHARACTER]: 'tests/unit/worker/shot-ai-tasks.test.ts',
|
||||
[TASK_TYPE.AI_CREATE_LOCATION]: 'tests/unit/worker/shot-ai-tasks.test.ts',
|
||||
[TASK_TYPE.REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',
|
||||
[TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',
|
||||
[TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',
|
||||
[TASK_TYPE.EPISODE_SPLIT_LLM]: 'tests/unit/worker/episode-split.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: 'tests/unit/worker/asset-hub-ai-design.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'tests/unit/worker/asset-hub-ai-design.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',
|
||||
} as const satisfies Record<TaskType, string>
|
||||
|
||||
export const TASK_TYPE_CATALOG: ReadonlyArray<TaskTypeCoverageEntry> = (Object.values(TASK_TYPE) as TaskType[])
|
||||
.map((taskType) => ({
|
||||
taskType,
|
||||
owner: TASK_TYPE_OWNER_MAP[taskType],
|
||||
layers: ['worker-unit', 'api-contract', 'chain'],
|
||||
}))
|
||||
|
||||
export const TASK_TYPE_COUNT = TASK_TYPE_CATALOG.length
|
||||
105
tests/contracts/tasktype-behavior-matrix.ts
Normal file
105
tests/contracts/tasktype-behavior-matrix.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { TASK_TYPE_CATALOG } from './task-type-catalog'
|
||||
import type { TaskType } from '@/lib/task/types'
|
||||
|
||||
export type TaskTypeBehaviorMatrixEntry = {
|
||||
taskType: TaskType
|
||||
caseId: string
|
||||
workerTest: string
|
||||
chainTest: string
|
||||
apiContractTest: string
|
||||
}
|
||||
|
||||
function resolveChainTestByTaskType(taskType: TaskType): string {
|
||||
if (taskType === 'video_panel' || taskType === 'lip_sync') {
|
||||
return 'tests/integration/chain/video.chain.test.ts'
|
||||
}
|
||||
if (taskType === 'voice_line' || taskType === 'voice_design' || taskType === 'asset_hub_voice_design') {
|
||||
return 'tests/integration/chain/voice.chain.test.ts'
|
||||
}
|
||||
if (
|
||||
taskType === 'analyze_novel'
|
||||
|| taskType === 'story_to_script_run'
|
||||
|| taskType === 'script_to_storyboard_run'
|
||||
|| taskType === 'clips_build'
|
||||
|| taskType === 'screenplay_convert'
|
||||
|| taskType === 'voice_analyze'
|
||||
|| taskType === 'analyze_global'
|
||||
|| taskType === 'ai_modify_appearance'
|
||||
|| taskType === 'ai_modify_location'
|
||||
|| taskType === 'ai_modify_shot_prompt'
|
||||
|| taskType === 'analyze_shot_variants'
|
||||
|| taskType === 'ai_create_character'
|
||||
|| taskType === 'ai_create_location'
|
||||
|| taskType === 'reference_to_character'
|
||||
|| taskType === 'character_profile_confirm'
|
||||
|| taskType === 'character_profile_batch_confirm'
|
||||
|| taskType === 'episode_split_llm'
|
||||
|| taskType === 'asset_hub_ai_design_character'
|
||||
|| taskType === 'asset_hub_ai_design_location'
|
||||
|| taskType === 'asset_hub_ai_modify_character'
|
||||
|| taskType === 'asset_hub_ai_modify_location'
|
||||
|| taskType === 'asset_hub_reference_to_character'
|
||||
) {
|
||||
return 'tests/integration/chain/text.chain.test.ts'
|
||||
}
|
||||
return 'tests/integration/chain/image.chain.test.ts'
|
||||
}
|
||||
|
||||
function resolveApiContractByTaskType(taskType: TaskType): string {
|
||||
if (
|
||||
taskType === 'analyze_novel'
|
||||
|| taskType === 'story_to_script_run'
|
||||
|| taskType === 'script_to_storyboard_run'
|
||||
|| taskType === 'clips_build'
|
||||
|| taskType === 'screenplay_convert'
|
||||
|| taskType === 'voice_analyze'
|
||||
|| taskType === 'analyze_global'
|
||||
|| taskType === 'ai_modify_appearance'
|
||||
|| taskType === 'ai_modify_location'
|
||||
|| taskType === 'ai_modify_shot_prompt'
|
||||
|| taskType === 'analyze_shot_variants'
|
||||
|| taskType === 'ai_create_character'
|
||||
|| taskType === 'ai_create_location'
|
||||
|| taskType === 'reference_to_character'
|
||||
|| taskType === 'character_profile_confirm'
|
||||
|| taskType === 'character_profile_batch_confirm'
|
||||
|| taskType === 'episode_split_llm'
|
||||
|| taskType === 'asset_hub_ai_design_character'
|
||||
|| taskType === 'asset_hub_ai_design_location'
|
||||
|| taskType === 'asset_hub_ai_modify_character'
|
||||
|| taskType === 'asset_hub_ai_modify_location'
|
||||
|| taskType === 'asset_hub_reference_to_character'
|
||||
) {
|
||||
return 'tests/integration/api/contract/llm-observe-routes.test.ts'
|
||||
}
|
||||
if (
|
||||
taskType === 'image_panel'
|
||||
|| taskType === 'image_character'
|
||||
|| taskType === 'image_location'
|
||||
|| taskType === 'video_panel'
|
||||
|| taskType === 'lip_sync'
|
||||
|| taskType === 'voice_line'
|
||||
|| taskType === 'voice_design'
|
||||
|| taskType === 'asset_hub_voice_design'
|
||||
|| taskType === 'insert_panel'
|
||||
|| taskType === 'panel_variant'
|
||||
|| taskType === 'modify_asset_image'
|
||||
|| taskType === 'regenerate_group'
|
||||
|| taskType === 'asset_hub_image'
|
||||
|| taskType === 'asset_hub_modify'
|
||||
|| taskType === 'regenerate_storyboard_text'
|
||||
) {
|
||||
return 'tests/integration/api/contract/direct-submit-routes.test.ts'
|
||||
}
|
||||
return 'tests/integration/api/contract/task-infra-routes.test.ts'
|
||||
}
|
||||
|
||||
export const TASKTYPE_BEHAVIOR_MATRIX: ReadonlyArray<TaskTypeBehaviorMatrixEntry> = TASK_TYPE_CATALOG.map((entry) => ({
|
||||
taskType: entry.taskType,
|
||||
caseId: `TASKTYPE:${entry.taskType}`,
|
||||
workerTest: entry.owner,
|
||||
chainTest: resolveChainTestByTaskType(entry.taskType),
|
||||
apiContractTest: resolveApiContractByTaskType(entry.taskType),
|
||||
}))
|
||||
|
||||
export const TASKTYPE_BEHAVIOR_COUNT = TASKTYPE_BEHAVIOR_MATRIX.length
|
||||
6
tests/fixtures/billing/cases.json
vendored
Normal file
6
tests/fixtures/billing/cases.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"textModel": "anthropic/claude-sonnet-4",
|
||||
"imageModel": "seedream",
|
||||
"videoModel": "doubao-seedance-1-0-pro-fast-251015",
|
||||
"voiceSeconds": 5
|
||||
}
|
||||
23
tests/helpers/assertions.ts
Normal file
23
tests/helpers/assertions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect } from 'vitest'
|
||||
import { prisma } from './prisma'
|
||||
import { toMoneyNumber } from '@/lib/billing/money'
|
||||
|
||||
export async function expectBalance(userId: string, params: {
|
||||
balance: number
|
||||
frozenAmount: number
|
||||
totalSpent: number
|
||||
}) {
|
||||
const row = await prisma.userBalance.findUnique({ where: { userId } })
|
||||
expect(row).toBeTruthy()
|
||||
expect(toMoneyNumber(row!.balance)).toBeCloseTo(params.balance, 8)
|
||||
expect(toMoneyNumber(row!.frozenAmount)).toBeCloseTo(params.frozenAmount, 8)
|
||||
expect(toMoneyNumber(row!.totalSpent)).toBeCloseTo(params.totalSpent, 8)
|
||||
}
|
||||
|
||||
export async function expectNoNegativeLedger(userId: string) {
|
||||
const row = await prisma.userBalance.findUnique({ where: { userId } })
|
||||
expect(row).toBeTruthy()
|
||||
expect(toMoneyNumber(row!.balance)).toBeGreaterThanOrEqual(0)
|
||||
expect(toMoneyNumber(row!.frozenAmount)).toBeGreaterThanOrEqual(0)
|
||||
expect(toMoneyNumber(row!.totalSpent)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
132
tests/helpers/auth.ts
Normal file
132
tests/helpers/auth.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
type SessionUser = {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
type SessionPayload = {
|
||||
user: SessionUser
|
||||
}
|
||||
|
||||
type MockAuthState = {
|
||||
session: SessionPayload | null
|
||||
projectAuthMode: 'allow' | 'forbidden' | 'not_found'
|
||||
}
|
||||
|
||||
const defaultSession: SessionPayload = {
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
name: 'test-user',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
let state: MockAuthState = {
|
||||
session: defaultSession,
|
||||
projectAuthMode: 'allow',
|
||||
}
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
function forbiddenResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Forbidden',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
function notFoundResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Project not found',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
export function installAuthMocks() {
|
||||
vi.doMock('@/lib/api-auth', () => ({
|
||||
isErrorResponse: (value: unknown) => value instanceof NextResponse,
|
||||
requireUserAuth: async () => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
return { session: state.session }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
|
||||
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
||||
return {
|
||||
session: state.session,
|
||||
project: { id: projectId, userId: state.session.user.id, name: 'project' },
|
||||
novelData: { id: 'novel-data-id' },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
|
||||
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
||||
return {
|
||||
session: state.session,
|
||||
project: { id: projectId, userId: state.session.user.id, name: 'project' },
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function mockAuthenticated(userId: string) {
|
||||
state = {
|
||||
...state,
|
||||
session: {
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mockUnauthenticated() {
|
||||
state = {
|
||||
...state,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mockProjectAuth(mode: 'allow' | 'forbidden' | 'not_found') {
|
||||
state = {
|
||||
...state,
|
||||
projectAuthMode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAuthMockState() {
|
||||
state = {
|
||||
session: defaultSession,
|
||||
projectAuthMode: 'allow',
|
||||
}
|
||||
vi.doUnmock('@/lib/api-auth')
|
||||
}
|
||||
68
tests/helpers/billing-fixtures.ts
Normal file
68
tests/helpers/billing-fixtures.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { TaskBillingInfo, TaskType } from '@/lib/task/types'
|
||||
import { TASK_STATUS } from '@/lib/task/types'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function createTestUser() {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
name: `billing_user_${suffix}`,
|
||||
email: `billing_${suffix}@example.com`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTestProject(userId: string) {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return await prisma.project.create({
|
||||
data: {
|
||||
name: `Billing Project ${suffix}`,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function seedBalance(userId: string, balance: number) {
|
||||
return await prisma.userBalance.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
balance,
|
||||
frozenAmount: 0,
|
||||
totalSpent: 0,
|
||||
},
|
||||
update: {
|
||||
balance,
|
||||
frozenAmount: 0,
|
||||
totalSpent: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createQueuedTask(params: {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
type: TaskType
|
||||
targetType: string
|
||||
targetId: string
|
||||
billingInfo?: TaskBillingInfo | null
|
||||
payload?: Record<string, unknown> | null
|
||||
}) {
|
||||
return await prisma.task.create({
|
||||
data: {
|
||||
id: params.id,
|
||||
userId: params.userId,
|
||||
projectId: params.projectId,
|
||||
type: params.type,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
status: TASK_STATUS.QUEUED,
|
||||
billingInfo: (params.billingInfo ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
payload: (params.payload ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
60
tests/helpers/db-reset.ts
Normal file
60
tests/helpers/db-reset.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function resetBillingState() {
|
||||
await prisma.balanceTransaction.deleteMany()
|
||||
await prisma.balanceFreeze.deleteMany()
|
||||
await prisma.usageCost.deleteMany()
|
||||
await prisma.taskEvent.deleteMany()
|
||||
await prisma.task.deleteMany()
|
||||
await prisma.userBalance.deleteMany()
|
||||
await prisma.project.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.userPreference.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetTaskState() {
|
||||
await prisma.taskEvent.deleteMany()
|
||||
await prisma.task.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetAssetHubState() {
|
||||
await prisma.globalCharacterAppearance.deleteMany()
|
||||
await prisma.globalCharacter.deleteMany()
|
||||
await prisma.globalLocationImage.deleteMany()
|
||||
await prisma.globalLocation.deleteMany()
|
||||
await prisma.globalVoice.deleteMany()
|
||||
await prisma.globalAssetFolder.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetNovelPromotionState() {
|
||||
await prisma.novelPromotionVoiceLine.deleteMany()
|
||||
await prisma.novelPromotionPanel.deleteMany()
|
||||
await prisma.supplementaryPanel.deleteMany()
|
||||
await prisma.novelPromotionStoryboard.deleteMany()
|
||||
await prisma.novelPromotionShot.deleteMany()
|
||||
await prisma.novelPromotionClip.deleteMany()
|
||||
await prisma.characterAppearance.deleteMany()
|
||||
await prisma.locationImage.deleteMany()
|
||||
await prisma.novelPromotionCharacter.deleteMany()
|
||||
await prisma.novelPromotionLocation.deleteMany()
|
||||
await prisma.videoEditorProject.deleteMany()
|
||||
await prisma.novelPromotionEpisode.deleteMany()
|
||||
await prisma.novelPromotionProject.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetSystemState() {
|
||||
await resetTaskState()
|
||||
await resetAssetHubState()
|
||||
await resetNovelPromotionState()
|
||||
await prisma.usageCost.deleteMany()
|
||||
await prisma.project.deleteMany()
|
||||
await prisma.userPreference.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.userBalance.deleteMany()
|
||||
await prisma.balanceFreeze.deleteMany()
|
||||
await prisma.balanceTransaction.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
}
|
||||
26
tests/helpers/fakes/llm.ts
Normal file
26
tests/helpers/fakes/llm.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
type CompletionResult = {
|
||||
text: string
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
const state: { nextText: string; nextReasoning: string } = {
|
||||
nextText: '{"ok":true}',
|
||||
nextReasoning: '',
|
||||
}
|
||||
|
||||
export function configureFakeLLM(result: CompletionResult) {
|
||||
state.nextText = result.text
|
||||
state.nextReasoning = result.reasoning || ''
|
||||
}
|
||||
|
||||
export function resetFakeLLM() {
|
||||
state.nextText = '{"ok":true}'
|
||||
state.nextReasoning = ''
|
||||
}
|
||||
|
||||
export async function fakeChatCompletion() {
|
||||
return {
|
||||
output_text: state.nextText,
|
||||
reasoning: state.nextReasoning,
|
||||
}
|
||||
}
|
||||
37
tests/helpers/fakes/media.ts
Normal file
37
tests/helpers/fakes/media.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const state: {
|
||||
nextImageUrl: string
|
||||
nextVideoUrl: string
|
||||
nextAudioUrl: string
|
||||
} = {
|
||||
nextImageUrl: 'images/fake-image.jpg',
|
||||
nextVideoUrl: 'video/fake-video.mp4',
|
||||
nextAudioUrl: 'voice/fake-audio.mp3',
|
||||
}
|
||||
|
||||
export function configureFakeMedia(params: {
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
audioUrl?: string
|
||||
}) {
|
||||
if (params.imageUrl) state.nextImageUrl = params.imageUrl
|
||||
if (params.videoUrl) state.nextVideoUrl = params.videoUrl
|
||||
if (params.audioUrl) state.nextAudioUrl = params.audioUrl
|
||||
}
|
||||
|
||||
export function resetFakeMedia() {
|
||||
state.nextImageUrl = 'images/fake-image.jpg'
|
||||
state.nextVideoUrl = 'video/fake-video.mp4'
|
||||
state.nextAudioUrl = 'voice/fake-audio.mp3'
|
||||
}
|
||||
|
||||
export async function fakeGenerateImage() {
|
||||
return { success: true, imageUrl: state.nextImageUrl }
|
||||
}
|
||||
|
||||
export async function fakeGenerateVideo() {
|
||||
return { success: true, videoUrl: state.nextVideoUrl }
|
||||
}
|
||||
|
||||
export async function fakeGenerateAudio() {
|
||||
return { success: true, audioUrl: state.nextAudioUrl }
|
||||
}
|
||||
35
tests/helpers/fakes/providers.ts
Normal file
35
tests/helpers/fakes/providers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const providerState: {
|
||||
falApiKey: string
|
||||
googleApiKey: string
|
||||
openrouterApiKey: string
|
||||
} = {
|
||||
falApiKey: 'fake-fal-key',
|
||||
googleApiKey: 'fake-google-key',
|
||||
openrouterApiKey: 'fake-openrouter-key',
|
||||
}
|
||||
|
||||
export function configureFakeProviders(params: {
|
||||
falApiKey?: string
|
||||
googleApiKey?: string
|
||||
openrouterApiKey?: string
|
||||
}) {
|
||||
if (params.falApiKey) providerState.falApiKey = params.falApiKey
|
||||
if (params.googleApiKey) providerState.googleApiKey = params.googleApiKey
|
||||
if (params.openrouterApiKey) providerState.openrouterApiKey = params.openrouterApiKey
|
||||
}
|
||||
|
||||
export function resetFakeProviders() {
|
||||
providerState.falApiKey = 'fake-fal-key'
|
||||
providerState.googleApiKey = 'fake-google-key'
|
||||
providerState.openrouterApiKey = 'fake-openrouter-key'
|
||||
}
|
||||
|
||||
export function getFakeProviderConfig(provider: 'fal' | 'google' | 'openrouter') {
|
||||
if (provider === 'fal') {
|
||||
return { apiKey: providerState.falApiKey }
|
||||
}
|
||||
if (provider === 'google') {
|
||||
return { apiKey: providerState.googleApiKey }
|
||||
}
|
||||
return { apiKey: providerState.openrouterApiKey }
|
||||
}
|
||||
99
tests/helpers/fixtures.ts
Normal file
99
tests/helpers/fixtures.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
function suffix() {
|
||||
return randomUUID().slice(0, 8)
|
||||
}
|
||||
|
||||
export async function createFixtureUser() {
|
||||
const id = suffix()
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
name: `user_${id}`,
|
||||
email: `user_${id}@example.com`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureProject(userId: string, mode: 'novel-promotion' | 'general' = 'novel-promotion') {
|
||||
const id = suffix()
|
||||
return await prisma.project.create({
|
||||
data: {
|
||||
userId,
|
||||
mode,
|
||||
name: `project_${id}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureNovelProject(projectId: string) {
|
||||
return await prisma.novelPromotionProject.create({
|
||||
data: {
|
||||
projectId,
|
||||
analysisModel: 'openrouter::anthropic/claude-sonnet-4',
|
||||
characterModel: 'fal::banana/character',
|
||||
locationModel: 'fal::banana/location',
|
||||
storyboardModel: 'fal::banana/storyboard',
|
||||
editModel: 'fal::banana/edit',
|
||||
videoModel: 'fal::seedance/video',
|
||||
videoRatio: '9:16',
|
||||
imageResolution: '2K',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalCharacter(userId: string, folderId: string | null = null) {
|
||||
const id = suffix()
|
||||
return await prisma.globalCharacter.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `character_${id}`,
|
||||
...(folderId ? { folderId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalCharacterAppearance(characterId: string, appearanceIndex = 0) {
|
||||
return await prisma.globalCharacterAppearance.create({
|
||||
data: {
|
||||
characterId,
|
||||
appearanceIndex,
|
||||
changeReason: 'default',
|
||||
imageUrls: JSON.stringify(['images/test-0.jpg']),
|
||||
selectedIndex: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalLocation(userId: string, folderId: string | null = null) {
|
||||
const id = suffix()
|
||||
return await prisma.globalLocation.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `location_${id}`,
|
||||
...(folderId ? { folderId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalLocationImage(locationId: string, imageIndex = 0) {
|
||||
return await prisma.globalLocationImage.create({
|
||||
data: {
|
||||
locationId,
|
||||
imageIndex,
|
||||
imageUrl: `images/location-${suffix()}.jpg`,
|
||||
isSelected: imageIndex === 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureEpisode(novelPromotionProjectId: string, episodeNumber = 1) {
|
||||
return await prisma.novelPromotionEpisode.create({
|
||||
data: {
|
||||
novelPromotionProjectId,
|
||||
episodeNumber,
|
||||
name: `Episode ${episodeNumber}`,
|
||||
novelText: 'test novel text',
|
||||
},
|
||||
})
|
||||
}
|
||||
72
tests/helpers/mock-query-client.ts
Normal file
72
tests/helpers/mock-query-client.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { QueryKey } from '@tanstack/react-query'
|
||||
|
||||
interface QueryFilter {
|
||||
queryKey: QueryKey
|
||||
exact?: boolean
|
||||
}
|
||||
|
||||
type Updater<T> = T | ((previous: T | undefined) => T | undefined)
|
||||
|
||||
interface StoredQueryEntry {
|
||||
queryKey: QueryKey
|
||||
data: unknown
|
||||
}
|
||||
|
||||
function isPrefixQueryKey(target: QueryKey, prefix: QueryKey): boolean {
|
||||
if (prefix.length > target.length) return false
|
||||
return prefix.every((value, index) => Object.is(value, target[index]))
|
||||
}
|
||||
|
||||
function keyOf(queryKey: QueryKey): string {
|
||||
return JSON.stringify(queryKey)
|
||||
}
|
||||
|
||||
export class MockQueryClient {
|
||||
private readonly queryMap = new Map<string, StoredQueryEntry>()
|
||||
|
||||
async cancelQueries(filters: QueryFilter): Promise<void> {
|
||||
void filters
|
||||
}
|
||||
|
||||
seedQuery<T>(queryKey: QueryKey, data: T | undefined) {
|
||||
this.queryMap.set(keyOf(queryKey), {
|
||||
queryKey,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
getQueryData<T>(queryKey: QueryKey): T | undefined {
|
||||
const entry = this.queryMap.get(keyOf(queryKey))
|
||||
return entry?.data as T | undefined
|
||||
}
|
||||
|
||||
setQueryData<T>(queryKey: QueryKey, updater: Updater<T | undefined>) {
|
||||
const previous = this.getQueryData<T>(queryKey)
|
||||
const next = typeof updater === 'function'
|
||||
? (updater as (prev: T | undefined) => T | undefined)(previous)
|
||||
: updater
|
||||
this.seedQuery(queryKey, next)
|
||||
}
|
||||
|
||||
getQueriesData<T>(filters: QueryFilter): Array<[QueryKey, T | undefined]> {
|
||||
const matched: Array<[QueryKey, T | undefined]> = []
|
||||
for (const { queryKey, data } of this.queryMap.values()) {
|
||||
const isMatch = filters.exact
|
||||
? keyOf(filters.queryKey) === keyOf(queryKey)
|
||||
: isPrefixQueryKey(queryKey, filters.queryKey)
|
||||
if (!isMatch) continue
|
||||
matched.push([queryKey, data as T | undefined])
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
setQueriesData<T>(
|
||||
filters: QueryFilter,
|
||||
updater: (previous: T | undefined) => T | undefined,
|
||||
) {
|
||||
const matches = this.getQueriesData<T>(filters)
|
||||
matches.forEach(([queryKey, previous]) => {
|
||||
this.seedQuery(queryKey, updater(previous))
|
||||
})
|
||||
}
|
||||
}
|
||||
6
tests/helpers/prisma.ts
Normal file
6
tests/helpers/prisma.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { loadTestEnv } from '../setup/env'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
loadTestEnv()
|
||||
|
||||
export { prisma }
|
||||
62
tests/helpers/request.ts
Normal file
62
tests/helpers/request.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
type HeaderMap = Record<string, string>
|
||||
type QueryMap = Record<string, string | number | boolean>
|
||||
|
||||
function toJsonBody(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
function appendQuery(url: URL, query?: QueryMap) {
|
||||
if (!query) return
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMockRequest(params: {
|
||||
path: string
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: HeaderMap
|
||||
query?: QueryMap
|
||||
}) {
|
||||
const url = new URL(params.path, 'http://localhost:3000')
|
||||
appendQuery(url, params.query)
|
||||
const jsonBody = toJsonBody(params.body)
|
||||
|
||||
const headers: HeaderMap = {
|
||||
...(params.headers || {}),
|
||||
}
|
||||
if (jsonBody !== undefined && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
|
||||
return new NextRequest(url, {
|
||||
method: params.method,
|
||||
headers,
|
||||
...(jsonBody !== undefined ? { body: jsonBody } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function callRoute<TContext>(
|
||||
handler: (req: NextRequest, ctx: TContext) => Promise<Response>,
|
||||
params: {
|
||||
path: string
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: HeaderMap
|
||||
query?: QueryMap
|
||||
context: TContext
|
||||
},
|
||||
) {
|
||||
const req = buildMockRequest({
|
||||
path: params.path,
|
||||
method: params.method,
|
||||
body: params.body,
|
||||
headers: params.headers,
|
||||
query: params.query,
|
||||
})
|
||||
return await handler(req, params.context)
|
||||
}
|
||||
396
tests/integration/api/contract/crud-routes.test.ts
Normal file
396
tests/integration/api/contract/crud-routes.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type RouteMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: false,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionLocation: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
locationImage: {
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionClip: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
|
||||
}))
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
function resolveParamValue(paramName: string): string {
|
||||
const key = paramName.toLowerCase()
|
||||
if (key.includes('project')) return 'project-1'
|
||||
if (key.includes('character')) return 'character-1'
|
||||
if (key.includes('location')) return 'location-1'
|
||||
if (key.includes('appearance')) return '0'
|
||||
if (key.includes('episode')) return 'episode-1'
|
||||
if (key.includes('storyboard')) return 'storyboard-1'
|
||||
if (key.includes('panel')) return 'panel-1'
|
||||
if (key.includes('clip')) return 'clip-1'
|
||||
if (key.includes('folder')) return 'folder-1'
|
||||
if (key === 'id') return 'id-1'
|
||||
return `${paramName}-1`
|
||||
}
|
||||
|
||||
function toApiPath(routeFile: string): { path: string; params: Record<string, string> } {
|
||||
const withoutPrefix = routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
const path = withoutPrefix.replace(/\[([^\]]+)\]/g, (_full, paramName: string) => {
|
||||
const value = resolveParamValue(paramName)
|
||||
params[paramName] = value
|
||||
return value
|
||||
})
|
||||
return { path, params }
|
||||
}
|
||||
|
||||
function buildGenericBody() {
|
||||
return {
|
||||
id: 'id-1',
|
||||
name: 'Name',
|
||||
type: 'character',
|
||||
userInstruction: 'instruction',
|
||||
characterId: 'character-1',
|
||||
locationId: 'location-1',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'modify prompt',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelId: 'panel-1',
|
||||
panelIndex: 0,
|
||||
episodeId: 'episode-1',
|
||||
content: 'x'.repeat(140),
|
||||
voicePrompt: 'voice prompt',
|
||||
previewText: 'preview text',
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
referenceImageUrls: ['https://example.com/ref.png'],
|
||||
lineId: 'line-1',
|
||||
audioModel: 'fal::audio-model',
|
||||
videoModel: 'fal::video-model',
|
||||
insertAfterPanelId: 'panel-1',
|
||||
sourcePanelId: 'panel-2',
|
||||
variant: { video_prompt: 'variant prompt' },
|
||||
currentDescription: 'description',
|
||||
modifyInstruction: 'instruction',
|
||||
currentPrompt: 'prompt',
|
||||
all: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeRouteMethod(
|
||||
routeFile: string,
|
||||
method: RouteMethod,
|
||||
): Promise<Response> {
|
||||
const { path, params } = toApiPath(routeFile)
|
||||
const modulePath = toModuleImportPath(routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const handler = mod[method] as ((req: Request, ctx?: RouteContext) => Promise<Response>) | undefined
|
||||
if (!handler) {
|
||||
throw new Error(`Route ${routeFile} missing method ${method}`)
|
||||
}
|
||||
const req = buildMockRequest({
|
||||
path,
|
||||
method,
|
||||
...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),
|
||||
})
|
||||
return await handler(req, { params: Promise.resolve(params) })
|
||||
}
|
||||
|
||||
describe('api contract - crud routes (behavior)', () => {
|
||||
const routes = ROUTE_CATALOG.filter(
|
||||
(entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = false
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
prismaMock.globalAssetFolder.findUnique.mockResolvedValue({
|
||||
id: 'folder-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
prismaMock.globalCharacter.update.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
name: 'Alice',
|
||||
userId: 'user-1',
|
||||
appearances: [],
|
||||
})
|
||||
prismaMock.globalCharacter.delete.mockResolvedValue({ id: 'character-1' })
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
characterId: 'character-1',
|
||||
imageUrls: JSON.stringify(['cos/char-0.png', 'cos/char-1.png']),
|
||||
imageUrl: null,
|
||||
selectedIndex: null,
|
||||
character: { id: 'character-1', name: 'Alice' },
|
||||
})
|
||||
prismaMock.characterAppearance.update.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'cos/char-1.png',
|
||||
})
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{ id: 'img-0', imageIndex: 0, imageUrl: 'cos/loc-0.png' },
|
||||
{ id: 'img-1', imageIndex: 1, imageUrl: 'cos/loc-1.png' },
|
||||
],
|
||||
})
|
||||
prismaMock.locationImage.updateMany.mockResolvedValue({ count: 2 })
|
||||
prismaMock.locationImage.update.mockResolvedValue({
|
||||
id: 'img-1',
|
||||
imageIndex: 1,
|
||||
imageUrl: 'cos/loc-1.png',
|
||||
isSelected: true,
|
||||
})
|
||||
prismaMock.novelPromotionLocation.update.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
selectedImageId: 'img-1',
|
||||
})
|
||||
prismaMock.novelPromotionClip.update.mockResolvedValue({
|
||||
id: 'clip-1',
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
})
|
||||
})
|
||||
|
||||
it('crud route group exists', () => {
|
||||
expect(routes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('all crud route methods reject unauthenticated requests (no 2xx pass-through)', async () => {
|
||||
const methods: ReadonlyArray<RouteMethod> = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']
|
||||
let checkedMethodCount = 0
|
||||
|
||||
for (const entry of routes) {
|
||||
const modulePath = toModuleImportPath(entry.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
for (const method of methods) {
|
||||
if (typeof mod[method] !== 'function') continue
|
||||
checkedMethodCount += 1
|
||||
const res = await invokeRouteMethod(entry.routeFile, method)
|
||||
expect(res.status, `${entry.routeFile}#${method} should reject unauthenticated`).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status, `${entry.routeFile}#${method} should not be server-error on auth gate`).toBeLessThan(500)
|
||||
}
|
||||
}
|
||||
|
||||
expect(checkedMethodCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('PATCH /asset-hub/characters/[characterId] writes normalized fields to prisma.globalCharacter.update', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: ' Alice ',
|
||||
aliases: ['A'],
|
||||
profileConfirmed: true,
|
||||
folderId: 'folder-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacter.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'character-1' },
|
||||
data: expect.objectContaining({
|
||||
name: 'Alice',
|
||||
aliases: ['A'],
|
||||
profileConfirmed: true,
|
||||
folderId: 'folder-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('DELETE /asset-hub/characters/[characterId] deletes owned character and blocks non-owner', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
const okReq = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'DELETE',
|
||||
})
|
||||
const okRes = await mod.DELETE(okReq, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(okRes.status).toBe(200)
|
||||
expect(prismaMock.globalCharacter.delete).toHaveBeenCalledWith({ where: { id: 'character-1' } })
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
|
||||
id: 'character-1',
|
||||
userId: 'other-user',
|
||||
})
|
||||
const forbiddenReq = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'DELETE',
|
||||
})
|
||||
const forbiddenRes = await mod.DELETE(forbiddenReq, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(forbiddenRes.status).toBe(403)
|
||||
})
|
||||
|
||||
it('POST /novel-promotion/[projectId]/select-character-image writes selectedIndex and imageUrl key', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/select-character-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/select-character-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
selectedIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'cos/char-1.png',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }
|
||||
expect(payload).toEqual({
|
||||
success: true,
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'https://signed.example/cos/char-1.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /novel-promotion/[projectId]/select-location-image toggles selected state and selectedImageId', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/select-location-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/select-location-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
locationId: 'location-1',
|
||||
selectedIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.locationImage.updateMany).toHaveBeenCalledWith({
|
||||
where: { locationId: 'location-1' },
|
||||
data: { isSelected: false },
|
||||
})
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { locationId_imageIndex: { locationId: 'location-1', imageIndex: 1 } },
|
||||
data: { isSelected: true },
|
||||
})
|
||||
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-1' },
|
||||
data: { selectedImageId: 'img-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('PATCH /novel-promotion/[projectId]/clips/[clipId] writes provided editable fields', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/clips/[clipId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/clips/clip-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ projectId: 'project-1', clipId: 'clip-1' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-1' },
|
||||
data: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
487
tests/integration/api/contract/direct-submit-routes.test.ts
Normal file
487
tests/integration/api/contract/direct-submit-routes.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
projectMode: 'novel-promotion' | 'other'
|
||||
}
|
||||
|
||||
type SubmitResult = {
|
||||
taskId: string
|
||||
async: true
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
type DirectRouteCase = {
|
||||
routeFile: string
|
||||
body: Record<string, unknown>
|
||||
params?: Record<string, string>
|
||||
expectedTaskType: TaskType
|
||||
expectedTargetType: string
|
||||
expectedProjectId: string
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: true,
|
||||
projectMode: 'novel-promotion',
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<SubmitResult>>())
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
editModel: 'img::edit',
|
||||
})),
|
||||
buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
editModel: 'img::edit',
|
||||
storyboardModel: 'img::storyboard',
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({
|
||||
resolution: '1024x1024',
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasGlobalCharacterOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationOutput: vi.fn(async () => false),
|
||||
hasGlobalCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationImageOutput: vi.fn(async () => false),
|
||||
hasCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasLocationImageOutput: vi.fn(async () => false),
|
||||
hasPanelLipSyncOutput: vi.fn(async () => false),
|
||||
hasPanelImageOutput: vi.fn(async () => false),
|
||||
hasPanelVideoOutput: vi.fn(async () => false),
|
||||
hasVoiceLineAudioOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findFirst: vi.fn(async () => ({ id: 'panel-1' })),
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
||||
const id = where?.id || 'panel-1'
|
||||
if (id === 'panel-src') {
|
||||
return {
|
||||
id,
|
||||
panelIndex: 1,
|
||||
shotType: 'wide',
|
||||
cameraMove: 'static',
|
||||
description: 'source description',
|
||||
videoPrompt: 'source video prompt',
|
||||
location: 'source location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
if (id === 'panel-ins') {
|
||||
return {
|
||||
id,
|
||||
panelIndex: 2,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'push',
|
||||
description: 'insert description',
|
||||
videoPrompt: 'insert video prompt',
|
||||
location: 'insert location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
panelIndex: 0,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
description: 'panel description',
|
||||
videoPrompt: 'panel prompt',
|
||||
location: 'panel location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}),
|
||||
update: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'project-data-1',
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.mp3' },
|
||||
],
|
||||
})),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 'line-1', speaker: 'Narrator', content: 'hello world voice line' },
|
||||
]),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'line-1',
|
||||
speaker: 'Narrator',
|
||||
content: 'hello world voice line',
|
||||
})),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: (tx: {
|
||||
novelPromotionPanel: {
|
||||
findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
create: (args: unknown) => Promise<{ id: string; panelIndex: number }>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionPanel: {
|
||||
findMany: async () => [],
|
||||
update: async () => ({}),
|
||||
create: async () => ({ id: 'panel-created', panelIndex: 3 }),
|
||||
},
|
||||
}
|
||||
return await fn(tx)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/task/submitter', () => ({
|
||||
submitTask: submitTaskMock,
|
||||
}))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/providers/bailian/voice-design', () => ({
|
||||
validateVoicePrompt: vi.fn(() => ({ valid: true })),
|
||||
validatePreviewText: vi.fn(() => ({ valid: true })),
|
||||
}))
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
sanitizeImageInputsForTaskPayload: vi.fn((inputs: unknown[]) => ({
|
||||
normalized: inputs
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0),
|
||||
issues: [] as Array<{ reason: string }>,
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => ({
|
||||
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
|
||||
}))
|
||||
vi.mock('@/lib/model-pricing/lookup', () => ({
|
||||
resolveBuiltinPricing: vi.fn(() => ({ status: 'ok' })),
|
||||
}))
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
resolveModelSelection: vi.fn(async () => ({
|
||||
model: 'img::storyboard',
|
||||
})),
|
||||
resolveModelSelectionOrSingle: vi.fn(async (_userId: string, model: string | null | undefined) => {
|
||||
const modelKey = typeof model === 'string' && model.trim().length > 0
|
||||
? model.trim()
|
||||
: 'fal::audio-model'
|
||||
const separator = modelKey.indexOf('::')
|
||||
const provider = separator === -1 ? modelKey : modelKey.slice(0, separator)
|
||||
const modelId = separator === -1 ? modelKey : modelKey.slice(separator + 2)
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
modelKey,
|
||||
mediaType: 'audio',
|
||||
}
|
||||
}),
|
||||
getProviderKey: vi.fn((providerId: string) => {
|
||||
const marker = providerId.indexOf(':')
|
||||
return marker === -1 ? providerId : providerId.slice(0, marker)
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
function toApiPath(routeFile: string): string {
|
||||
return routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
.replace('[projectId]', 'project-1')
|
||||
}
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/generate-image/route.ts',
|
||||
body: { type: 'character', id: 'global-character-1', appearanceIndex: 0, artStyle: 'realistic' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/modify-image/route.ts',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
modifyPrompt: 'sharpen details',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 0,
|
||||
extraImageUrls: ['https://example.com/ref-a.png'],
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,
|
||||
expectedTargetType: 'GlobalCharacterAppearance',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/voice-design/route.ts',
|
||||
body: { voicePrompt: 'female calm narrator', previewText: '你好世界' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
expectedTargetType: 'GlobalAssetHubVoiceDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
||||
body: { videoModel: 'fal::video-model', storyboardId: 'storyboard-1', panelIndex: 0 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',
|
||||
body: { storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.INSERT_PANEL,
|
||||
expectedTargetType: 'NovelPromotionStoryboard',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncModel: 'fal::lip-model',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.LIP_SYNC,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',
|
||||
body: {
|
||||
type: 'character',
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'enhance texture',
|
||||
extraImageUrls: ['https://example.com/ref-b.png'],
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
modifyPrompt: 'increase contrast',
|
||||
extraImageUrls: ['https://example.com/ref-c.png'],
|
||||
selectedAssets: [{ imageUrl: 'https://example.com/ref-d.png' }],
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'new prompt', description: 'variant desc' },
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.PANEL_VARIANT,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REGENERATE_GROUP,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',
|
||||
body: { panelId: 'panel-1', count: 1 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_PANEL,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1', imageIndex: 0 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',
|
||||
body: { storyboardId: 'storyboard-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REGENERATE_STORYBOARD_TEXT,
|
||||
expectedTargetType: 'NovelPromotionStoryboard',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',
|
||||
body: { voicePrompt: 'warm female voice', previewText: 'This is preview text' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_DESIGN,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',
|
||||
body: { episodeId: 'episode-1', lineId: 'line-1', audioModel: 'fal::audio-model' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_LINE,
|
||||
expectedTargetType: 'NovelPromotionVoiceLine',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
]
|
||||
|
||||
async function invokePostRoute(routeCase: DirectRouteCase): Promise<Response> {
|
||||
const modulePath = toModuleImportPath(routeCase.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
||||
const req = buildMockRequest({
|
||||
path: toApiPath(routeCase.routeFile),
|
||||
method: 'POST',
|
||||
body: routeCase.body,
|
||||
})
|
||||
return await post(req, { params: Promise.resolve(routeCase.params || {}) })
|
||||
}
|
||||
|
||||
describe('api contract - direct submit routes (behavior)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
authState.projectMode = 'novel-promotion'
|
||||
let seq = 0
|
||||
submitTaskMock.mockImplementation(async () => ({
|
||||
taskId: `task-${++seq}`,
|
||||
async: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps expected coverage size', () => {
|
||||
expect(DIRECT_CASES.length).toBe(16)
|
||||
})
|
||||
|
||||
for (const routeCase of DIRECT_CASES) {
|
||||
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
|
||||
authState.authenticated = false
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(401)
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it(`${routeCase.routeFile} -> submits task with expected contract when authenticated`, async () => {
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(200)
|
||||
expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: routeCase.expectedTaskType,
|
||||
targetType: routeCase.expectedTargetType,
|
||||
projectId: routeCase.expectedProjectId,
|
||||
userId: 'user-1',
|
||||
}))
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
|
||||
expect(submitArg?.type).toBe(routeCase.expectedTaskType)
|
||||
expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)
|
||||
expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)
|
||||
expect(submitArg?.userId).toBe('user-1')
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
|
||||
if (isVoiceGenerateRoute) {
|
||||
expect(json.success).toBe(true)
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
} else {
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
371
tests/integration/api/contract/llm-observe-routes.test.ts
Normal file
371
tests/integration/api/contract/llm-observe-routes.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
projectMode: 'novel-promotion' | 'other'
|
||||
}
|
||||
|
||||
type LLMRouteCase = {
|
||||
routeFile: string
|
||||
body: Record<string, unknown>
|
||||
params?: Record<string, string>
|
||||
expectedTaskType: TaskType
|
||||
expectedTargetType: string
|
||||
expectedProjectId: string
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: true,
|
||||
projectMode: 'novel-promotion',
|
||||
}))
|
||||
|
||||
const maybeSubmitLLMTaskMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/llm-observe/route-task').maybeSubmitLLMTask>(async () => NextResponse.json({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})),
|
||||
)
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'global-character-1',
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
globalLocation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'global-location-1',
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/llm-observe/route-task', () => ({
|
||||
maybeSubmitLLMTask: maybeSubmitLLMTaskMock,
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
function toApiPath(routeFile: string): string {
|
||||
return routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
.replace('[projectId]', 'project-1')
|
||||
}
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
const ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-design-character/route.ts',
|
||||
body: { userInstruction: 'design a heroic character' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,
|
||||
expectedTargetType: 'GlobalAssetHubCharacterDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-design-location/route.ts',
|
||||
body: { userInstruction: 'design a noir city location' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,
|
||||
expectedTargetType: 'GlobalAssetHubLocationDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-modify-character/route.ts',
|
||||
body: {
|
||||
characterId: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'make the outfit darker',
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-modify-location/route.ts',
|
||||
body: {
|
||||
locationId: 'global-location-1',
|
||||
imageIndex: 0,
|
||||
currentDescription: 'old location desc',
|
||||
modifyInstruction: 'add more fog',
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,
|
||||
expectedTargetType: 'GlobalLocation',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/reference-to-character/route.ts',
|
||||
body: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',
|
||||
body: { userInstruction: 'create a rebel hero' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_CREATE_CHARACTER,
|
||||
expectedTargetType: 'NovelPromotionCharacterDesign',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',
|
||||
body: { userInstruction: 'create a mountain temple' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_CREATE_LOCATION,
|
||||
expectedTargetType: 'NovelPromotionLocationDesign',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old appearance',
|
||||
modifyInstruction: 'add armor',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',
|
||||
body: {
|
||||
locationId: 'location-1',
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'add rain',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_LOCATION,
|
||||
expectedTargetType: 'NovelPromotionLocation',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',
|
||||
body: {
|
||||
panelId: 'panel-1',
|
||||
currentPrompt: 'old prompt',
|
||||
modifyInstruction: 'more dramatic angle',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',
|
||||
body: {},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_GLOBAL,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',
|
||||
body: { panelId: 'panel-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze/route.ts',
|
||||
body: { episodeId: 'episode-1', content: 'Analyze this chapter' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_NOVEL,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',
|
||||
body: { items: ['character-1', 'character-2'] },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',
|
||||
body: { characterId: 'character-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_CONFIRM,
|
||||
expectedTargetType: 'NovelPromotionCharacter',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/clips/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CLIPS_BUILD,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',
|
||||
body: { content: 'x'.repeat(120) },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',
|
||||
body: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.SCREENPLAY_CONVERT,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',
|
||||
body: { episodeId: 'episode-1', content: 'story text' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_ANALYZE,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
]
|
||||
|
||||
async function invokePostRoute(routeCase: LLMRouteCase): Promise<Response> {
|
||||
const modulePath = toModuleImportPath(routeCase.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
||||
const req = buildMockRequest({
|
||||
path: toApiPath(routeCase.routeFile),
|
||||
method: 'POST',
|
||||
body: routeCase.body,
|
||||
})
|
||||
return await post(req, { params: Promise.resolve(routeCase.params || {}) })
|
||||
}
|
||||
|
||||
describe('api contract - llm observe routes (behavior)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
authState.projectMode = 'novel-promotion'
|
||||
maybeSubmitLLMTaskMock.mockResolvedValue(
|
||||
NextResponse.json({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps expected coverage size', () => {
|
||||
expect(ROUTE_CASES.length).toBe(22)
|
||||
})
|
||||
|
||||
for (const routeCase of ROUTE_CASES) {
|
||||
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
|
||||
authState.authenticated = false
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(401)
|
||||
expect(maybeSubmitLLMTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it(`${routeCase.routeFile} -> submits llm task with expected contract when authenticated`, async () => {
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(200)
|
||||
expect(maybeSubmitLLMTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: routeCase.expectedTaskType,
|
||||
targetType: routeCase.expectedTargetType,
|
||||
projectId: routeCase.expectedProjectId,
|
||||
userId: 'user-1',
|
||||
}))
|
||||
|
||||
const callArg = maybeSubmitLLMTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
|
||||
expect(callArg?.type).toBe(routeCase.expectedTaskType)
|
||||
expect(callArg?.targetType).toBe(routeCase.expectedTargetType)
|
||||
expect(callArg?.projectId).toBe(routeCase.expectedProjectId)
|
||||
expect(callArg?.userId).toBe('user-1')
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
})
|
||||
}
|
||||
})
|
||||
134
tests/integration/api/contract/run-step-retry.route.test.ts
Normal file
134
tests/integration/api/contract/run-step-retry.route.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ runId: string; stepKey: string }>
|
||||
}
|
||||
|
||||
const authState = vi.hoisted(() => ({ authenticated: true }))
|
||||
const getRunByIdMock = vi.hoisted(() => vi.fn())
|
||||
const retryFailedStepMock = vi.hoisted(() => vi.fn())
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn())
|
||||
const resolveRequiredTaskLocaleMock = vi.hoisted(() => vi.fn(() => 'zh'))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/run-runtime/service', () => ({
|
||||
getRunById: getRunByIdMock,
|
||||
retryFailedStep: retryFailedStepMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/submitter', () => ({
|
||||
submitTask: submitTaskMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: resolveRequiredTaskLocaleMock,
|
||||
}))
|
||||
|
||||
describe('api contract - run step retry route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
|
||||
getRunByIdMock.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
workflowType: 'story_to_script_run',
|
||||
taskType: 'story_to_script_run',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
input: {
|
||||
episodeId: 'episode-1',
|
||||
content: 'test content',
|
||||
meta: { locale: 'zh' },
|
||||
},
|
||||
})
|
||||
retryFailedStepMock.mockResolvedValue({
|
||||
run: { id: 'run-1' },
|
||||
step: { stepKey: 'screenplay_clip_2' },
|
||||
retryAttempt: 2,
|
||||
})
|
||||
submitTaskMock.mockResolvedValue({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-retry-1',
|
||||
runId: 'run-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects retry when step is not failed', async () => {
|
||||
retryFailedStepMock.mockRejectedValue(new Error('RUN_STEP_NOT_FAILED'))
|
||||
const route = await import('@/app/api/runs/[runId]/steps/[stepKey]/retry/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/runs/run-1/steps/screenplay_clip_2/retry',
|
||||
method: 'POST',
|
||||
body: { modelOverride: 'openai/gpt-5' },
|
||||
})
|
||||
const res = await route.POST(req, {
|
||||
params: Promise.resolve({ runId: 'run-1', stepKey: 'screenplay_clip_2' }),
|
||||
} as RouteContext)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits retry task bound to existing run id', async () => {
|
||||
const route = await import('@/app/api/runs/[runId]/steps/[stepKey]/retry/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/runs/run-1/steps/screenplay_clip_2/retry',
|
||||
method: 'POST',
|
||||
body: {
|
||||
modelOverride: 'openai/gpt-5',
|
||||
reason: 'manual retry',
|
||||
},
|
||||
})
|
||||
const res = await route.POST(req, {
|
||||
params: Promise.resolve({ runId: 'run-1', stepKey: 'screenplay_clip_2' }),
|
||||
} as RouteContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const payload = await res.json() as {
|
||||
success: boolean
|
||||
runId: string
|
||||
stepKey: string
|
||||
retryAttempt: number
|
||||
taskId: string
|
||||
}
|
||||
expect(payload.success).toBe(true)
|
||||
expect(payload.runId).toBe('run-1')
|
||||
expect(payload.stepKey).toBe('screenplay_clip_2')
|
||||
expect(payload.retryAttempt).toBe(2)
|
||||
expect(payload.taskId).toBe('task-retry-1')
|
||||
|
||||
expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
type: 'story_to_script_run',
|
||||
payload: expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
retryStepKey: 'screenplay_clip_2',
|
||||
retryStepAttempt: 2,
|
||||
model: 'openai/gpt-5',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
463
tests/integration/api/contract/task-infra-routes.test.ts
Normal file
463
tests/integration/api/contract/task-infra-routes.test.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_STATUS } from '@/lib/task/types'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ taskId: string }>
|
||||
}
|
||||
|
||||
type EmptyRouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
type ReplayEvent = Awaited<ReturnType<typeof import('@/lib/task/publisher').listEventsAfter>>[number]
|
||||
type TaskLifecycleReplayEvent = Awaited<ReturnType<typeof import('@/lib/task/publisher').listTaskLifecycleEvents>>[number]
|
||||
|
||||
type TaskRecord = {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
type: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
status: string
|
||||
errorCode: string | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: true,
|
||||
}))
|
||||
|
||||
const queryTasksMock = vi.hoisted(() => vi.fn())
|
||||
const dismissFailedTasksMock = vi.hoisted(() => vi.fn())
|
||||
const getTaskByIdMock = vi.hoisted(() => vi.fn())
|
||||
const cancelTaskMock = vi.hoisted(() => vi.fn())
|
||||
const removeTaskJobMock = vi.hoisted(() => vi.fn(async () => true))
|
||||
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const queryTaskTargetStatesMock = vi.hoisted(() => vi.fn())
|
||||
const withPrismaRetryMock = vi.hoisted(() => vi.fn(async <T>(fn: () => Promise<T>) => await fn()))
|
||||
const listEventsAfterMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/task/publisher').listEventsAfter>(async () => []),
|
||||
)
|
||||
const listTaskLifecycleEventsMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/task/publisher').listTaskLifecycleEvents>(async () => []),
|
||||
)
|
||||
const addChannelListenerMock = vi.hoisted(() =>
|
||||
vi.fn<(channel: string, listener: (message: string) => void) => Promise<() => Promise<void>>>(
|
||||
async () => async () => undefined,
|
||||
),
|
||||
)
|
||||
const subscriberState = vi.hoisted(() => ({
|
||||
listener: null as ((message: string) => void) | null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1' },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/task/service', () => ({
|
||||
queryTasks: queryTasksMock,
|
||||
dismissFailedTasks: dismissFailedTasksMock,
|
||||
getTaskById: getTaskByIdMock,
|
||||
cancelTask: cancelTaskMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/queues', () => ({
|
||||
removeTaskJob: removeTaskJobMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: publishTaskEventMock,
|
||||
getProjectChannel: vi.fn((projectId: string) => `project:${projectId}`),
|
||||
listEventsAfter: listEventsAfterMock,
|
||||
listTaskLifecycleEvents: listTaskLifecycleEventsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/state-service', () => ({
|
||||
queryTaskTargetStates: queryTaskTargetStatesMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma-retry', () => ({
|
||||
withPrismaRetry: withPrismaRetryMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/sse/shared-subscriber', () => ({
|
||||
getSharedSubscriber: vi.fn(() => ({
|
||||
addChannelListener: addChannelListenerMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
task: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const baseTask: TaskRecord = {
|
||||
id: 'task-1',
|
||||
userId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
type: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
status: TASK_STATUS.FAILED,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
|
||||
describe('api contract - task infra routes (behavior)', () => {
|
||||
const emptyRouteContext: EmptyRouteContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
subscriberState.listener = null
|
||||
|
||||
queryTasksMock.mockResolvedValue([baseTask])
|
||||
dismissFailedTasksMock.mockResolvedValue(1)
|
||||
getTaskByIdMock.mockResolvedValue(baseTask)
|
||||
cancelTaskMock.mockResolvedValue({
|
||||
task: {
|
||||
...baseTask,
|
||||
status: TASK_STATUS.FAILED,
|
||||
errorCode: 'TASK_CANCELLED',
|
||||
errorMessage: 'Task cancelled by user',
|
||||
},
|
||||
cancelled: true,
|
||||
})
|
||||
queryTaskTargetStatesMock.mockResolvedValue([
|
||||
{
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
active: true,
|
||||
status: TASK_STATUS.PROCESSING,
|
||||
taskId: 'task-1',
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
addChannelListenerMock.mockImplementation(async (_channel: string, listener: (message: string) => void) => {
|
||||
subscriberState.listener = listener
|
||||
return async () => undefined
|
||||
})
|
||||
listTaskLifecycleEventsMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('GET /api/tasks: unauthenticated -> 401; authenticated -> 200 with caller-owned tasks', async () => {
|
||||
const { GET } = await import('@/app/api/tasks/route')
|
||||
|
||||
authState.authenticated = false
|
||||
const unauthorizedReq = buildMockRequest({
|
||||
path: '/api/tasks',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1', limit: 20 },
|
||||
})
|
||||
const unauthorizedRes = await GET(unauthorizedReq, emptyRouteContext)
|
||||
expect(unauthorizedRes.status).toBe(401)
|
||||
|
||||
authState.authenticated = true
|
||||
const req = buildMockRequest({
|
||||
path: '/api/tasks',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1', limit: 20, targetId: 'appearance-1' },
|
||||
})
|
||||
const res = await GET(req, emptyRouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { tasks: TaskRecord[] }
|
||||
expect(payload.tasks).toHaveLength(1)
|
||||
expect(payload.tasks[0]?.id).toBe('task-1')
|
||||
expect(queryTasksMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
targetId: 'appearance-1',
|
||||
limit: 20,
|
||||
}))
|
||||
})
|
||||
|
||||
it('POST /api/tasks/dismiss: invalid params -> 400; success -> dismissed count', async () => {
|
||||
const { POST } = await import('@/app/api/tasks/dismiss/route')
|
||||
|
||||
const invalidReq = buildMockRequest({
|
||||
path: '/api/tasks/dismiss',
|
||||
method: 'POST',
|
||||
body: { taskIds: [] },
|
||||
})
|
||||
const invalidRes = await POST(invalidReq, emptyRouteContext)
|
||||
expect(invalidRes.status).toBe(400)
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/tasks/dismiss',
|
||||
method: 'POST',
|
||||
body: { taskIds: ['task-1', 'task-2'] },
|
||||
})
|
||||
const res = await POST(req, emptyRouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { success: boolean; dismissed: number }
|
||||
expect(payload.success).toBe(true)
|
||||
expect(payload.dismissed).toBe(1)
|
||||
expect(dismissFailedTasksMock).toHaveBeenCalledWith(['task-1', 'task-2'], 'user-1')
|
||||
})
|
||||
|
||||
it('POST /api/task-target-states: validates payload and returns queried states', async () => {
|
||||
const { POST } = await import('@/app/api/task-target-states/route')
|
||||
|
||||
const invalidReq = buildMockRequest({
|
||||
path: '/api/task-target-states',
|
||||
method: 'POST',
|
||||
body: { projectId: 'project-1' },
|
||||
})
|
||||
const invalidRes = await POST(invalidReq, emptyRouteContext)
|
||||
expect(invalidRes.status).toBe(400)
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/task-target-states',
|
||||
method: 'POST',
|
||||
body: {
|
||||
projectId: 'project-1',
|
||||
targets: [
|
||||
{
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
types: ['IMAGE_CHARACTER'],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const res = await POST(req, emptyRouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { states: Array<Record<string, unknown>> }
|
||||
expect(payload.states).toHaveLength(1)
|
||||
expect(withPrismaRetryMock).toHaveBeenCalledTimes(1)
|
||||
expect(queryTaskTargetStatesMock).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
targets: [
|
||||
{
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
types: ['IMAGE_CHARACTER'],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/tasks/[taskId]: enforces ownership and returns task detail', async () => {
|
||||
const route = await import('@/app/api/tasks/[taskId]/route')
|
||||
|
||||
authState.authenticated = false
|
||||
const unauthorizedReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
|
||||
const unauthorizedRes = await route.GET(unauthorizedReq, { params: Promise.resolve({ taskId: 'task-1' }) })
|
||||
expect(unauthorizedRes.status).toBe(401)
|
||||
|
||||
authState.authenticated = true
|
||||
getTaskByIdMock.mockResolvedValueOnce({ ...baseTask, userId: 'other-user' })
|
||||
const notFoundReq = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
|
||||
const notFoundRes = await route.GET(notFoundReq, { params: Promise.resolve({ taskId: 'task-1' }) })
|
||||
expect(notFoundRes.status).toBe(404)
|
||||
|
||||
const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'GET' })
|
||||
const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { task: TaskRecord }
|
||||
expect(payload.task.id).toBe('task-1')
|
||||
})
|
||||
|
||||
it('GET /api/tasks/[taskId]?includeEvents=1: returns lifecycle events for refresh replay', async () => {
|
||||
const route = await import('@/app/api/tasks/[taskId]/route')
|
||||
const replayEvents: TaskLifecycleReplayEvent[] = [
|
||||
{
|
||||
id: '11',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: {
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'clip_1_phase1',
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 3,
|
||||
message: 'running',
|
||||
},
|
||||
},
|
||||
]
|
||||
listTaskLifecycleEventsMock.mockResolvedValueOnce(replayEvents)
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/tasks/task-1',
|
||||
method: 'GET',
|
||||
query: { includeEvents: '1', eventsLimit: '1200' },
|
||||
})
|
||||
const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { task: TaskRecord; events: Array<Record<string, unknown>> }
|
||||
expect(payload.task.id).toBe('task-1')
|
||||
expect(payload.events).toHaveLength(1)
|
||||
expect(payload.events[0]?.id).toBe('11')
|
||||
expect(listTaskLifecycleEventsMock).toHaveBeenCalledWith('task-1', 1200)
|
||||
})
|
||||
|
||||
it('DELETE /api/tasks/[taskId]: cancellation publishes cancelled event payload', async () => {
|
||||
const { DELETE } = await import('@/app/api/tasks/[taskId]/route')
|
||||
|
||||
const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'DELETE' })
|
||||
const res = await DELETE(req, { params: Promise.resolve({ taskId: 'task-1' }) } as RouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
expect(removeTaskJobMock).toHaveBeenCalledWith('task-1')
|
||||
expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
payload: expect.objectContaining({
|
||||
cancelled: true,
|
||||
stage: 'cancelled',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('GET /api/sse: missing projectId -> 400; unauthenticated with projectId -> 401', async () => {
|
||||
const { GET } = await import('@/app/api/sse/route')
|
||||
|
||||
const invalidReq = buildMockRequest({ path: '/api/sse', method: 'GET' })
|
||||
const invalidRes = await GET(invalidReq, emptyRouteContext)
|
||||
expect(invalidRes.status).toBe(400)
|
||||
|
||||
authState.authenticated = false
|
||||
const unauthorizedReq = buildMockRequest({
|
||||
path: '/api/sse',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1' },
|
||||
})
|
||||
const unauthorizedRes = await GET(unauthorizedReq, emptyRouteContext)
|
||||
expect(unauthorizedRes.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /api/sse: authenticated replay request returns SSE stream and replays missed events', async () => {
|
||||
const { GET } = await import('@/app/api/sse/route')
|
||||
|
||||
listEventsAfterMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: '4',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: { lifecycleType: 'task.created' },
|
||||
} satisfies ReplayEvent,
|
||||
])
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/sse',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1' },
|
||||
headers: { 'last-event-id': '3' },
|
||||
})
|
||||
const res = await GET(req, emptyRouteContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
||||
expect(listEventsAfterMock).toHaveBeenCalledWith('project-1', 3, 5000)
|
||||
expect(addChannelListenerMock).toHaveBeenCalledWith('project:project-1', expect.any(Function))
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
expect(reader).toBeTruthy()
|
||||
const firstChunk = await reader!.read()
|
||||
expect(firstChunk.done).toBe(false)
|
||||
const decoded = new TextDecoder().decode(firstChunk.value)
|
||||
expect(decoded).toContain('event:')
|
||||
await reader!.cancel()
|
||||
})
|
||||
|
||||
it('GET /api/sse: channel lifecycle stream includes terminal completed event', async () => {
|
||||
const { GET } = await import('@/app/api/sse/route')
|
||||
listEventsAfterMock.mockResolvedValueOnce([])
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/sse',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1' },
|
||||
headers: { 'last-event-id': '10' },
|
||||
})
|
||||
const res = await GET(req, emptyRouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const listener = subscriberState.listener
|
||||
expect(listener).toBeTruthy()
|
||||
|
||||
listener!(JSON.stringify({
|
||||
id: '11',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: { lifecycleType: 'processing', progress: 60 },
|
||||
}))
|
||||
listener!(JSON.stringify({
|
||||
id: '12',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: { lifecycleType: 'completed', progress: 100 },
|
||||
}))
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
expect(reader).toBeTruthy()
|
||||
const chunk1 = await reader!.read()
|
||||
const chunk2 = await reader!.read()
|
||||
const merged = `${new TextDecoder().decode(chunk1.value)}${new TextDecoder().decode(chunk2.value)}`
|
||||
|
||||
expect(merged).toContain('"lifecycleType":"processing"')
|
||||
expect(merged).toContain('"lifecycleType":"completed"')
|
||||
expect(merged).toContain('"taskId":"task-1"')
|
||||
await reader!.cancel()
|
||||
})
|
||||
})
|
||||
35
tests/integration/api/helpers/call-route.ts
Normal file
35
tests/integration/api/helpers/call-route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
type RouteParams = Record<string, string>
|
||||
type HeaderMap = Record<string, string>
|
||||
|
||||
type RouteHandler = (
|
||||
req: NextRequest,
|
||||
ctx?: { params: Promise<RouteParams> },
|
||||
) => Promise<Response>
|
||||
|
||||
export async function callRoute(
|
||||
handler: RouteHandler,
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
options?: { headers?: HeaderMap; params?: RouteParams; query?: Record<string, string> },
|
||||
) {
|
||||
const url = new URL('http://localhost:3000/api/test')
|
||||
if (options?.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body)
|
||||
const req = new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(payload ? { 'content-type': 'application/json' } : {}),
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
...(payload ? { body: payload } : {}),
|
||||
})
|
||||
const context = { params: Promise.resolve(options?.params || {}) }
|
||||
return await handler(req, context)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
authenticated: true,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-new' })),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
describe('api specific - asset hub appearances route', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
appearances: [
|
||||
{ id: 'appearance-1', appearanceIndex: 0, artStyle: 'realistic' },
|
||||
],
|
||||
})
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
description: 'old description',
|
||||
descriptions: JSON.stringify(['old description', 'variant description']),
|
||||
})
|
||||
})
|
||||
|
||||
it('PATCH preserves description array length instead of rewriting fixed triple entries', async () => {
|
||||
const mod = await import('@/app/api/asset-hub/appearances/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/appearances',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
description: 'updated description',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
description: 'updated description',
|
||||
descriptions: JSON.stringify(['updated description', 'variant description']),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('POST initializes new appearance with a single description entry', async () => {
|
||||
const mod = await import('@/app/api/asset-hub/appearances/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/appearances',
|
||||
method: 'POST',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
changeReason: '新造型',
|
||||
description: 'new description',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
description: 'new description',
|
||||
descriptions: JSON.stringify(['new description']),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{
|
||||
success: boolean
|
||||
async: boolean
|
||||
taskId: string
|
||||
status: string
|
||||
deduped: boolean
|
||||
}>>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: null,
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: null,
|
||||
editModel: null,
|
||||
videoModel: null,
|
||||
capabilityDefaults: {},
|
||||
})),
|
||||
buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasGlobalCharacterOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacterAppearance: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
findMany: vi.fn(async () => []),
|
||||
createMany: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => billingMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - asset hub generate image art style', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses persisted appearance artStyle when request payload does not provide one', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacterAppearance.findFirst).toHaveBeenCalled()
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('realistic')
|
||||
})
|
||||
|
||||
it('uses persisted location artStyle when request payload does not provide one', async () => {
|
||||
prismaMock.globalLocation.findFirst
|
||||
.mockResolvedValueOnce({ artStyle: 'japanese-anime' })
|
||||
.mockResolvedValueOnce({ name: 'Location 1', summary: 'Summary 1' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'location',
|
||||
id: 'location-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalLocation.findFirst).toHaveBeenCalled()
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('japanese-anime')
|
||||
expect(submitArg?.payload?.count).toBe(3)
|
||||
})
|
||||
|
||||
it('fails with invalid params when persisted artStyle is missing', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: null })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards requested count into asset hub image task payload', async () => {
|
||||
prismaMock.globalCharacterAppearance.findFirst.mockResolvedValueOnce({ artStyle: 'realistic' })
|
||||
const mod = await import('@/app/api/asset-hub/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as {
|
||||
payload?: Record<string, unknown>
|
||||
dedupeKey?: string
|
||||
} | undefined
|
||||
expect(submitArg?.payload?.count).toBe(5)
|
||||
expect(submitArg?.dedupeKey).toContain(':5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
},
|
||||
globalLocation: {
|
||||
create: vi.fn(async () => ({ id: 'location-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),
|
||||
},
|
||||
globalLocationImage: {
|
||||
createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(
|
||||
async () => ({ count: 0 }),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - asset hub location create', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images after creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/asset-hub/locations/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/locations',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
summary: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
const createManyArg = prismaMock.globalLocationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn<() => Promise<{ session: { user: { id: string } } } | Response>>(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
globalCharacter: {
|
||||
create: vi.fn(async () => ({ id: 'character-1', userId: 'user-1' })),
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
name: 'Hero',
|
||||
appearances: [],
|
||||
})),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const mediaAttachMock = vi.hoisted(() => ({
|
||||
attachMediaFieldsToGlobalCharacter: vi.fn(async (value: unknown) => value),
|
||||
}))
|
||||
|
||||
const mediaServiceMock = vi.hoisted(() => ({
|
||||
resolveMediaRefFromLegacyValue: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/attach', () => mediaAttachMock)
|
||||
vi.mock('@/lib/media/service', () => mediaServiceMock)
|
||||
vi.mock('@/lib/env', () => envMock)
|
||||
|
||||
describe('api specific - characters POST forwarding to reference task', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.globalAssetFolder.findUnique.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('forwards locale and accept-language into background reference task payload', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Hero',
|
||||
artStyle: 'realistic',
|
||||
generateFromReference: true,
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
customDescription: '冷静,黑发',
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const calledUrl = fetchMock.mock.calls[0]?.[0]
|
||||
const calledInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
expect(String(calledUrl)).toContain('/api/asset-hub/reference-to-character')
|
||||
expect((calledInit?.headers as Record<string, string>)['Accept-Language']).toBe('zh-CN,zh;q=0.9')
|
||||
|
||||
const rawBody = calledInit?.body
|
||||
expect(typeof rawBody).toBe('string')
|
||||
const forwarded = JSON.parse(String(rawBody)) as {
|
||||
locale?: string
|
||||
meta?: { locale?: string }
|
||||
customDescription?: string
|
||||
artStyle?: string
|
||||
referenceImageUrls?: string[]
|
||||
appearanceId?: string
|
||||
characterId?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
expect(forwarded.locale).toBe('zh')
|
||||
expect(forwarded.meta?.locale).toBe('zh')
|
||||
expect(forwarded.customDescription).toBe('冷静,黑发')
|
||||
expect(forwarded.artStyle).toBe('realistic')
|
||||
expect(forwarded.referenceImageUrls).toEqual(['https://example.com/ref.png'])
|
||||
expect(forwarded.characterId).toBe('character-1')
|
||||
expect(forwarded.appearanceId).toBe('appearance-1')
|
||||
expect(forwarded.count).toBe(5)
|
||||
})
|
||||
|
||||
it('returns unauthorized when auth fails', async () => {
|
||||
authMock.requireUserAuth.mockResolvedValueOnce(
|
||||
NextResponse.json({ error: { code: 'UNAUTHORIZED' } }, { status: 401 }),
|
||||
)
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'Hero' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
61
tests/integration/api/specific/characters-post.test.ts
Normal file
61
tests/integration/api/specific/characters-post.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
mockUnauthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
describe('api specific - characters POST', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('returns unauthorized when user is not authenticated', async () => {
|
||||
installAuthMocks()
|
||||
mockUnauthenticated()
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'A' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns invalid params when name is missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
|
||||
it('returns invalid params when artStyle is missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/characters/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters',
|
||||
method: 'POST',
|
||||
body: { name: 'Hero' },
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
novelData: { id: 'novel-data-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionCharacter: {
|
||||
create: vi.fn(async () => ({ id: 'character-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'character-1', appearances: [] })),
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({ id: 'appearance-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/env', () => envMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion character style forwarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images when creating by text prompt', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/character',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Hero',
|
||||
description: '主角设定',
|
||||
artStyle: 'realistic',
|
||||
count: 4,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle before creating character', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/character',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Hero',
|
||||
description: '主角设定',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionCharacter.create).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<(input: unknown) => Promise<{
|
||||
success: boolean
|
||||
async: boolean
|
||||
taskId: string
|
||||
status: string
|
||||
deduped: boolean
|
||||
}>>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
analysisModel: null,
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: null,
|
||||
editModel: null,
|
||||
videoModel: null,
|
||||
videoRatio: '16:9',
|
||||
artStyle: 'american-comic',
|
||||
capabilityDefaults: {},
|
||||
capabilityOverrides: {},
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasLocationImageOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ billable: false })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => billingMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion generate image art style', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and forwards it into task payload', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as { payload?: Record<string, unknown> } | undefined
|
||||
expect(submitArg?.payload?.artStyle).toBe('realistic')
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards requested count into task payload and dedupe key', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/generate-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
count: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls[0]?.[0] as {
|
||||
payload?: Record<string, unknown>
|
||||
dedupeKey?: string
|
||||
} | undefined
|
||||
expect(submitArg?.payload?.count).toBe(6)
|
||||
expect(submitArg?.dedupeKey).toBe('image_character:appearance-1:6')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
novelData: { id: 'novel-data-1' },
|
||||
})),
|
||||
requireProjectAuthLight: vi.fn(),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionLocation: {
|
||||
create: vi.fn(async () => ({ id: 'location-1' })),
|
||||
findUnique: vi.fn(async () => ({ id: 'location-1', images: [] })),
|
||||
},
|
||||
locationImage: {
|
||||
createMany: vi.fn<(input: { data: Array<{ imageIndex: number }> }) => Promise<{ count: number }>>(
|
||||
async () => ({ count: 0 }),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
describe('api specific - novel promotion location style forwarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not auto-generate images when creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
},
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle before creating location', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionLocation.create).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates requested number of slots and forwards count', async () => {
|
||||
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
||||
async () => new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/location/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/location',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Old Town',
|
||||
description: '雨夜街道',
|
||||
artStyle: 'realistic',
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const createManyArg = prismaMock.locationImage.createMany.mock.calls[0]?.[0] as {
|
||||
data?: Array<{ imageIndex: number }>
|
||||
} | undefined
|
||||
expect(createManyArg?.data?.map((item) => item.imageIndex)).toEqual([0, 1, 2, 3, 4])
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1', name: 'User 1' } },
|
||||
project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion', name: 'Project 1' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: 'img::storyboard',
|
||||
editModel: 'img::edit',
|
||||
videoModel: 'video::model',
|
||||
audioModel: 'audio::model',
|
||||
})),
|
||||
update: vi.fn(async () => ({
|
||||
id: 'np-1',
|
||||
artStyle: 'realistic',
|
||||
})),
|
||||
},
|
||||
userPreference: {
|
||||
upsert: vi.fn(async () => ({ userId: 'user-1', artStyle: 'realistic' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const mediaAttachMock = vi.hoisted(() => ({
|
||||
attachMediaFieldsToProject: vi.fn(async (value: unknown) => value),
|
||||
}))
|
||||
|
||||
const logMock = vi.hoisted(() => ({
|
||||
logProjectAction: vi.fn(),
|
||||
}))
|
||||
|
||||
const modelConfigContractMock = vi.hoisted(() => ({
|
||||
parseModelKeyStrict: vi.fn(() => ({ provider: 'mock', modelId: 'mock-model' })),
|
||||
}))
|
||||
|
||||
const capabilityLookupMock = vi.hoisted(() => ({
|
||||
resolveBuiltinModelContext: vi.fn(() => null),
|
||||
getCapabilityOptionFields: vi.fn(() => ({})),
|
||||
validateCapabilitySelectionsPayload: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/attach', () => mediaAttachMock)
|
||||
vi.mock('@/lib/logging/semantic', () => logMock)
|
||||
vi.mock('@/lib/model-config-contract', () => modelConfigContractMock)
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => capabilityLookupMock)
|
||||
|
||||
describe('api specific - novel promotion project art style validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and syncs to user preference', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
artStyle: ' realistic ',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ artStyle: 'realistic' }),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.userPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ artStyle: 'realistic' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
artStyle: 'anime',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionProject.update).not.toHaveBeenCalled()
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts audioModel and syncs it to user preference', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prismaMock.userPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
audioModel: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
storyboardModel: 'img::storyboard',
|
||||
editModel: 'img::edit',
|
||||
videoModel: 'video::model',
|
||||
audioModel: 'audio::tts',
|
||||
videoRatio: '9:16',
|
||||
artStyle: 'realistic',
|
||||
ttsRate: '+0%',
|
||||
})),
|
||||
},
|
||||
project: {
|
||||
create: vi.fn(async () => ({
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
description: null,
|
||||
mode: 'novel-promotion',
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
create: vi.fn(async () => ({ id: 'np-1', projectId: 'project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - project create default audio model', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('copies user preference audioModel into the new novel promotion project', async () => {
|
||||
const mod = await import('@/app/api/projects/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/projects',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Test Project',
|
||||
description: '',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, routeContext)
|
||||
expect(res.status).toBe(201)
|
||||
expect(prismaMock.novelPromotionProject.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
audioModel: 'audio::tts',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
mockUnauthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
describe('api specific - reference to character route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('returns unauthorized when user is not authenticated', async () => {
|
||||
installAuthMocks()
|
||||
mockUnauthenticated()
|
||||
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/reference-to-character',
|
||||
method: 'POST',
|
||||
body: {
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns invalid params when references are missing', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-a')
|
||||
const mod = await import('@/app/api/asset-hub/reference-to-character/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/reference-to-character',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({}) })
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({ id: 'np-1' })),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
update: vi.fn<(args: { data?: { speakerVoices?: string } }) => Promise<{ id: string }>>(async () => ({ id: 'episode-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn(async (input: string) => {
|
||||
if (input.includes('fal')) return 'voice/storage/fal.wav'
|
||||
if (input.includes('preview')) return 'voice/storage/preview.wav'
|
||||
return null
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/service', () => ({
|
||||
resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,
|
||||
}))
|
||||
|
||||
describe('api specific - speaker voice provider contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when provider is missing in PATCH payload', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: '/m/fal-reference',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.novelPromotionEpisode.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores fal speaker voice with explicit provider and normalized audio storage key', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
provider: 'fal',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: '/m/fal-reference',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as
|
||||
| [{ data?: { speakerVoices?: string } }]
|
||||
| undefined
|
||||
expect(updateCall).toBeTruthy()
|
||||
if (!updateCall) throw new Error('expected update call')
|
||||
const updateArg = updateCall[0]
|
||||
const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>
|
||||
|
||||
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/fal-reference')
|
||||
expect(saved.Narrator).toEqual({
|
||||
provider: 'fal',
|
||||
voiceType: 'uploaded',
|
||||
audioUrl: 'voice/storage/fal.wav',
|
||||
})
|
||||
})
|
||||
|
||||
it('stores bailian speaker voice with explicit provider and voiceId', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/speaker-voice/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/speaker-voice',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
speaker: 'Narrator',
|
||||
provider: 'bailian',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'qwen-tts-vd-001',
|
||||
previewAudioUrl: '/m/preview-audio',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const updateCall = prismaMock.novelPromotionEpisode.update.mock.calls[0] as
|
||||
| [{ data?: { speakerVoices?: string } }]
|
||||
| undefined
|
||||
expect(updateCall).toBeTruthy()
|
||||
if (!updateCall) throw new Error('expected update call')
|
||||
const updateArg = updateCall[0]
|
||||
const saved = JSON.parse(updateArg.data?.speakerVoices || '{}') as Record<string, unknown>
|
||||
|
||||
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('/m/preview-audio')
|
||||
expect(saved.Narrator).toEqual({
|
||||
provider: 'bailian',
|
||||
voiceType: 'qwen-designed',
|
||||
voiceId: 'qwen-tts-vd-001',
|
||||
previewAudioUrl: 'voice/storage/preview.wav',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
const probeModelLlmProtocolMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
success: true,
|
||||
protocol: 'responses' as const,
|
||||
checkedAt: '2026-03-05T00:00:00.000Z',
|
||||
traces: [],
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/user-api/model-llm-protocol-probe', () => ({
|
||||
probeModelLlmProtocol: probeModelLlmProtocolMock,
|
||||
}))
|
||||
|
||||
describe('api specific - user api-config probe model llm protocol', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('probes protocol for openai-compatible provider/model', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { success: boolean; protocol?: string }
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.protocol).toBe('responses')
|
||||
expect(probeModelLlmProtocolMock).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-openai-compatible provider ids', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'gemini-compatible:node-1',
|
||||
modelId: 'gemini-3-pro-preview',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid body payload', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/api-config/probe-model-llm-protocol/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/api-config/probe-model-llm-protocol',
|
||||
method: 'POST',
|
||||
body: {
|
||||
providerId: 'openai-compatible:node-1',
|
||||
modelId: '',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(probeModelLlmProtocolMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
1298
tests/integration/api/specific/user-api-config-put.test.ts
Normal file
1298
tests/integration/api/specific/user-api-config-put.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
import {
|
||||
installAuthMocks,
|
||||
mockAuthenticated,
|
||||
resetAuthMockState,
|
||||
} from '../../../helpers/auth'
|
||||
|
||||
const createAssistantChatResponseMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new Response('event: done\ndata: ok\n\n', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/assistant-platform', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/assistant-platform')>('@/lib/assistant-platform')
|
||||
return {
|
||||
...actual,
|
||||
createAssistantChatResponse: createAssistantChatResponseMock,
|
||||
}
|
||||
})
|
||||
|
||||
describe('api specific - user assistant chat', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
resetAuthMockState()
|
||||
})
|
||||
|
||||
it('accepts api-config-template assistant request and forwards payload', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: '请配置文生视频模板' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(createAssistantChatResponseMock).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: '请配置文生视频模板' }],
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid assistantId', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'unknown-assistant',
|
||||
messages: [],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createAssistantChatResponseMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps assistant platform missing-config error to 400 response', async () => {
|
||||
installAuthMocks()
|
||||
mockAuthenticated('user-1')
|
||||
const { AssistantPlatformError } = await import('@/lib/assistant-platform')
|
||||
createAssistantChatResponseMock.mockRejectedValueOnce(
|
||||
new AssistantPlatformError('ASSISTANT_MODEL_NOT_CONFIGURED', 'analysisModel is required'),
|
||||
)
|
||||
const route = await import('@/app/api/user/assistant/chat/route')
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/assistant/chat',
|
||||
method: 'POST',
|
||||
body: {
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'm1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
const res = await route.POST(req, routeContext)
|
||||
expect(res.status).toBe(400)
|
||||
const payload = await res.json() as { code?: string; error?: { code?: string; details?: { code?: string } } }
|
||||
expect(payload.error?.code).toBe('MISSING_CONFIG')
|
||||
expect(payload.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')
|
||||
expect(payload.error?.details?.code).toBe('ASSISTANT_MODEL_NOT_CONFIGURED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
customModels: JSON.stringify([
|
||||
{
|
||||
modelId: 'qwen3-tts-vd-2026-01-26',
|
||||
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
name: 'Qwen3 TTS',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
},
|
||||
{
|
||||
modelId: 'qwen-voice-design',
|
||||
modelKey: 'bailian::qwen-voice-design',
|
||||
name: 'Qwen Voice Design',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
},
|
||||
]),
|
||||
customProviders: JSON.stringify([
|
||||
{
|
||||
id: 'bailian',
|
||||
name: 'Alibaba Bailian',
|
||||
apiKey: 'k-bailian',
|
||||
},
|
||||
]),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/model-capabilities/catalog', () => ({
|
||||
findBuiltinCapabilities: vi.fn(() => undefined),
|
||||
}))
|
||||
vi.mock('@/lib/model-pricing/catalog', () => ({
|
||||
findBuiltinPricingCatalogEntry: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
describe('api specific - user models audio filter', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('excludes voice design models from the audio model list', async () => {
|
||||
const mod = await import('@/app/api/user/models/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user/models',
|
||||
method: 'GET',
|
||||
})
|
||||
const res = await mod.GET(req, routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { audio: Array<{ value: string }> }
|
||||
expect(body.audio.map((item) => item.value)).toEqual([
|
||||
'bailian::qwen3-tts-vd-2026-01-26',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireUserAuth: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
upsert: vi.fn(async () => ({
|
||||
userId: 'user-1',
|
||||
artStyle: 'realistic',
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
describe('api specific - user preference art style validation', () => {
|
||||
const routeContext = { params: Promise.resolve({}) }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts valid artStyle and persists normalized value', async () => {
|
||||
const mod = await import('@/app/api/user-preference/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user-preference',
|
||||
method: 'PATCH',
|
||||
body: { artStyle: ' realistic ' },
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.userPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ artStyle: 'realistic' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid artStyle with invalid params', async () => {
|
||||
const mod = await import('@/app/api/user-preference/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/user-preference',
|
||||
method: 'PATCH',
|
||||
body: { artStyle: 'anime' },
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, routeContext)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_PARAMS')
|
||||
expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
requireProjectAuthLight: vi.fn(async () => ({
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },
|
||||
})),
|
||||
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({ audioModel: 'fal::fal-ai/index-tts-2/text-to-speech' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn<() => Promise<{
|
||||
id: string
|
||||
audioModel: string | null
|
||||
characters: Array<{ name: string; customVoiceUrl: string; voiceId: string | null }>
|
||||
} | null>>(async () => ({
|
||||
id: 'np-1',
|
||||
audioModel: 'fal::project-tts-model',
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },
|
||||
],
|
||||
})),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'line-1',
|
||||
speaker: 'Narrator',
|
||||
content: 'hello world',
|
||||
})),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-1',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
resolveModelSelectionOrSingle: vi.fn(async (_userId: string, model: string | null | undefined) => ({
|
||||
provider: 'fal',
|
||||
modelId: 'fal-ai/index-tts-2/text-to-speech',
|
||||
modelKey: model || 'fal::fal-ai/index-tts-2/text-to-speech',
|
||||
mediaType: 'audio',
|
||||
})),
|
||||
getProviderKey: vi.fn((providerId: string) => providerId),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/task/has-output', () => ({
|
||||
hasVoiceLineAudioOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
describe('api specific - voice generate default audio model', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('uses project audioModel when request does not provide one', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::project-tts-model',
|
||||
'audio',
|
||||
)
|
||||
|
||||
const submitCall = submitTaskMock.mock.calls[0] as [{ payload?: Record<string, unknown> }] | undefined
|
||||
const submitArg = submitCall?.[0]
|
||||
expect(submitArg?.payload?.audioModel).toBe('fal::project-tts-model')
|
||||
})
|
||||
|
||||
it('request audioModel overrides user preference audioModel', async () => {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
audioModel: 'fal::custom-tts',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::custom-tts',
|
||||
'audio',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to user preference audioModel when project audioModel is empty', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-1',
|
||||
audioModel: null,
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.wav', voiceId: null },
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(apiConfigMock.resolveModelSelectionOrSingle).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'fal::fal-ai/index-tts-2/text-to-speech',
|
||||
'audio',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an explicit qwen voiceId error when only uploaded reference audio is available', async () => {
|
||||
apiConfigMock.resolveModelSelectionOrSingle.mockResolvedValueOnce({
|
||||
provider: 'bailian',
|
||||
modelId: 'qwen3-tts-vd-2026-01-26',
|
||||
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
mediaType: 'audio',
|
||||
})
|
||||
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/voice-generate',
|
||||
method: 'POST',
|
||||
body: {
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const json = await res.json()
|
||||
expect(json.error?.message).toBe('无音色ID,QwenTTS 必须使用 AI 设计音色')
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
182
tests/integration/chain/image.chain.test.ts
Normal file
182
tests/integration/chain/image.chain.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getUserModels: vi.fn(async () => ({
|
||||
characterModel: 'model-character-1',
|
||||
locationModel: 'model-location-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),
|
||||
parseJsonStringArray: vi.fn(() => [] as string[]),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - image queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('image tasks are enqueued into image queue with jobId=taskId', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('modify asset image task also routes to image queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-2',
|
||||
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
})
|
||||
|
||||
it('queued image job payload can be consumed by worker handler and persist image fields', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: '黑发,风衣',
|
||||
descriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-chain-worker-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)
|
||||
|
||||
const result = await handleAssetHubImageTask(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
type: 'character',
|
||||
appearanceId: 'appearance-1',
|
||||
imageCount: 3,
|
||||
})
|
||||
|
||||
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify(['cos/global-character-generated.png', 'cos/global-character-generated.png', 'cos/global-character-generated.png']),
|
||||
imageUrl: 'cos/global-character-generated.png',
|
||||
selectedIndex: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
185
tests/integration/chain/text.chain.test.ts
Normal file
185
tests/integration/chain/text.chain.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => JSON.stringify({
|
||||
episodes: [
|
||||
{
|
||||
number: 1,
|
||||
title: '第一集',
|
||||
summary: '开端',
|
||||
startMarker: 'START_MARKER',
|
||||
endMarker: 'END_MARKER',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
|
||||
buildPrompt: vi.fn(() => 'episode-split-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createTextMarkerMatcher: (content: string) => ({
|
||||
matchMarker: (marker: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(marker, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
return { startIndex, endIndex: startIndex + marker.length }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - text queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('text tasks are enqueued into text queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-1',
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
options: expect.objectContaining({ jobId: 'task-text-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('explicit priority is preserved for text queue jobs', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-2',
|
||||
type: TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
userId: 'user-1',
|
||||
}, { priority: 7 })
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))
|
||||
})
|
||||
|
||||
it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')
|
||||
|
||||
const content = [
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'START_MARKER',
|
||||
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于链路测试验证。',
|
||||
'END_MARKER',
|
||||
'后置内容用于确保边界外还有文本,并继续补足长度。',
|
||||
].join('')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-chain-worker-1',
|
||||
type: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { content },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)
|
||||
|
||||
const result = await handleEpisodeSplitTask(toJob(queued!))
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.episodes).toHaveLength(1)
|
||||
expect(result.episodes[0]?.title).toBe('第一集')
|
||||
expect(result.episodes[0]?.content).toContain('START_MARKER')
|
||||
expect(result.episodes[0]?.content).toContain('END_MARKER')
|
||||
})
|
||||
})
|
||||
203
tests/integration/chain/video.chain.test.ts
Normal file
203
tests/integration/chain/video.chain.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
|
||||
resolveVideoSourceFromGeneration: vi.fn(async () => 'https://provider.example/video.mp4'),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
|
||||
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
|
||||
}))
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserWorkflowConcurrencyConfig: vi.fn(async () => ({
|
||||
analysis: 5,
|
||||
image: 5,
|
||||
video: 5,
|
||||
})),
|
||||
}))
|
||||
const concurrencyGateMock = vi.hoisted(() => ({
|
||||
withUserConcurrencyGate: vi.fn(async <T>(input: {
|
||||
run: () => Promise<T>
|
||||
}) => await input.run()),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => undefined),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
withTaskLifecycle: workerMock.withTaskLifecycle,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
|
||||
}))
|
||||
vi.mock('@/lib/model-capabilities/lookup', () => ({
|
||||
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
|
||||
}))
|
||||
vi.mock('@/lib/model-config-contract', () => ({
|
||||
parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),
|
||||
}))
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/workers/user-concurrency-gate', () => concurrencyGateMock)
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - video queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
videoUrl: 'cos/base-video.mp4',
|
||||
})
|
||||
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
|
||||
id: 'line-1',
|
||||
audioUrl: 'cos/line-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-1',
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { videoModel: 'fal::video-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VIDEO_PANEL,
|
||||
options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('LIP_SYNC is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-2',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
})
|
||||
|
||||
it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVideoWorker } = await import('@/lib/workers/video.worker')
|
||||
createVideoWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-chain-worker-1',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
|
||||
const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
})
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
lipSyncTaskId: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
172
tests/integration/chain/voice.chain.test.ts
Normal file
172
tests/integration/chain/voice.chain.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
|
||||
}))
|
||||
|
||||
const voiceMock = vi.hoisted(() => ({
|
||||
generateVoiceLine: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
|
||||
}))
|
||||
|
||||
const voiceDesignMock = vi.hoisted(() => ({
|
||||
handleVoiceDesignTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/voice/generate-voice-line', () => ({
|
||||
generateVoiceLine: voiceMock.generateVoiceLine,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
withTaskLifecycle: workerMock.withTaskLifecycle,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/voice-design', () => ({
|
||||
handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - voice queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
voiceMock.generateVoiceLine.mockResolvedValue({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({
|
||||
presetId: 'voice-design-1',
|
||||
previewAudioUrl: 'cos/preview-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VOICE_LINE is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: { lineId: 'line-1', episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VOICE_LINE,
|
||||
options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-2',
|
||||
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalAssetHubVoiceDesign',
|
||||
targetId: 'voice-design-1',
|
||||
payload: { voicePrompt: 'female calm narrator' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)
|
||||
})
|
||||
|
||||
it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVoiceWorker } = await import('@/lib/workers/voice.worker')
|
||||
createVoiceWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-chain-worker-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: {
|
||||
lineId: 'line-1',
|
||||
episodeId: 'episode-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
},
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)
|
||||
|
||||
const result = await processor!(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
userId: 'user-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
})
|
||||
})
|
||||
})
|
||||
72
tests/setup/env.ts
Normal file
72
tests/setup/env.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
let loaded = false
|
||||
|
||||
function parseEnvLine(line: string) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
const idx = trimmed.indexOf('=')
|
||||
if (idx <= 0) return null
|
||||
const key = trimmed.slice(0, idx).trim()
|
||||
if (!key) return null
|
||||
const rawValue = trimmed.slice(idx + 1).trim()
|
||||
const unquoted =
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"'))
|
||||
|| (rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
? rawValue.slice(1, -1)
|
||||
: rawValue
|
||||
return { key, value: unquoted }
|
||||
}
|
||||
|
||||
export function loadTestEnv() {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
const mutableEnv = process.env as Record<string, string | undefined>
|
||||
const setIfMissing = (key: string, value: string) => {
|
||||
if (!mutableEnv[key]) {
|
||||
mutableEnv[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = path.resolve(process.cwd(), '.env.test')
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf8')
|
||||
for (const line of content.split('\n')) {
|
||||
const pair = parseEnvLine(line)
|
||||
if (!pair) continue
|
||||
if (mutableEnv[pair.key] === undefined) {
|
||||
mutableEnv[pair.key] = pair.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIfMissing('NODE_ENV', 'test')
|
||||
setIfMissing('BILLING_MODE', 'OFF')
|
||||
setIfMissing('DATABASE_URL', 'mysql://root:root@127.0.0.1:3307/waoowaoo_test')
|
||||
setIfMissing('REDIS_HOST', '127.0.0.1')
|
||||
setIfMissing('REDIS_PORT', '6380')
|
||||
}
|
||||
|
||||
loadTestEnv()
|
||||
|
||||
if (process.env.ALLOW_TEST_NETWORK !== '1' && typeof globalThis.fetch === 'function') {
|
||||
const originalFetch = globalThis.fetch
|
||||
const allowHosts = new Set(['localhost', '127.0.0.1'])
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const rawUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
const parsed = new URL(rawUrl, 'http://localhost')
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
if (!allowHosts.has(parsed.hostname)) {
|
||||
throw new Error(`Network blocked in tests: ${parsed.hostname}`)
|
||||
}
|
||||
}
|
||||
return await originalFetch(input, init)
|
||||
}) as typeof fetch
|
||||
}
|
||||
99
tests/setup/global-setup.ts
Normal file
99
tests/setup/global-setup.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import mysql from 'mysql2/promise'
|
||||
import Redis from 'ioredis'
|
||||
import { loadTestEnv } from './env'
|
||||
import { runGlobalTeardown } from './global-teardown'
|
||||
|
||||
function parseDbUrl(dbUrl: string) {
|
||||
const url = new URL(dbUrl)
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMysql(maxAttempts = 180) {
|
||||
const db = parseDbUrl(process.env.DATABASE_URL || '')
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
user: db.user,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
connectTimeout: 5_000,
|
||||
})
|
||||
await conn.query('SELECT 1')
|
||||
await conn.end()
|
||||
return
|
||||
} catch {
|
||||
await sleep(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('MySQL test service did not become ready in time')
|
||||
}
|
||||
|
||||
async function waitForRedis(maxAttempts = 60) {
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT || '6380'),
|
||||
maxRetriesPerRequest: 1,
|
||||
lazyConnect: true,
|
||||
})
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
if (redis.status !== 'ready') {
|
||||
await redis.connect()
|
||||
}
|
||||
const pong = await redis.ping()
|
||||
if (pong === 'PONG') return
|
||||
} catch {
|
||||
await sleep(1_000)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
throw new Error('Redis test service did not become ready in time')
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
loadTestEnv()
|
||||
|
||||
const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'
|
||||
if (!shouldBootstrap) {
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml up -d --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
await waitForMysql()
|
||||
await waitForRedis()
|
||||
|
||||
execSync('npx prisma db push --skip-generate --schema prisma/schema.prisma', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await runGlobalTeardown()
|
||||
}
|
||||
}
|
||||
15
tests/setup/global-teardown.ts
Normal file
15
tests/setup/global-teardown.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { loadTestEnv } from './env'
|
||||
|
||||
export async function runGlobalTeardown() {
|
||||
loadTestEnv()
|
||||
|
||||
const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'
|
||||
if (!shouldBootstrap) return
|
||||
if (process.env.BILLING_TEST_KEEP_SERVICES === '1') return
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
34
tests/unit/ai-runtime/errors.test.ts
Normal file
34
tests/unit/ai-runtime/errors.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toAiRuntimeError } from '@/lib/ai-runtime/errors'
|
||||
|
||||
describe('toAiRuntimeError empty response mapping', () => {
|
||||
it('maps nested Gemini empty response signal to EMPTY_RESPONSE even when status is 429', () => {
|
||||
const upstreamError = new Error('Too Many Requests') as Error & {
|
||||
status?: number
|
||||
cause?: unknown
|
||||
}
|
||||
upstreamError.status = 429
|
||||
upstreamError.cause = {
|
||||
error: {
|
||||
message: 'received empty response from Gemini: no meaningful content in candidates (request id: x)',
|
||||
type: 'channel_error',
|
||||
code: 'channel:empty_response',
|
||||
},
|
||||
code: 429,
|
||||
status: 'Too Many Requests',
|
||||
}
|
||||
|
||||
const runtimeError = toAiRuntimeError(upstreamError)
|
||||
expect(runtimeError.code).toBe('EMPTY_RESPONSE')
|
||||
expect(runtimeError.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps RATE_LIMIT when there is no empty response signal', () => {
|
||||
const runtimeError = toAiRuntimeError({
|
||||
status: 429,
|
||||
message: 'Too Many Requests',
|
||||
})
|
||||
expect(runtimeError.code).toBe('RATE_LIMIT')
|
||||
expect(runtimeError.retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
71
tests/unit/api-config/assistant-chat-modal-content.test.ts
Normal file
71
tests/unit/api-config/assistant-chat-modal-content.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMessageContent } from '@/components/assistant/AssistantChatModal'
|
||||
|
||||
function createAssistantMessage(parts: Array<Record<string, unknown>>): UIMessage {
|
||||
return {
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
parts,
|
||||
} as unknown as UIMessage
|
||||
}
|
||||
|
||||
describe('assistant chat modal message content parser', () => {
|
||||
it('keeps reasoning parts out of normal visible lines', () => {
|
||||
const message = createAssistantMessage([
|
||||
{ type: 'reasoning', text: '先分析接口字段映射' },
|
||||
{ type: 'text', text: '我需要你的 status 返回样例。' },
|
||||
])
|
||||
|
||||
const content = extractMessageContent(message)
|
||||
|
||||
expect(content.lines).toEqual(['我需要你的 status 返回样例。'])
|
||||
expect(content.reasoningLines).toEqual(['先分析接口字段映射'])
|
||||
})
|
||||
|
||||
it('extracts think tags from text into reasoning section', () => {
|
||||
const message = createAssistantMessage([
|
||||
{
|
||||
type: 'text',
|
||||
text: '<think>先确认 create/status/content 三个端点</think>请补充 status 返回 JSON',
|
||||
},
|
||||
])
|
||||
|
||||
const content = extractMessageContent(message)
|
||||
|
||||
expect(content.lines).toEqual(['请补充 status 返回 JSON'])
|
||||
expect(content.reasoningLines).toEqual(['先确认 create/status/content 三个端点'])
|
||||
})
|
||||
|
||||
it('extracts reasoning from unclosed think tag during streaming', () => {
|
||||
const message = createAssistantMessage([
|
||||
{
|
||||
type: 'text',
|
||||
text: '<think>先确认任务状态枚举和输出路径',
|
||||
},
|
||||
])
|
||||
|
||||
const content = extractMessageContent(message)
|
||||
|
||||
expect(content.lines).toEqual([])
|
||||
expect(content.reasoningLines).toEqual(['先确认任务状态枚举和输出路径'])
|
||||
})
|
||||
|
||||
it('preserves tool output and issues as visible lines', () => {
|
||||
const message = createAssistantMessage([
|
||||
{
|
||||
type: 'tool-saveModelTemplate',
|
||||
state: 'output-available',
|
||||
output: {
|
||||
message: '模型已保存',
|
||||
issues: [{ field: 'response.statusPath', message: 'missing' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const content = extractMessageContent(message)
|
||||
|
||||
expect(content.lines).toEqual(['模型已保存', 'response.statusPath: missing'])
|
||||
expect(content.reasoningLines).toEqual([])
|
||||
})
|
||||
})
|
||||
22
tests/unit/api-config/minimax-preset.test.ts
Normal file
22
tests/unit/api-config/minimax-preset.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PRESET_MODELS, PRESET_PROVIDERS } from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
describe('api-config minimax preset', () => {
|
||||
it('uses official minimax baseUrl in preset provider', () => {
|
||||
const minimaxProvider = PRESET_PROVIDERS.find((provider) => provider.id === 'minimax')
|
||||
expect(minimaxProvider).toBeDefined()
|
||||
expect(minimaxProvider?.baseUrl).toBe('https://api.minimaxi.com/v1')
|
||||
})
|
||||
|
||||
it('includes all required minimax official llm preset models', () => {
|
||||
const minimaxLlmModelIds = PRESET_MODELS
|
||||
.filter((model) => model.provider === 'minimax' && model.type === 'llm')
|
||||
.map((model) => model.modelId)
|
||||
|
||||
expect(minimaxLlmModelIds).toContain('MiniMax-M2.5')
|
||||
expect(minimaxLlmModelIds).toContain('MiniMax-M2.5-highspeed')
|
||||
expect(minimaxLlmModelIds).toContain('MiniMax-M2.1')
|
||||
expect(minimaxLlmModelIds).toContain('MiniMax-M2.1-highspeed')
|
||||
expect(minimaxLlmModelIds).toContain('MiniMax-M2')
|
||||
})
|
||||
})
|
||||
52
tests/unit/api-config/preset-coming-soon.test.ts
Normal file
52
tests/unit/api-config/preset-coming-soon.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PRESET_MODELS,
|
||||
encodeModelKey,
|
||||
isPresetComingSoonModel,
|
||||
isPresetComingSoonModelKey,
|
||||
} from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
describe('api-config preset coming soon', () => {
|
||||
it('registers Nano Banana 2 under Google AI Studio presets', () => {
|
||||
const model = PRESET_MODELS.find(
|
||||
(entry) => entry.provider === 'google' && entry.modelId === 'gemini-3.1-flash-image-preview',
|
||||
)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.name).toBe('Nano Banana 2')
|
||||
})
|
||||
|
||||
it('registers Seedance 2.0 as a coming-soon preset model', () => {
|
||||
const model = PRESET_MODELS.find(
|
||||
(entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128',
|
||||
)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.name).toContain('待上线')
|
||||
})
|
||||
|
||||
it('recognizes coming-soon model by provider/modelId and modelKey', () => {
|
||||
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')
|
||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true)
|
||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark normal preset models as coming soon', () => {
|
||||
const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215')
|
||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)
|
||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('registers Bailian Wan i2v preset models', () => {
|
||||
const modelIds = PRESET_MODELS
|
||||
.filter((entry) => entry.provider === 'bailian' && entry.type === 'video')
|
||||
.map((entry) => entry.modelId)
|
||||
|
||||
expect(modelIds).toEqual(expect.arrayContaining([
|
||||
'wan2.6-i2v-flash',
|
||||
'wan2.6-i2v',
|
||||
'wan2.5-i2v-preview',
|
||||
'wan2.2-i2v-plus',
|
||||
'wan2.2-kf2v-flash',
|
||||
'wanx2.1-kf2v-plus',
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAssistantSavedModelLabel } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
|
||||
|
||||
describe('provider card assistant saved label', () => {
|
||||
it('prefers draft model name when available', () => {
|
||||
const label = getAssistantSavedModelLabel({
|
||||
savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',
|
||||
draftModel: {
|
||||
modelId: 'veo_3_1-fast-4K',
|
||||
name: 'Veo 3.1 Fast 4K',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/v1/video/create',
|
||||
},
|
||||
status: {
|
||||
method: 'GET',
|
||||
path: '/v1/video/query?id={{task_id}}',
|
||||
},
|
||||
response: {
|
||||
taskIdPath: '$.id',
|
||||
statusPath: '$.status',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 5000,
|
||||
timeoutMs: 600000,
|
||||
doneStates: ['completed'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(label).toBe('Veo 3.1 Fast 4K')
|
||||
})
|
||||
|
||||
it('falls back to model id parsed from savedModelKey', () => {
|
||||
const label = getAssistantSavedModelLabel({
|
||||
savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',
|
||||
})
|
||||
|
||||
expect(label).toBe('veo_3_1-fast-4K')
|
||||
})
|
||||
})
|
||||
173
tests/unit/api-config/provider-card-pricing-form.test.ts
Normal file
173
tests/unit/api-config/provider-card-pricing-form.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getAddableModelTypesForProvider,
|
||||
getVisibleModelTypesForProvider,
|
||||
shouldShowOpenAICompatVideoHint,
|
||||
} from '@/app/[locale]/profile/components/api-config/provider-card/ProviderAdvancedFields'
|
||||
import {
|
||||
buildCustomPricingFromModelForm,
|
||||
buildProviderConnectionPayload,
|
||||
} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
|
||||
|
||||
describe('provider card pricing form behavior', () => {
|
||||
it('allows openai-compatible provider to add llm/image/video', () => {
|
||||
expect(getAddableModelTypesForProvider('openai-compatible:oa-1')).toEqual(['llm', 'image', 'video'])
|
||||
})
|
||||
|
||||
it('shows llm/image/video tabs by default for openai-compatible even with only image models', () => {
|
||||
const visible = getVisibleModelTypesForProvider(
|
||||
'openai-compatible:oa-1',
|
||||
{
|
||||
image: [
|
||||
{
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:oa-1::gpt-image-1',
|
||||
name: 'Image',
|
||||
type: 'image',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(visible).toEqual(['llm', 'image', 'video'])
|
||||
})
|
||||
|
||||
it('shows the openai-compatible video hint only for openai-compatible video add forms', () => {
|
||||
expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'video')).toBe(true)
|
||||
expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'image')).toBe(false)
|
||||
expect(shouldShowOpenAICompatVideoHint('gemini-compatible:gm-1', 'video')).toBe(false)
|
||||
expect(shouldShowOpenAICompatVideoHint('ark', 'video')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps payload without customPricing when pricing toggle is off', () => {
|
||||
const result = buildCustomPricingFromModelForm(
|
||||
'image',
|
||||
{
|
||||
name: 'Image',
|
||||
modelId: 'gpt-image-1',
|
||||
enableCustomPricing: false,
|
||||
basePrice: '0.8',
|
||||
},
|
||||
{ needsCustomPricing: true },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('builds llm customPricing payload when pricing toggle is on', () => {
|
||||
const result = buildCustomPricingFromModelForm(
|
||||
'llm',
|
||||
{
|
||||
name: 'GPT',
|
||||
modelId: 'gpt-4.1',
|
||||
enableCustomPricing: true,
|
||||
priceInput: '2.5',
|
||||
priceOutput: '8',
|
||||
},
|
||||
{ needsCustomPricing: true },
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
customPricing: {
|
||||
llm: {
|
||||
inputPerMillion: 2.5,
|
||||
outputPerMillion: 8,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('builds media customPricing payload with option prices when enabled', () => {
|
||||
const result = buildCustomPricingFromModelForm(
|
||||
'video',
|
||||
{
|
||||
name: 'Sora',
|
||||
modelId: 'sora-2',
|
||||
enableCustomPricing: true,
|
||||
basePrice: '0.9',
|
||||
optionPricesJson: '{"resolution":{"720x1280":0.1},"duration":{"8":0.4}}',
|
||||
},
|
||||
{ needsCustomPricing: true },
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
customPricing: {
|
||||
video: {
|
||||
basePrice: 0.9,
|
||||
optionPrices: {
|
||||
resolution: {
|
||||
'720x1280': 0.1,
|
||||
},
|
||||
duration: {
|
||||
'8': 0.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid media optionPrices JSON when enabled', () => {
|
||||
const result = buildCustomPricingFromModelForm(
|
||||
'image',
|
||||
{
|
||||
name: 'Image',
|
||||
modelId: 'gpt-image-1',
|
||||
enableCustomPricing: true,
|
||||
basePrice: '0.3',
|
||||
optionPricesJson: '{"resolution":{"1024x1024":"free"}}',
|
||||
},
|
||||
{ needsCustomPricing: true },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'invalid' })
|
||||
})
|
||||
|
||||
it('bugfix: includes baseUrl for openai-compatible provider connection test payload', () => {
|
||||
const payload = buildProviderConnectionPayload({
|
||||
providerKey: 'openai-compatible',
|
||||
apiKey: ' sk-test ',
|
||||
baseUrl: ' https://api.openai-proxy.example/v1 ',
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
apiType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai-proxy.example/v1',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits baseUrl for non-compatible provider connection test payload', () => {
|
||||
const payload = buildProviderConnectionPayload({
|
||||
providerKey: 'ark',
|
||||
apiKey: ' ark-key ',
|
||||
baseUrl: ' https://ignored.example/v1 ',
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
apiType: 'ark',
|
||||
apiKey: 'ark-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('includes llmModel in provider connection test payload when configured', () => {
|
||||
const payload = buildProviderConnectionPayload({
|
||||
providerKey: 'openai-compatible',
|
||||
apiKey: ' sk-test ',
|
||||
baseUrl: ' https://compat.example.com/v1 ',
|
||||
llmModel: ' gpt-4.1-mini ',
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
apiType: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://compat.example.com/v1',
|
||||
llmModel: 'gpt-4.1-mini',
|
||||
})
|
||||
})
|
||||
})
|
||||
83
tests/unit/api-config/provider-card-protocol-probe.test.ts
Normal file
83
tests/unit/api-config/provider-card-protocol-probe.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CustomModel } from '@/app/[locale]/profile/components/api-config/types'
|
||||
import {
|
||||
probeModelLlmProtocolViaApi,
|
||||
shouldProbeModelLlmProtocol,
|
||||
shouldReprobeModelLlmProtocol,
|
||||
} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
|
||||
|
||||
describe('api-config provider-card protocol probe helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('only probes openai-compatible llm models', () => {
|
||||
expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'llm' })).toBe(true)
|
||||
expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'image' })).toBe(false)
|
||||
expect(shouldProbeModelLlmProtocol({ providerId: 'gemini-compatible:gm-1', modelType: 'llm' })).toBe(false)
|
||||
})
|
||||
|
||||
it('re-probes only when modelId/provider changed on openai-compatible llm', () => {
|
||||
const originalModel: CustomModel = {
|
||||
modelId: 'gpt-4.1-mini',
|
||||
modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',
|
||||
name: 'GPT 4.1 Mini',
|
||||
type: 'llm',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
llmProtocol: 'chat-completions',
|
||||
llmProtocolCheckedAt: '2026-01-01T00:00:00.000Z',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
expect(shouldReprobeModelLlmProtocol({
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
originalModel,
|
||||
nextModelId: 'gpt-4.1-mini',
|
||||
})).toBe(false)
|
||||
|
||||
expect(shouldReprobeModelLlmProtocol({
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
originalModel,
|
||||
nextModelId: 'gpt-4.1',
|
||||
})).toBe(true)
|
||||
|
||||
expect(shouldReprobeModelLlmProtocol({
|
||||
providerId: 'gemini-compatible:gm-1',
|
||||
originalModel,
|
||||
nextModelId: 'gpt-4.1',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('parses successful probe response payload', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
success: true,
|
||||
protocol: 'responses',
|
||||
checkedAt: '2026-03-05T10:00:00.000Z',
|
||||
}), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await probeModelLlmProtocolViaApi({
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
llmProtocol: 'responses',
|
||||
llmProtocolCheckedAt: '2026-03-05T10:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws probe failure code on unsuccessful probe response', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
success: false,
|
||||
code: 'PROBE_INCONCLUSIVE',
|
||||
}), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(probeModelLlmProtocolViaApi({
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
modelId: 'gpt-4.1-mini',
|
||||
})).rejects.toThrow('PROBE_INCONCLUSIVE')
|
||||
})
|
||||
})
|
||||
25
tests/unit/api-config/provider-card-shell.test.ts
Normal file
25
tests/unit/api-config/provider-card-shell.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getCompatibilityLayerBadgeLabel } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
|
||||
|
||||
describe('provider card shell compatibility layer badge', () => {
|
||||
const t = (key: string): string => {
|
||||
if (key === 'compatibilityLayerOpenAI') return 'OpenAI 兼容层'
|
||||
if (key === 'compatibilityLayerGemini') return 'Gemini 兼容层'
|
||||
return key
|
||||
}
|
||||
|
||||
it('shows OpenAI compatible layer label for openai-compatible providers', () => {
|
||||
expect(getCompatibilityLayerBadgeLabel('openai-compatible:oa-1', t)).toBe('OpenAI 兼容层')
|
||||
})
|
||||
|
||||
it('shows Gemini compatible layer label for gemini-compatible providers', () => {
|
||||
expect(getCompatibilityLayerBadgeLabel('gemini-compatible:gm-1', t)).toBe('Gemini 兼容层')
|
||||
})
|
||||
|
||||
it('does not show compatibility label for preset providers', () => {
|
||||
expect(getCompatibilityLayerBadgeLabel('google', t)).toBeNull()
|
||||
expect(getCompatibilityLayerBadgeLabel('ark', t)).toBeNull()
|
||||
expect(getCompatibilityLayerBadgeLabel('bailian', t)).toBeNull()
|
||||
expect(getCompatibilityLayerBadgeLabel('siliconflow', t)).toBeNull()
|
||||
})
|
||||
})
|
||||
119
tests/unit/api-config/use-api-config-filters.test.ts
Normal file
119
tests/unit/api-config/use-api-config-filters.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo: <T,>(factory: () => T) => factory(),
|
||||
}
|
||||
})
|
||||
|
||||
import { useApiConfigFilters } from '@/app/[locale]/profile/components/api-config-tab/hooks/useApiConfigFilters'
|
||||
import type { CustomModel, Provider } from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
describe('api config filters', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('merges audio providers into modelProviders and removes audioProviders output', () => {
|
||||
const providers: Provider[] = [
|
||||
{ id: 'fal', name: 'FAL', hasApiKey: true, apiKey: 'k-fal' },
|
||||
{ id: 'bailian', name: 'Alibaba Bailian', hasApiKey: true, apiKey: 'k-bl' },
|
||||
]
|
||||
const models: CustomModel[] = [
|
||||
{
|
||||
modelId: 'fal-ai/index-tts-2/text-to-speech',
|
||||
modelKey: 'fal::fal-ai/index-tts-2/text-to-speech',
|
||||
name: 'IndexTTS 2',
|
||||
type: 'audio',
|
||||
provider: 'fal',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
modelId: 'qwen3-tts-vd-2026-01-26',
|
||||
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
|
||||
name: 'Qwen3 TTS',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
modelId: 'qwen-voice-design',
|
||||
modelKey: 'bailian::qwen-voice-design',
|
||||
name: 'Qwen Voice Design',
|
||||
type: 'audio',
|
||||
provider: 'bailian',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
modelId: 'qwen3.5-flash',
|
||||
modelKey: 'bailian::qwen3.5-flash',
|
||||
name: 'Qwen 3.5 Flash',
|
||||
type: 'llm',
|
||||
provider: 'bailian',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const result = useApiConfigFilters({ providers, models })
|
||||
const providerIds = result.modelProviders.map((provider) => provider.id)
|
||||
const audioDefaultIds = result.getEnabledModelsByType('audio').map((model) => model.modelId)
|
||||
|
||||
expect(providerIds).toEqual(['fal', 'bailian'])
|
||||
expect(audioDefaultIds).toEqual(expect.arrayContaining([
|
||||
'fal-ai/index-tts-2/text-to-speech',
|
||||
'qwen3-tts-vd-2026-01-26',
|
||||
]))
|
||||
expect(audioDefaultIds).not.toContain('qwen-voice-design')
|
||||
expect(Object.prototype.hasOwnProperty.call(result, 'audioProviders')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps modelProviders order aligned with providers input order', () => {
|
||||
const providers: Provider[] = [
|
||||
{ id: 'google', name: 'Google AI Studio', hasApiKey: true, apiKey: 'k-google' },
|
||||
{ id: 'openai-compatible:oa-2', name: 'OpenAI B', hasApiKey: true, apiKey: 'k-oa2' },
|
||||
{ id: 'ark', name: 'Volcengine Ark', hasApiKey: true, apiKey: 'k-ark' },
|
||||
]
|
||||
const models: CustomModel[] = [
|
||||
{
|
||||
modelId: 'gemini-3.1-pro-preview',
|
||||
modelKey: 'google::gemini-3.1-pro-preview',
|
||||
name: 'Gemini 3.1 Pro',
|
||||
type: 'llm',
|
||||
provider: 'google',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4.1',
|
||||
modelKey: 'openai-compatible:oa-2::gpt-4.1',
|
||||
name: 'GPT 4.1',
|
||||
type: 'llm',
|
||||
provider: 'openai-compatible:oa-2',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
modelId: 'doubao-seed-2-0-pro-260215',
|
||||
modelKey: 'ark::doubao-seed-2-0-pro-260215',
|
||||
name: 'Doubao Seed 2.0 Pro',
|
||||
type: 'llm',
|
||||
provider: 'ark',
|
||||
price: 0,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const result = useApiConfigFilters({ providers, models })
|
||||
expect(result.modelProviders.map((provider) => provider.id)).toEqual([
|
||||
'google',
|
||||
'openai-compatible:oa-2',
|
||||
'ark',
|
||||
])
|
||||
})
|
||||
})
|
||||
100
tests/unit/api-config/use-assistant-chat-saved-events.test.ts
Normal file
100
tests/unit/api-config/use-assistant-chat-saved-events.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { UIMessage } from 'ai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectSavedEvents } from '@/components/assistant/useAssistantChat'
|
||||
|
||||
describe('assistant chat saved events parser', () => {
|
||||
it('parses single save tool output event', () => {
|
||||
const messages = [{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
parts: [{
|
||||
type: 'tool-saveModelTemplate',
|
||||
state: 'output-available',
|
||||
output: {
|
||||
status: 'saved',
|
||||
savedModelKey: 'openai-compatible:oa-1::veo3-fast',
|
||||
draftModel: {
|
||||
modelId: 'veo3-fast',
|
||||
name: 'Veo 3 Fast',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/video/create' },
|
||||
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
|
||||
response: { taskIdPath: '$.id', statusPath: '$.status' },
|
||||
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
}] as unknown as UIMessage[]
|
||||
|
||||
const events = collectSavedEvents(messages)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.savedModelKey).toBe('openai-compatible:oa-1::veo3-fast')
|
||||
expect(events[0]?.draftModel?.modelId).toBe('veo3-fast')
|
||||
})
|
||||
|
||||
it('parses batch save tool output events', () => {
|
||||
const messages = [{
|
||||
id: 'm2',
|
||||
role: 'assistant',
|
||||
parts: [{
|
||||
type: 'tool-saveModelTemplates',
|
||||
state: 'output-available',
|
||||
output: {
|
||||
status: 'saved',
|
||||
savedModelKeys: [
|
||||
'openai-compatible:oa-1::veo3-fast',
|
||||
'openai-compatible:oa-1::veo3.1-fast',
|
||||
],
|
||||
draftModels: [
|
||||
{
|
||||
modelId: 'veo3-fast',
|
||||
name: 'Veo 3 Fast',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/video/create' },
|
||||
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
|
||||
response: { taskIdPath: '$.id', statusPath: '$.status' },
|
||||
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'veo3.1-fast',
|
||||
name: 'Veo 3.1 Fast',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/video/create' },
|
||||
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
|
||||
response: { taskIdPath: '$.id', statusPath: '$.status' },
|
||||
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
}] as unknown as UIMessage[]
|
||||
|
||||
const events = collectSavedEvents(messages)
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((item) => item.savedModelKey)).toEqual([
|
||||
'openai-compatible:oa-1::veo3-fast',
|
||||
'openai-compatible:oa-1::veo3.1-fast',
|
||||
])
|
||||
expect(events[1]?.draftModel?.name).toBe('Veo 3.1 Fast')
|
||||
})
|
||||
})
|
||||
65
tests/unit/api-config/use-providers-order.test.ts
Normal file
65
tests/unit/api-config/use-providers-order.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeProvidersForDisplay } from '@/app/[locale]/profile/components/api-config/hooks'
|
||||
import type { Provider } from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
describe('useProviders provider order merge', () => {
|
||||
it('preserves saved providers order and appends missing presets at the end', () => {
|
||||
const presetProviders: Provider[] = [
|
||||
{ id: 'ark', name: '火山引擎 Ark' },
|
||||
{ id: 'google', name: 'Google AI Studio' },
|
||||
{ id: 'bailian', name: '阿里云百炼' },
|
||||
]
|
||||
const savedProviders: Provider[] = [
|
||||
{ id: 'google', name: 'Google Legacy Name', apiKey: 'google-key', hidden: true },
|
||||
{ id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key' },
|
||||
{ id: 'ark', name: 'Ark Legacy Name', apiKey: 'ark-key' },
|
||||
]
|
||||
|
||||
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
|
||||
expect(merged.map((provider) => provider.id)).toEqual([
|
||||
'google',
|
||||
'openai-compatible:oa-2',
|
||||
'ark',
|
||||
'bailian',
|
||||
])
|
||||
expect(merged[0]?.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('uses preset localized names for preset providers while keeping apiKey/baseUrl from saved data', () => {
|
||||
const presetProviders: Provider[] = [
|
||||
{ id: 'google', name: 'Google AI Studio', baseUrl: 'https://google.default' },
|
||||
]
|
||||
const savedProviders: Provider[] = [
|
||||
{ id: 'google', name: 'Google Old Name', baseUrl: 'https://google.custom', apiKey: 'google-key' },
|
||||
]
|
||||
|
||||
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: 'google',
|
||||
name: 'Google AI Studio',
|
||||
baseUrl: 'https://google.custom',
|
||||
apiKey: 'google-key',
|
||||
hasApiKey: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses preset official baseUrl for minimax even when saved payload contains a custom baseUrl', () => {
|
||||
const presetProviders: Provider[] = [
|
||||
{ id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://api.minimaxi.com/v1' },
|
||||
]
|
||||
const savedProviders: Provider[] = [
|
||||
{ id: 'minimax', name: 'MiniMax Legacy', baseUrl: 'https://custom.minimax.proxy/v1', apiKey: 'mm-key' },
|
||||
]
|
||||
|
||||
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: 'minimax',
|
||||
name: 'MiniMax Hailuo',
|
||||
baseUrl: 'https://api.minimaxi.com/v1',
|
||||
apiKey: 'mm-key',
|
||||
hasApiKey: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
15
tests/unit/assistant-platform/registry.test.ts
Normal file
15
tests/unit/assistant-platform/registry.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAssistantSkill, isAssistantId } from '@/lib/assistant-platform'
|
||||
|
||||
describe('assistant-platform registry', () => {
|
||||
it('recognizes supported assistant ids', () => {
|
||||
expect(isAssistantId('api-config-template')).toBe(true)
|
||||
expect(isAssistantId('tutorial')).toBe(true)
|
||||
expect(isAssistantId('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns registered skills', () => {
|
||||
expect(getAssistantSkill('api-config-template').id).toBe('api-config-template')
|
||||
expect(getAssistantSkill('tutorial').id).toBe('tutorial')
|
||||
})
|
||||
})
|
||||
46
tests/unit/assistant-platform/runtime.test.ts
Normal file
46
tests/unit/assistant-platform/runtime.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getUserModelConfigMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ analysisModel: null })),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/config-service', () => ({
|
||||
getUserModelConfig: getUserModelConfigMock,
|
||||
}))
|
||||
|
||||
import { AssistantPlatformError } from '@/lib/assistant-platform'
|
||||
import { createAssistantChatResponse } from '@/lib/assistant-platform/runtime'
|
||||
|
||||
describe('assistant-platform runtime', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('throws invalid request when messages payload is malformed', async () => {
|
||||
await expect(createAssistantChatResponse({
|
||||
userId: 'user-1',
|
||||
assistantId: 'api-config-template',
|
||||
context: {},
|
||||
messages: { invalid: true },
|
||||
})).rejects.toMatchObject({
|
||||
code: 'ASSISTANT_INVALID_REQUEST',
|
||||
} as Partial<AssistantPlatformError>)
|
||||
})
|
||||
|
||||
it('throws missing model when analysisModel is not configured', async () => {
|
||||
await expect(createAssistantChatResponse({
|
||||
userId: 'user-1',
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
messages: [{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
}],
|
||||
})).rejects.toMatchObject({
|
||||
code: 'ASSISTANT_MODEL_NOT_CONFIGURED',
|
||||
} as Partial<AssistantPlatformError>)
|
||||
})
|
||||
})
|
||||
230
tests/unit/assistant-platform/skills-api-config-template.test.ts
Normal file
230
tests/unit/assistant-platform/skills-api-config-template.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantRuntimeContext } from '@/lib/assistant-platform'
|
||||
|
||||
const saveModelTemplateConfigurationMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ modelKey: 'openai-compatible:oa-1::veo3.1' })),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/user-api/model-template/save', () => ({
|
||||
saveModelTemplateConfiguration: saveModelTemplateConfigurationMock,
|
||||
}))
|
||||
|
||||
import { apiConfigTemplateSkill } from '@/lib/assistant-platform/skills/api-config-template'
|
||||
|
||||
function buildRuntimeContext(): AssistantRuntimeContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
assistantId: 'api-config-template',
|
||||
context: {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
},
|
||||
analysisModelKey: 'openrouter::gpt-5-mini',
|
||||
resolvedModel: {
|
||||
providerId: 'openrouter',
|
||||
providerKey: 'openrouter',
|
||||
modelId: 'gpt-5-mini',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('assistant-platform api-config-template skill', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns invalid when template fails schema validation', async () => {
|
||||
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
|
||||
expect(tools).toBeTruthy()
|
||||
const saveTool = tools?.saveModelTemplate
|
||||
expect(saveTool).toBeTruthy()
|
||||
if (!saveTool?.execute) {
|
||||
throw new Error('saveModelTemplate.execute is required for test')
|
||||
}
|
||||
|
||||
const result = await saveTool.execute({
|
||||
modelId: 'veo3.1',
|
||||
name: 'Veo 3.1',
|
||||
type: 'video',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/v2/videos/generations',
|
||||
},
|
||||
response: {
|
||||
taskIdPath: '$.task_id',
|
||||
},
|
||||
},
|
||||
}, {} as never)
|
||||
|
||||
expect(result.status).toBe('invalid')
|
||||
expect(result.code).toBe('MODEL_TEMPLATE_INVALID')
|
||||
expect(saveModelTemplateConfigurationMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves template when payload is valid', async () => {
|
||||
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
|
||||
expect(tools).toBeTruthy()
|
||||
const saveTool = tools?.saveModelTemplate
|
||||
expect(saveTool).toBeTruthy()
|
||||
if (!saveTool?.execute) {
|
||||
throw new Error('saveModelTemplate.execute is required for test')
|
||||
}
|
||||
|
||||
const result = await saveTool.execute({
|
||||
modelId: 'veo3.1',
|
||||
name: 'Veo 3.1',
|
||||
type: 'video',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/v2/videos/generations',
|
||||
contentType: 'application/json',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
method: 'GET',
|
||||
path: '/v2/videos/generations/{{task_id}}',
|
||||
},
|
||||
response: {
|
||||
taskIdPath: '$.task_id',
|
||||
statusPath: '$.status',
|
||||
outputUrlPath: '$.video_url',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 3000,
|
||||
timeoutMs: 180000,
|
||||
doneStates: ['done'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
}, {} as never)
|
||||
|
||||
expect(result.status).toBe('saved')
|
||||
expect(result.savedModelKey).toBe('openai-compatible:oa-1::veo3.1')
|
||||
expect(saveModelTemplateConfigurationMock).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
modelId: 'veo3.1',
|
||||
name: 'Veo 3.1',
|
||||
type: 'video',
|
||||
template: expect.objectContaining({
|
||||
mediaType: 'video',
|
||||
}),
|
||||
source: 'ai',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves multiple templates when batch payload is valid', async () => {
|
||||
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
|
||||
expect(tools).toBeTruthy()
|
||||
const batchTool = tools?.saveModelTemplates
|
||||
expect(batchTool).toBeTruthy()
|
||||
if (!batchTool?.execute) {
|
||||
throw new Error('saveModelTemplates.execute is required for test')
|
||||
}
|
||||
|
||||
const result = await batchTool.execute({
|
||||
models: [
|
||||
{
|
||||
modelId: 'veo3-fast',
|
||||
name: 'Veo 3 Fast',
|
||||
type: 'video',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/video/create',
|
||||
contentType: 'application/json',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
images: ['{{image}}'],
|
||||
},
|
||||
},
|
||||
status: {
|
||||
method: 'GET',
|
||||
path: '/video/query?id={{task_id}}',
|
||||
},
|
||||
response: {
|
||||
taskIdPath: '$.id',
|
||||
statusPath: '$.status',
|
||||
outputUrlPath: '$.video_url',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 5000,
|
||||
timeoutMs: 600000,
|
||||
doneStates: ['completed'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'veo3.1-fast',
|
||||
name: 'Veo 3.1 Fast',
|
||||
type: 'video',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: {
|
||||
method: 'POST',
|
||||
path: '/video/create',
|
||||
contentType: 'application/json',
|
||||
bodyTemplate: {
|
||||
model: '{{model}}',
|
||||
prompt: '{{prompt}}',
|
||||
images: ['{{image}}'],
|
||||
},
|
||||
},
|
||||
status: {
|
||||
method: 'GET',
|
||||
path: '/video/query?id={{task_id}}',
|
||||
},
|
||||
response: {
|
||||
taskIdPath: '$.id',
|
||||
statusPath: '$.status',
|
||||
outputUrlPath: '$.video_url',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 5000,
|
||||
timeoutMs: 600000,
|
||||
doneStates: ['completed'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}, {} as never)
|
||||
|
||||
expect(result.status).toBe('saved')
|
||||
expect(result.savedModelKeys).toHaveLength(2)
|
||||
expect(saveModelTemplateConfigurationMock).toHaveBeenCalledTimes(2)
|
||||
expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
modelId: 'veo3-fast',
|
||||
name: 'Veo 3 Fast',
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
userId: 'user-1',
|
||||
type: 'video',
|
||||
source: 'ai',
|
||||
}))
|
||||
expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
modelId: 'veo3.1-fast',
|
||||
name: 'Veo 3.1 Fast',
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
userId: 'user-1',
|
||||
type: 'video',
|
||||
source: 'ai',
|
||||
}))
|
||||
})
|
||||
})
|
||||
21
tests/unit/assistant-platform/system-prompts.test.ts
Normal file
21
tests/unit/assistant-platform/system-prompts.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderAssistantSystemPrompt } from '@/lib/assistant-platform/system-prompts'
|
||||
|
||||
describe('assistant-platform system prompts', () => {
|
||||
it('loads api-config-template prompt from lib/prompts/skills and injects providerId', () => {
|
||||
const prompt = renderAssistantSystemPrompt('api-config-template', {
|
||||
providerId: 'openai-compatible:oa-1',
|
||||
})
|
||||
|
||||
expect(prompt).toContain('你是 API 配置助手')
|
||||
expect(prompt).toContain('当前 providerId=openai-compatible:oa-1')
|
||||
expect(prompt).not.toContain('{{providerId}}')
|
||||
})
|
||||
|
||||
it('loads tutorial prompt from lib/prompts/skills', () => {
|
||||
const prompt = renderAssistantSystemPrompt('tutorial')
|
||||
|
||||
expect(prompt).toContain('你是产品教程助手')
|
||||
expect(prompt).toContain('禁止编造不存在的页面')
|
||||
})
|
||||
})
|
||||
166
tests/unit/async-poll-ocompat.test.ts
Normal file
166
tests/unit/async-poll-ocompat.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
id: 'openai-compatible:oa-1',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://compat.example.com/v1',
|
||||
})))
|
||||
const getUserModelsMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/api-config').getUserModels>(async () => []),
|
||||
)
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
getUserModels: getUserModelsMock,
|
||||
}))
|
||||
|
||||
import { pollAsyncTask } from '@/lib/async-poll'
|
||||
|
||||
function encode(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
describe('async poll ocompat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.fetch = vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
|
||||
it('returns completed with output url when async status reaches done', async () => {
|
||||
getUserModelsMock.mockResolvedValueOnce([
|
||||
{
|
||||
modelKey: 'openai-compatible:oa-1::veo3.1',
|
||||
modelId: 'veo3.1',
|
||||
name: 'Veo 3.1',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
price: 0,
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/v2/videos/generations' },
|
||||
status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },
|
||||
response: {
|
||||
statusPath: '$.status',
|
||||
outputUrlPath: '$.video_url',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 3000,
|
||||
timeoutMs: 180000,
|
||||
doneStates: ['succeeded'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
status: 'succeeded',
|
||||
video_url: 'https://cdn.test/video.mp4',
|
||||
}), { status: 200 }))
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const result = await pollAsyncTask(
|
||||
`OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_1`,
|
||||
'user-1',
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
resultUrl: 'https://cdn.test/video.mp4',
|
||||
videoUrl: 'https://cdn.test/video.mp4',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses content endpoint when output url is missing', async () => {
|
||||
getUserModelsMock.mockResolvedValueOnce([
|
||||
{
|
||||
modelKey: 'openai-compatible:oa-1::veo3.1',
|
||||
modelId: 'veo3.1',
|
||||
name: 'Veo 3.1',
|
||||
type: 'video',
|
||||
provider: 'openai-compatible:oa-1',
|
||||
price: 0,
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/v2/videos/generations' },
|
||||
status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },
|
||||
content: { method: 'GET', path: '/v2/videos/generations/{{task_id}}/content' },
|
||||
response: {
|
||||
statusPath: '$.status',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 3000,
|
||||
timeoutMs: 180000,
|
||||
doneStates: ['succeeded'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
status: 'succeeded',
|
||||
}), { status: 200 }))
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const result = await pollAsyncTask(
|
||||
`OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_2`,
|
||||
'user-1',
|
||||
)
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.videoUrl).toBe('https://compat.example.com/v1/v2/videos/generations/task_2/content')
|
||||
expect(result.downloadHeaders).toEqual({
|
||||
Authorization: 'Bearer sk-test',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts compact OCOMPAT token encoded from modelId', async () => {
|
||||
const providerUuid = '33331fb0-2806-4da6-85ff-cd2433b587d0'
|
||||
getUserModelsMock.mockResolvedValueOnce([
|
||||
{
|
||||
modelKey: `openai-compatible:${providerUuid}::veo3.1-fast`,
|
||||
modelId: 'veo3.1-fast',
|
||||
name: 'Veo 3.1 Fast',
|
||||
type: 'video',
|
||||
provider: `openai-compatible:${providerUuid}`,
|
||||
price: 0,
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/video/create' },
|
||||
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
|
||||
response: {
|
||||
statusPath: '$.status',
|
||||
outputUrlPath: '$.video_url',
|
||||
},
|
||||
polling: {
|
||||
intervalMs: 3000,
|
||||
timeoutMs: 180000,
|
||||
doneStates: ['completed'],
|
||||
failStates: ['failed'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
status: 'completed',
|
||||
video_url: 'https://cdn.test/video-fast.mp4',
|
||||
}), { status: 200 }))
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const result = await pollAsyncTask(
|
||||
`OCOMPAT:VIDEO:u_${providerUuid}:${encode('veo3.1-fast')}:task_3`,
|
||||
'user-1',
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
resultUrl: 'https://cdn.test/video-fast.mp4',
|
||||
videoUrl: 'https://cdn.test/video-fast.mp4',
|
||||
})
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
116
tests/unit/components/character-creation-modal.test.ts
Normal file
116
tests/unit/components/character-creation-modal.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import { CharacterCreationModal } from '@/components/shared/assets/CharacterCreationModal'
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useProjectAssets: vi.fn(() => ({ data: { characters: [] } })),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/shared/assets/character-creation/hooks/useCharacterCreationSubmit', () => ({
|
||||
useCharacterCreationSubmit: vi.fn(() => ({
|
||||
isSubmitting: false,
|
||||
isAiDesigning: false,
|
||||
isExtracting: false,
|
||||
characterGenerationCount: 3,
|
||||
setCharacterGenerationCount: vi.fn(),
|
||||
referenceCharacterGenerationCount: 3,
|
||||
setReferenceCharacterGenerationCount: vi.fn(),
|
||||
handleExtractDescription: vi.fn(),
|
||||
handleCreateWithReference: vi.fn(),
|
||||
handleAiDesign: vi.fn(),
|
||||
handleSubmit: vi.fn(),
|
||||
handleSubmitAndGenerate: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetModal: {
|
||||
character: {
|
||||
title: '新建角色',
|
||||
name: '角色名称',
|
||||
namePlaceholder: '请输入角色名称',
|
||||
modeReference: '参考图模式',
|
||||
modeDescription: '描述模式',
|
||||
uploadReference: '上传参考图',
|
||||
pasteHint: 'Ctrl+V 粘贴',
|
||||
generationMode: '生成方式',
|
||||
directGenerate: '直接生成',
|
||||
extractPrompt: '反推提示词',
|
||||
extractFirst: '先提取描述',
|
||||
description: '角色描述',
|
||||
descPlaceholder: '请输入角色外貌描述...',
|
||||
isSubAppearance: '这是一个子形象',
|
||||
isSubAppearanceHint: '为已有角色添加新的形象状态',
|
||||
selectMainCharacter: '选择主角色',
|
||||
selectCharacterPlaceholder: '请选择角色...',
|
||||
appearancesCount: '{count} 个形象',
|
||||
changeReason: '形象变化原因',
|
||||
changeReasonPlaceholder: '例如',
|
||||
useReferenceGeneratePrefix: '使用参考图生成',
|
||||
generateCountSuffix: '张图片',
|
||||
selectReferenceGenerateCount: '选择参考图生成数量',
|
||||
},
|
||||
artStyle: { title: '画面风格' },
|
||||
aiDesign: {
|
||||
title: 'AI 设计',
|
||||
placeholder: '描述你想要的角色特征...',
|
||||
generating: '设计中...',
|
||||
generate: '生成',
|
||||
},
|
||||
common: {
|
||||
creating: '创建中...',
|
||||
cancel: '取消',
|
||||
adding: '添加中...',
|
||||
add: '添加',
|
||||
addOnly: '仅添加角色',
|
||||
addOnlyToAssetHub: '仅添加人物到资产库',
|
||||
addAndGeneratePrefix: '添加并生成',
|
||||
generateCountSuffix: '张图片',
|
||||
selectGenerateCount: '选择生成数量',
|
||||
optional: '(可选)',
|
||||
},
|
||||
errors: {
|
||||
uploadFailed: '上传失败',
|
||||
extractDescriptionFailed: '提取描述失败',
|
||||
createFailed: '创建失败',
|
||||
aiDesignFailed: 'AI 设计失败',
|
||||
addSubAppearanceFailed: '添加子形象失败',
|
||||
insufficientBalance: '账户余额不足',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const renderWithIntl = (node: ReactElement) => {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('CharacterCreationModal', () => {
|
||||
it('renders add-only and add-and-generate actions in the fixed footer', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const html = renderWithIntl(
|
||||
createElement(CharacterCreationModal, {
|
||||
mode: 'asset-hub',
|
||||
onClose: () => undefined,
|
||||
onSuccess: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('仅添加人物到资产库')
|
||||
expect(html).toContain('添加并生成')
|
||||
expect(html).toContain('取消')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
|
||||
describe('ImageGenerationInlineCountButton', () => {
|
||||
it('keeps the select enabled when only the action is disabled', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(ImageGenerationInlineCountButton, {
|
||||
prefix: createElement('span', null, '生成'),
|
||||
suffix: createElement('span', null, '张图片'),
|
||||
value: 3,
|
||||
options: [1, 2, 3],
|
||||
onValueChange: () => undefined,
|
||||
onClick: () => undefined,
|
||||
actionDisabled: true,
|
||||
selectDisabled: false,
|
||||
ariaLabel: '选择生成数量',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('aria-disabled="true"')
|
||||
expect(html).toContain('opacity-60 cursor-not-allowed')
|
||||
expect(html).not.toContain('<select disabled=""')
|
||||
})
|
||||
})
|
||||
81
tests/unit/components/location-creation-modal.test.ts
Normal file
81
tests/unit/components/location-creation-modal.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import { LocationCreationModal } from '@/components/shared/assets/LocationCreationModal'
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useAiCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useAiDesignLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useCreateAssetHubLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useGenerateLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
useGenerateProjectLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assetModal: {
|
||||
location: {
|
||||
title: '新建场景',
|
||||
name: '场景名称',
|
||||
namePlaceholder: '请输入场景名称',
|
||||
description: '场景描述',
|
||||
descPlaceholder: '请输入场景描述...',
|
||||
},
|
||||
artStyle: { title: '画面风格' },
|
||||
aiDesign: {
|
||||
title: 'AI 设计',
|
||||
placeholderLocation: '描述场景氛围和环境...',
|
||||
generating: '设计中...',
|
||||
generate: '生成',
|
||||
tip: '输入简单描述,AI 帮你生成详细设定',
|
||||
},
|
||||
common: {
|
||||
cancel: '取消',
|
||||
addOnlyLocation: '仅添加场景',
|
||||
addOnlyToAssetHubLocation: '仅添加场景到资产库',
|
||||
addAndGeneratePrefix: '添加并生成',
|
||||
generateCountSuffix: '张图片',
|
||||
selectGenerateCount: '选择生成数量',
|
||||
optional: '(可选)',
|
||||
},
|
||||
errors: {
|
||||
createFailed: '创建失败',
|
||||
aiDesignFailed: 'AI 设计失败',
|
||||
insufficientBalance: '账户余额不足',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const renderWithIntl = (node: ReactElement) => {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('LocationCreationModal', () => {
|
||||
it('renders add-only and add-and-generate actions in the fixed footer', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const html = renderWithIntl(
|
||||
createElement(LocationCreationModal, {
|
||||
mode: 'asset-hub',
|
||||
onClose: () => undefined,
|
||||
onSuccess: () => undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('仅添加场景到资产库')
|
||||
expect(html).toContain('添加并生成')
|
||||
expect(html).toContain('取消')
|
||||
})
|
||||
})
|
||||
68
tests/unit/components/voice-design-shared.test.ts
Normal file
68
tests/unit/components/voice-design-shared.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_VOICE_SCHEME_COUNT,
|
||||
MAX_VOICE_SCHEME_COUNT,
|
||||
MIN_VOICE_SCHEME_COUNT,
|
||||
generateVoiceDesignOptions,
|
||||
normalizeVoiceSchemeCount,
|
||||
} from '@/components/voice/voice-design-shared'
|
||||
|
||||
describe('voice-design-shared', () => {
|
||||
it('clamps scheme count into the supported range', () => {
|
||||
expect(normalizeVoiceSchemeCount(undefined)).toBe(DEFAULT_VOICE_SCHEME_COUNT)
|
||||
expect(normalizeVoiceSchemeCount('not-a-number')).toBe(DEFAULT_VOICE_SCHEME_COUNT)
|
||||
expect(normalizeVoiceSchemeCount(0)).toBe(MIN_VOICE_SCHEME_COUNT)
|
||||
expect(normalizeVoiceSchemeCount(99)).toBe(MAX_VOICE_SCHEME_COUNT)
|
||||
expect(normalizeVoiceSchemeCount('5')).toBe(5)
|
||||
})
|
||||
|
||||
it('generates the requested number of voice options with default preview text fallback', async () => {
|
||||
const onDesignVoice = vi
|
||||
.fn<(_: {
|
||||
voicePrompt: string
|
||||
previewText: string
|
||||
preferredName: string
|
||||
language: 'zh'
|
||||
}) => Promise<{ voiceId: string; audioBase64: string }>>()
|
||||
.mockResolvedValueOnce({ voiceId: 'voice-1', audioBase64: 'audio-1' })
|
||||
.mockResolvedValueOnce({ voiceId: 'voice-2', audioBase64: 'audio-2' })
|
||||
.mockResolvedValueOnce({ voiceId: 'voice-3', audioBase64: 'audio-3' })
|
||||
.mockResolvedValueOnce({ voiceId: 'voice-4', audioBase64: 'audio-4' })
|
||||
|
||||
const result = await generateVoiceDesignOptions({
|
||||
count: '4',
|
||||
voicePrompt: ' 温柔女声 ',
|
||||
previewText: ' ',
|
||||
defaultPreviewText: '默认试听文案',
|
||||
onDesignVoice,
|
||||
createPreferredName: (index) => `preferred-${index + 1}`,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{ voiceId: 'voice-1', audioBase64: 'audio-1', audioUrl: 'data:audio/wav;base64,audio-1' },
|
||||
{ voiceId: 'voice-2', audioBase64: 'audio-2', audioUrl: 'data:audio/wav;base64,audio-2' },
|
||||
{ voiceId: 'voice-3', audioBase64: 'audio-3', audioUrl: 'data:audio/wav;base64,audio-3' },
|
||||
{ voiceId: 'voice-4', audioBase64: 'audio-4', audioUrl: 'data:audio/wav;base64,audio-4' },
|
||||
])
|
||||
expect(onDesignVoice.mock.calls).toEqual([
|
||||
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-1', language: 'zh' }],
|
||||
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-2', language: 'zh' }],
|
||||
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-3', language: 'zh' }],
|
||||
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-4', language: 'zh' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('fails explicitly when a designed voice is missing voiceId', async () => {
|
||||
const onDesignVoice = vi.fn(async () => ({ voiceId: '', audioBase64: 'audio-only' }))
|
||||
|
||||
await expect(
|
||||
generateVoiceDesignOptions({
|
||||
count: 1,
|
||||
voicePrompt: '旁白',
|
||||
previewText: '测试',
|
||||
defaultPreviewText: '默认试听文案',
|
||||
onDesignVoice,
|
||||
}),
|
||||
).rejects.toThrow('VOICE_DESIGN_INVALID_RESPONSE: missing voiceId')
|
||||
})
|
||||
})
|
||||
105
tests/unit/generator-api-openai-template-required.test.ts
Normal file
105
tests/unit/generator-api-openai-template-required.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const resolveModelSelectionMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
provider: 'openai-compatible:oa-1',
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:oa-1::gpt-image-1',
|
||||
mediaType: 'image',
|
||||
compatMediaTemplate: undefined,
|
||||
})),
|
||||
)
|
||||
const getProviderConfigMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
id: 'openai-compatible:oa-1',
|
||||
name: 'OpenAI Compat',
|
||||
apiKey: 'oa-key',
|
||||
gatewayRoute: 'openai-compat' as const,
|
||||
})),
|
||||
)
|
||||
const resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'openai-compat'))
|
||||
const generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))
|
||||
const generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))
|
||||
const generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))
|
||||
const generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
resolveModelSelection: resolveModelSelectionMock,
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
getProviderKey: (providerId: string) => providerId.split(':')[0] || providerId,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/model-gateway', () => ({
|
||||
resolveModelGatewayRoute: resolveModelGatewayRouteMock,
|
||||
generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,
|
||||
generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,
|
||||
generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,
|
||||
generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/generators/factory', () => ({
|
||||
createImageGenerator: vi.fn(() => ({ generate: vi.fn() })),
|
||||
createVideoGenerator: vi.fn(() => ({ generate: vi.fn() })),
|
||||
createAudioGenerator: vi.fn(() => ({ generate: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/bailian', () => ({
|
||||
generateBailianImage: vi.fn(),
|
||||
generateBailianVideo: vi.fn(),
|
||||
generateBailianAudio: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/siliconflow', () => ({
|
||||
generateSiliconFlowImage: vi.fn(),
|
||||
generateSiliconFlowVideo: vi.fn(),
|
||||
generateSiliconFlowAudio: vi.fn(),
|
||||
}))
|
||||
|
||||
import { generateImage, generateVideo } from '@/lib/generator-api'
|
||||
|
||||
describe('generator-api requires compat media template for openai-compatible media', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolveModelGatewayRouteMock.mockReturnValue('openai-compat')
|
||||
getProviderConfigMock.mockResolvedValue({
|
||||
id: 'openai-compatible:oa-1',
|
||||
name: 'OpenAI Compat',
|
||||
apiKey: 'oa-key',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws for image model without compatMediaTemplate', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'openai-compatible:oa-1',
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:oa-1::gpt-image-1',
|
||||
mediaType: 'image',
|
||||
compatMediaTemplate: undefined,
|
||||
})
|
||||
|
||||
await expect(
|
||||
generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat'),
|
||||
).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')
|
||||
|
||||
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(generateImageViaOpenAICompatTemplateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for video model without compatMediaTemplate', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'openai-compatible:oa-1',
|
||||
modelId: 'veo3.1',
|
||||
modelKey: 'openai-compatible:oa-1::veo3.1',
|
||||
mediaType: 'video',
|
||||
compatMediaTemplate: undefined,
|
||||
})
|
||||
|
||||
await expect(
|
||||
generateVideo('user-1', 'openai-compatible:oa-1::veo3.1', 'https://example.com/a.png', { prompt: 'animate' }),
|
||||
).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')
|
||||
|
||||
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(generateVideoViaOpenAICompatTemplateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
301
tests/unit/generator-api.test.ts
Normal file
301
tests/unit/generator-api.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const resolveModelSelectionMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/api-config').resolveModelSelection>(async () => ({
|
||||
provider: 'google',
|
||||
modelId: 'gemini-3.1',
|
||||
modelKey: 'google::gemini-3.1',
|
||||
mediaType: 'image',
|
||||
})),
|
||||
)
|
||||
const getProviderConfigMock = vi.hoisted(() =>
|
||||
vi.fn<typeof import('@/lib/api-config').getProviderConfig>(async () => ({
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
apiKey: 'google-key',
|
||||
apiMode: undefined,
|
||||
gatewayRoute: undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
const generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-image' })))
|
||||
const generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-video' })))
|
||||
const generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-template-image' })))
|
||||
const generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-template-video' })))
|
||||
const resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'official'))
|
||||
|
||||
const imageGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'official-image' })))
|
||||
const videoGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'official-video' })))
|
||||
const audioGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'audio' })))
|
||||
|
||||
const createImageGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: imageGeneratorGenerateMock })))
|
||||
const createVideoGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: videoGeneratorGenerateMock })))
|
||||
const createAudioGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: audioGeneratorGenerateMock })))
|
||||
const generateBailianImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'bailian-image' })))
|
||||
const generateBailianVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'bailian-video' })))
|
||||
const generateBailianAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'bailian-audio' })))
|
||||
const generateSiliconFlowImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'siliconflow-image' })))
|
||||
const generateSiliconFlowVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'siliconflow-video' })))
|
||||
const generateSiliconFlowAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'siliconflow-audio' })))
|
||||
|
||||
vi.mock('@/lib/api-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/api-config')>()
|
||||
return {
|
||||
...actual,
|
||||
resolveModelSelection: resolveModelSelectionMock,
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/model-gateway', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/model-gateway')>()
|
||||
return {
|
||||
...actual,
|
||||
generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,
|
||||
generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,
|
||||
generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,
|
||||
generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,
|
||||
resolveModelGatewayRoute: resolveModelGatewayRouteMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/generators/factory', () => ({
|
||||
createImageGenerator: createImageGeneratorMock,
|
||||
createVideoGenerator: createVideoGeneratorMock,
|
||||
createAudioGenerator: createAudioGeneratorMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/bailian', () => ({
|
||||
generateBailianImage: generateBailianImageMock,
|
||||
generateBailianVideo: generateBailianVideoMock,
|
||||
generateBailianAudio: generateBailianAudioMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/providers/siliconflow', () => ({
|
||||
generateSiliconFlowImage: generateSiliconFlowImageMock,
|
||||
generateSiliconFlowVideo: generateSiliconFlowVideoMock,
|
||||
generateSiliconFlowAudio: generateSiliconFlowAudioMock,
|
||||
}))
|
||||
|
||||
import { generateAudio, generateImage, generateVideo } from '@/lib/generator-api'
|
||||
|
||||
describe('generator-api gateway routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolveModelGatewayRouteMock.mockReset()
|
||||
resolveModelGatewayRouteMock.mockReturnValue('official')
|
||||
getProviderConfigMock.mockResolvedValue({
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
apiKey: 'google-key',
|
||||
apiMode: undefined,
|
||||
gatewayRoute: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('routes openai-compatible image requests to openai-compat gateway', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'openai-compatible:oa-1',
|
||||
modelId: 'gpt-image-1',
|
||||
modelKey: 'openai-compatible:oa-1::gpt-image-1',
|
||||
mediaType: 'image',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'image',
|
||||
mode: 'sync',
|
||||
create: { method: 'POST', path: '/v1/images/generations' },
|
||||
response: { outputUrlPath: 'data[0].url' },
|
||||
},
|
||||
})
|
||||
resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')
|
||||
|
||||
const result = await generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat', {
|
||||
size: '1024x1024',
|
||||
})
|
||||
|
||||
expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)
|
||||
expect(createImageGeneratorMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' })
|
||||
})
|
||||
|
||||
it('routes official image requests to provider generator', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'google',
|
||||
modelId: 'imagen-4.0',
|
||||
modelKey: 'google::imagen-4.0',
|
||||
mediaType: 'image',
|
||||
})
|
||||
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
|
||||
|
||||
const result = await generateImage('user-1', 'google::imagen-4.0', 'draw house')
|
||||
|
||||
expect(createImageGeneratorMock).toHaveBeenCalledWith('google', 'imagen-4.0')
|
||||
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, imageUrl: 'official-image' })
|
||||
})
|
||||
|
||||
it('routes gemini-compatible image to official generator', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'gemini-compatible:gm-1',
|
||||
modelId: 'gemini-2.5-flash-image-preview',
|
||||
modelKey: 'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',
|
||||
mediaType: 'image',
|
||||
})
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
name: 'Gemini Compatible',
|
||||
apiKey: 'gm-key',
|
||||
baseUrl: 'https://gm.test',
|
||||
apiMode: 'gemini-sdk',
|
||||
gatewayRoute: 'official',
|
||||
})
|
||||
|
||||
const result = await generateImage(
|
||||
'user-1',
|
||||
'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',
|
||||
'draw cat',
|
||||
{ aspectRatio: '3:4' },
|
||||
)
|
||||
|
||||
expect(createImageGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1', 'gemini-2.5-flash-image-preview')
|
||||
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, imageUrl: 'official-image' })
|
||||
})
|
||||
|
||||
it('routes openai-compatible video requests to openai-compat gateway', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'openai-compatible:oa-1',
|
||||
modelId: 'sora-2',
|
||||
modelKey: 'openai-compatible:oa-1::sora-2',
|
||||
mediaType: 'video',
|
||||
compatMediaTemplate: {
|
||||
version: 1,
|
||||
mediaType: 'video',
|
||||
mode: 'async',
|
||||
create: { method: 'POST', path: '/v1/videos/generations' },
|
||||
response: { taskIdPath: 'id' },
|
||||
},
|
||||
})
|
||||
resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')
|
||||
|
||||
const result = await generateVideo(
|
||||
'user-1',
|
||||
'openai-compatible:oa-1::sora-2',
|
||||
'https://example.com/source.png',
|
||||
{ prompt: 'animate' },
|
||||
)
|
||||
|
||||
expect(generateVideoViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)
|
||||
expect(createVideoGeneratorMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, videoUrl: 'compat-template-video' })
|
||||
})
|
||||
|
||||
it('routes gemini-compatible video to official provider generator', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'gemini-compatible:gm-1',
|
||||
modelId: 'veo-3.1-generate-preview',
|
||||
modelKey: 'gemini-compatible:gm-1::veo-3.1-generate-preview',
|
||||
mediaType: 'video',
|
||||
})
|
||||
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
|
||||
|
||||
const result = await generateVideo('user-1', 'gemini-compatible:gm-1::veo-3.1-generate-preview', 'https://example.com/source.png')
|
||||
|
||||
expect(createVideoGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1')
|
||||
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, videoUrl: 'official-video' })
|
||||
})
|
||||
|
||||
it('routes official video requests to provider generator', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'fal',
|
||||
modelId: 'kling',
|
||||
modelKey: 'fal::kling',
|
||||
mediaType: 'video',
|
||||
})
|
||||
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
|
||||
|
||||
const result = await generateVideo('user-1', 'fal::kling', 'https://example.com/source.png')
|
||||
|
||||
expect(createVideoGeneratorMock).toHaveBeenCalledWith('fal')
|
||||
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, videoUrl: 'official-video' })
|
||||
})
|
||||
|
||||
it('keeps audio generation on provider generator path', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'fal',
|
||||
modelId: 'tts-1',
|
||||
modelKey: 'fal::tts-1',
|
||||
mediaType: 'audio',
|
||||
})
|
||||
|
||||
const result = await generateAudio('user-1', 'fal::tts-1', 'hello')
|
||||
|
||||
expect(createAudioGeneratorMock).toHaveBeenCalledWith('fal')
|
||||
expect(result).toEqual({ success: true, audioUrl: 'audio' })
|
||||
})
|
||||
|
||||
it('routes bailian image generation to official provider adapter', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'bailian',
|
||||
modelId: 'wanx-image',
|
||||
modelKey: 'bailian::wanx-image',
|
||||
mediaType: 'image',
|
||||
})
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'bailian',
|
||||
name: 'Bailian',
|
||||
apiKey: 'bl-key',
|
||||
gatewayRoute: 'official',
|
||||
apiMode: undefined,
|
||||
})
|
||||
|
||||
const result = await generateImage('user-1', 'bailian::wanx-image', 'draw sky')
|
||||
|
||||
expect(generateBailianImageMock).toHaveBeenCalledTimes(1)
|
||||
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(createImageGeneratorMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, imageUrl: 'bailian-image' })
|
||||
})
|
||||
|
||||
it('routes siliconflow video generation to official provider adapter', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'siliconflow',
|
||||
modelId: 'sf-video',
|
||||
modelKey: 'siliconflow::sf-video',
|
||||
mediaType: 'video',
|
||||
})
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'siliconflow',
|
||||
name: 'SiliconFlow',
|
||||
apiKey: 'sf-key',
|
||||
gatewayRoute: 'official',
|
||||
apiMode: undefined,
|
||||
})
|
||||
|
||||
const result = await generateVideo('user-1', 'siliconflow::sf-video', 'https://example.com/source.png', {
|
||||
prompt: 'animate',
|
||||
})
|
||||
|
||||
expect(generateSiliconFlowVideoMock).toHaveBeenCalledTimes(1)
|
||||
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
|
||||
expect(createVideoGeneratorMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, videoUrl: 'siliconflow-video' })
|
||||
})
|
||||
|
||||
it('routes bailian audio generation to official provider adapter', async () => {
|
||||
resolveModelSelectionMock.mockResolvedValueOnce({
|
||||
provider: 'bailian',
|
||||
modelId: 'bailian-tts',
|
||||
modelKey: 'bailian::bailian-tts',
|
||||
mediaType: 'audio',
|
||||
})
|
||||
|
||||
const result = await generateAudio('user-1', 'bailian::bailian-tts', 'hello')
|
||||
|
||||
expect(generateBailianAudioMock).toHaveBeenCalledTimes(1)
|
||||
expect(createAudioGeneratorMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true, audioUrl: 'bailian-audio' })
|
||||
})
|
||||
})
|
||||
22
tests/unit/generators/factory.test.ts
Normal file
22
tests/unit/generators/factory.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAudioGenerator, createImageGenerator, createVideoGenerator } from '@/lib/generators/factory'
|
||||
import { GoogleVeoVideoGenerator } from '@/lib/generators/video/google'
|
||||
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
|
||||
import { BailianAudioGenerator, BailianImageGenerator, BailianVideoGenerator, SiliconFlowAudioGenerator } from '@/lib/generators/official'
|
||||
|
||||
describe('generator factory', () => {
|
||||
it('routes gemini-compatible video provider to Google video generator', () => {
|
||||
const generator = createVideoGenerator('gemini-compatible:gm-1')
|
||||
expect(generator).toBeInstanceOf(GoogleVeoVideoGenerator)
|
||||
})
|
||||
|
||||
it('routes bailian official providers to official generators', () => {
|
||||
expect(createImageGenerator('bailian')).toBeInstanceOf(BailianImageGenerator)
|
||||
expect(createVideoGenerator('bailian')).toBeInstanceOf(BailianVideoGenerator)
|
||||
expect(createAudioGenerator('bailian')).toBeInstanceOf(BailianAudioGenerator)
|
||||
})
|
||||
|
||||
it('routes siliconflow audio provider to official generator', () => {
|
||||
expect(createAudioGenerator('siliconflow')).toBeInstanceOf(SiliconFlowAudioGenerator)
|
||||
})
|
||||
})
|
||||
94
tests/unit/generators/fal-video-kling-presets.test.ts
Normal file
94
tests/unit/generators/fal-video-kling-presets.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
|
||||
}))
|
||||
|
||||
const asyncSubmitMock = vi.hoisted(() => ({
|
||||
submitFalTask: vi.fn(async () => 'req_kling_1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
|
||||
|
||||
import { FalVideoGenerator } from '@/lib/generators/fal'
|
||||
|
||||
type KlingModelCase = {
|
||||
modelId: string
|
||||
endpoint: string
|
||||
imageField: 'image_url' | 'start_image_url'
|
||||
}
|
||||
|
||||
const KLING_MODEL_CASES: KlingModelCase[] = [
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
|
||||
imageField: 'image_url',
|
||||
},
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v3/standard/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v3/standard/image-to-video',
|
||||
imageField: 'start_image_url',
|
||||
},
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v3/pro/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v3/pro/image-to-video',
|
||||
imageField: 'start_image_url',
|
||||
},
|
||||
]
|
||||
|
||||
describe('FalVideoGenerator kling presets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'fal-key' })
|
||||
asyncSubmitMock.submitFalTask.mockResolvedValue('req_kling_1')
|
||||
})
|
||||
|
||||
it.each(KLING_MODEL_CASES)('submits $modelId to expected endpoint and payload', async ({ modelId, endpoint, imageField }) => {
|
||||
const generator = new FalVideoGenerator()
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/start.png',
|
||||
prompt: 'test prompt',
|
||||
options: {
|
||||
modelId,
|
||||
duration: 5,
|
||||
aspectRatio: '16:9',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.endpoint).toBe(endpoint)
|
||||
expect(result.requestId).toBe('req_kling_1')
|
||||
expect(result.externalId).toBe(`FAL:VIDEO:${endpoint}:req_kling_1`)
|
||||
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'fal')
|
||||
|
||||
const submitCall = asyncSubmitMock.submitFalTask.mock.calls.at(0) as
|
||||
| [string, Record<string, unknown>, string]
|
||||
| undefined
|
||||
expect(submitCall).toBeTruthy()
|
||||
if (!submitCall) {
|
||||
throw new Error('submitFalTask should be called')
|
||||
}
|
||||
|
||||
expect(submitCall[0]).toBe(endpoint)
|
||||
expect(submitCall[2]).toBe('fal-key')
|
||||
|
||||
const payload = submitCall[1]
|
||||
expect(payload.prompt).toBe('test prompt')
|
||||
expect(payload.duration).toBe('5')
|
||||
|
||||
if (imageField === 'image_url') {
|
||||
expect(payload.image_url).toBe('https://example.com/start.png')
|
||||
expect(payload.start_image_url).toBeUndefined()
|
||||
expect(payload.negative_prompt).toBe('blur, distort, and low quality')
|
||||
expect(payload.cfg_scale).toBe(0.5)
|
||||
return
|
||||
}
|
||||
|
||||
expect(payload.start_image_url).toBe('https://example.com/start.png')
|
||||
expect(payload.image_url).toBeUndefined()
|
||||
expect(payload.aspect_ratio).toBe('16:9')
|
||||
expect(payload.generate_audio).toBe(false)
|
||||
})
|
||||
})
|
||||
234
tests/unit/generators/image-provider-smoke.test.ts
Normal file
234
tests/unit/generators/image-provider-smoke.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const googleGenerateContentMock = vi.hoisted(() => vi.fn())
|
||||
const getProviderConfigMock = vi.hoisted(() => vi.fn())
|
||||
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,UkVG'))
|
||||
const arkImageGenerationMock = vi.hoisted(() => vi.fn())
|
||||
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'UkVG'))
|
||||
|
||||
vi.mock('@google/genai', () => ({
|
||||
GoogleGenAI: class GoogleGenAI {
|
||||
models = {
|
||||
generateContent: googleGenerateContentMock,
|
||||
}
|
||||
},
|
||||
HarmCategory: {
|
||||
HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',
|
||||
HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',
|
||||
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
},
|
||||
HarmBlockThreshold: {
|
||||
BLOCK_NONE: 'BLOCK_NONE',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-cache', () => ({
|
||||
getImageBase64Cached: getImageBase64CachedMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ark-api', () => ({
|
||||
arkImageGeneration: arkImageGenerationMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
|
||||
}))
|
||||
|
||||
import { ArkSeedreamGenerator } from '@/lib/generators/ark'
|
||||
import { GeminiCompatibleImageGenerator } from '@/lib/generators/image/gemini-compatible'
|
||||
import { GoogleGeminiImageGenerator } from '@/lib/generators/image/google'
|
||||
|
||||
describe('image provider smoke tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('Google Gemini 官方文生图可用 -> 返回 data URL', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'google',
|
||||
apiKey: 'google-key',
|
||||
})
|
||||
googleGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'R09PR0xF',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const generator = new GoogleGeminiImageGenerator('gemini-3-pro-image-preview')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'draw a mountain',
|
||||
options: {
|
||||
aspectRatio: '3:4',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageBase64: 'R09PR0xF',
|
||||
imageUrl: 'data:image/png;base64,R09PR0xF',
|
||||
})
|
||||
expect(googleGenerateContentMock).toHaveBeenCalledWith({
|
||||
model: 'gemini-3-pro-image-preview',
|
||||
contents: [{ parts: [{ text: 'draw a mountain' }] }],
|
||||
config: expect.objectContaining({
|
||||
responseModalities: ['TEXT', 'IMAGE'],
|
||||
imageConfig: { aspectRatio: '3:4' },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('Seedream 图生图可用 -> 返回 ARK 图片 URL', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'ark',
|
||||
apiKey: 'ark-key',
|
||||
})
|
||||
arkImageGenerationMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://seedream.test/image.png' }],
|
||||
})
|
||||
|
||||
const generator = new ArkSeedreamGenerator()
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'refine this style',
|
||||
referenceImages: ['https://example.com/ref.png'],
|
||||
options: {
|
||||
modelId: 'doubao-seedream-4-5-251128',
|
||||
aspectRatio: '3:4',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageUrl: 'https://seedream.test/image.png',
|
||||
})
|
||||
expect(arkImageGenerationMock).toHaveBeenCalledWith({
|
||||
model: 'doubao-seedream-4-5-251128',
|
||||
prompt: 'refine this style',
|
||||
sequential_image_generation: 'disabled',
|
||||
response_format: 'url',
|
||||
stream: false,
|
||||
watermark: false,
|
||||
size: '3544x4728',
|
||||
image: ['UkVG'],
|
||||
}, {
|
||||
apiKey: 'ark-key',
|
||||
logPrefix: '[ARK Image]',
|
||||
})
|
||||
})
|
||||
|
||||
it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiKey: 'gm-key',
|
||||
baseUrl: 'https://gm.test',
|
||||
})
|
||||
googleGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/webp',
|
||||
data: 'R01fVEVYVA==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'draw a cat',
|
||||
options: {
|
||||
aspectRatio: '1:1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageBase64: 'R01fVEVYVA==',
|
||||
imageUrl: 'data:image/webp;base64,R01fVEVYVA==',
|
||||
})
|
||||
expect(googleGenerateContentMock).toHaveBeenCalledWith({
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents: [{ parts: [{ text: 'draw a cat' }] }],
|
||||
config: expect.objectContaining({
|
||||
responseModalities: ['TEXT', 'IMAGE'],
|
||||
imageConfig: { aspectRatio: '1:1' },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('Gemini 兼容层图生图可用 -> 参考图会注入 inlineData', async () => {
|
||||
getProviderConfigMock.mockResolvedValueOnce({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiKey: 'gm-key',
|
||||
baseUrl: 'https://gm.test',
|
||||
})
|
||||
googleGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'R01fSTJJPQ==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'restyle this portrait',
|
||||
referenceImages: ['/api/files/ref-image'],
|
||||
options: {
|
||||
resolution: '2K',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
imageBase64: 'R01fSTJJPQ==',
|
||||
imageUrl: 'data:image/png;base64,R01fSTJJPQ==',
|
||||
})
|
||||
const call = googleGenerateContentMock.mock.calls[0]
|
||||
expect(call).toBeTruthy()
|
||||
if (!call) {
|
||||
throw new Error('Gemini generateContent should be called')
|
||||
}
|
||||
const content = call[0] as {
|
||||
contents: Array<{ parts: Array<{ inlineData?: { mimeType: string; data: string }; text?: string }> }>
|
||||
config: { imageConfig?: { imageSize?: string } }
|
||||
}
|
||||
expect(content.contents[0].parts[0].inlineData).toEqual({ mimeType: 'image/png', data: 'UkVG' })
|
||||
expect(content.contents[0].parts[1].text).toBe('restyle this portrait')
|
||||
expect(content.config.imageConfig).toEqual({ imageSize: '2K' })
|
||||
})
|
||||
})
|
||||
122
tests/unit/generators/openai-compatible-image.test.ts
Normal file
122
tests/unit/generators/openai-compatible-image.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openAIState = vi.hoisted(() => ({
|
||||
generate: vi.fn(),
|
||||
edit: vi.fn(),
|
||||
toFile: vi.fn(async () => ({ name: 'mock-file' })),
|
||||
}))
|
||||
|
||||
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
id: 'openai-compatible:oa-1',
|
||||
apiKey: 'oa-key',
|
||||
baseUrl: 'https://oa.test/v1',
|
||||
})))
|
||||
|
||||
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
|
||||
|
||||
vi.mock('openai', () => ({
|
||||
default: class OpenAI {
|
||||
images = {
|
||||
generate: openAIState.generate,
|
||||
edit: openAIState.edit,
|
||||
}
|
||||
},
|
||||
toFile: openAIState.toFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/image-cache', () => ({
|
||||
getImageBase64Cached: getImageBase64CachedMock,
|
||||
}))
|
||||
|
||||
import { OpenAICompatibleImageGenerator } from '@/lib/generators/image/openai-compatible'
|
||||
|
||||
describe('OpenAICompatibleImageGenerator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getProviderConfigMock.mockResolvedValue({
|
||||
id: 'openai-compatible:oa-1',
|
||||
apiKey: 'oa-key',
|
||||
baseUrl: 'https://oa.test/v1',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses official images.generate payload parameters', async () => {
|
||||
openAIState.generate.mockResolvedValueOnce({
|
||||
data: [{ b64_json: 'YmFzZTY0' }],
|
||||
})
|
||||
|
||||
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'draw a lighthouse',
|
||||
options: {
|
||||
size: '1024x1024',
|
||||
quality: 'high',
|
||||
outputFormat: 'png',
|
||||
responseFormat: 'b64_json',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.imageBase64).toBe('YmFzZTY0')
|
||||
expect(result.imageUrl).toBe('data:image/png;base64,YmFzZTY0')
|
||||
expect(openAIState.generate).toHaveBeenCalledWith({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'draw a lighthouse',
|
||||
response_format: 'b64_json',
|
||||
output_format: 'png',
|
||||
quality: 'high',
|
||||
size: '1024x1024',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses official images.edit payload when reference images are provided', async () => {
|
||||
openAIState.edit.mockResolvedValueOnce({
|
||||
data: [{ b64_json: 'ZWRpdA==' }],
|
||||
})
|
||||
|
||||
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'edit this image',
|
||||
referenceImages: ['data:image/png;base64,QQ=='],
|
||||
options: {
|
||||
quality: 'medium',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(openAIState.toFile).toHaveBeenCalledTimes(1)
|
||||
|
||||
const call = openAIState.edit.mock.calls[0]
|
||||
expect(call).toBeTruthy()
|
||||
if (!call) {
|
||||
throw new Error('images.edit should be called')
|
||||
}
|
||||
expect(call[0]).toMatchObject({
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'edit this image',
|
||||
response_format: 'b64_json',
|
||||
quality: 'medium',
|
||||
})
|
||||
expect(Array.isArray((call[0] as { image?: unknown }).image)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails explicitly on unsupported option values', async () => {
|
||||
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
prompt: 'draw',
|
||||
options: {
|
||||
quality: 'ultra',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED')
|
||||
})
|
||||
})
|
||||
166
tests/unit/generators/openai-compatible-video.test.ts
Normal file
166
tests/unit/generators/openai-compatible-video.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openAIState = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
toFile: vi.fn(async () => ({ name: 'reference-file' })),
|
||||
}))
|
||||
|
||||
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
|
||||
id: 'openai-compatible:oa-1',
|
||||
apiKey: 'oa-key',
|
||||
baseUrl: 'https://oa.test/v1',
|
||||
})))
|
||||
|
||||
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
|
||||
|
||||
vi.mock('openai', () => ({
|
||||
default: class OpenAI {
|
||||
videos = {
|
||||
create: openAIState.create,
|
||||
}
|
||||
},
|
||||
toFile: openAIState.toFile,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', () => ({
|
||||
getProviderConfig: getProviderConfigMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/media/outbound-image', () => ({
|
||||
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
|
||||
}))
|
||||
|
||||
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
|
||||
|
||||
describe('OpenAICompatibleVideoGenerator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getProviderConfigMock.mockResolvedValue({
|
||||
id: 'openai-compatible:oa-1',
|
||||
apiKey: 'oa-key',
|
||||
baseUrl: 'https://oa.test/v1',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits official videos.create payload and returns OPENAI externalId', async () => {
|
||||
openAIState.create.mockResolvedValueOnce({ id: 'vid_123' })
|
||||
|
||||
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/seed.png',
|
||||
prompt: 'animate this character',
|
||||
options: {
|
||||
modelId: 'sora-2',
|
||||
duration: 8,
|
||||
resolution: '720p',
|
||||
aspectRatio: '16:9',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.async).toBe(true)
|
||||
expect(result.requestId).toBe('vid_123')
|
||||
|
||||
const expectedProviderToken = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')
|
||||
expect(result.externalId).toBe(`OPENAI:VIDEO:${expectedProviderToken}:vid_123`)
|
||||
|
||||
const createCall = openAIState.create.mock.calls[0]
|
||||
expect(createCall).toBeTruthy()
|
||||
if (!createCall) {
|
||||
throw new Error('videos.create should be called')
|
||||
}
|
||||
|
||||
expect(createCall[0]).toMatchObject({
|
||||
prompt: 'animate this character',
|
||||
model: 'sora-2',
|
||||
seconds: '8',
|
||||
size: '1280x720',
|
||||
})
|
||||
expect((createCall[0] as { input_reference?: unknown }).input_reference).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows custom model ids for openai-compatible gateways', async () => {
|
||||
openAIState.create.mockResolvedValueOnce({ id: 'vid_custom' })
|
||||
|
||||
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/seed.png',
|
||||
prompt: 'animate',
|
||||
options: {
|
||||
modelId: 'veo_3_1-fast-4K',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const createCall = openAIState.create.mock.calls.at(0)
|
||||
expect(createCall).toBeTruthy()
|
||||
if (!createCall) {
|
||||
throw new Error('videos.create should be called')
|
||||
}
|
||||
expect((createCall[0] as { model?: string }).model).toBe('veo_3_1-fast-4K')
|
||||
})
|
||||
|
||||
it('maps 3:2 to landscape size explicitly', async () => {
|
||||
openAIState.create.mockResolvedValueOnce({ id: 'vid_32' })
|
||||
|
||||
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/seed.png',
|
||||
prompt: 'animate',
|
||||
options: {
|
||||
resolution: '1080p',
|
||||
aspectRatio: '3:2',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const createCall = openAIState.create.mock.calls.at(0)
|
||||
expect(createCall).toBeTruthy()
|
||||
if (!createCall) {
|
||||
throw new Error('videos.create should be called')
|
||||
}
|
||||
expect((createCall[0] as { size?: string }).size).toBe('1792x1024')
|
||||
})
|
||||
|
||||
it('maps 2:3 to portrait size explicitly', async () => {
|
||||
openAIState.create.mockResolvedValueOnce({ id: 'vid_23' })
|
||||
|
||||
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/seed.png',
|
||||
prompt: 'animate',
|
||||
options: {
|
||||
resolution: '720p',
|
||||
aspectRatio: '2:3',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const createCall = openAIState.create.mock.calls.at(0)
|
||||
expect(createCall).toBeTruthy()
|
||||
if (!createCall) {
|
||||
throw new Error('videos.create should be called')
|
||||
}
|
||||
expect((createCall[0] as { size?: string }).size).toBe('720x1280')
|
||||
})
|
||||
|
||||
it('fails explicitly on unsupported aspect ratios', async () => {
|
||||
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/seed.png',
|
||||
prompt: 'animate',
|
||||
options: {
|
||||
resolution: '720p',
|
||||
aspectRatio: '5:4',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED')
|
||||
})
|
||||
})
|
||||
58
tests/unit/helpers/api-fetch.test.ts
Normal file
58
tests/unit/helpers/api-fetch.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
describe('apiFetch locale header injection', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('injects Accept-Language for internal /api requests', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('/api/tasks?status=running', { method: 'GET' })
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Accept-Language')).toBe('zh')
|
||||
})
|
||||
|
||||
it('uses pathname locale and does not override explicit Accept-Language', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: {
|
||||
pathname: '/en/workspace',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': 'ja',
|
||||
},
|
||||
body: JSON.stringify({ ok: true }),
|
||||
})
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Accept-Language')).toBe('ja')
|
||||
})
|
||||
|
||||
it('does not inject locale header for non-internal URLs', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await apiFetch('https://example.com/health', { method: 'GET' })
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.has('Accept-Language')).toBe(false)
|
||||
})
|
||||
})
|
||||
185
tests/unit/helpers/json-repair.test.ts
Normal file
185
tests/unit/helpers/json-repair.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeParseJson, safeParseJsonObject, safeParseJsonArray } from '@/lib/json-repair'
|
||||
|
||||
// ─── safeParseJson ───────────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJson', () => {
|
||||
it('正常 JSON 字符串 -> 直接解析成功', () => {
|
||||
const result = safeParseJson('{"name":"孙悟空","age":500}')
|
||||
expect(result).toEqual({ name: '孙悟空', age: 500 })
|
||||
})
|
||||
|
||||
it('包含 markdown 代码块 -> 剥离后解析成功', () => {
|
||||
const input = '```json\n{"key":"value"}\n```'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('包含大写 JSON 标记的 markdown 代码块 -> 剥离后解析成功', () => {
|
||||
const input = '```JSON\n{"key":"value"}\n```'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ key: 'value' })
|
||||
})
|
||||
|
||||
it('尾部逗号 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = '{"a":1,"b":2,}'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
|
||||
it('单引号包裹字符串 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = "{'name':'张三','age':25}"
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ name: '张三', age: 25 })
|
||||
})
|
||||
|
||||
it('JSON 前后有多余文字 -> jsonrepair 修复后解析成功', () => {
|
||||
const input = '以下是分析结果:\n{"result":"success"}\n以上是所有内容。'
|
||||
const result = safeParseJson(input)
|
||||
expect(result).toEqual({ result: 'success' })
|
||||
})
|
||||
|
||||
it('完全无效内容(无任何 JSON 结构字符)-> jsonrepair 将其视为字符串', () => {
|
||||
// jsonrepair 会把纯文本修复为 JSON 字符串
|
||||
const result = safeParseJson('这不是JSON')
|
||||
expect(result).toBe('这不是JSON')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── safeParseJsonObject ─────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJsonObject', () => {
|
||||
it('正常 JSON 对象 -> 返回对象', () => {
|
||||
const result = safeParseJsonObject('{"characters":[],"locations":[]}')
|
||||
expect(result).toEqual({ characters: [], locations: [] })
|
||||
})
|
||||
|
||||
it('markdown 包裹的 JSON 对象 -> 剥离后返回对象', () => {
|
||||
const input = '```json\n{"episodes":[{"number":1}]}\n```'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result).toHaveProperty('episodes')
|
||||
expect((result.episodes as unknown[])[0]).toEqual({ number: 1 })
|
||||
})
|
||||
|
||||
it('包含中文角引号「」的内容 -> 正常解析保留', () => {
|
||||
const input = '{"lines":"孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」"}'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result.lines).toBe('孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」')
|
||||
})
|
||||
|
||||
it('LLM 输出数组而非对象 -> 抛出 Expected JSON object 错误', () => {
|
||||
expect(() => safeParseJsonObject('[1,2,3]')).toThrow('Expected JSON object')
|
||||
})
|
||||
|
||||
it('尾部逗号 + markdown 包裹 -> 修复后返回正确对象', () => {
|
||||
const input = '```json\n{"a":1,"b":"hello",}\n```'
|
||||
const result = safeParseJsonObject(input)
|
||||
expect(result).toEqual({ a: 1, b: 'hello' })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── safeParseJsonArray ──────────────────────────────────────────────
|
||||
|
||||
describe('safeParseJsonArray', () => {
|
||||
it('正常 JSON 数组 -> 返回对象数组', () => {
|
||||
const input = '[{"id":1,"name":"角色A"},{"id":2,"name":"角色B"}]'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ id: 1, name: '角色A' })
|
||||
expect(result[1]).toEqual({ id: 2, name: '角色B' })
|
||||
})
|
||||
|
||||
it('对象包裹数组 + fallbackKey -> 提取内部数组', () => {
|
||||
const input = '{"clips":[{"id":1},{"id":2}]}'
|
||||
const result = safeParseJsonArray(input, 'clips')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it('对象包裹数组 + 无 fallbackKey -> 自动发现第一个数组字段', () => {
|
||||
const input = '{"episodes":[{"number":1},{"number":2}]}'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ number: 1 })
|
||||
})
|
||||
|
||||
it('markdown 包裹 + 尾部逗号 -> 修复后返回正确数组', () => {
|
||||
const input = '```json\n[{"a":1},{"b":2},]\n```'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ a: 1 })
|
||||
expect(result[1]).toEqual({ b: 2 })
|
||||
})
|
||||
|
||||
it('过滤非对象元素(数字、字符串等)-> 只保留对象', () => {
|
||||
const input = '[{"valid":true}, 42, "string", null, {"also":true}]'
|
||||
const result = safeParseJsonArray(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ valid: true })
|
||||
expect(result[1]).toEqual({ also: true })
|
||||
})
|
||||
|
||||
it('空数组 -> 返回空数组', () => {
|
||||
const result = safeParseJsonArray('[]')
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('非数组非对象 -> 抛出错误', () => {
|
||||
expect(() => safeParseJsonArray('"just a string"')).toThrow('Expected JSON array')
|
||||
})
|
||||
|
||||
it('对象不含数组字段 -> 抛出错误', () => {
|
||||
expect(() => safeParseJsonArray('{"key":"value"}')).toThrow('Expected JSON array')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 真实 LLM 畸形输出回归测试 ───────────────────────────────────────
|
||||
|
||||
describe('LLM 畸形 JSON 输出回归测试', () => {
|
||||
it('中文弯引号嵌套在 JSON 值中 -> jsonrepair 修复成功', () => {
|
||||
// 这是导致 "Invalid clip JSON format" 的典型场景
|
||||
const llmOutput = '```json\n[{"description":"孙悟空怒道,\\u201c一个冒牌货!\\u201d"}]\n```'
|
||||
const result = safeParseJsonArray(llmOutput)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].description).toContain('孙悟空')
|
||||
})
|
||||
|
||||
it('LLM 输出前后带解释文字 -> 提取并解析 JSON', () => {
|
||||
const llmOutput = `好的,以下是分析结果:
|
||||
|
||||
{"locations":[{"name":"客厅_白天","summary":"主角居住的客厅"}]}
|
||||
|
||||
以上是所有场景分析。`
|
||||
const result = safeParseJsonObject(llmOutput)
|
||||
expect(result.locations).toBeDefined()
|
||||
const locations = result.locations as unknown[]
|
||||
expect(locations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('使用「」角引号的台词内容 -> 正确解析不破坏 JSON', () => {
|
||||
// 改造后的提示词要求 LLM 用「」替代引号
|
||||
const llmOutput = '[{"speaker":"孙悟空","content":"「你竟敢拦我的路!」","emotionStrength":0.4}]'
|
||||
const result = safeParseJsonArray(llmOutput)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].speaker).toBe('孙悟空')
|
||||
expect(result[0].content).toBe('「你竟敢拦我的路!」')
|
||||
expect(result[0].emotionStrength).toBe(0.4)
|
||||
})
|
||||
|
||||
it('带控制字符的 JSON -> jsonrepair 修复成功', () => {
|
||||
// LLM 有时在字符串值中输出真实换行符
|
||||
const llmOutput = '{"text":"第一行\\n第二行","count":2}'
|
||||
const result = safeParseJsonObject(llmOutput)
|
||||
expect(result.text).toBe('第一行\n第二行')
|
||||
expect(result.count).toBe(2)
|
||||
})
|
||||
|
||||
it('clips 包裹在对象中 -> 正确提取', () => {
|
||||
// clips-build 中常见的 LLM 输出格式
|
||||
const llmOutput = '{"clips":[{"id":"clip_1","startText":"从前"},{"id":"clip_2","startText":"后来"}]}'
|
||||
const result = safeParseJsonArray(llmOutput, 'clips')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe('clip_1')
|
||||
expect(result[1].startText).toBe('后来')
|
||||
})
|
||||
})
|
||||
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal file
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitStructuredOutput } from '@/components/llm-console/LLMStageStreamCard'
|
||||
|
||||
describe('LLMStageStreamCard structured output parsing', () => {
|
||||
it('moves think-tagged text from final block into reasoning', () => {
|
||||
const parsed = splitStructuredOutput(`【思考过程】
|
||||
已有思考
|
||||
|
||||
【最终结果】
|
||||
<think>追加思考</think>
|
||||
{"locations":[]}`)
|
||||
|
||||
expect(parsed.reasoning).toContain('已有思考')
|
||||
expect(parsed.reasoning).toContain('追加思考')
|
||||
expect(parsed.finalText).toBe('{"locations":[]}')
|
||||
})
|
||||
|
||||
it('handles unmatched think opening tag during streaming', () => {
|
||||
const parsed = splitStructuredOutput(`【最终结果】
|
||||
<think>流式中的思考还没结束`)
|
||||
|
||||
expect(parsed.reasoning).toBe('流式中的思考还没结束')
|
||||
expect(parsed.finalText).toBe('')
|
||||
})
|
||||
})
|
||||
63
tests/unit/helpers/logging-core.test.ts
Normal file
63
tests/unit/helpers/logging-core.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('logging core suppression', () => {
|
||||
let originalLogLevel: string | undefined
|
||||
let originalUnifiedEnabled: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
originalLogLevel = process.env.LOG_LEVEL
|
||||
originalUnifiedEnabled = process.env.LOG_UNIFIED_ENABLED
|
||||
process.env.LOG_LEVEL = 'INFO'
|
||||
process.env.LOG_UNIFIED_ENABLED = 'true'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env.LOG_LEVEL
|
||||
} else {
|
||||
process.env.LOG_LEVEL = originalLogLevel
|
||||
}
|
||||
if (originalUnifiedEnabled === undefined) {
|
||||
delete process.env.LOG_UNIFIED_ENABLED
|
||||
} else {
|
||||
process.env.LOG_UNIFIED_ENABLED = originalUnifiedEnabled
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('suppresses worker.progress.stream logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress.stream',
|
||||
message: 'worker stream chunk',
|
||||
details: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps non-suppressed logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress',
|
||||
message: 'worker progress update',
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls[0]?.[0])) as { action?: string; message?: string }
|
||||
expect(payload.action).toBe('worker.progress')
|
||||
expect(payload.message).toBe('worker progress update')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
migrateGatewayRoutePayload,
|
||||
migrateProviderEntry,
|
||||
} from '@/lib/migrations/gateway-route-openai-compat'
|
||||
|
||||
describe('gateway-route openai-compat migration', () => {
|
||||
it('migrates openai-compatible litellm route to openai-compat', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'litellm',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
|
||||
})
|
||||
|
||||
it('forces gemini-compatible to gemini-sdk + official route', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'openai-official',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'gemini-sdk',
|
||||
gatewayRoute: 'official',
|
||||
})
|
||||
expect(result.summary.geminiApiModeCorrected).toBe(1)
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
})
|
||||
|
||||
it('forces non-openai-compatible compat routes to official', () => {
|
||||
const result = migrateProviderEntry({
|
||||
id: 'openrouter',
|
||||
gatewayRoute: 'openai-compat',
|
||||
})
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.next).toMatchObject({
|
||||
id: 'openrouter',
|
||||
gatewayRoute: 'official',
|
||||
})
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
})
|
||||
|
||||
it('returns invalid status for malformed payload json', () => {
|
||||
const result = migrateGatewayRoutePayload('{bad-json')
|
||||
expect(result.status).toBe('invalid')
|
||||
expect(result.summary.invalidPayload).toBe(true)
|
||||
})
|
||||
|
||||
it('migrates mixed provider payload and reports aggregate stats', () => {
|
||||
const result = migrateGatewayRoutePayload(JSON.stringify([
|
||||
{
|
||||
id: 'openai-compatible:oa-1',
|
||||
gatewayRoute: 'litellm',
|
||||
},
|
||||
{
|
||||
id: 'gemini-compatible:gm-1',
|
||||
apiMode: 'openai-official',
|
||||
gatewayRoute: 'openai-compat',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
gatewayRoute: 'official',
|
||||
},
|
||||
]))
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.summary.providersScanned).toBe(3)
|
||||
expect(result.summary.providersChanged).toBe(2)
|
||||
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
|
||||
expect(result.summary.routeForcedOfficial).toBe(1)
|
||||
expect(result.summary.geminiApiModeCorrected).toBe(1)
|
||||
|
||||
const nextPayload = JSON.parse(result.nextRaw || '[]') as Array<Record<string, unknown>>
|
||||
expect(nextPayload[0]?.gatewayRoute).toBe('openai-compat')
|
||||
expect(nextPayload[1]?.apiMode).toBe('gemini-sdk')
|
||||
expect(nextPayload[1]?.gatewayRoute).toBe('official')
|
||||
})
|
||||
})
|
||||
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addCharacterPromptSuffix,
|
||||
CHARACTER_PROMPT_SUFFIX,
|
||||
removeCharacterPromptSuffix,
|
||||
} from '@/lib/constants'
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
if (!target) return 0
|
||||
return input.split(target).length - 1
|
||||
}
|
||||
|
||||
describe('character prompt suffix regression', () => {
|
||||
it('appends suffix when generating prompt', () => {
|
||||
const basePrompt = 'A brave knight in silver armor'
|
||||
const generated = addCharacterPromptSuffix(basePrompt)
|
||||
|
||||
expect(generated).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(generated, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
})
|
||||
|
||||
it('removes suffix text from prompt', () => {
|
||||
const basePrompt = 'A calm detective with short black hair'
|
||||
const withSuffix = addCharacterPromptSuffix(basePrompt)
|
||||
const removed = removeCharacterPromptSuffix(withSuffix)
|
||||
|
||||
expect(removed).not.toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removed).toContain(basePrompt)
|
||||
})
|
||||
|
||||
it('uses suffix as full prompt when base prompt is empty', () => {
|
||||
expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removeCharacterPromptSuffix('')).toBe('')
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCondition(condition: () => boolean, timeoutMs = 1000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (condition()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('condition not met before timeout')
|
||||
}
|
||||
|
||||
describe('recovered run subscription', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
if (originalFetch) {
|
||||
globalThis.fetch = originalFetch
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
}
|
||||
})
|
||||
|
||||
it('replays run events and keeps recovering when no terminal event is present', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'step.start',
|
||||
stepKey: 'clip_1_phase1',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 4,
|
||||
message: 'running',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run-1/events?afterSeq=0&limit=500',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.start',
|
||||
runId: 'run-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
}))
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('settles recovery when replay hits terminal run event', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.error',
|
||||
payload: {
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:02.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => onSettled.mock.calls.length === 1 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('replays step.chunk output so refresh keeps prior text', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'step.chunk',
|
||||
stepKey: 'clip_1_phase1',
|
||||
payload: {
|
||||
stream: {
|
||||
kind: 'text',
|
||||
lane: 'main',
|
||||
seq: 1,
|
||||
delta: '旧输出',
|
||||
},
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
runId: 'run-1',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.chunk',
|
||||
runId: 'run-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
textDelta: '旧输出',
|
||||
}))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('emits run.error and settles when idle timeout is reached', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
events: [],
|
||||
}),
|
||||
)
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-timeout',
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_200)
|
||||
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-timeout',
|
||||
message: 'run stream timeout: run-timeout',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resets idle timeout when a new event arrives during recovery', async () => {
|
||||
vi.useFakeTimers()
|
||||
let eventFetchCount = 0
|
||||
const fetchMock = vi.fn().mockImplementation(async () => {
|
||||
eventFetchCount += 1
|
||||
if (eventFetchCount === 2) {
|
||||
return jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'resumed' },
|
||||
createdAt: '2026-02-28T00:00:01.500Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-recover',
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_200)
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.start',
|
||||
runId: 'run-recover',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reconciles run snapshot to failed when event polling stays empty', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/api/runs/run-reconcile/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
if (url === '/api/runs/run-reconcile') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run-reconcile',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
},
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
runId: 'run-reconcile',
|
||||
taskStreamTimeoutMs: 20_000,
|
||||
applyAndCapture,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500)
|
||||
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run-reconcile',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'run-reconcile',
|
||||
message: 'Ark Responses 调用失败',
|
||||
}))
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseReferenceImages, readBoolean, readString } from '@/lib/workers/handlers/reference-to-character-helpers'
|
||||
|
||||
describe('reference-to-character helpers', () => {
|
||||
it('parses and trims single reference image', () => {
|
||||
expect(parseReferenceImages({ referenceImageUrl: ' https://x/a.png ' })).toEqual(['https://x/a.png'])
|
||||
})
|
||||
|
||||
it('parses multi reference images and truncates to max 5', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
'https://x/6.png',
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters empty values', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [' ', '\n', 'https://x/ok.png'],
|
||||
}),
|
||||
).toEqual(['https://x/ok.png'])
|
||||
})
|
||||
|
||||
it('readString trims and normalizes invalid values', () => {
|
||||
expect(readString(' abc ')).toBe('abc')
|
||||
expect(readString(1)).toBe('')
|
||||
expect(readString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('readBoolean supports boolean/number/string flags', () => {
|
||||
expect(readBoolean(true)).toBe(true)
|
||||
expect(readBoolean(1)).toBe(true)
|
||||
expect(readBoolean('true')).toBe(true)
|
||||
expect(readBoolean('YES')).toBe(true)
|
||||
expect(readBoolean('on')).toBe(true)
|
||||
expect(readBoolean('0')).toBe(false)
|
||||
expect(readBoolean(false)).toBe(false)
|
||||
expect(readBoolean(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import {
|
||||
parseSyncFlag,
|
||||
resolveDisplayMode,
|
||||
resolvePositiveInteger,
|
||||
shouldRunSyncTask,
|
||||
} from '@/lib/llm-observe/route-task'
|
||||
|
||||
function buildRequest(path: string, headers?: Record<string, string>) {
|
||||
return new NextRequest(new URL(path, 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: headers || {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('route-task helpers', () => {
|
||||
it('parseSyncFlag supports boolean-like values', () => {
|
||||
expect(parseSyncFlag(true)).toBe(true)
|
||||
expect(parseSyncFlag(1)).toBe(true)
|
||||
expect(parseSyncFlag('1')).toBe(true)
|
||||
expect(parseSyncFlag('true')).toBe(true)
|
||||
expect(parseSyncFlag('yes')).toBe(true)
|
||||
expect(parseSyncFlag('on')).toBe(true)
|
||||
expect(parseSyncFlag('false')).toBe(false)
|
||||
expect(parseSyncFlag(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when internal task header exists', () => {
|
||||
const req = buildRequest('/api/test', { 'x-internal-task-id': 'task-1' })
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when body sync flag exists', () => {
|
||||
const req = buildRequest('/api/test')
|
||||
expect(shouldRunSyncTask(req, { sync: 'true' })).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when query sync flag exists', () => {
|
||||
const req = buildRequest('/api/test?sync=1')
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveDisplayMode falls back to default on invalid value', () => {
|
||||
expect(resolveDisplayMode('detail', 'loading')).toBe('detail')
|
||||
expect(resolveDisplayMode('loading', 'detail')).toBe('loading')
|
||||
expect(resolveDisplayMode('invalid', 'loading')).toBe('loading')
|
||||
})
|
||||
|
||||
it('resolvePositiveInteger returns safe integer fallback', () => {
|
||||
expect(resolvePositiveInteger(2.9, 1)).toBe(2)
|
||||
expect(resolvePositiveInteger('9', 1)).toBe(9)
|
||||
expect(resolvePositiveInteger('0', 7)).toBe(7)
|
||||
expect(resolvePositiveInteger('abc', 7)).toBe(7)
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal file
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { executeRunRequest } from '@/lib/query/hooks/run-stream/run-request-executor'
|
||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('run-request-executor run events path', () => {
|
||||
it('uses /api/runs/:runId/events when async response includes runId', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
runId: 'run_1',
|
||||
afterSeq: 0,
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'started' },
|
||||
createdAt: '2026-02-28T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
eventType: 'step.start',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
stepTitle: 'Step A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.000Z',
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
eventType: 'step.chunk',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
lane: 'text',
|
||||
payload: {
|
||||
stream: {
|
||||
delta: 'hello',
|
||||
seq: 1,
|
||||
},
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:01.100Z',
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
eventType: 'step.complete',
|
||||
stepKey: 'step_a',
|
||||
attempt: 1,
|
||||
payload: {
|
||||
text: 'hello',
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:02.000Z',
|
||||
},
|
||||
{
|
||||
seq: 5,
|
||||
eventType: 'run.complete',
|
||||
payload: {
|
||||
summary: { ok: true },
|
||||
},
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const captured: RunStreamEvent[] = []
|
||||
const controller = new AbortController()
|
||||
const result = await executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: (event) => {
|
||||
captured.push(event)
|
||||
},
|
||||
finalResultRef: { current: null },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.runId).toBe('run_1')
|
||||
expect(captured.some((event) => event.event === 'step.chunk' && event.textDelta === 'hello')).toBe(true)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/runs/run_1/events?afterSeq=0&limit=500')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces run-events fetch errors instead of swallowing them', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
}))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
error: {
|
||||
message: 'events backend unavailable',
|
||||
},
|
||||
}, 503))
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
await expect(executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: () => undefined,
|
||||
finalResultRef: { current: null },
|
||||
})).rejects.toThrow('run events fetch failed (HTTP 503): events backend unavailable')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('uses idle timeout and resets the timer when new events arrive', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
let eventsRequestCount = 0
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/story-to-script-stream')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/runs/run_1') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run_1',
|
||||
status: 'running',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!url.includes('/api/runs/run_1/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
|
||||
eventsRequestCount += 1
|
||||
if (eventsRequestCount === 3) {
|
||||
return jsonResponse({
|
||||
events: [
|
||||
{
|
||||
seq: 1,
|
||||
eventType: 'run.start',
|
||||
payload: { message: 'started' },
|
||||
createdAt: '2026-02-28T00:00:03.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
let settled = false
|
||||
const request = executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 3_000,
|
||||
applyAndCapture: () => undefined,
|
||||
finalResultRef: { current: null },
|
||||
}).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await expect(request).resolves.toEqual(expect.objectContaining({
|
||||
runId: 'run_1',
|
||||
status: 'failed',
|
||||
errorMessage: 'run stream timeout: run_1',
|
||||
}))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles terminal failed run status when events stream has no new rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/story-to-script-stream')) {
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task_2',
|
||||
runId: 'run_2',
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/runs/run_2/events')) {
|
||||
return jsonResponse({ events: [] })
|
||||
}
|
||||
if (url === '/api/runs/run_2') {
|
||||
return jsonResponse({
|
||||
run: {
|
||||
id: 'run_2',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
},
|
||||
})
|
||||
}
|
||||
return jsonResponse({ events: [] })
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
try {
|
||||
const captured: RunStreamEvent[] = []
|
||||
const controller = new AbortController()
|
||||
const request = executeRunRequest({
|
||||
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
|
||||
requestBody: { episodeId: 'episode_1' },
|
||||
controller,
|
||||
taskStreamTimeoutMs: 30_000,
|
||||
applyAndCapture: (event) => {
|
||||
captured.push(event)
|
||||
},
|
||||
finalResultRef: { current: null },
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_500)
|
||||
await expect(request).resolves.toEqual(expect.objectContaining({
|
||||
runId: 'run_2',
|
||||
status: 'failed',
|
||||
errorMessage: 'Ark Responses 调用失败',
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runs/run_2',
|
||||
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
|
||||
)
|
||||
expect(captured.some((event) => event.event === 'run.error' && event.message === 'Ark Responses 调用失败')).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user