first commit
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"textModel": "anthropic/claude-sonnet-4",
|
||||
"imageModel": "seedream",
|
||||
"videoModel": "doubao-seedance-1-0-pro-fast-251015",
|
||||
"voiceSeconds": 5
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { loadTestEnv } from '../setup/env'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
loadTestEnv()
|
||||
|
||||
export { prisma }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('无音色ID,QwenTTS 必须使用 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '暂无角色介绍',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user