first commit

This commit is contained in:
wsq
2026-05-13 21:58:19 +08:00
commit 0167c66cb7
1475 changed files with 233414 additions and 0 deletions
@@ -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)
})
})
+29
View File
@@ -0,0 +1,29 @@
# Behavior Test Standard
## Scope
- `tests/integration/api/contract/**/*.test.ts`
- `tests/integration/provider/**/*.test.ts`
- `tests/integration/chain/**/*.test.ts`
- `tests/system/**/*.test.ts`
- `tests/regression/**/*.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.
- Provider or gateway protocol changes must add a provider contract test or update an existing localhost fake-provider scenario.
@@ -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)
}
}
})
})
+159
View File
@@ -0,0 +1,159 @@
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',
'tests/system/generate-image.system.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',
'tests/system/generate-video.system.test.ts',
],
},
{
id: 'REQ-NP-INSERT-PANEL-AUTO-ANALYZE',
feature: 'Novel promotion insert panel',
userValue: 'AI 自动分析插入分镜时不会因空输入失败',
risk: 'route 与 worker 契约分叉导致异步任务直接报错',
priority: 'P0',
tests: [
'tests/unit/novel-promotion/insert-panel-user-input.test.ts',
'tests/integration/api/contract/direct-submit-routes.test.ts',
'tests/system/text-workflow.system.test.ts',
],
},
{
id: 'REQ-NP-PANEL-VARIANT-SAFETY',
feature: 'Novel promotion panel variant',
userValue: '镜头变体只能插入当前 storyboard,任务失败可回滚,资产开关真实生效',
risk: '跨分镜误插入、创建脏 panel、参考图开关失效',
priority: 'P0',
tests: [
'tests/integration/api/specific/panel-variant-route.test.ts',
'tests/integration/api/contract/direct-submit-routes.test.ts',
'tests/unit/worker/panel-variant-task-handler.test.ts',
'tests/regression/panel-variant-cross-storyboard.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',
'tests/system/text-workflow.system.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/integration/task/create-task-dedupe.integration.test.ts',
'tests/unit/optimistic/sse-invalidation.test.ts',
],
},
{
id: 'REQ-PROVIDER-PROTOCOL-CONTRACT',
feature: 'Provider protocol contract',
userValue: '外部 provider 请求格式、轮询状态和错误分类保持稳定',
risk: 'provider 协议漂移导致系统链路仅在真实调用时失败',
priority: 'P0',
tests: [
'tests/integration/provider/fal-provider.contract.test.ts',
'tests/integration/provider/openai-compat-provider.contract.test.ts',
'tests/unit/task/async-poll-external-id.test.ts',
],
},
{
id: 'REQ-TASK-DEDUPE-COMPENSATION',
feature: 'Task dedupe and enqueue compensation',
userValue: '重复提交不会卡死,队列失败不会留下脏冻结或孤儿任务',
risk: '重复任务、孤儿 dedupeKey、enqueue 失败后冻结金额未回滚',
priority: 'P0',
tests: [
'tests/integration/task/create-task-dedupe.integration.test.ts',
'tests/integration/billing/submitter.integration.test.ts',
'tests/regression/task-dedupe-recovery.test.ts',
'tests/regression/task-enqueue-billing-rollback.test.ts',
'tests/unit/worker/user-concurrency-gate.test.ts',
],
},
{
id: 'REQ-API-CONFIG-TUTORIAL-PORTAL',
feature: 'API config tutorial modal layering',
userValue: '开通教程浮层只高亮当前教程,不污染其他 provider card',
risk: '弹层挂载在局部层叠上下文内,导致高亮重叠和误覆盖',
priority: 'P1',
tests: [
'tests/unit/api-config/provider-card-tutorial-modal.test.ts',
],
},
{
id: 'REQ-INFRA-PUBLIC-ROUTES',
feature: 'Infra and public routes',
userValue: '基础公共路由可稳定访问,公开范围明确且有测试兜底',
risk: '特殊公开路由缺少约束或回归覆盖,导致泄漏、误拦截或行为漂移',
priority: 'P1',
tests: [
'tests/integration/api/contract/infra-routes.test.ts',
],
},
]
+51
View File
@@ -0,0 +1,51 @@
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-assets-routes': 'tests/integration/api/contract/crud-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/infra-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
+250
View File
@@ -0,0 +1,250 @@
export type RouteCategory =
| 'assets'
| 'asset-hub'
| 'novel-promotion'
| 'projects'
| 'tasks'
| 'user'
| 'auth'
| 'infra'
| 'system'
export type RouteContractGroup =
| 'llm-observe-routes'
| 'direct-submit-routes'
| 'crud-assets-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/admin/download-logs/route.ts',
'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/ai-modify-prop/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/assets/[assetId]/copy/route.ts',
'src/app/api/assets/[assetId]/generate/route.ts',
'src/app/api/assets/[assetId]/modify-render/route.ts',
'src/app/api/assets/[assetId]/revert-render/route.ts',
'src/app/api/assets/[assetId]/route.ts',
'src/app/api/assets/[assetId]/select-render/route.ts',
'src/app/api/assets/[assetId]/update-label/route.ts',
'src/app/api/assets/[assetId]/variants/[variantId]/route.ts',
'src/app/api/assets/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-prop/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/ai-story-expand/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/assets/')) return 'assets'
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('/generate/route.ts')
|| routeFile.endsWith('/modify-image/route.ts')
|| routeFile.endsWith('/modify-render/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/assets/')) return 'crud-assets-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
+61
View File
@@ -0,0 +1,61 @@
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_STORY_EXPAND]: 'tests/unit/worker/ai-story-expand.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_PROP]: 'tests/unit/helpers/prop-modify-task-registration.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_AI_MODIFY_PROP]: 'tests/unit/helpers/prop-modify-task-registration.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
View 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
View File
@@ -0,0 +1,6 @@
{
"textModel": "anthropic/claude-sonnet-4",
"imageModel": "seedream",
"videoModel": "doubao-seedance-1-0-pro-fast-251015",
"voiceSeconds": 5
}
+23
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }
}
+193
View File
@@ -0,0 +1,193 @@
import http, { type IncomingMessage, type ServerResponse } from 'node:http'
export type FakeScenarioMode =
| 'success'
| 'queued_then_success'
| 'retryable_error_then_success'
| 'fatal_error'
| 'malformed_response'
| 'timeout'
export type FakeResponseSpec = {
status: number
headers?: Record<string, string>
body?: string | Buffer | Record<string, unknown> | unknown[] | null
delayMs?: number
}
export type FakeRequestRecord = {
method: string
path: string
query: string
bodyText: string
headers: Record<string, string | string[] | undefined>
}
type RouteKey = `${Uppercase<string>} ${string}`
type RouteScenario = {
mode: FakeScenarioMode
submitResponse?: FakeResponseSpec
pollSequence?: FakeResponseSpec[]
errorCode?: string
delayMs?: number
}
function routeKey(method: string, path: string): RouteKey {
return `${method.toUpperCase()} ${path}` as RouteKey
}
function normalizeHeaders(headers: IncomingMessage['headers']): Record<string, string | string[] | undefined> {
return Object.fromEntries(Object.entries(headers))
}
function toBodyText(chunks: Buffer[]): string {
if (chunks.length === 0) return ''
return Buffer.concat(chunks).toString('utf8')
}
function isJsonBody(body: FakeResponseSpec['body']): body is Record<string, unknown> | unknown[] | null {
return body === null || Array.isArray(body) || (!!body && typeof body === 'object' && !Buffer.isBuffer(body))
}
async function writeResponse(
res: ServerResponse,
spec: FakeResponseSpec,
inheritedDelayMs: number | undefined,
) {
const delayMs = spec.delayMs ?? inheritedDelayMs ?? 0
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
const headers = { ...(spec.headers || {}) }
if (isJsonBody(spec.body) && !headers['content-type']) {
headers['content-type'] = 'application/json'
}
res.writeHead(spec.status, headers)
if (spec.body === undefined) {
res.end()
return
}
if (Buffer.isBuffer(spec.body)) {
res.end(spec.body)
return
}
if (isJsonBody(spec.body)) {
res.end(JSON.stringify(spec.body))
return
}
res.end(spec.body)
}
export async function startScenarioServer() {
const requests = new Map<RouteKey, FakeRequestRecord[]>()
const routes = new Map<RouteKey, { queue: FakeResponseSpec[]; mode: FakeScenarioMode; delayMs?: number }>()
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1')
const key = routeKey(req.method || 'GET', url.pathname)
const entry = routes.get(key)
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const bodyText = toBodyText(chunks)
const history = requests.get(key) || []
history.push({
method: (req.method || 'GET').toUpperCase(),
path: url.pathname,
query: url.search,
bodyText,
headers: normalizeHeaders(req.headers),
})
requests.set(key, history)
if (!entry) {
await writeResponse(res, {
status: 404,
body: { error: 'SCENARIO_ROUTE_NOT_FOUND', path: url.pathname },
}, 0)
return
}
const next = entry.queue.length > 1 ? entry.queue.shift() : entry.queue[0]
if (!next) {
await writeResponse(res, {
status: 500,
body: { error: 'SCENARIO_DEPLETED', path: url.pathname, mode: entry.mode },
}, entry.delayMs)
return
}
await writeResponse(res, next, entry.delayMs)
})
await new Promise<void>((resolve, reject) => {
server.listen(0, '127.0.0.1', () => resolve())
server.once('error', reject)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('SCENARIO_SERVER_ADDRESS_INVALID')
}
const baseUrl = `http://127.0.0.1:${address.port}`
return {
baseUrl,
defineScenario(input: {
method: string
path: string
mode: FakeScenarioMode
submitResponse?: FakeResponseSpec
pollSequence?: FakeResponseSpec[]
errorCode?: string
delayMs?: number
}) {
const key = routeKey(input.method, input.path)
const queue: FakeResponseSpec[] = []
if (input.submitResponse) {
queue.push(input.submitResponse)
}
if (input.pollSequence && input.pollSequence.length > 0) {
queue.push(...input.pollSequence)
}
if (queue.length === 0) {
throw new Error(`SCENARIO_EMPTY_QUEUE: ${key}`)
}
const scenario: RouteScenario = {
mode: input.mode,
submitResponse: input.submitResponse,
pollSequence: input.pollSequence,
errorCode: input.errorCode,
delayMs: input.delayMs,
}
routes.set(key, {
queue,
mode: scenario.mode,
delayMs: scenario.delayMs,
})
requests.delete(key)
},
getRequests(method: string, path: string): FakeRequestRecord[] {
return [...(requests.get(routeKey(method, path)) || [])]
},
reset() {
routes.clear()
requests.clear()
},
async close() {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
},
}
}
+98
View File
@@ -0,0 +1,98 @@
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) {
const id = suffix()
return await prisma.project.create({
data: {
userId,
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
View 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
View File
@@ -0,0 +1,6 @@
import { loadTestEnv } from '../setup/env'
import { prisma } from '@/lib/prisma'
loadTestEnv()
export { prisma }
+62
View 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)
}
+8
View File
@@ -0,0 +1,8 @@
# Hidden Acceptance Reserve
This directory is intentionally reserved for private acceptance and regression suites that are not exposed to code-writing agents.
Public repo rules:
- Do not add executable public tests here.
- Keep provider/system helper interfaces stable so private CI can mount hidden evals without patching production code.
- Private hidden suites should target the same route entrypoints and fake-provider hooks used by `tests/system/**`.
@@ -0,0 +1,469 @@
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(),
},
novelPromotionStoryboard: {
findUnique: vi.fn(),
update: vi.fn(),
},
novelPromotionPanel: {
findUnique: vi.fn(),
update: vi.fn(),
create: vi.fn(),
count: 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' },
}
},
requireProjectAuthLight: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1' },
}
},
}
})
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' ? {} : { 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-assets-routes'
|| 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',
props: JSON.stringify(['Bronze Dagger']),
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
})
prismaMock.novelPromotionStoryboard.findUnique.mockResolvedValue({
id: 'storyboard-1',
projectId: 'project-1',
})
prismaMock.novelPromotionStoryboard.update.mockResolvedValue({
id: 'storyboard-1',
panelCount: 1,
})
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
})
prismaMock.novelPromotionPanel.update.mockResolvedValue({
id: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
props: JSON.stringify(['Bronze Dagger']),
})
prismaMock.novelPromotionPanel.create.mockResolvedValue({
id: 'panel-2',
storyboardId: 'storyboard-1',
panelIndex: 1,
props: JSON.stringify(['Bronze Dagger']),
})
prismaMock.novelPromotionPanel.count.mockResolvedValue(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 }
expect(payload).toEqual({
success: true,
})
})
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',
props: JSON.stringify(['Bronze Dagger']),
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',
props: JSON.stringify(['Bronze Dagger']),
content: 'clip content',
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
},
})
})
it('PUT /novel-promotion/[projectId]/panel writes provided props to prisma.novelPromotionPanel.update', async () => {
authState.authenticated = true
const mod = await import('@/app/api/novel-promotion/[projectId]/panel/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/panel',
method: 'PUT',
body: {
storyboardId: 'storyboard-1',
panelIndex: 0,
location: 'Old Town',
characters: JSON.stringify(['Alice']),
props: JSON.stringify(['Bronze Dagger']),
description: 'panel description',
},
})
const res = await mod.PUT(req, {
params: Promise.resolve({ projectId: 'project-1' }),
})
expect(res.status).toBe(200)
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
location: 'Old Town',
characters: JSON.stringify(['Alice']),
props: JSON.stringify(['Bronze Dagger']),
description: 'panel description',
},
})
})
})
@@ -0,0 +1,606 @@
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
}
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
expectedPayloadSubset?: Record<string, unknown>
}
const authState = vi.hoisted<AuthState>(() => ({
authenticated: true,
}))
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' })),
},
novelPromotionStoryboard: {
findUnique: vi.fn(async () => ({
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
})),
update: vi.fn(async () => ({})),
},
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,
storyboardId: 'storyboard-1',
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,
storyboardId: 'storyboard-1',
panelIndex: 2,
shotType: 'medium',
cameraMove: 'push',
description: 'insert description',
videoPrompt: 'insert video prompt',
location: 'insert location',
characters: '[]',
srtSegment: '',
duration: 3,
}
}
return {
id,
storyboardId: 'storyboard-1',
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 })),
findUniqueOrThrow: vi.fn(),
delete: vi.fn(async () => ({})),
count: vi.fn(async () => 3),
updateMany: vi.fn(async () => ({ count: 0 })),
},
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: { data?: { id?: string; panelIndex?: number } }) => Promise<{ id: string; panelIndex: number }>
findFirst: (args: unknown) => Promise<{ panelIndex: number } | null>
delete: (args: unknown) => Promise<unknown>
count: (args: unknown) => Promise<number>
updateMany: (args: unknown) => Promise<{ count: number }>
}
novelPromotionStoryboard: {
update: (args: unknown) => Promise<unknown>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionPanel: {
findMany: async () => [],
update: async () => ({}),
create: async (args: { data?: { id?: string; panelIndex?: number } }) => ({
id: args.data?.id || 'panel-created',
panelIndex: args.data?.panelIndex ?? 3,
}),
findFirst: async () => ({ panelIndex: 3 }),
delete: async () => ({}),
count: async () => 3,
updateMany: async () => ({ count: 0 }),
},
novelPromotionStoryboard: {
update: async () => ({}),
},
}
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' },
}
},
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/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, params?: Record<string, string>): string {
return routeFile
.replace(/^src\/app/, '')
.replace(/\/route\.ts$/, '')
.replace('[projectId]', params?.projectId || 'project-1')
.replace('[assetId]', params?.assetId || 'asset-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/assets/[assetId]/generate/route.ts',
body: {
scope: 'global',
kind: 'character',
appearanceIndex: 0,
artStyle: 'realistic',
},
params: { assetId: 'global-character-1' },
expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,
expectedTargetType: 'GlobalCharacter',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/assets/[assetId]/generate/route.ts',
body: {
scope: 'project',
kind: 'character',
projectId: 'project-1',
appearanceId: 'appearance-1',
},
params: { assetId: 'character-1' },
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
expectedTargetType: 'CharacterAppearance',
expectedProjectId: 'project-1',
},
{
routeFile: 'src/app/api/assets/[assetId]/modify-render/route.ts',
body: {
scope: 'global',
kind: 'character',
modifyPrompt: 'sharpen details',
appearanceIndex: 0,
imageIndex: 0,
extraImageUrls: ['https://example.com/ref-a.png'],
},
params: { assetId: 'global-character-1' },
expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,
expectedTargetType: 'GlobalCharacterAppearance',
expectedProjectId: 'global-asset-hub',
},
{
routeFile: 'src/app/api/assets/[assetId]/modify-render/route.ts',
body: {
scope: 'project',
kind: 'character',
projectId: 'project-1',
appearanceId: 'appearance-1',
modifyPrompt: 'enhance texture',
extraImageUrls: ['https://example.com/ref-b.png'],
},
params: { assetId: 'character-1' },
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
expectedTargetType: 'CharacterAppearance',
expectedProjectId: 'project-1',
},
{
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: 'ark::doubao-seedance-2-0-260128',
storyboardId: 'storyboard-1',
panelIndex: 0,
generationOptions: {
resolution: '720p',
duration: 5,
},
firstLastFrame: {
flModel: 'ark::doubao-seedance-2-0-260128',
},
},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
expectedTargetType: 'NovelPromotionPanel',
expectedProjectId: 'project-1',
expectedPayloadSubset: {
videoModel: 'ark::doubao-seedance-2-0-260128',
generationOptions: {
resolution: '720p',
duration: 5,
},
firstLastFrame: {
flModel: 'ark::doubao-seedance-2-0-260128',
},
},
},
{
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',
expectedPayloadSubset: {
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
userInput: '请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
},
},
{
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, routeCase.params),
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
let seq = 0
submitTaskMock.mockImplementation(async () => ({
taskId: `task-${++seq}`,
async: true,
}))
})
it('keeps expected coverage size', () => {
expect(DIRECT_CASES.length).toBe(20)
})
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')
if (routeCase.expectedPayloadSubset) {
expect(submitArg?.payload).toEqual(expect.objectContaining(routeCase.expectedPayloadSubset))
}
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')
}
})
}
})
@@ -0,0 +1,207 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
import { buildMockRequest } from '../../../helpers/request'
const authState = vi.hoisted(() => ({
authenticated: false,
}))
const loggingMock = vi.hoisted(() => ({
readAllLogs: vi.fn(async () => 'worker log line 1\nworker log line 2'),
}))
const storageMock = vi.hoisted(() => ({
getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`),
}))
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/logging/file-writer', () => loggingMock)
vi.mock('@/lib/storage', () => storageMock)
describe('api contract - infra routes (behavior)', () => {
const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes')
const originalUploadDir = process.env.UPLOAD_DIR
const tempState = {
uploadDirAbs: '',
uploadDirRel: '',
}
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = false
vi.resetModules()
})
afterEach(async () => {
vi.resetModules()
if (tempState.uploadDirAbs) {
await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true })
tempState.uploadDirAbs = ''
tempState.uploadDirRel = ''
}
if (originalUploadDir === undefined) {
delete process.env.UPLOAD_DIR
} else {
process.env.UPLOAD_DIR = originalUploadDir
}
})
async function prepareUploadDir(): Promise<void> {
const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
tempState.uploadDirRel = path.join('.tmp', unique)
tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel)
process.env.UPLOAD_DIR = tempState.uploadDirRel
await fs.mkdir(tempState.uploadDirAbs, { recursive: true })
}
it('infra route group exists', () => {
expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([
'src/app/api/admin/download-logs/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/system/boot-id/route.ts',
]))
})
it('GET /api/admin/download-logs rejects unauthenticated requests', async () => {
const mod = await import('@/app/api/admin/download-logs/route')
const req = buildMockRequest({
path: '/api/admin/download-logs',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(401)
expect(loggingMock.readAllLogs).not.toHaveBeenCalled()
})
it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => {
authState.authenticated = true
const mod = await import('@/app/api/admin/download-logs/route')
const req = buildMockRequest({
path: '/api/admin/download-logs',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toContain('worker log line 1')
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="waoowaoo-logs-/)
})
it('GET /api/cos/image redirects to signed storage route with normalized query', async () => {
const mod = await import('@/app/api/cos/image/route')
const req = buildMockRequest({
path: '/api/cos/image?key=folder/a.png&expires=7200',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200')
})
it('GET /api/storage/sign redirects to signed object url with default ttl', async () => {
const mod = await import('@/app/api/storage/sign/route')
const req = buildMockRequest({
path: '/api/storage/sign?key=folder/a.png',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600)
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600')
})
it('GET /api/system/boot-id returns the current server boot id', async () => {
const mod = await import('@/app/api/system/boot-id/route')
const serverBoot = await import('@/lib/server-boot')
const res = await mod.GET()
const json = await res.json() as { bootId: string }
expect(res.status).toBe(200)
expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID)
expect(typeof json.bootId).toBe('string')
expect(json.bootId.length).toBeGreaterThan(0)
})
it('GET /api/files/[...path] rejects path traversal attempts', async () => {
await prepareUploadDir()
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/%2E%2E/secret.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['..', 'secret.txt'] }),
})
const json = await res.json() as { error: string }
expect(res.status).toBe(403)
expect(json.error).toBe('Access denied')
})
it('GET /api/files/[...path] returns 404 when the file is missing', async () => {
await prepareUploadDir()
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/missing.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['missing.txt'] }),
})
const json = await res.json() as { error: string }
expect(res.status).toBe(404)
expect(json.error).toBe('File not found')
})
it('GET /api/files/[...path] serves local files from the configured upload dir', async () => {
await prepareUploadDir()
const nestedDir = path.join(tempState.uploadDirAbs, 'folder')
await fs.mkdir(nestedDir, { recursive: true })
await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8')
const mod = await import('@/app/api/files/[...path]/route')
const req = buildMockRequest({
path: '/api/files/folder/hello.txt',
method: 'GET',
})
const res = await mod.GET(req, {
params: Promise.resolve({ path: ['folder', 'hello.txt'] }),
})
const text = await res.text()
expect(res.status).toBe(200)
expect(text).toBe('hello local file')
expect(res.headers.get('content-type')).toBe('text/plain')
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000')
})
})
@@ -0,0 +1,417 @@
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
}
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,
}))
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',
name: '遗物匕首',
})),
findFirst: vi.fn(async () => ({
id: 'global-location-1',
userId: 'user-1',
name: '遗物匕首',
})),
},
novelPromotionProject: {
findUnique: vi.fn(async () => ({
id: 'novel-project-1',
})),
},
novelPromotionLocation: {
findFirst: vi.fn(async () => ({
id: 'project-prop-1',
name: '遗物匕首',
})),
},
}))
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' },
}
},
requireProjectAuthLight: async (projectId: string) => {
if (!authState.authenticated) return unauthorized()
return {
session: { user: { id: 'user-1' } },
project: { id: projectId, userId: 'user-1' },
}
},
}
})
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/ai-modify-prop/route.ts',
body: {
propId: 'global-location-1',
variantId: 'prop-variant-1',
currentDescription: 'old prop desc',
modifyInstruction: 'make it look older',
},
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP,
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/user/ai-story-expand/route.ts',
body: { prompt: '宫廷复仇女主回京' },
expectedTaskType: TASK_TYPE.AI_STORY_EXPAND,
expectedTargetType: 'HomeAiStoryExpand',
expectedProjectId: 'home-ai-write',
},
{
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-prop/route.ts',
body: {
propId: 'project-prop-1',
variantId: 'project-prop-variant-1',
currentDescription: 'old prop',
modifyInstruction: 'add engraved details',
},
params: { projectId: 'project-1' },
expectedTaskType: TASK_TYPE.AI_MODIFY_PROP,
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
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(25)
})
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')
})
}
})
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
const authMock = vi.hoisted(() => ({
requireProjectAuth: vi.fn(async () => ({
novelData: { id: 'np-1', projectId: 'project-1' },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionEpisode: {
findFirst: vi.fn(async () => null),
create: vi.fn(async () => ({
id: 'episode-1',
novelPromotionProjectId: 'np-1',
episodeNumber: 1,
name: '第 1 集',
description: null,
novelText: '第一章内容',
})),
},
novelPromotionProject: {
update: vi.fn(async () => ({
id: 'np-1',
lastEpisodeId: 'episode-1',
})),
},
}))
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
describe('api specific - novel promotion episode create text', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('persists novelText when creating the first episode from home launch', async () => {
const mod = await import('@/app/api/novel-promotion/[projectId]/episodes/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/episodes',
method: 'POST',
body: {
name: '第 1 集',
novelText: '第一章内容',
},
})
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
expect(res.status).toBe(201)
expect(prismaMock.novelPromotionEpisode.create).toHaveBeenCalledWith({
data: {
novelPromotionProjectId: 'np-1',
episodeNumber: 1,
name: '第 1 集',
description: null,
novelText: '第一章内容',
},
})
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
where: { id: 'np-1' },
data: { lastEpisodeId: 'episode-1' },
})
})
})
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
const authState = vi.hoisted(() => ({ authenticated: true }))
const getRunByIdMock = vi.hoisted(() => vi.fn())
const requestRunCancelMock = vi.hoisted(() => vi.fn())
const cancelTaskMock = vi.hoisted(() => vi.fn())
const publishRunEventMock = vi.hoisted(() => vi.fn(async () => undefined))
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,
requestRunCancel: requestRunCancelMock,
}))
vi.mock('@/lib/task/service', () => ({
cancelTask: cancelTaskMock,
}))
vi.mock('@/lib/run-runtime/publisher', () => ({
publishRunEvent: publishRunEventMock,
}))
describe('api contract - run cancel route', () => {
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = true
getRunByIdMock.mockResolvedValue({
id: 'run-1',
userId: 'user-1',
projectId: 'project-1',
taskId: 'task-1',
})
requestRunCancelMock.mockResolvedValue({
id: 'run-1',
userId: 'user-1',
projectId: 'project-1',
taskId: 'task-1',
status: 'canceling',
})
cancelTaskMock.mockResolvedValue({
task: {
id: 'task-1',
status: 'canceled',
errorCode: 'TASK_CANCELLED',
errorMessage: 'Run cancelled by user',
},
cancelled: true,
})
})
it('marks the run canceled and mirrors task cancellation without failing the task', async () => {
const { POST } = await import('@/app/api/runs/[runId]/cancel/route')
const req = buildMockRequest({
path: '/api/runs/run-1/cancel',
method: 'POST',
})
const res = await POST(req, {
params: Promise.resolve({ runId: 'run-1' }),
})
expect(res.status).toBe(200)
const payload = await res.json() as {
success: boolean
run: {
id: string
status: string
}
}
expect(payload.success).toBe(true)
expect(payload.run).toMatchObject({
id: 'run-1',
status: 'canceling',
})
expect(cancelTaskMock).toHaveBeenCalledWith('task-1', 'Run cancelled by user')
expect(publishRunEventMock).toHaveBeenCalledWith(expect.objectContaining({
runId: 'run-1',
eventType: 'run.canceled',
}))
})
})
@@ -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',
}),
}))
})
})
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
const authState = vi.hoisted(() => ({ authenticated: true }))
const listRunsMock = vi.hoisted(() => vi.fn())
const createRunMock = vi.hoisted(() => 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' } } }
},
}
})
vi.mock('@/lib/run-runtime/service', () => ({
listRuns: listRunsMock,
createRun: createRunMock,
}))
describe('api contract - runs list route', () => {
const emptyRouteContext = {
params: Promise.resolve({}),
}
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = true
listRunsMock.mockResolvedValue([
{
id: 'run-1',
status: 'running',
},
])
})
it('tightens scoped active run queries to the latest recoverable run', async () => {
const { GET } = await import('@/app/api/runs/route')
const req = buildMockRequest({
path: '/api/runs?projectId=project-1&workflowType=story_to_script_run&targetType=NovelPromotionEpisode&targetId=episode-1&episodeId=episode-1&status=queued&status=running&status=canceling&limit=20',
method: 'GET',
})
const res = await GET(req, emptyRouteContext)
expect(res.status).toBe(200)
expect(listRunsMock).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
projectId: 'project-1',
workflowType: 'story_to_script_run',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
episodeId: 'episode-1',
statuses: ['queued', 'running', 'canceling'],
limit: 20,
recoverableOnly: true,
latestOnly: true,
}))
})
it('keeps non-active queries as normal list requests', async () => {
const { GET } = await import('@/app/api/runs/route')
const req = buildMockRequest({
path: '/api/runs?projectId=project-1&workflowType=story_to_script_run&targetType=NovelPromotionEpisode&targetId=episode-1&status=completed&limit=20',
method: 'GET',
})
const res = await GET(req, emptyRouteContext)
expect(res.status).toBe(200)
expect(listRunsMock).toHaveBeenCalledWith(expect.objectContaining({
statuses: ['completed'],
recoverableOnly: false,
latestOnly: false,
}))
})
})
@@ -0,0 +1,466 @@
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.CANCELED,
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)
const payload = await res.json() as { task: TaskRecord; cancelled: boolean }
expect(removeTaskJobMock).toHaveBeenCalledWith('task-1')
expect(payload.cancelled).toBe(true)
expect(payload.task.status).toBe(TASK_STATUS.CANCELED)
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()
})
})
@@ -0,0 +1,36 @@
import { NextRequest } from 'next/server'
type RouteParamValue = string | string[] | undefined
type RouteParams = Record<string, RouteParamValue>
type HeaderMap = Record<string, string>
type RouteHandler<TParams extends RouteParams = RouteParams> = (
req: NextRequest,
ctx: { params: Promise<TParams> },
) => Promise<Response>
export async function callRoute<TParams extends RouteParams>(
handler: RouteHandler<TParams>,
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
options?: { headers?: HeaderMap; params?: TParams; 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 || {}) as TParams) }
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,440 @@
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' } },
})),
requireProjectAuth: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
project: { id: 'project-1' },
})),
requireProjectAuthLight: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
project: { id: 'project-1' },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const readAssetsMock = vi.hoisted(() => vi.fn())
const updateAssetRenderLabelMock = vi.hoisted(() => vi.fn())
const submitAssetGenerateTaskMock = vi.hoisted(() => vi.fn())
const copyAssetFromGlobalMock = vi.hoisted(() => vi.fn())
const createAssetMock = vi.hoisted(() => vi.fn())
const updateAssetMock = vi.hoisted(() => vi.fn())
const removeAssetMock = vi.hoisted(() => vi.fn())
const updateAssetVariantMock = vi.hoisted(() => vi.fn())
const selectAssetRenderMock = vi.hoisted(() => vi.fn())
const revertAssetRenderMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/assets/services/read-assets', () => ({
readAssets: readAssetsMock,
}))
vi.mock('@/lib/assets/services/asset-label', () => ({
updateAssetRenderLabel: updateAssetRenderLabelMock,
}))
vi.mock('@/lib/assets/services/asset-actions', () => ({
createAsset: createAssetMock,
submitAssetGenerateTask: submitAssetGenerateTaskMock,
copyAssetFromGlobal: copyAssetFromGlobalMock,
updateAsset: updateAssetMock,
removeAsset: removeAssetMock,
updateAssetVariant: updateAssetVariantMock,
submitAssetModifyTask: vi.fn(),
selectAssetRender: selectAssetRenderMock,
revertAssetRender: revertAssetRenderMock,
}))
describe('api specific - unified assets routes', () => {
beforeEach(() => {
vi.clearAllMocks()
readAssetsMock.mockResolvedValue([{ id: 'asset-1', kind: 'character' }])
updateAssetRenderLabelMock.mockResolvedValue(undefined)
submitAssetGenerateTaskMock.mockResolvedValue({ success: true, taskId: 'task-1' })
copyAssetFromGlobalMock.mockResolvedValue({ success: true })
createAssetMock.mockResolvedValue({ success: true, assetId: 'prop-1' })
updateAssetMock.mockResolvedValue({ success: true })
removeAssetMock.mockResolvedValue({ success: true })
updateAssetVariantMock.mockResolvedValue({ success: true })
selectAssetRenderMock.mockResolvedValue({ success: true })
revertAssetRenderMock.mockResolvedValue({ success: true })
})
it('GET /api/assets reads global assets with the authenticated user scope', async () => {
const mod = await import('@/app/api/assets/route')
const req = buildMockRequest({
path: '/api/assets?scope=global&kind=character',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(200)
expect(authMock.requireUserAuth).toHaveBeenCalled()
expect(readAssetsMock).toHaveBeenCalledWith({
scope: 'global',
projectId: null,
folderId: null,
kind: 'character',
}, {
userId: 'user-1',
})
expect(body).toEqual({ assets: [{ id: 'asset-1', kind: 'character' }] })
})
it('GET /api/assets reads prop assets through the unified filter contract', async () => {
readAssetsMock.mockResolvedValue([{ id: 'prop-1', kind: 'prop' }])
const mod = await import('@/app/api/assets/route')
const req = buildMockRequest({
path: '/api/assets?scope=project&projectId=project-1&kind=prop',
method: 'GET',
})
const res = await mod.GET(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(200)
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
expect(readAssetsMock).toHaveBeenCalledWith({
scope: 'project',
projectId: 'project-1',
folderId: null,
kind: 'prop',
})
expect(body).toEqual({ assets: [{ id: 'prop-1', kind: 'prop' }] })
})
it('POST /api/assets creates a project prop through the centralized asset action service', async () => {
const mod = await import('@/app/api/assets/route')
const req = buildMockRequest({
path: '/api/assets',
method: 'POST',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
description: '一把短小青铜匕首,雕纹手柄,刃面磨损发暗',
},
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(200)
expect(createAssetMock).toHaveBeenCalledWith({
kind: 'prop',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
description: '一把短小青铜匕首,雕纹手柄,刃面磨损发暗',
},
access: {
scope: 'project',
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true, assetId: 'prop-1' })
})
it('POST /api/assets/[assetId]/update-label forwards to the centralized label service', async () => {
const mod = await import('@/app/api/assets/[assetId]/update-label/route')
const req = buildMockRequest({
path: '/api/assets/asset-1/update-label',
method: 'POST',
body: {
scope: 'project',
kind: 'character',
projectId: 'project-1',
newName: '林夏',
},
})
const res = await mod.POST(req, {
params: Promise.resolve({ assetId: 'asset-1' }),
})
expect(res.status).toBe(200)
expect(authMock.requireProjectAuth).toHaveBeenCalledWith('project-1')
expect(updateAssetRenderLabelMock).toHaveBeenCalledWith({
scope: 'project',
kind: 'character',
assetId: 'asset-1',
projectId: 'project-1',
newName: '林夏',
})
})
it('POST /api/asset-hub/update-asset-label explicitly rejects global image label updates', async () => {
const mod = await import('@/app/api/asset-hub/update-asset-label/route')
const req = buildMockRequest({
path: '/api/asset-hub/update-asset-label',
method: 'POST',
body: {
type: 'character',
id: 'asset-1',
newName: '林夏',
},
})
const res = await mod.POST(req, { params: Promise.resolve({}) })
const body = await res.json()
expect(res.status).toBe(400)
expect(authMock.requireUserAuth).toHaveBeenCalled()
expect(body.error.code).toBe('INVALID_PARAMS')
expect(body.error.message).toBe('Global asset images no longer support label updates')
expect(updateAssetRenderLabelMock).not.toHaveBeenCalled()
})
it('PATCH /api/assets/[assetId] updates a global prop through the unified route', async () => {
const mod = await import('@/app/api/assets/[assetId]/route')
const req = buildMockRequest({
path: '/api/assets/prop-1',
method: 'PATCH',
body: {
scope: 'global',
kind: 'prop',
name: '青铜短刃',
summary: '更锋利的版本',
},
})
const res = await mod.PATCH(req, {
params: Promise.resolve({ assetId: 'prop-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(authMock.requireUserAuth).toHaveBeenCalled()
expect(updateAssetMock).toHaveBeenCalledWith({
kind: 'prop',
assetId: 'prop-1',
body: {
scope: 'global',
kind: 'prop',
name: '青铜短刃',
summary: '更锋利的版本',
},
access: {
scope: 'global',
userId: 'user-1',
},
})
expect(body).toEqual({ success: true })
})
it('DELETE /api/assets/[assetId] removes a project prop through the unified route', async () => {
const mod = await import('@/app/api/assets/[assetId]/route')
const req = buildMockRequest({
path: '/api/assets/prop-1',
method: 'DELETE',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
},
})
const res = await mod.DELETE(req, {
params: Promise.resolve({ assetId: 'prop-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(removeAssetMock).toHaveBeenCalledWith({
kind: 'prop',
assetId: 'prop-1',
access: {
scope: 'project',
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true })
})
it('POST /api/assets/[assetId]/generate forwards project asset generation to the unified service', async () => {
const mod = await import('@/app/api/assets/[assetId]/generate/route')
const req = buildMockRequest({
path: '/api/assets/asset-1/generate',
method: 'POST',
body: {
scope: 'project',
kind: 'character',
projectId: 'project-1',
appearanceId: 'appearance-1',
count: 2,
},
})
const res = await mod.POST(req, {
params: Promise.resolve({ assetId: 'asset-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
expect(submitAssetGenerateTaskMock).toHaveBeenCalledWith({
request: req,
kind: 'character',
assetId: 'asset-1',
body: {
scope: 'project',
kind: 'character',
projectId: 'project-1',
appearanceId: 'appearance-1',
count: 2,
},
access: {
scope: 'project',
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true, taskId: 'task-1' })
})
it('PATCH /api/assets/[assetId]/variants/[variantId] updates a prop variant through the unified route', async () => {
const mod = await import('@/app/api/assets/[assetId]/variants/[variantId]/route')
const req = buildMockRequest({
path: '/api/assets/prop-1/variants/prop-image-1',
method: 'PATCH',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
description: '古旧短刃,雕纹手柄',
},
})
const res = await mod.PATCH(req, {
params: Promise.resolve({ assetId: 'prop-1', variantId: 'prop-image-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(updateAssetVariantMock).toHaveBeenCalledWith({
kind: 'prop',
assetId: 'prop-1',
variantId: 'prop-image-1',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
description: '古旧短刃,雕纹手柄',
},
access: {
scope: 'project',
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true })
})
it('POST /api/assets/[assetId]/select-render confirms a project prop through the unified route', async () => {
const mod = await import('@/app/api/assets/[assetId]/select-render/route')
const req = buildMockRequest({
path: '/api/assets/prop-1/select-render',
method: 'POST',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
confirm: true,
},
})
const res = await mod.POST(req, {
params: Promise.resolve({ assetId: 'prop-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(authMock.requireProjectAuthLight).toHaveBeenCalledWith('project-1')
expect(selectAssetRenderMock).toHaveBeenCalledWith({
kind: 'prop',
assetId: 'prop-1',
body: {
scope: 'project',
kind: 'prop',
projectId: 'project-1',
confirm: true,
},
access: {
scope: 'project',
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true })
})
it('POST /api/novel-promotion/[projectId]/copy-from-global delegates to the centralized copy service', async () => {
const mod = await import('@/app/api/novel-promotion/[projectId]/copy-from-global/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/copy-from-global',
method: 'POST',
body: {
type: 'voice',
targetId: 'character-1',
globalAssetId: 'voice-1',
},
})
const res = await mod.POST(req, {
params: Promise.resolve({ projectId: 'project-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
kind: 'voice',
targetId: 'character-1',
globalAssetId: 'voice-1',
access: {
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true })
})
it('POST /api/assets/[assetId]/copy delegates prop copy to the centralized copy service', async () => {
const mod = await import('@/app/api/assets/[assetId]/copy/route')
const req = buildMockRequest({
path: '/api/assets/prop-target-1/copy',
method: 'POST',
body: {
kind: 'prop',
projectId: 'project-1',
globalAssetId: 'prop-global-1',
},
})
const res = await mod.POST(req, {
params: Promise.resolve({ assetId: 'prop-target-1' }),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(copyAssetFromGlobalMock).toHaveBeenCalledWith({
kind: 'prop',
targetId: 'prop-target-1',
globalAssetId: 'prop-global-1',
access: {
userId: 'user-1',
projectId: 'project-1',
},
})
expect(body).toEqual({ success: true })
})
})
@@ -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)
})
})
@@ -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,122 @@
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', 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 keeps user preference unchanged', 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).not.toHaveBeenCalled()
})
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 keeps user preference unchanged', 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).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
type PanelRecord = {
id: string
storyboardId: string
panelIndex: number
shotType: string
cameraMove: string
description: string
videoPrompt: string
location: string
characters: string
srtSegment: string
duration: number
}
type StoryboardRecord = {
id: string
episode: {
novelPromotionProject: {
projectId: string
}
}
}
const authMock = vi.hoisted(() => ({
requireProjectAuthLight: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
project: { id: 'project-1', userId: 'user-1' },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
success: true,
async: true,
taskId: 'task-panel-variant',
runId: null,
status: 'queued',
deduped: false,
})))
const configServiceMock = vi.hoisted(() => ({
getProjectModelConfig: vi.fn(async () => ({
storyboardModel: 'img::storyboard',
})),
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
...input.basePayload,
generationOptions: { resolution: '1024x1024' },
})),
}))
const rollbackSpy = vi.hoisted(() => ({
delete: vi.fn(async () => ({})),
findFirst: vi.fn(async () => ({ panelIndex: 4 })),
updateMany: vi.fn(async () => ({ count: 2 })),
count: vi.fn(async () => 3),
storyboardUpdate: vi.fn(async () => ({})),
}))
const createTxSpy = vi.hoisted(() => ({
findMany: vi.fn(async () => [
{ id: 'panel-after-1', panelIndex: 2 },
{ id: 'panel-after-2', panelIndex: 3 },
]),
update: vi.fn(async () => ({})),
create: vi.fn(async (args: { data: PanelRecord }) => ({
id: args.data.id,
panelIndex: args.data.panelIndex,
})),
count: vi.fn(async () => 4),
storyboardUpdate: vi.fn(async () => ({})),
}))
const routeState = vi.hoisted(() => ({
storyboard: {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
} satisfies StoryboardRecord,
panels: new Map<string, PanelRecord>(),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionStoryboard: {
findUnique: vi.fn(async () => routeState.storyboard),
},
novelPromotionPanel: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),
},
$transaction: vi.fn(async (
fn: (tx: {
novelPromotionPanel: {
findMany: typeof createTxSpy.findMany
update: typeof createTxSpy.update
create: typeof createTxSpy.create
delete: typeof rollbackSpy.delete
findFirst: typeof rollbackSpy.findFirst
updateMany: typeof rollbackSpy.updateMany
count: typeof rollbackSpy.count
}
novelPromotionStoryboard: {
update: typeof createTxSpy.storyboardUpdate
}
}) => Promise<unknown>,
) => {
const invocation = prismaMock.$transaction.mock.calls.length
if (invocation > 1) {
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: rollbackSpy.storyboardUpdate,
},
})
}
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: createTxSpy.storyboardUpdate,
},
})
}),
}))
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
vi.mock('@/lib/config-service', () => configServiceMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/billing', () => ({
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
}))
vi.mock('@/lib/task/resolve-locale', () => ({
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
}))
function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {
return {
id,
storyboardId,
panelIndex,
shotType: 'medium',
cameraMove: 'static',
description: `description-${id}`,
videoPrompt: `prompt-${id}`,
location: 'Old Town',
characters: '[]',
srtSegment: '',
duration: 3,
}
}
async function invokeRoute(body: Record<string, unknown>): Promise<Response> {
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/panel-variant',
method: 'POST',
body,
})
return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
}
describe('api specific - panel variant route', () => {
beforeEach(() => {
vi.clearAllMocks()
routeState.storyboard = {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
}
routeState.panels = new Map<string, PanelRecord>([
['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],
['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],
])
})
it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('does not create panel when image billing payload validation fails', async () => {
configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string; message: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(json.error.message).toBe('missing capability')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('rolls back the created panel when submitTask fails after insertion', async () => {
submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(502)
expect(json.error.code).toBe('EXTERNAL_ERROR')
expect(createTxSpy.create).toHaveBeenCalledTimes(1)
const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id
expect(createdPanelId).toEqual(expect.any(String))
expect(rollbackSpy.delete).toHaveBeenCalledWith({
where: { id: createdPanelId },
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 3 },
},
data: {
panelIndex: { increment: 1004 },
panelNumber: { increment: 1004 },
},
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 1007 },
},
data: {
panelIndex: { decrement: 1005 },
panelNumber: { decrement: 1005 },
},
})
expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({
where: { id: 'storyboard-1' },
data: { panelCount: 3 },
})
})
})
@@ -0,0 +1,110 @@
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,
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.project.create).toHaveBeenCalledWith({
data: {
name: 'Test Project',
description: null,
userId: 'user-1',
},
})
expect(prismaMock.novelPromotionProject.create).toHaveBeenCalledWith({
data: expect.objectContaining({
projectId: 'project-1',
audioModel: 'audio::tts',
}),
})
})
it('returns an explicit validation error when description exceeds the max length', async () => {
const mod = await import('@/app/api/projects/route')
const req = buildMockRequest({
path: '/api/projects',
method: 'POST',
headers: {
'accept-language': 'zh-CN',
},
body: {
name: 'Test Project',
description: 'a'.repeat(501),
},
})
const res = await mod.POST(req, routeContext)
const body = await res.json() as {
error?: {
code?: string
message?: string
details?: {
field?: string
limit?: number
}
}
}
expect(res.status).toBe(400)
expect(body.error?.code).toBe('INVALID_PARAMS')
expect(body.error?.message).toBe('项目描述不能超过 500 个字符。')
expect(body.error?.details?.field).toBe('description')
expect(body.error?.details?.limit).toBe(500)
expect(prismaMock.project.create).not.toHaveBeenCalled()
})
})
@@ -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' },
})),
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()
})
})
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' },
})),
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('无音色IDQwenTTS 必须使用 AI 设计音色')
expect(submitTaskMock).not.toHaveBeenCalled()
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -0,0 +1,326 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ApiError } from '@/lib/api-errors'
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
import { createRun } from '@/lib/run-runtime/service'
import { submitTask } from '@/lib/task/submitter'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
const queueState = vi.hoisted(() => ({
mode: 'success' as 'success' | 'fail',
errorMessage: 'queue add failed',
}))
const addTaskJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'mock-job' })))
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))
vi.mock('@/lib/task/queues', () => ({
addTaskJob: addTaskJobMock,
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: publishTaskEventMock,
}))
addTaskJobMock.mockImplementation(async () => {
if (queueState.mode === 'fail') {
throw new Error(queueState.errorMessage)
}
return { id: 'mock-job' }
})
describe('billing/submitter integration', () => {
beforeEach(async () => {
await resetBillingState()
process.env.BILLING_MODE = 'ENFORCE'
queueState.mode = 'success'
queueState.errorMessage = 'queue add failed'
vi.clearAllMocks()
})
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')
})
it('rolls back billing freeze and marks task failed when queue enqueue fails', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
queueState.mode = 'fail'
queueState.errorMessage = 'queue unavailable'
await expect(
submitTask({
userId: user.id,
locale: 'en',
projectId: 'project-e',
type: TASK_TYPE.VOICE_LINE,
targetType: 'VoiceLine',
targetId: 'line-e',
payload: { maxSeconds: 6 },
}),
).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' } satisfies Pick<ApiError, 'code'>)
const task = await prisma.task.findFirst({
where: {
userId: user.id,
type: TASK_TYPE.VOICE_LINE,
},
orderBy: { createdAt: 'desc' },
})
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
expect(task).toBeTruthy()
expect(task?.status).toBe('failed')
expect(task?.errorCode).toBe('ENQUEUE_FAILED')
expect(task?.errorMessage).toContain('queue unavailable')
expect(task?.billingInfo).toMatchObject({
billable: true,
status: 'rolled_back',
})
expect(balance?.balance).toBeCloseTo(10, 8)
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
expect(await prisma.balanceFreeze.count()).toBe(1)
const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
expect(freeze?.status).toBe('rolled_back')
})
it('reuses the active core analysis run instead of creating a second run', async () => {
process.env.BILLING_MODE = 'OFF'
const user = await createTestUser()
const existingTask = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-core',
episodeId: 'episode-core',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core',
status: TASK_STATUS.QUEUED,
payload: {
episodeId: 'episode-core',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
queuedAt: new Date(),
},
})
const run = await createRun({
userId: user.id,
projectId: 'project-core',
episodeId: 'episode-core',
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskId: existingTask.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core',
input: {
episodeId: 'episode-core',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
})
await prisma.task.update({
where: { id: existingTask.id },
data: {
payload: {
episodeId: 'episode-core',
analysisModel: 'model-core',
runId: run.id,
meta: { locale: 'zh', runId: run.id },
},
},
})
const result = await submitTask({
userId: user.id,
locale: 'zh',
projectId: 'project-core',
episodeId: 'episode-core',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core',
payload: {
episodeId: 'episode-core',
analysisModel: 'model-core',
},
dedupeKey: 'story_to_script:episode-core',
})
expect(result.deduped).toBe(true)
expect(result.taskId).toBe(existingTask.id)
expect(result.runId).toBe(run.id)
expect(await prisma.graphRun.count()).toBe(1)
expect(addTaskJobMock).not.toHaveBeenCalled()
})
it('reattaches a new task to the existing active run when the old task is already terminal', async () => {
process.env.BILLING_MODE = 'OFF'
const user = await createTestUser()
const failedTask = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-core-retry',
episodeId: 'episode-core-retry',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core-retry',
status: TASK_STATUS.FAILED,
errorCode: 'TEST_FAILED',
errorMessage: 'old task already failed',
payload: {
episodeId: 'episode-core-retry',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
queuedAt: new Date(),
finishedAt: new Date(),
},
})
const run = await createRun({
userId: user.id,
projectId: 'project-core-retry',
episodeId: 'episode-core-retry',
workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskId: failedTask.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core-retry',
input: {
episodeId: 'episode-core-retry',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
})
const result = await submitTask({
userId: user.id,
locale: 'zh',
projectId: 'project-core-retry',
episodeId: 'episode-core-retry',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-core-retry',
payload: {
episodeId: 'episode-core-retry',
analysisModel: 'model-core',
},
dedupeKey: 'script_to_storyboard:episode-core-retry',
})
expect(result.deduped).toBe(false)
expect(result.runId).toBe(run.id)
expect(result.taskId).not.toBe(failedTask.id)
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
const newTask = await prisma.task.findUnique({ where: { id: result.taskId } })
expect(refreshedRun?.taskId).toBe(result.taskId)
expect(newTask?.status).toBe(TASK_STATUS.QUEUED)
expect(newTask?.payload).toMatchObject({
runId: run.id,
})
})
})
@@ -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
View 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(() => ({
generateCleanImageToStorage: 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,
generateCleanImageToStorage: sharedMock.generateCleanImageToStorage,
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,
},
})
})
})
+208
View File
@@ -0,0 +1,208 @@
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' })),
},
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, attempts: 1 }),
}))
})
it('forces single queue attempt for core analysis workflows', async () => {
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
await addTaskJob({
taskId: 'task-text-story-1',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: { episodeId: 'episode-1' },
userId: 'user-1',
}, { attempts: 5 })
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
expect(calls).toHaveLength(1)
expect(calls[0]?.options).toEqual(expect.objectContaining({
jobId: 'task-text-story-1',
attempts: 1,
}))
})
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
View 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
View 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',
})
})
})
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { arkCreateVideoTask } from '@/lib/ark-api'
import { querySeedanceVideoStatus } from '@/lib/async-task-utils'
describe('provider contract - ark seedance', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('submits Seedance 2.0 multimodal create payload with official request fields', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ id: 'cgt-task-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await arkCreateVideoTask({
model: 'doubao-seedance-2-0-260128',
content: [
{ type: 'text', text: 'reference 视频1 的运镜,参考音频1 的节奏' },
{ type: 'image_url', image_url: { url: 'https://example.com/first.png' }, role: 'reference_image' },
{ type: 'video_url', video_url: { url: 'https://example.com/ref.mp4' }, role: 'reference_video' },
{ type: 'audio_url', audio_url: { url: 'https://example.com/ref.mp3' }, role: 'reference_audio' },
],
resolution: '720p',
ratio: '16:9',
duration: 15,
generate_audio: true,
watermark: true,
tools: [{ type: 'web_search' }],
}, {
apiKey: 'ark-key',
maxRetries: 1,
timeoutMs: 1000,
logPrefix: '[Ark Test]',
})
expect(result.id).toBe('cgt-task-1')
expect(fetchMock).toHaveBeenCalledTimes(1)
const firstCall = fetchMock.mock.calls[0]
expect(firstCall).toBeTruthy()
const [url, init] = firstCall as unknown as [string, RequestInit]
expect(url).toBe('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({
'Content-Type': 'application/json',
'Authorization': 'Bearer ark-key',
})
expect(JSON.parse(String(init.body))).toEqual({
model: 'doubao-seedance-2-0-260128',
content: [
{ type: 'text', text: 'reference 视频1 的运镜,参考音频1 的节奏' },
{ type: 'image_url', image_url: { url: 'https://example.com/first.png' }, role: 'reference_image' },
{ type: 'video_url', video_url: { url: 'https://example.com/ref.mp4' }, role: 'reference_video' },
{ type: 'audio_url', audio_url: { url: 'https://example.com/ref.mp3' }, role: 'reference_audio' },
],
resolution: '720p',
ratio: '16:9',
duration: 15,
generate_audio: true,
watermark: true,
tools: [{ type: 'web_search' }],
})
})
it('reads Ark task usage.total_tokens from status query', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
status: 'succeeded',
content: {
video_url: 'https://example.com/result.mp4',
},
usage: {
total_tokens: 108000,
completion_tokens: 108000,
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await querySeedanceVideoStatus('cgt-task-2', 'ark-key')
expect(result).toEqual({
status: 'completed',
videoUrl: 'https://example.com/result.mp4',
actualVideoTokens: 108000,
})
expect(fetchMock).toHaveBeenCalledWith(
'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/cgt-task-2',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ark-key',
},
cache: 'no-store',
},
)
})
})
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { queryFalStatus, submitFalTask } from '@/lib/async-submit'
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
describe('provider contract - fal queue', () => {
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
beforeEach(async () => {
server = await startScenarioServer()
process.env.FAL_QUEUE_BASE_URL = `${server.baseUrl}/fal`
})
afterEach(async () => {
delete process.env.FAL_QUEUE_BASE_URL
await server?.close()
server = null
})
it('submits the expected auth header and json payload', async () => {
server!.defineScenario({
method: 'POST',
path: '/fal/fal-ai/nano-banana-pro',
mode: 'success',
submitResponse: {
status: 200,
body: { request_id: 'req_image_1' },
},
})
const requestId = await submitFalTask(
'fal-ai/nano-banana-pro',
{
prompt: 'generate image',
image_urls: ['data:image/png;base64,AAAA'],
},
'fal-key-1',
)
expect(requestId).toBe('req_image_1')
const requests = server!.getRequests('POST', '/fal/fal-ai/nano-banana-pro')
expect(requests).toHaveLength(1)
expect(requests[0]?.headers.authorization).toBe('Key fal-key-1')
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
prompt: 'generate image',
image_urls: ['data:image/png;base64,AAAA'],
})
})
it('treats transient status failure as pending and completes after retry', async () => {
server!.defineScenario({
method: 'GET',
path: '/fal/fal-ai/veo3.1/requests/req_video_1/status',
mode: 'retryable_error_then_success',
pollSequence: [
{ status: 503, body: { error: 'upstream unavailable' } },
{ status: 200, body: { status: 'COMPLETED' } },
],
})
server!.defineScenario({
method: 'GET',
path: '/fal/fal-ai/veo3.1/fast/image-to-video/requests/req_video_1',
mode: 'success',
submitResponse: {
status: 200,
body: {
video: { url: 'https://cdn.local/video.mp4' },
},
},
})
const first = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
const second = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
expect(first).toEqual({
status: 'IN_PROGRESS',
completed: false,
failed: false,
})
expect(second).toEqual({
status: 'COMPLETED',
completed: true,
failed: false,
resultUrl: 'https://cdn.local/video.mp4',
})
})
it('marks a failed status response as failed with explicit provider error', async () => {
server!.defineScenario({
method: 'GET',
path: '/fal/fal-ai/veo3.1/requests/req_failed/status',
mode: 'fatal_error',
submitResponse: {
status: 200,
body: {
status: 'FAILED',
error: 'content moderation failed',
},
},
})
const result = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_failed', 'fal-key-3')
expect(result).toEqual({
status: 'FAILED',
completed: false,
failed: true,
error: 'content moderation failed',
})
})
it('fails explicitly when submit response is malformed', async () => {
server!.defineScenario({
method: 'POST',
path: '/fal/fal-ai/nano-banana-pro',
mode: 'malformed_response',
submitResponse: {
status: 200,
body: { ok: true },
},
})
await expect(
submitFalTask('fal-ai/nano-banana-pro', { prompt: 'bad response' }, 'fal-key-4'),
).rejects.toThrow('FAL未返回request_id')
})
it('treats completed result without media url as failed', async () => {
server!.defineScenario({
method: 'GET',
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media/status',
mode: 'queued_then_success',
submitResponse: {
status: 200,
body: { status: 'COMPLETED' },
},
})
server!.defineScenario({
method: 'GET',
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media',
mode: 'malformed_response',
submitResponse: {
status: 200,
body: { images: [] },
},
})
const result = await queryFalStatus('fal-ai/nano-banana-pro', 'req_no_media', 'fal-key-5')
expect(result).toEqual({
status: 'COMPLETED',
completed: true,
failed: false,
resultUrl: undefined,
})
})
})
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'
import { pollAsyncTask } from '@/lib/async-poll'
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const getUserModelsMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getUserModels: getUserModelsMock,
}))
function encode(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url')
}
describe('provider contract - openai compatible media template', () => {
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
beforeEach(async () => {
server = await startScenarioServer()
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:provider-local',
apiKey: 'sk-local',
baseUrl: `${server.baseUrl}/compat/v1`,
})
})
afterEach(async () => {
await server?.close()
server = null
})
it('renders create request against provider baseUrl and returns OCOMPAT externalId', async () => {
server!.defineScenario({
method: 'POST',
path: '/compat/v1/video/create',
mode: 'success',
submitResponse: {
status: 200,
body: { status: 'queued', task_id: 'task_local_1' },
},
})
const result = await generateVideoViaOpenAICompatTemplate({
userId: 'user-local',
providerId: 'openai-compatible:provider-local',
modelId: 'veo-local',
modelKey: 'openai-compatible:provider-local::veo-local',
imageUrl: 'data:image/png;base64,AAAA',
prompt: 'animate this frame',
options: {
duration: 5,
aspectRatio: '9:16',
},
profile: 'openai-compatible',
template: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
image: '{{image}}',
duration: '{{duration}}',
},
},
status: { method: 'GET', path: '/video/status/{{task_id}}' },
response: {
taskIdPath: '$.task_id',
statusPath: '$.status',
},
polling: {
intervalMs: 1000,
timeoutMs: 30_000,
doneStates: ['done'],
failStates: ['failed'],
},
},
})
expect(result).toMatchObject({
success: true,
async: true,
requestId: 'task_local_1',
externalId: `OCOMPAT:VIDEO:b64_${encode('openai-compatible:provider-local')}:${encode('veo-local')}:task_local_1`,
})
const requests = server!.getRequests('POST', '/compat/v1/video/create')
expect(requests).toHaveLength(1)
expect(requests[0]?.headers.authorization).toBe('Bearer sk-local')
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
model: 'veo-local',
prompt: 'animate this frame',
image: 'data:image/png;base64,AAAA',
duration: 5,
})
})
it('polls localhost provider status and falls back to content endpoint when output url is missing', async () => {
getUserModelsMock.mockResolvedValue([
{
modelKey: 'openai-compatible:provider-local::veo-local',
modelId: 'veo-local',
name: 'Local Veo',
type: 'video',
provider: 'openai-compatible:provider-local',
price: 0,
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/video/create' },
status: { method: 'GET', path: '/video/status/{{task_id}}' },
content: { method: 'GET', path: '/video/content/{{task_id}}' },
response: {
statusPath: '$.status',
},
polling: {
intervalMs: 1000,
timeoutMs: 30_000,
doneStates: ['done'],
failStates: ['failed'],
},
},
},
])
server!.defineScenario({
method: 'GET',
path: '/compat/v1/video/status/task_local_2',
mode: 'queued_then_success',
pollSequence: [
{ status: 200, body: { status: 'running' } },
{ status: 200, body: { status: 'done' } },
],
})
const first = await pollAsyncTask(
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
'user-local',
)
const second = await pollAsyncTask(
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
'user-local',
)
expect(first).toEqual({ status: 'pending' })
expect(second).toEqual({
status: 'completed',
resultUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
videoUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
downloadHeaders: {
Authorization: 'Bearer sk-local',
},
})
})
it('fails explicitly when async create response omits task id', async () => {
server!.defineScenario({
method: 'POST',
path: '/compat/v1/video/create',
mode: 'malformed_response',
submitResponse: {
status: 200,
body: { status: 'queued' },
},
})
await expect(
generateVideoViaOpenAICompatTemplate({
userId: 'user-local',
providerId: 'openai-compatible:provider-local',
modelId: 'veo-local',
modelKey: 'openai-compatible:provider-local::veo-local',
imageUrl: 'data:image/png;base64,AAAA',
prompt: 'bad create payload',
profile: 'openai-compatible',
template: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
bodyTemplate: { prompt: '{{prompt}}' },
},
status: { method: 'GET', path: '/video/status/{{task_id}}' },
response: {
taskIdPath: '$.task_id',
statusPath: '$.status',
},
polling: {
intervalMs: 1000,
timeoutMs: 30_000,
doneStates: ['done'],
failStates: ['failed'],
},
},
}),
).rejects.toThrow('OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND')
})
})
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { reconcileActiveRunsFromTasks } from '@/lib/run-runtime/reconcile'
import { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser } from '../../helpers/billing-fixtures'
describe('run runtime reconcileActiveRunsFromTasks', () => {
beforeEach(async () => {
await resetBillingState()
})
it('marks a running run completed when the linked task already completed', async () => {
const user = await createTestUser()
const finishedAt = new Date('2026-03-30T08:00:00.000Z')
const task = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-run-complete',
episodeId: 'episode-run-complete',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-complete',
status: TASK_STATUS.COMPLETED,
progress: 100,
payload: { episodeId: 'episode-run-complete' },
result: {
episodeId: 'episode-run-complete',
persistedClips: 12,
},
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
startedAt: new Date('2026-03-30T07:56:00.000Z'),
finishedAt,
},
})
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-run-complete',
episodeId: 'episode-run-complete',
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskId: task.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-complete',
status: RUN_STATUS.RUNNING,
leaseOwner: 'worker:story-to-script',
leaseExpiresAt: new Date('2026-03-30T08:05:00.000Z'),
heartbeatAt: new Date('2026-03-30T07:59:30.000Z'),
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
startedAt: new Date('2026-03-30T07:56:00.000Z'),
},
})
await prisma.graphStep.create({
data: {
runId: run.id,
stepKey: 'story_to_script_persist',
stepTitle: 'Persist screenplay',
status: RUN_STEP_STATUS.RUNNING,
currentAttempt: 1,
stepIndex: 4,
stepTotal: 4,
startedAt: new Date('2026-03-30T07:58:00.000Z'),
},
})
const reconciled = await reconcileActiveRunsFromTasks()
expect(reconciled).toEqual([{
runId: run.id,
taskId: task.id,
nextStatus: 'completed',
reason: 'linked task already completed',
}])
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
expect(refreshedRun).toMatchObject({
status: RUN_STATUS.COMPLETED,
output: {
episodeId: 'episode-run-complete',
persistedClips: 12,
},
errorCode: null,
errorMessage: null,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
})
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
const refreshedStep = await prisma.graphStep.findUnique({
where: {
runId_stepKey: {
runId: run.id,
stepKey: 'story_to_script_persist',
},
},
})
expect(refreshedStep).toMatchObject({
status: RUN_STEP_STATUS.COMPLETED,
lastErrorCode: null,
lastErrorMessage: null,
})
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
})
it('marks a running run failed when the linked task already failed', async () => {
const user = await createTestUser()
const finishedAt = new Date('2026-03-30T09:00:00.000Z')
const task = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-run-failed',
episodeId: 'episode-run-failed',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-failed',
status: TASK_STATUS.FAILED,
progress: 72,
payload: { episodeId: 'episode-run-failed' },
errorCode: 'WATCHDOG_TIMEOUT',
errorMessage: 'Task heartbeat timeout',
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
startedAt: new Date('2026-03-30T08:51:00.000Z'),
finishedAt,
},
})
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-run-failed',
episodeId: 'episode-run-failed',
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskId: task.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-failed',
status: RUN_STATUS.RUNNING,
leaseOwner: 'worker:story-to-script',
leaseExpiresAt: new Date('2026-03-30T08:55:00.000Z'),
heartbeatAt: new Date('2026-03-30T08:54:00.000Z'),
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
startedAt: new Date('2026-03-30T08:51:00.000Z'),
},
})
await prisma.graphStep.create({
data: {
runId: run.id,
stepKey: 'screenplay_clip-1',
stepTitle: 'Screenplay clip 1',
status: RUN_STEP_STATUS.RUNNING,
currentAttempt: 1,
stepIndex: 3,
stepTotal: 6,
startedAt: new Date('2026-03-30T08:52:00.000Z'),
},
})
const reconciled = await reconcileActiveRunsFromTasks()
expect(reconciled).toEqual([{
runId: run.id,
taskId: task.id,
nextStatus: 'failed',
reason: 'linked task already failed',
}])
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
expect(refreshedRun).toMatchObject({
status: RUN_STATUS.FAILED,
errorCode: 'WATCHDOG_TIMEOUT',
errorMessage: 'Task heartbeat timeout',
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
})
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
const refreshedStep = await prisma.graphStep.findUnique({
where: {
runId_stepKey: {
runId: run.id,
stepKey: 'screenplay_clip-1',
},
},
})
expect(refreshedStep).toMatchObject({
status: RUN_STEP_STATUS.FAILED,
lastErrorCode: 'WATCHDOG_TIMEOUT',
lastErrorMessage: 'Task heartbeat timeout',
})
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
})
})
@@ -0,0 +1,343 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { retryFailedStep } from '@/lib/run-runtime/service'
import { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser } from '../../helpers/billing-fixtures'
describe('run runtime retryFailedStep invalidation', () => {
beforeEach(async () => {
await resetBillingState()
})
it('invalidates downstream story-to-script steps and artifacts', async () => {
const user = await createTestUser()
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-retry-story',
episodeId: 'episode-retry-story',
workflowType: 'story_to_script_run',
taskType: 'story_to_script_run',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-retry-story',
status: RUN_STATUS.FAILED,
queuedAt: new Date(),
startedAt: new Date(),
finishedAt: new Date(),
},
})
await prisma.graphStep.createMany({
data: [
{
runId: run.id,
stepKey: 'analyze_characters',
stepTitle: 'Analyze Characters',
status: RUN_STEP_STATUS.FAILED,
currentAttempt: 1,
stepIndex: 1,
stepTotal: 5,
startedAt: new Date(),
finishedAt: new Date(),
lastErrorCode: 'STEP_FAILED',
lastErrorMessage: 'characters failed',
},
{
runId: run.id,
stepKey: 'analyze_locations',
stepTitle: 'Analyze Locations',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 2,
stepTotal: 5,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'split_clips',
stepTitle: 'Split Clips',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 3,
stepTotal: 5,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'screenplay_clip-a',
stepTitle: 'Screenplay A',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 4,
stepTotal: 5,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'screenplay_clip-b',
stepTitle: 'Screenplay B',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 5,
stepTotal: 5,
startedAt: new Date(),
finishedAt: new Date(),
},
],
})
await prisma.graphArtifact.createMany({
data: [
{
runId: run.id,
stepKey: 'analyze_characters',
artifactType: 'analysis.characters',
refId: 'episode-retry-story',
payload: { rows: [{ name: 'Hero' }] },
},
{
runId: run.id,
stepKey: 'analyze_locations',
artifactType: 'analysis.locations',
refId: 'episode-retry-story',
payload: { rows: [{ name: 'City' }] },
},
{
runId: run.id,
stepKey: 'split_clips',
artifactType: 'clips',
refId: 'episode-retry-story',
payload: { clips: [{ id: 'clip-a' }] },
},
{
runId: run.id,
stepKey: 'screenplay_clip-a',
artifactType: 'screenplay.clip',
refId: 'clip-a',
payload: { scenes: [{ id: 1 }] },
},
],
})
const retried = await retryFailedStep({
runId: run.id,
userId: user.id,
stepKey: 'analyze_characters',
})
expect(retried?.retryAttempt).toBe(2)
expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([
'analyze_characters',
'screenplay_clip-a',
'screenplay_clip-b',
'split_clips',
])
const steps = await prisma.graphStep.findMany({
where: { runId: run.id },
orderBy: { stepIndex: 'asc' },
})
const stepMap = new Map(steps.map((step) => [step.stepKey, step]))
expect(stepMap.get('analyze_characters')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 2,
lastErrorCode: null,
lastErrorMessage: null,
})
expect(stepMap.get('split_clips')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 0,
})
expect(stepMap.get('screenplay_clip-a')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 0,
})
expect(stepMap.get('analyze_locations')).toMatchObject({
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
})
const artifacts = await prisma.graphArtifact.findMany({
where: { runId: run.id },
orderBy: { stepKey: 'asc' },
})
expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['analyze_locations'])
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
expect(refreshedRun?.status).toBe(RUN_STATUS.RUNNING)
expect(refreshedRun?.errorCode).toBeNull()
expect(refreshedRun?.errorMessage).toBeNull()
})
it('invalidates only the dependent storyboard branch plus voice analyze', async () => {
const user = await createTestUser()
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-retry-storyboard',
episodeId: 'episode-retry-storyboard',
workflowType: 'script_to_storyboard_run',
taskType: 'script_to_storyboard_run',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-retry-storyboard',
status: RUN_STATUS.FAILED,
queuedAt: new Date(),
startedAt: new Date(),
finishedAt: new Date(),
},
})
await prisma.graphStep.createMany({
data: [
{
runId: run.id,
stepKey: 'clip_clip-1_phase1',
stepTitle: 'Clip 1 Phase 1',
status: RUN_STEP_STATUS.FAILED,
currentAttempt: 1,
stepIndex: 1,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
lastErrorCode: 'STEP_FAILED',
lastErrorMessage: 'phase1 failed',
},
{
runId: run.id,
stepKey: 'clip_clip-1_phase2_cinematography',
stepTitle: 'Clip 1 Phase 2 Cine',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 2,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'clip_clip-1_phase2_acting',
stepTitle: 'Clip 1 Phase 2 Acting',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 3,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'clip_clip-1_phase3_detail',
stepTitle: 'Clip 1 Phase 3',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 4,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'clip_clip-2_phase3_detail',
stepTitle: 'Clip 2 Phase 3',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 5,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
},
{
runId: run.id,
stepKey: 'voice_analyze',
stepTitle: 'Voice Analyze',
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
stepIndex: 6,
stepTotal: 6,
startedAt: new Date(),
finishedAt: new Date(),
},
],
})
await prisma.graphArtifact.createMany({
data: [
{
runId: run.id,
stepKey: 'clip_clip-1_phase1',
artifactType: 'storyboard.clip.phase1',
refId: 'clip-1',
payload: { panels: [] },
},
{
runId: run.id,
stepKey: 'clip_clip-1_phase2_cinematography',
artifactType: 'storyboard.clip.phase2.cine',
refId: 'clip-1',
payload: { rules: [] },
},
{
runId: run.id,
stepKey: 'clip_clip-2_phase3_detail',
artifactType: 'storyboard.clip.phase3',
refId: 'clip-2',
payload: { panels: [] },
},
{
runId: run.id,
stepKey: 'voice_analyze',
artifactType: 'voice.lines',
refId: 'episode-retry-storyboard',
payload: { lines: [] },
},
],
})
const retried = await retryFailedStep({
runId: run.id,
userId: user.id,
stepKey: 'clip_clip-1_phase1',
})
expect(retried?.retryAttempt).toBe(2)
expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([
'clip_clip-1_phase1',
'clip_clip-1_phase2_acting',
'clip_clip-1_phase2_cinematography',
'clip_clip-1_phase3_detail',
'voice_analyze',
])
const steps = await prisma.graphStep.findMany({
where: { runId: run.id },
orderBy: { stepIndex: 'asc' },
})
const stepMap = new Map(steps.map((step) => [step.stepKey, step]))
expect(stepMap.get('clip_clip-1_phase1')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 2,
})
expect(stepMap.get('clip_clip-1_phase2_cinematography')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 0,
})
expect(stepMap.get('voice_analyze')).toMatchObject({
status: RUN_STEP_STATUS.PENDING,
currentAttempt: 0,
})
expect(stepMap.get('clip_clip-2_phase3_detail')).toMatchObject({
status: RUN_STEP_STATUS.COMPLETED,
currentAttempt: 1,
})
const artifacts = await prisma.graphArtifact.findMany({
where: { runId: run.id },
orderBy: { stepKey: 'asc' },
})
expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['clip_clip-2_phase3_detail'])
})
})
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { createTask } from '@/lib/task/service'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestProject, createTestUser } from '../../helpers/billing-fixtures'
const reconcileMock = vi.hoisted(() => ({
isJobAlive: vi.fn(async () => true),
}))
vi.mock('@/lib/task/reconcile', () => reconcileMock)
describe('task service dedupe + orphan recovery', () => {
beforeEach(async () => {
await resetBillingState()
vi.clearAllMocks()
reconcileMock.isJobAlive.mockResolvedValue(true)
})
it('dedupes to an active task when dedupeKey matches and queue job is alive', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
const existing = await prisma.task.create({
data: {
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VOICE_LINE,
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
status: TASK_STATUS.QUEUED,
payload: {
episodeId: 'episode-1',
lineId: 'line-1',
meta: { locale: 'zh' },
},
dedupeKey: 'voice_line:line-1',
queuedAt: new Date(),
},
})
const result = await createTask({
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VOICE_LINE,
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
payload: {
episodeId: 'episode-1',
lineId: 'line-1',
meta: { locale: 'zh' },
},
dedupeKey: 'voice_line:line-1',
})
expect(result.deduped).toBe(true)
expect(result.task.id).toBe(existing.id)
expect(reconcileMock.isJobAlive).toHaveBeenCalledWith(existing.id)
})
it('fails orphaned active task and creates a replacement when queue job is missing', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
const existing = await prisma.task.create({
data: {
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VIDEO_PANEL,
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
status: TASK_STATUS.QUEUED,
payload: {
storyboardId: 'storyboard-1',
panelIndex: 1,
meta: { locale: 'zh' },
},
dedupeKey: 'video_panel:panel-1',
queuedAt: new Date(),
},
})
reconcileMock.isJobAlive.mockResolvedValue(false)
const result = await createTask({
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VIDEO_PANEL,
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload: {
storyboardId: 'storyboard-1',
panelIndex: 1,
meta: { locale: 'zh' },
},
dedupeKey: 'video_panel:panel-1',
})
expect(result.deduped).toBe(false)
expect(result.task.id).not.toBe(existing.id)
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
expect(failedExisting).toMatchObject({
status: TASK_STATUS.FAILED,
errorCode: 'RECONCILE_ORPHAN',
dedupeKey: null,
})
})
it('fails locale-less active task and replaces it instead of deduping forever', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
const existing = await prisma.task.create({
data: {
userId: user.id,
projectId: project.id,
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
status: TASK_STATUS.QUEUED,
payload: {
episodeId: 'episode-1',
},
dedupeKey: 'script_to_storyboard_run:episode-1',
queuedAt: new Date(),
},
})
const result = await createTask({
userId: user.id,
projectId: project.id,
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: {
episodeId: 'episode-1',
meta: { locale: 'zh' },
},
dedupeKey: 'script_to_storyboard_run:episode-1',
})
expect(result.deduped).toBe(false)
expect(result.task.id).not.toBe(existing.id)
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
expect(failedExisting).toMatchObject({
status: TASK_STATUS.FAILED,
errorCode: 'TASK_LOCALE_REQUIRED',
dedupeKey: null,
})
})
})
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from '../system/helpers/seed'
describe('regression - panel variant cross storyboard safety', () => {
beforeEach(async () => {
await resetSystemState()
installAuthMocks()
})
it('sourcePanelId from another storyboard -> explicit invalid params and no dirty panel', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
const beforeCount = await prisma.novelPromotionPanel.count({
where: { storyboardId: seeded.storyboard.id },
})
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
storyboardId: seeded.storyboard.id,
insertAfterPanelId: seeded.panel.id,
sourcePanelId: seeded.foreignPanel.id,
variant: {
video_prompt: 'variant prompt',
description: 'variant description',
},
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(400)
const json = await response.json() as { error?: { code?: string } }
expect(json.error?.code).toBe('INVALID_PARAMS')
const afterCount = await prisma.novelPromotionPanel.count({
where: { storyboardId: seeded.storyboard.id },
})
expect(afterCount).toBe(beforeCount)
resetAuthMockState()
})
})
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { createTask } from '@/lib/task/service'
import { prisma } from '../helpers/prisma'
import { resetBillingState } from '../helpers/db-reset'
import { createTestProject, createTestUser } from '../helpers/billing-fixtures'
const reconcileMock = vi.hoisted(() => ({
isJobAlive: vi.fn(async () => true),
}))
vi.mock('@/lib/task/reconcile', () => reconcileMock)
describe('regression - task dedupe recovery', () => {
beforeEach(async () => {
await resetBillingState()
vi.clearAllMocks()
reconcileMock.isJobAlive.mockResolvedValue(true)
})
it('replaces locale-less queued task instead of deduping forever', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
const stale = await prisma.task.create({
data: {
userId: user.id,
projectId: project.id,
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-1',
status: TASK_STATUS.QUEUED,
payload: { episodeId: 'episode-regression-1' },
dedupeKey: 'script_to_storyboard_run:episode-regression-1',
queuedAt: new Date(),
},
})
const replacement = await createTask({
userId: user.id,
projectId: project.id,
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-1',
payload: {
episodeId: 'episode-regression-1',
meta: { locale: 'zh' },
},
dedupeKey: 'script_to_storyboard_run:episode-regression-1',
})
expect(replacement.deduped).toBe(false)
expect(replacement.task.id).not.toBe(stale.id)
const failedStale = await prisma.task.findUnique({ where: { id: stale.id } })
expect(failedStale).toMatchObject({
status: TASK_STATUS.FAILED,
errorCode: 'TASK_LOCALE_REQUIRED',
dedupeKey: null,
})
})
it('replaces orphaned queued task when queue job is gone', async () => {
const user = await createTestUser()
const project = await createTestProject(user.id)
const orphan = await prisma.task.create({
data: {
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VIDEO_PANEL,
targetType: 'NovelPromotionPanel',
targetId: 'panel-regression-1',
status: TASK_STATUS.QUEUED,
payload: {
storyboardId: 'storyboard-regression-1',
panelIndex: 1,
meta: { locale: 'zh' },
},
dedupeKey: 'video_panel:panel-regression-1',
queuedAt: new Date(),
},
})
reconcileMock.isJobAlive.mockResolvedValue(false)
const replacement = await createTask({
userId: user.id,
projectId: project.id,
type: TASK_TYPE.VIDEO_PANEL,
targetType: 'NovelPromotionPanel',
targetId: 'panel-regression-1',
payload: {
storyboardId: 'storyboard-regression-1',
panelIndex: 1,
meta: { locale: 'zh' },
},
dedupeKey: 'video_panel:panel-regression-1',
})
expect(replacement.deduped).toBe(false)
expect(replacement.task.id).not.toBe(orphan.id)
const failedOrphan = await prisma.task.findUnique({ where: { id: orphan.id } })
expect(failedOrphan).toMatchObject({
status: TASK_STATUS.FAILED,
errorCode: 'RECONCILE_ORPHAN',
dedupeKey: null,
})
})
})
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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'
const queueState = vi.hoisted(() => ({
message: 'queue add failed',
}))
vi.mock('@/lib/task/queues', () => ({
addTaskJob: vi.fn(async () => {
throw new Error(queueState.message)
}),
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: vi.fn(async () => ({})),
}))
describe('regression - enqueue compensation', () => {
beforeEach(async () => {
await resetBillingState()
vi.clearAllMocks()
process.env.BILLING_MODE = 'ENFORCE'
queueState.message = 'queue unavailable'
})
it('rolls back frozen balance when queue submission fails', async () => {
const user = await createTestUser()
await seedBalance(user.id, 10)
await expect(
submitTask({
userId: user.id,
locale: 'en',
projectId: 'project-regression-enqueue',
type: TASK_TYPE.VOICE_LINE,
targetType: 'VoiceLine',
targetId: 'line-regression-enqueue',
payload: { maxSeconds: 6 },
}),
).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' })
const task = await prisma.task.findFirst({
where: {
userId: user.id,
type: TASK_TYPE.VOICE_LINE,
},
orderBy: { createdAt: 'desc' },
})
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
expect(task).toMatchObject({
status: 'failed',
errorCode: 'ENQUEUE_FAILED',
})
expect(task?.billingInfo).toMatchObject({
billable: true,
status: 'rolled_back',
})
expect(balance?.balance).toBeCloseTo(10, 8)
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
expect(freeze?.status).toBe('rolled_back')
})
})
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createRun } from '@/lib/run-runtime/service'
import { submitTask } from '@/lib/task/submitter'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../helpers/prisma'
import { resetBillingState } from '../helpers/db-reset'
import { createTestUser } from '../helpers/billing-fixtures'
const addTaskJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'mock-job' })))
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))
vi.mock('@/lib/task/queues', () => ({
addTaskJob: addTaskJobMock,
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: publishTaskEventMock,
}))
describe('regression - reusable active run reattach', () => {
beforeEach(async () => {
await resetBillingState()
vi.clearAllMocks()
process.env.BILLING_MODE = 'OFF'
})
it('reattaches a new run-centric task to the existing active run when the linked task is already terminal', async () => {
const user = await createTestUser()
const failedTask = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
status: TASK_STATUS.FAILED,
errorCode: 'TEST_FAILED',
errorMessage: 'old task already failed',
payload: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
queuedAt: new Date(),
finishedAt: new Date(),
},
})
const run = await createRun({
userId: user.id,
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskId: failedTask.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
input: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
})
const result = await submitTask({
userId: user.id,
locale: 'zh',
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
payload: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
},
dedupeKey: 'script_to_storyboard:episode-regression-run',
})
expect(result.deduped).toBe(false)
expect(result.runId).toBe(run.id)
expect(result.taskId).not.toBe(failedTask.id)
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
const newTask = await prisma.task.findUnique({ where: { id: result.taskId } })
expect(refreshedRun?.taskId).toBe(result.taskId)
expect(newTask?.status).toBe(TASK_STATUS.QUEUED)
expect(newTask?.payload).toMatchObject({
runId: run.id,
})
})
})
+72
View 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
View 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
View 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',
})
}
+142
View File
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from './helpers/seed'
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
const imageState = vi.hoisted(() => ({
mode: 'success' as 'success' | 'fatal',
cosKey: 'cos/system-image-generated.png',
errorMessage: 'IMAGE_GENERATION_FATAL',
}))
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,
generateProjectLabeledImageToStorage: vi.fn(async () => {
if (imageState.mode === 'fatal') {
throw new Error(imageState.errorMessage)
}
return imageState.cosKey
}),
}
})
vi.mock('@/lib/media/outbound-image', async () => {
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
return {
...actual,
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
}
})
describe('system - generate image', () => {
let workers: SystemWorkers = {}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
imageState.mode = 'success'
imageState.cosKey = 'cos/system-image-generated.png'
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
await resetSystemState()
installAuthMocks()
})
afterEach(async () => {
await stopSystemWorkers(workers)
workers = {}
resetAuthMockState()
})
it('route -> queue -> worker -> db writes imageUrl and lifecycle events', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
workers = await startSystemWorkers(['image'])
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
type: 'character',
id: seeded.character.id,
appearanceId: seeded.appearance.id,
count: 1,
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { async: boolean; taskId: string }
expect(json.async).toBe(true)
expect(typeof json.taskId).toBe('string')
const task = await waitForTaskTerminalState(json.taskId)
expect(task.status).toBe('completed')
expect(task.type).toBe('image_character')
expect(task.targetId).toBe(seeded.appearance.id)
const appearance = await prisma.characterAppearance.findUnique({
where: { id: seeded.appearance.id },
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
})
expect(appearance).toEqual({
imageUrl: imageState.cosKey,
imageUrls: JSON.stringify([imageState.cosKey]),
selectedIndex: 0,
})
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'completed')
})
it('fatal provider path -> task fails and existing appearance images stay unchanged', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
imageState.mode = 'fatal'
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
workers = await startSystemWorkers(['image'])
const originalAppearance = await prisma.characterAppearance.findUnique({
where: { id: seeded.appearance.id },
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
})
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
type: 'character',
id: seeded.character.id,
appearanceId: seeded.appearance.id,
count: 1,
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { taskId: string }
const task = await waitForTaskTerminalState(json.taskId)
expect(task.status).toBe('failed')
expect(task.errorMessage).toContain('IMAGE_GENERATION_FATAL')
const appearance = await prisma.characterAppearance.findUnique({
where: { id: seeded.appearance.id },
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
})
expect(appearance).toEqual(originalAppearance)
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'failed')
})
})
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from './helpers/seed'
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
type PollState = {
status: 'processing' | 'completed'
resultUrl?: string
}
const videoState = vi.hoisted(() => ({
pollResponses: new Map<string, PollState[]>(),
uploadedCosKey: 'video/system-video.mp4',
}))
vi.mock('@/lib/generator-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/generator-api')>('@/lib/generator-api')
return {
...actual,
generateVideo: vi.fn(async () => ({
success: true,
async: true,
externalId: 'video-ext-1',
})),
}
})
vi.mock('@/lib/async-poll', async () => {
const actual = await vi.importActual<typeof import('@/lib/async-poll')>('@/lib/async-poll')
return {
...actual,
pollAsyncTask: vi.fn(async (externalId: string) => {
const queue = videoState.pollResponses.get(externalId) || []
const next = queue.shift()
if (!next) {
return { status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' }
}
videoState.pollResponses.set(externalId, queue)
return next
}),
}
})
vi.mock('@/lib/media/outbound-image', async () => {
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
return {
...actual,
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
}
})
vi.mock('@/lib/workers/utils', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/utils')>('@/lib/workers/utils')
return {
...actual,
uploadVideoSourceToCos: vi.fn(async () => videoState.uploadedCosKey),
}
})
describe('system - generate video', () => {
let workers: SystemWorkers = {}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
videoState.uploadedCosKey = 'video/system-video.mp4'
videoState.pollResponses.clear()
videoState.pollResponses.set('video-ext-1', [
{ status: 'processing' },
{ status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' },
])
await resetSystemState()
installAuthMocks()
})
afterEach(async () => {
await stopSystemWorkers(workers)
workers = {}
resetAuthMockState()
})
it('queued external generation -> polling -> videoUrl persisted', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
workers = await startSystemWorkers(['video'])
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-video/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
storyboardId: seeded.storyboard.id,
panelIndex: 0,
videoModel: 'fal::seedance/video',
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { async: boolean; taskId: string }
const task = await waitForTaskTerminalState(json.taskId)
expect(task.status).toBe('completed')
expect(task.type).toBe('video_panel')
expect(task.externalId).toBe('video-ext-1')
const panel = await prisma.novelPromotionPanel.findUnique({
where: { id: seeded.panel.id },
select: { videoUrl: true },
})
expect(panel?.videoUrl).toBe(videoState.uploadedCosKey)
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'completed')
})
})
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from 'node:crypto'
import { prisma } from '../../helpers/prisma'
import {
createFixtureEpisode,
createFixtureNovelProject,
createFixtureProject,
createFixtureUser,
} from '../../helpers/fixtures'
function nextSuffix() {
return randomUUID().slice(0, 8)
}
export async function seedMinimalDomainState() {
const user = await createFixtureUser()
const project = await createFixtureProject(user.id)
const novelProject = await createFixtureNovelProject(project.id)
const episode = await createFixtureEpisode(novelProject.id)
const clip = await prisma.novelPromotionClip.create({
data: {
episodeId: episode.id,
summary: 'seed clip',
content: 'seed clip content',
screenplay: 'seed screenplay',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})
const storyboard = await prisma.novelPromotionStoryboard.create({
data: {
episodeId: episode.id,
clipId: clip.id,
panelCount: 1,
},
})
const panel = await prisma.novelPromotionPanel.create({
data: {
storyboardId: storyboard.id,
panelIndex: 0,
panelNumber: 1,
shotType: '中景',
cameraMove: '固定',
description: 'seed panel',
videoPrompt: 'seed video prompt',
location: 'Office',
characters: JSON.stringify(['Narrator']),
imageUrl: 'https://provider.example/panel.jpg',
},
})
const character = await prisma.novelPromotionCharacter.create({
data: {
novelPromotionProjectId: novelProject.id,
name: 'Narrator',
},
})
const appearance = await prisma.characterAppearance.create({
data: {
characterId: character.id,
appearanceIndex: 0,
changeReason: 'default',
description: 'Narrator appearance',
imageUrls: JSON.stringify(['images/character-seed.jpg']),
imageUrl: 'images/character-seed.jpg',
selectedIndex: 0,
},
})
const location = await prisma.novelPromotionLocation.create({
data: {
novelPromotionProjectId: novelProject.id,
name: 'Office',
summary: 'Office summary',
},
})
const locationImage = await prisma.locationImage.create({
data: {
locationId: location.id,
imageIndex: 0,
description: 'Office image',
imageUrl: 'images/location-seed.jpg',
isSelected: true,
},
})
const voiceLine = await prisma.novelPromotionVoiceLine.create({
data: {
episodeId: episode.id,
lineIndex: 1,
speaker: 'Narrator',
content: 'Hello world',
matchedPanelId: panel.id,
matchedStoryboardId: storyboard.id,
matchedPanelIndex: panel.panelIndex,
},
})
await prisma.novelPromotionEpisode.update({
where: { id: episode.id },
data: {
speakerVoices: JSON.stringify({
Narrator: {
provider: 'fal',
voiceType: 'uploaded',
audioUrl: 'https://provider.example/reference.wav',
},
}),
},
})
const secondaryPanel = await prisma.novelPromotionPanel.create({
data: {
storyboardId: storyboard.id,
panelIndex: 1,
panelNumber: 2,
shotType: '近景',
cameraMove: '推镜',
description: 'secondary panel',
videoPrompt: 'secondary prompt',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})
await prisma.novelPromotionStoryboard.update({
where: { id: storyboard.id },
data: { panelCount: 2 },
})
const foreignStoryboard = await prisma.novelPromotionStoryboard.create({
data: {
episodeId: episode.id,
clipId: (await prisma.novelPromotionClip.create({
data: {
episodeId: episode.id,
summary: 'foreign clip',
content: 'foreign clip content',
screenplay: 'foreign screenplay',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})).id,
panelCount: 1,
},
})
const foreignPanel = await prisma.novelPromotionPanel.create({
data: {
id: `panel-foreign-${nextSuffix()}`,
storyboardId: foreignStoryboard.id,
panelIndex: 0,
panelNumber: 1,
shotType: '远景',
cameraMove: '固定',
description: 'foreign panel',
videoPrompt: 'foreign prompt',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})
return {
user,
project,
novelProject,
episode,
clip,
storyboard,
panel,
secondaryPanel,
foreignStoryboard,
foreignPanel,
character,
appearance,
location,
locationImage,
voiceLine,
}
}
+53
View File
@@ -0,0 +1,53 @@
import { TASK_EVENT_TYPE, TASK_STATUS, type TaskEventType, type TaskStatus } from '@/lib/task/types'
import { expect } from 'vitest'
import { prisma } from '../../helpers/prisma'
type WaitTaskOptions = {
timeoutMs?: number
intervalMs?: number
}
const TERMINAL_STATUSES = new Set<TaskStatus>([
TASK_STATUS.COMPLETED,
TASK_STATUS.FAILED,
TASK_STATUS.CANCELED,
TASK_STATUS.DISMISSED,
])
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function waitForTaskTerminalState(taskId: string, options: WaitTaskOptions = {}) {
const timeoutMs = options.timeoutMs ?? 15_000
const intervalMs = options.intervalMs ?? 100
const startedAt = Date.now()
while (Date.now() - startedAt <= timeoutMs) {
const task = await prisma.task.findUnique({
where: { id: taskId },
})
if (task && TERMINAL_STATUSES.has(task.status as TaskStatus)) {
return task
}
await sleep(intervalMs)
}
throw new Error(`TASK_WAIT_TIMEOUT: ${taskId}`)
}
export async function listTaskEventTypes(taskId: string): Promise<TaskEventType[]> {
const events = await prisma.taskEvent.findMany({
where: { taskId },
orderBy: { createdAt: 'asc' },
select: { eventType: true },
})
return events.map((event) => event.eventType as TaskEventType)
}
export function expectLifecycleEvents(types: ReadonlyArray<TaskEventType>, terminal: 'completed' | 'failed') {
const expectedTerminal = terminal === 'completed' ? TASK_EVENT_TYPE.COMPLETED : TASK_EVENT_TYPE.FAILED
expect(types).toContain(TASK_EVENT_TYPE.CREATED)
expect(types).toContain(TASK_EVENT_TYPE.PROCESSING)
expect(types).toContain(expectedTerminal)
}
+40
View File
@@ -0,0 +1,40 @@
import type { Worker } from 'bullmq'
import type { TaskJobData } from '@/lib/task/types'
export type SystemWorkerScope = 'image' | 'video' | 'voice' | 'text'
export type SystemWorkers = Partial<Record<SystemWorkerScope, Worker<TaskJobData>>>
async function createWorker(scope: SystemWorkerScope): Promise<Worker<TaskJobData>> {
if (scope === 'image') {
const mod = await import('@/lib/workers/image.worker')
return mod.createImageWorker()
}
if (scope === 'video') {
const mod = await import('@/lib/workers/video.worker')
return mod.createVideoWorker()
}
if (scope === 'voice') {
const mod = await import('@/lib/workers/voice.worker')
return mod.createVoiceWorker()
}
const mod = await import('@/lib/workers/text.worker')
return mod.createTextWorker()
}
export async function startSystemWorkers(scopes: ReadonlyArray<SystemWorkerScope>): Promise<SystemWorkers> {
const started: SystemWorkers = {}
for (const scope of scopes) {
const worker = await createWorker(scope)
await worker.waitUntilReady()
started[scope] = worker
}
return started
}
export async function stopSystemWorkers(workers: SystemWorkers): Promise<void> {
for (const worker of Object.values(workers)) {
if (!worker) continue
await worker.close()
}
}
+352
View File
@@ -0,0 +1,352 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from './helpers/seed'
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
import { createFixtureEpisode, createFixtureNovelProject, createFixtureProject, createFixtureUser } from '../helpers/fixtures'
type FakeAiResult = {
text: string
reasoning?: string
}
type FakeVoiceLineRow = {
lineIndex: number
speaker: string
content: string
emotionStrength: number
matchedPanel: {
storyboardId: string
panelIndex: number
}
}
const textState = vi.hoisted(() => ({
aiResults: [] as FakeAiResult[],
voiceLineResults: [] as FakeVoiceLineRow[],
parseFailureCount: 0,
orchestratorClipId: 'clip-seed',
}))
vi.mock('@/lib/ai-runtime', async () => {
const actual = await vi.importActual<typeof import('@/lib/ai-runtime')>('@/lib/ai-runtime')
return {
...actual,
executeAiTextStep: vi.fn(async () => {
const next = textState.aiResults.shift()
if (!next) {
return {
text: '{"ok":true}',
reasoning: '',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
}
}
return {
text: next.text,
reasoning: next.reasoning || '',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
}
}),
}
})
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {
const actual = await vi.importActual<typeof import('@/lib/novel-promotion/script-to-storyboard/orchestrator')>(
'@/lib/novel-promotion/script-to-storyboard/orchestrator',
)
return {
...actual,
runScriptToStoryboardOrchestrator: vi.fn(async () => ({
clipPanels: [
{
clipId: textState.orchestratorClipId,
clipIndex: 0,
finalPanels: [
{
panel_number: 1,
shot_type: 'close-up',
camera_move: 'static',
description: 'system generated panel',
video_prompt: 'system video prompt',
location: 'Office',
characters: ['Narrator'],
},
],
},
],
summary: {
totalPanelCount: 1,
totalStepCount: 4,
},
})),
}
})
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/script-to-storyboard-helpers')>(
'@/lib/workers/handlers/script-to-storyboard-helpers',
)
return {
...actual,
parseVoiceLinesJson: vi.fn(() => {
if (textState.parseFailureCount > 0) {
textState.parseFailureCount -= 1
throw new Error('invalid voice json')
}
return textState.voiceLineResults
}),
persistStoryboardsAndPanels: vi.fn(async (input: { episodeId: string }) => {
const clip = await prisma.novelPromotionClip.findFirst({
where: { episodeId: input.episodeId },
orderBy: { createdAt: 'asc' },
})
if (!clip) {
throw new Error(`TEST_CLIP_NOT_FOUND: ${input.episodeId}`)
}
const storyboard = await prisma.novelPromotionStoryboard.create({
data: {
id: 'storyboard-1',
episodeId: input.episodeId,
clipId: clip.id,
panelCount: 1,
},
})
const panel = await prisma.novelPromotionPanel.create({
data: {
id: 'panel-1',
storyboardId: storyboard.id,
panelIndex: 1,
panelNumber: 1,
shotType: 'close-up',
cameraMove: 'static',
description: 'system generated panel',
videoPrompt: 'system video prompt',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})
return [{ storyboardId: storyboard.id, panels: [{ id: panel.id, panelIndex: 1 }] }]
}),
}
})
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(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
async function seedScriptToStoryboardState() {
const user = await createFixtureUser()
const project = await createFixtureProject(user.id)
const novelProject = await createFixtureNovelProject(project.id)
const episode = await createFixtureEpisode(novelProject.id)
const clip = await prisma.novelPromotionClip.create({
data: {
episodeId: episode.id,
summary: 'script clip',
content: 'clip content',
screenplay: 'screenplay text',
location: 'Office',
characters: JSON.stringify(['Narrator']),
},
})
await prisma.novelPromotionCharacter.create({
data: {
novelPromotionProjectId: novelProject.id,
name: 'Narrator',
},
})
await prisma.novelPromotionLocation.create({
data: {
novelPromotionProjectId: novelProject.id,
name: 'Office',
summary: 'Office',
},
})
textState.orchestratorClipId = clip.id
return { user, project, novelProject, episode, clip }
}
describe('system - text workflows', () => {
let workers: SystemWorkers = {}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
textState.aiResults = []
textState.voiceLineResults = []
textState.parseFailureCount = 0
textState.orchestratorClipId = 'clip-seed'
await resetSystemState()
installAuthMocks()
})
afterEach(async () => {
await stopSystemWorkers(workers)
workers = {}
resetAuthMockState()
})
it('script-to-storyboard success -> persists storyboard/panel/voiceLine and completes task', async () => {
const seeded = await seedScriptToStoryboardState()
mockAuthenticated(seeded.user.id)
textState.aiResults = [{ text: 'voice-lines-json' }]
textState.voiceLineResults = [
{
lineIndex: 1,
speaker: 'Narrator',
content: 'Hello world',
emotionStrength: 0.8,
matchedPanel: {
storyboardId: seeded.clip.id,
panelIndex: 0,
},
},
]
workers = await startSystemWorkers(['text'])
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
const response = await callRoute(
mod.POST,
'POST',
{ locale: 'zh', episodeId: seeded.episode.id },
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { taskId: string }
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
expect(task.status).toBe('completed')
expect(task.type).toBe('script_to_storyboard_run')
expect(task.result).toEqual(expect.objectContaining({
episodeId: seeded.episode.id,
panelCount: 1,
voiceLineCount: 1,
}))
const storyboards = await prisma.novelPromotionStoryboard.findMany({
where: { episodeId: seeded.episode.id },
select: { id: true, panelCount: true },
})
expect(storyboards.length).toBeGreaterThan(0)
const persistedVoiceLines = await prisma.novelPromotionVoiceLine.findMany({
where: { episodeId: seeded.episode.id },
orderBy: { lineIndex: 'asc' },
select: {
lineIndex: true,
speaker: true,
content: true,
matchedPanelId: true,
matchedPanelIndex: true,
},
})
expect(persistedVoiceLines).toEqual([
{
lineIndex: 1,
speaker: 'Narrator',
content: 'Hello world',
matchedPanelId: expect.any(String),
matchedPanelIndex: 0,
},
])
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'completed')
})
it('script-to-storyboard parse retry -> second attempt succeeds', async () => {
const seeded = await seedScriptToStoryboardState()
mockAuthenticated(seeded.user.id)
textState.aiResults = [
{ text: 'invalid-voice-json' },
{ text: 'valid-voice-json' },
]
textState.voiceLineResults = [
{
lineIndex: 1,
speaker: 'Narrator',
content: 'Retry success',
emotionStrength: 0.4,
matchedPanel: {
storyboardId: seeded.clip.id,
panelIndex: 0,
},
},
]
textState.parseFailureCount = 1
workers = await startSystemWorkers(['text'])
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
const response = await callRoute(
mod.POST,
'POST',
{ locale: 'zh', episodeId: seeded.episode.id },
{ params: { projectId: seeded.project.id } },
)
const json = await response.json() as { taskId: string }
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
expect(task.status).toBe('completed')
expect(task.result).toEqual(expect.objectContaining({
voiceLineCount: 1,
}))
const voiceLines = await prisma.novelPromotionVoiceLine.findMany({
where: { episodeId: seeded.episode.id },
select: { content: true },
})
expect(voiceLines).toEqual([{ content: 'Retry success' }])
})
it('insert-panel invalid ai payload -> task fails and no dirty panel remains', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
textState.aiResults = [{ text: 'not-json' }]
workers = await startSystemWorkers(['text'])
const beforeCount = await prisma.novelPromotionPanel.count({
where: { storyboardId: seeded.storyboard.id },
})
const mod = await import('@/app/api/novel-promotion/[projectId]/insert-panel/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
storyboardId: seeded.storyboard.id,
insertAfterPanelId: seeded.panel.id,
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { taskId: string }
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
expect(task.status).toBe('failed')
const afterCount = await prisma.novelPromotionPanel.count({
where: { storyboardId: seeded.storyboard.id },
})
expect(afterCount).toBe(beforeCount)
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'failed')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { callRoute } from '../integration/api/helpers/call-route'
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
import { resetSystemState } from '../helpers/db-reset'
import { prisma } from '../helpers/prisma'
import { seedMinimalDomainState } from './helpers/seed'
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
const voiceState = vi.hoisted(() => ({
audioUrl: 'voice/system-line.wav',
audioDuration: 1200,
}))
vi.mock('@/lib/api-config', async () => {
const actual = await vi.importActual<typeof import('@/lib/api-config')>('@/lib/api-config')
return {
...actual,
resolveModelSelectionOrSingle: vi.fn(async () => ({
provider: 'fal',
modelId: 'fal-audio-model',
modelKey: 'fal::audio-model',
mediaType: 'audio',
})),
getProviderKey: vi.fn((providerId: string) => providerId),
}
})
vi.mock('@/lib/voice/generate-voice-line', async () => {
const actual = await vi.importActual<typeof import('@/lib/voice/generate-voice-line')>('@/lib/voice/generate-voice-line')
return {
...actual,
generateVoiceLine: vi.fn(async (params: {
lineId: string
}) => {
await prisma.novelPromotionVoiceLine.update({
where: { id: params.lineId },
data: {
audioUrl: voiceState.audioUrl,
audioDuration: voiceState.audioDuration,
},
})
return {
lineId: params.lineId,
audioUrl: voiceState.audioUrl,
storageKey: voiceState.audioUrl,
audioDuration: voiceState.audioDuration,
}
}),
}
})
describe('system - voice generate', () => {
let workers: SystemWorkers = {}
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
voiceState.audioUrl = 'voice/system-line.wav'
voiceState.audioDuration = 1200
await resetSystemState()
installAuthMocks()
})
afterEach(async () => {
await stopSystemWorkers(workers)
workers = {}
resetAuthMockState()
})
it('route -> voice worker -> line audio persisted', async () => {
const seeded = await seedMinimalDomainState()
mockAuthenticated(seeded.user.id)
workers = await startSystemWorkers(['voice'])
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
const response = await callRoute(
mod.POST,
'POST',
{
locale: 'zh',
episodeId: seeded.episode.id,
lineId: seeded.voiceLine.id,
audioModel: 'fal::audio-model',
},
{ params: { projectId: seeded.project.id } },
)
expect(response.status).toBe(200)
const json = await response.json() as { success: boolean; async: boolean; taskId: string }
expect(json.success).toBe(true)
const task = await waitForTaskTerminalState(json.taskId)
expect(task.status).toBe('completed')
expect(task.type).toBe('voice_line')
const voiceLine = await prisma.novelPromotionVoiceLine.findUnique({
where: { id: seeded.voiceLine.id },
select: { audioUrl: true, audioDuration: true },
})
expect(voiceLine).toEqual({
audioUrl: voiceState.audioUrl,
audioDuration: voiceState.audioDuration,
})
const eventTypes = await listTaskEventTypes(json.taskId)
expectLifecycleEvents(eventTypes, 'completed')
})
})
+34
View 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)
})
})
@@ -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([])
})
})
@@ -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')
})
})
@@ -0,0 +1,62 @@
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 and Seedance 2.0 Fast as preset video models', () => {
const modelIds = PRESET_MODELS
.filter((entry) => entry.provider === 'ark' && entry.type === 'video')
.map((entry) => entry.modelId)
expect(modelIds).toEqual(expect.arrayContaining([
'doubao-seedance-2-0-260128',
'doubao-seedance-2-0-fast-260128',
]))
})
it('does not mark live preset models as coming soon', () => {
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(false)
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
})
it('does not mark normal preset models as coming soon', () => {
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-fast-260128')
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-fast-260128')).toBe(false)
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
})
it('keeps existing live preset models non-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.7-i2v',
'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')
})
})
@@ -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',
})
})
})
@@ -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')
})
})
@@ -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()
})
})
@@ -0,0 +1,182 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { UseProviderCardStateResult } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
import { ProviderCardShell } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
import type { ProviderTutorial } from '@/app/[locale]/profile/components/api-config/types'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
return {
providerKey: 'ark',
isPresetProvider: true,
showBaseUrlEdit: false,
tutorial,
groupedModels: {},
hasModels: false,
isEditing: false,
isEditingUrl: false,
showKey: false,
tempKey: '',
tempUrl: '',
showTutorial: true,
showAddForm: null,
newModel: {
name: '',
modelId: '',
enableCustomPricing: false,
priceInput: '',
priceOutput: '',
basePrice: '',
optionPricesJson: '',
},
batchMode: false,
editingModelId: null,
editModel: {
name: '',
modelId: '',
enableCustomPricing: false,
priceInput: '',
priceOutput: '',
basePrice: '',
optionPricesJson: '',
},
maskedKey: '',
isPresetModel: () => false,
isDefaultModel: () => false,
setShowKey: () => undefined,
setShowTutorial: () => undefined,
setShowAddForm: () => undefined,
setBatchMode: () => undefined,
setNewModel: () => undefined,
setEditModel: () => undefined,
setTempKey: () => undefined,
setTempUrl: () => undefined,
startEditKey: () => undefined,
startEditUrl: () => undefined,
handleSaveKey: () => Promise.resolve(),
handleCancelEdit: () => undefined,
handleSaveUrl: () => undefined,
handleCancelUrlEdit: () => undefined,
handleEditModel: () => undefined,
handleCancelEditModel: () => undefined,
handleSaveModel: () => Promise.resolve(),
handleAddModel: () => Promise.resolve(),
handleCancelAdd: () => undefined,
needsCustomPricing: false,
keyTestStatus: 'idle',
keyTestSteps: [],
handleForceSaveKey: () => undefined,
handleTestOnly: () => undefined,
handleDismissTest: () => undefined,
isModelSavePending: false,
assistantEnabled: false,
isAssistantOpen: false,
assistantSavedEvent: null,
assistantChat: {
messages: [],
input: '',
status: 'ready',
pending: false,
error: undefined,
setInput: () => undefined,
send: async () => undefined,
clear: () => undefined,
},
openAssistant: () => undefined,
closeAssistant: () => undefined,
handleAssistantSend: () => Promise.resolve(),
}
}
function ProviderCardShellWithBody(
props: Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>,
): React.ReactElement {
const ProviderCardShellComponent =
ProviderCardShell as unknown as React.ComponentType<
React.PropsWithChildren<Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>>
>
return createElement(
ProviderCardShellComponent,
props,
createElement('div', null, 'provider-body'),
)
}
describe('ProviderCardShell tutorial modal', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('mounts the tutorial modal through a portal to document.body', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
portalMocks.currentPortalTarget = fakeDocument.body
Reflect.set(globalThis, 'document', fakeDocument)
const tutorial: ProviderTutorial = {
providerId: 'ark',
steps: [
{
text: 'ark_step1',
url: 'https://example.com/ark-key',
},
],
}
const state = createState(tutorial)
const t = (key: string): string => {
if (key === 'tutorial.button') return '开通教程'
if (key === 'tutorial.title') return '开通教程'
if (key === 'tutorial.subtitle') return '按照以下步骤完成配置'
if (key === 'tutorial.steps.ark_step1') return '进入控制台创建 API Key'
if (key === 'tutorial.openLink') return '点击打开'
if (key === 'tutorial.close') return '关闭'
return key
}
const html = renderToStaticMarkup(
createElement(
ProviderCardShellWithBody,
{
provider: {
id: 'ark',
name: '阿里云百炼',
hasApiKey: true,
},
onDeleteProvider: () => undefined,
t,
state,
},
),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('进入控制台创建 API Key')
expect(html).toContain('href="https://example.com/ark-key"')
})
})
@@ -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',
])
})
})
@@ -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')
})
})
@@ -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,
})
})
})
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
$queryRaw: vi.fn(),
$executeRaw: vi.fn(),
$transaction: vi.fn(),
locationImage: { createMany: vi.fn() },
globalLocationImage: { createMany: vi.fn() },
}))
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
describe('location-backed assets service', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$queryRaw
.mockResolvedValueOnce([
{
id: 'location-1',
novelPromotionProjectId: 'novel-project-1',
name: 'Bronze Dagger',
summary: 'Old bronze dagger',
selectedImageId: null,
sourceGlobalLocationId: null,
assetKind: 'prop',
},
])
.mockResolvedValueOnce([])
})
it('queries project location-backed assets with real schema column names', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
await mod.listProjectLocationBackedAssets('novel-project-1', 'prop')
const assetQuery = prismaMock.$queryRaw.mock.calls[0]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
const imageQuery = prismaMock.$queryRaw.mock.calls[1]?.[0] as { strings?: ReadonlyArray<string>; sql?: string }
const assetSql = assetQuery.strings?.join(' ') ?? assetQuery.sql ?? ''
const imageSql = imageQuery.strings?.join(' ') ?? imageQuery.sql ?? ''
expect(assetSql).toContain('FROM novel_promotion_locations')
expect(assetSql).toContain('novelPromotionProjectId')
expect(assetSql).not.toContain('projectId')
expect(imageSql).toContain('FROM location_images')
expect(imageSql).toContain('NULL AS previousImageMediaId')
})
it('seeds an initial project image slot when creating a prop asset', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
const result = await mod.createProjectLocationBackedAsset({
novelPromotionProjectId: 'novel-project-1',
name: 'Bronze Dagger',
summary: 'Old bronze dagger',
initialDescription: 'A bronze dagger with a carved handle and weathered blade',
kind: 'prop',
})
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
data: [
{
locationId: result.id,
imageIndex: 0,
description: 'A bronze dagger with a carved handle and weathered blade',
availableSlots: '[]',
},
],
})
})
it('seeds multiple project image slots when explicit descriptions are provided', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
await mod.seedProjectLocationBackedImageSlots({
locationId: 'location-1',
descriptions: ['Night street', 'Rainy alley'],
fallbackDescription: 'Night street',
})
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
data: [
{
locationId: 'location-1',
imageIndex: 0,
description: 'Night street',
availableSlots: '[]',
},
{
locationId: 'location-1',
imageIndex: 1,
description: 'Rainy alley',
availableSlots: '[]',
},
],
})
})
})
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
describe('location-backed asset generation rules', () => {
it('requires props to have a visual description before generation', () => {
expect(canGenerateLocationBackedAsset({
id: 'prop-1',
name: '金箍棒',
summary: '一根两头包裹金片的黑铁长棍',
images: [],
}, 'prop')).toBe(false)
})
it('allows locations to generate from seeded image descriptions', () => {
expect(canGenerateLocationBackedAsset({
id: 'location-1',
name: '雨夜街道',
summary: null,
images: [
{
id: 'image-1',
imageIndex: 0,
description: '潮湿反光的老街',
imageUrl: null,
previousImageUrl: null,
previousDescription: null,
isSelected: false,
},
],
}, 'location')).toBe(true)
})
it('routes prop generation through the prop branch', () => {
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
expect(resolveLocationBackedGenerateType('location')).toBe('location')
})
})
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { mapGlobalVoiceToAsset, mapProjectCharacterToAsset, mapProjectPropToAsset } from '@/lib/assets/mappers'
import { groupAssetsByKind } from '@/lib/assets/grouping'
describe('asset mappers', () => {
it('maps project characters into the unified character asset contract', () => {
const asset = mapProjectCharacterToAsset({
id: 'character-1',
name: '林夏',
introduction: '主角',
profileData: JSON.stringify({ archetype: 'lead' }),
voiceType: 'custom',
voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
profileConfirmed: true,
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: '初始形象',
description: '短发,风衣',
imageUrl: 'https://example.com/char.jpg',
media: null,
imageUrls: ['https://example.com/char.jpg'],
imageMedias: [],
selectedIndex: 0,
previousImageUrl: null,
previousMedia: null,
previousImageUrls: [],
previousImageMedias: [],
},
],
})
expect(asset).toEqual(expect.objectContaining({
id: 'character-1',
scope: 'project',
kind: 'character',
introduction: '主角',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: true,
voice: expect.objectContaining({
voiceType: 'custom',
voiceId: 'voice-1',
}),
}))
expect(asset.variants[0]).toEqual(expect.objectContaining({
id: 'appearance-1',
index: 0,
label: '初始形象',
}))
})
it('maps global voices into the unified audio asset contract', () => {
const asset = mapGlobalVoiceToAsset({
id: 'voice-1',
name: '旁白',
description: '低沉稳重',
voiceId: 'voice-provider-1',
voiceType: 'designed',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
voicePrompt: '低沉稳重',
gender: 'male',
language: 'zh',
folderId: 'folder-1',
})
expect(asset).toEqual(expect.objectContaining({
id: 'voice-1',
scope: 'global',
kind: 'voice',
voiceMeta: expect.objectContaining({
voiceType: 'designed',
gender: 'male',
language: 'zh',
}),
}))
})
it('maps project props into the unified visual asset contract and groups them by kind', () => {
const propAsset = mapProjectPropToAsset({
id: 'prop-1',
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
images: [
{
id: 'prop-image-1',
imageIndex: 0,
description: '古旧短刃,雕纹手柄',
imageUrl: 'https://example.com/prop.jpg',
media: null,
previousImageUrl: null,
previousMedia: null,
isSelected: true,
},
],
})
const voiceAsset = mapGlobalVoiceToAsset({
id: 'voice-1',
name: '旁白',
description: '低沉稳重',
voiceId: 'voice-provider-1',
voiceType: 'designed',
customVoiceUrl: 'https://example.com/voice.mp3',
media: null,
voicePrompt: '低沉稳重',
gender: 'male',
language: 'zh',
folderId: 'folder-1',
})
expect(propAsset).toEqual(expect.objectContaining({
id: 'prop-1',
scope: 'project',
kind: 'prop',
summary: '古旧短刃,雕纹手柄',
selectedVariantId: 'prop-image-1',
}))
expect(propAsset.variants[0]).toEqual(expect.objectContaining({
id: 'prop-image-1',
index: 0,
description: '古旧短刃,雕纹手柄',
}))
const groups = groupAssetsByKind([propAsset, voiceAsset])
expect(groups.prop.map((asset) => asset.id)).toEqual(['prop-1'])
expect(groups.voice.map((asset) => asset.id)).toEqual(['voice-1'])
})
})
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const deleteObjectMock = vi.hoisted(() => vi.fn())
const resolveStorageKeyFromMediaValueMock = vi.hoisted(() => vi.fn())
const prismaMock = vi.hoisted(() => ({
novelPromotionLocation: {
findUnique: vi.fn(),
update: vi.fn(),
},
locationImage: {
update: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/storage', () => ({
deleteObject: deleteObjectMock,
}))
vi.mock('@/lib/media/service', () => ({
resolveStorageKeyFromMediaValue: resolveStorageKeyFromMediaValueMock,
}))
describe('project location-backed selection service', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (
callback: (tx: {
locationImage: {
update: typeof prismaMock.locationImage.update
deleteMany: typeof prismaMock.locationImage.deleteMany
}
novelPromotionLocation: {
update: typeof prismaMock.novelPromotionLocation.update
}
}) => Promise<void>,
) => callback({
locationImage: prismaMock.locationImage,
novelPromotionLocation: prismaMock.novelPromotionLocation,
}))
resolveStorageKeyFromMediaValueMock.mockImplementation(async (value: string) => `key:${value}`)
deleteObjectMock.mockResolvedValue(undefined)
prismaMock.locationImage.deleteMany.mockResolvedValue({ count: 1 })
prismaMock.locationImage.update.mockResolvedValue(undefined)
prismaMock.novelPromotionLocation.update.mockResolvedValue(undefined)
})
it('confirms a prop selection by keeping only the selected render', async () => {
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
id: 'prop-1',
selectedImageId: 'prop-image-2',
images: [
{
id: 'prop-image-1',
imageIndex: 0,
imageUrl: 'https://example.com/prop-1.png',
isSelected: false,
},
{
id: 'prop-image-2',
imageIndex: 1,
imageUrl: 'https://example.com/prop-2.png',
isSelected: true,
},
],
})
const mod = await import('@/lib/assets/services/project-location-backed-selection')
const result = await mod.confirmProjectLocationBackedSelection('prop-1')
expect(result).toEqual({ success: true })
expect(resolveStorageKeyFromMediaValueMock).toHaveBeenCalledWith('https://example.com/prop-1.png')
expect(deleteObjectMock).toHaveBeenCalledWith('key:https://example.com/prop-1.png')
expect(prismaMock.locationImage.deleteMany).toHaveBeenCalledWith({
where: {
locationId: 'prop-1',
id: { not: 'prop-image-2' },
},
})
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
where: { id: 'prop-image-2' },
data: {
imageIndex: 0,
isSelected: true,
},
})
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
where: { id: 'prop-1' },
data: { selectedImageId: 'prop-image-2' },
})
})
it('fails explicitly when confirming without a selected prop render', async () => {
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
id: 'prop-1',
selectedImageId: null,
images: [
{
id: 'prop-image-1',
imageIndex: 0,
imageUrl: 'https://example.com/prop-1.png',
isSelected: false,
},
{
id: 'prop-image-2',
imageIndex: 1,
imageUrl: 'https://example.com/prop-2.png',
isSelected: false,
},
],
})
const mod = await import('@/lib/assets/services/project-location-backed-selection')
await expect(mod.confirmProjectLocationBackedSelection('prop-1')).rejects.toMatchObject({
code: 'INVALID_PARAMS',
})
expect(prismaMock.locationImage.deleteMany).not.toHaveBeenCalled()
expect(deleteObjectMock).not.toHaveBeenCalled()
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { buildPromptAssetContext, compileAssetPromptFragments } from '@/lib/assets/services/asset-prompt-context'
describe('asset prompt context', () => {
it('compiles subject, environment, and prop prompt fragments from the centralized asset context', () => {
const context = buildPromptAssetContext({
characters: [
{
name: '小雨/雨',
appearances: [
{
changeReason: '初始形象',
descriptions: ['黑色短发,校服,冷静表情'],
selectedIndex: 0,
description: 'fallback description',
},
],
},
],
locations: [
{
name: '天台',
images: [
{
isSelected: true,
description: '夜晚天台,冷风,霓虹远景',
availableSlots: JSON.stringify([
'天台栏杆左侧靠近边缘的位置',
]),
},
],
},
],
props: [
{
name: '青铜匕首',
summary: '古旧短刃,雕纹手柄',
},
],
clipCharacters: [{ name: '雨' }],
clipLocation: '天台',
clipProps: ['青铜匕首'],
})
expect(compileAssetPromptFragments(context)).toEqual({
appearanceListText: '小雨/雨: ["初始形象"]',
fullDescriptionText: '【小雨/雨 - 初始形象】黑色短发,校服,冷静表情',
locationDescriptionText: '夜晚天台,冷风,霓虹远景\n\n可站位置:\n- 天台栏杆左侧靠近边缘的位置',
propsDescriptionText: '【青铜匕首】古旧短刃,雕纹手柄',
charactersIntroductionText: '暂无角色介绍',
})
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { assetKindRegistry, getAssetKindRegistration } from '@/lib/assets/kinds/registry'
describe('asset kind registry', () => {
it('declares the supported asset kinds with stable capability contracts', () => {
expect(Object.keys(assetKindRegistry)).toEqual(['character', 'location', 'prop', 'voice'])
expect(getAssetKindRegistration('character')).toEqual(expect.objectContaining({
kind: 'character',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: true,
capabilities: expect.objectContaining({
canGenerate: true,
canBindVoice: true,
}),
}))
expect(getAssetKindRegistration('location')).toEqual(expect.objectContaining({
kind: 'location',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: false,
}))
expect(getAssetKindRegistration('prop')).toEqual(expect.objectContaining({
kind: 'prop',
family: 'visual',
supportsMultipleVariants: true,
supportsVoiceBinding: false,
capabilities: expect.objectContaining({
canGenerate: true,
canSelectRender: true,
canCopyFromGlobal: true,
}),
}))
expect(getAssetKindRegistration('voice')).toEqual(expect.objectContaining({
kind: 'voice',
family: 'audio',
supportsMultipleVariants: false,
capabilities: expect.objectContaining({
canGenerate: false,
canSelectRender: false,
}),
}))
})
})
@@ -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')
})
})

Some files were not shown because too many files have changed in this diff Show More