release: opensource snapshot 2026-02-27 19:25:00
This commit is contained in:
125
tests/concurrency/billing/ledger.concurrency.test.ts
Normal file
125
tests/concurrency/billing/ledger.concurrency.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { calcText } from '@/lib/billing/cost'
|
||||
import {
|
||||
confirmChargeWithRecord,
|
||||
freezeBalance,
|
||||
getBalance,
|
||||
rollbackFreeze,
|
||||
} from '@/lib/billing/ledger'
|
||||
import { withTextBilling } from '@/lib/billing/service'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
import { expectNoNegativeLedger } from '../../helpers/assertions'
|
||||
|
||||
describe('billing/concurrency', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('does not create negative balance during high-concurrency freezes', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const attempts = Array.from({ length: 40 }, (_, idx) =>
|
||||
freezeBalance(user.id, 1, { idempotencyKey: `concurrency_freeze_${idx}` }))
|
||||
const freezeIds = await Promise.all(attempts)
|
||||
const successCount = freezeIds.filter(Boolean).length
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(successCount).toBeLessThanOrEqual(10)
|
||||
expect(balance.balance).toBeCloseTo(10 - successCount, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(successCount, 8)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
|
||||
it('applies idempotency key correctly under concurrent duplicate requests', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const attempts = Array.from({ length: 20 }, () =>
|
||||
freezeBalance(user.id, 2, { idempotencyKey: 'same_key_concurrency' }))
|
||||
const freezeIds = await Promise.all(attempts)
|
||||
const uniqueIds = new Set(freezeIds.filter(Boolean))
|
||||
|
||||
expect(uniqueIds.size).toBe(1)
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(8, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(2, 8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a valid final state when confirm and rollback race', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 5, { idempotencyKey: 'race_key' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const [confirmResult, rollbackResult] = await Promise.allSettled([
|
||||
confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'race_confirm',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 10,
|
||||
unit: 'token',
|
||||
},
|
||||
{ chargedAmount: 3 },
|
||||
),
|
||||
rollbackFreeze(freezeId!),
|
||||
])
|
||||
|
||||
expect(['fulfilled', 'rejected']).toContain(confirmResult.status)
|
||||
expect(['fulfilled', 'rejected']).toContain(rollbackResult.status)
|
||||
expect(confirmResult.status === 'fulfilled' || rollbackResult.status === 'fulfilled').toBe(true)
|
||||
|
||||
const freeze = await prisma.balanceFreeze.findUnique({ where: { id: freezeId! } })
|
||||
expect(['confirmed', 'rolled_back']).toContain(freeze?.status)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
if (freeze?.status === 'confirmed') {
|
||||
expect(balance.balance).toBeCloseTo(7, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(3, 8)
|
||||
} else {
|
||||
expect(balance.balance).toBeCloseTo(10, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(0, 8)
|
||||
}
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
|
||||
it('prevents duplicate consumption on retried sync billing with same requestId', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 5)
|
||||
|
||||
const attempt = () =>
|
||||
withTextBilling(
|
||||
user.id,
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
500,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'retry_no_double_charge',
|
||||
requestId: 'fixed_request_id',
|
||||
},
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled([attempt(), attempt(), attempt()])
|
||||
expect(results.some((item) => item.status === 'fulfilled')).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
const expected = calcText('anthropic/claude-sonnet-4', 1000, 500)
|
||||
expect(balance.totalSpent).toBeLessThanOrEqual(expected + 1e-8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'consume' } })).toBeLessThanOrEqual(1)
|
||||
await expectNoNegativeLedger(user.id)
|
||||
})
|
||||
})
|
||||
25
tests/contracts/behavior-test-standard.md
Normal file
25
tests/contracts/behavior-test-standard.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Behavior Test Standard
|
||||
|
||||
## Scope
|
||||
- `tests/integration/api/contract/**/*.test.ts`
|
||||
- `tests/integration/chain/**/*.test.ts`
|
||||
- `tests/unit/worker/**/*.test.ts`
|
||||
|
||||
## Must-have
|
||||
- Assert observable results: response payload/status, persisted fields, or queue/job payload.
|
||||
- Include at least one concrete-value assertion for each key business branch.
|
||||
- Cover at least one failure branch for each critical route/handler.
|
||||
|
||||
## Forbidden patterns
|
||||
- Source-text contract assertions (for example checking route code contains `apiHandler`, `submitTask`, `maybeSubmitLLMTask`).
|
||||
- Using only weak call assertions like `toHaveBeenCalled()` as the primary proof.
|
||||
- Structural tests that pass without executing route/worker logic.
|
||||
|
||||
## Minimum assertion quality
|
||||
- Prefer `toHaveBeenCalledWith(...)` with `objectContaining(...)` on critical fields.
|
||||
- Validate exact business fields (`description`, `imageUrl`, `referenceImages`, `aspectRatio`, `taskId`, `async`).
|
||||
- For async task chains, validate queue selection and job metadata (`jobId`, `priority`, `type`).
|
||||
|
||||
## Regression rule
|
||||
- One historical bug must map to at least one dedicated regression test case.
|
||||
- Bug fix without matching behavior regression test is incomplete.
|
||||
24
tests/contracts/requirements-matrix.test.ts
Normal file
24
tests/contracts/requirements-matrix.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { REQUIREMENTS_MATRIX } from './requirements-matrix'
|
||||
|
||||
function fileExists(repoPath: string) {
|
||||
return fs.existsSync(path.resolve(process.cwd(), repoPath))
|
||||
}
|
||||
|
||||
describe('requirements matrix integrity', () => {
|
||||
it('requirement ids are unique', () => {
|
||||
const ids = REQUIREMENTS_MATRIX.map((entry) => entry.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('all declared test files exist', () => {
|
||||
for (const entry of REQUIREMENTS_MATRIX) {
|
||||
expect(entry.tests.length, entry.id).toBeGreaterThan(0)
|
||||
for (const testPath of entry.tests) {
|
||||
expect(fileExists(testPath), `${entry.id} -> ${testPath}`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
84
tests/contracts/requirements-matrix.ts
Normal file
84
tests/contracts/requirements-matrix.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type RequirementPriority = 'P0' | 'P1' | 'P2'
|
||||
|
||||
export type RequirementCoverageEntry = {
|
||||
id: string
|
||||
feature: string
|
||||
userValue: string
|
||||
risk: string
|
||||
priority: RequirementPriority
|
||||
tests: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
||||
{
|
||||
id: 'REQ-ASSETHUB-CHARACTER-EDIT',
|
||||
feature: 'Asset Hub character edit',
|
||||
userValue: '角色信息编辑后立即可见并正确保存',
|
||||
risk: '字段映射漂移导致保存失败或误写',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-ASSETHUB-REFERENCE-TO-CHARACTER',
|
||||
feature: 'Asset Hub reference-to-character',
|
||||
userValue: '上传参考图后生成角色形象且使用参考图',
|
||||
risk: 'referenceImages 丢失或分支走错',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/unit/helpers/reference-to-character-helpers.test.ts',
|
||||
'tests/unit/worker/reference-to-character.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-GENERATE-IMAGE',
|
||||
feature: 'Novel promotion image generation',
|
||||
userValue: '角色/场景/分镜图可稳定生成并回写',
|
||||
risk: '任务 payload 漂移、worker 写回错误实体',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
'tests/integration/chain/image.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-GENERATE-VIDEO',
|
||||
feature: 'Novel promotion video generation',
|
||||
userValue: '面板视频可生成并可追踪状态',
|
||||
risk: 'panel 定位错误、model 能力判断错误、状态错乱',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'tests/unit/worker/video-worker.test.ts',
|
||||
'tests/integration/chain/video.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-NP-TEXT-ANALYSIS',
|
||||
feature: 'Text analysis and storyboard orchestration',
|
||||
userValue: '文本分析链路稳定并可回放结果',
|
||||
risk: 'step 编排变化导致结果结构损坏',
|
||||
priority: 'P1',
|
||||
tests: [
|
||||
'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||
'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
'tests/integration/chain/text.chain.test.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'REQ-TASK-STATE-CONSISTENCY',
|
||||
feature: 'Task state and SSE consistency',
|
||||
userValue: '前端状态与任务真实状态一致',
|
||||
risk: 'target-state 与 SSE 失配导致误提示',
|
||||
priority: 'P0',
|
||||
tests: [
|
||||
'tests/unit/helpers/task-state-service.test.ts',
|
||||
'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||
'tests/unit/optimistic/sse-invalidation.test.ts',
|
||||
],
|
||||
},
|
||||
]
|
||||
50
tests/contracts/route-behavior-matrix.ts
Normal file
50
tests/contracts/route-behavior-matrix.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ROUTE_CATALOG, type RouteCatalogEntry } from './route-catalog'
|
||||
|
||||
export type RouteBehaviorMatrixEntry = {
|
||||
routeFile: string
|
||||
contractGroup: RouteCatalogEntry['contractGroup']
|
||||
caseId: string
|
||||
tests: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string> = {
|
||||
'llm-observe-routes': 'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||
'direct-submit-routes': 'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||
'crud-asset-hub-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'crud-novel-promotion-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||
'user-project-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'auth-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
'infra-routes': 'tests/integration/api/contract/crud-routes.test.ts',
|
||||
}
|
||||
|
||||
function resolveChainTest(routeFile: string): string {
|
||||
if (routeFile.includes('/generate-video/') || routeFile.includes('/lip-sync/')) {
|
||||
return 'tests/integration/chain/video.chain.test.ts'
|
||||
}
|
||||
if (routeFile.includes('/voice-') || routeFile.includes('/voice/')) {
|
||||
return 'tests/integration/chain/voice.chain.test.ts'
|
||||
}
|
||||
if (
|
||||
routeFile.includes('/analyze')
|
||||
|| routeFile.includes('/story-to-script')
|
||||
|| routeFile.includes('/script-to-storyboard')
|
||||
|| routeFile.includes('/screenplay-conversion')
|
||||
|| routeFile.includes('/reference-to-character')
|
||||
) {
|
||||
return 'tests/integration/chain/text.chain.test.ts'
|
||||
}
|
||||
return 'tests/integration/chain/image.chain.test.ts'
|
||||
}
|
||||
|
||||
export const ROUTE_BEHAVIOR_MATRIX: ReadonlyArray<RouteBehaviorMatrixEntry> = ROUTE_CATALOG.map((entry) => ({
|
||||
routeFile: entry.routeFile,
|
||||
contractGroup: entry.contractGroup,
|
||||
caseId: `ROUTE:${entry.routeFile.replace(/^src\/app\/api\//, '').replace(/\/route\.ts$/, '')}`,
|
||||
tests: [
|
||||
CONTRACT_TEST_BY_GROUP[entry.contractGroup],
|
||||
resolveChainTest(entry.routeFile),
|
||||
],
|
||||
}))
|
||||
|
||||
export const ROUTE_BEHAVIOR_COUNT = ROUTE_BEHAVIOR_MATRIX.length
|
||||
213
tests/contracts/route-catalog.ts
Normal file
213
tests/contracts/route-catalog.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
export type RouteCategory =
|
||||
| 'asset-hub'
|
||||
| 'novel-promotion'
|
||||
| 'projects'
|
||||
| 'tasks'
|
||||
| 'user'
|
||||
| 'auth'
|
||||
| 'infra'
|
||||
| 'system'
|
||||
|
||||
export type RouteContractGroup =
|
||||
| 'llm-observe-routes'
|
||||
| 'direct-submit-routes'
|
||||
| 'crud-asset-hub-routes'
|
||||
| 'crud-novel-promotion-routes'
|
||||
| 'task-infra-routes'
|
||||
| 'user-project-routes'
|
||||
| 'auth-routes'
|
||||
| 'infra-routes'
|
||||
|
||||
export type RouteCatalogEntry = {
|
||||
routeFile: string
|
||||
category: RouteCategory
|
||||
contractGroup: RouteContractGroup
|
||||
}
|
||||
|
||||
const ROUTE_FILES = [
|
||||
'src/app/api/asset-hub/ai-design-character/route.ts',
|
||||
'src/app/api/asset-hub/ai-design-location/route.ts',
|
||||
'src/app/api/asset-hub/ai-modify-character/route.ts',
|
||||
'src/app/api/asset-hub/ai-modify-location/route.ts',
|
||||
'src/app/api/asset-hub/appearances/route.ts',
|
||||
'src/app/api/asset-hub/character-voice/route.ts',
|
||||
'src/app/api/asset-hub/characters/[characterId]/appearances/[appearanceIndex]/route.ts',
|
||||
'src/app/api/asset-hub/characters/[characterId]/route.ts',
|
||||
'src/app/api/asset-hub/characters/route.ts',
|
||||
'src/app/api/asset-hub/folders/[folderId]/route.ts',
|
||||
'src/app/api/asset-hub/folders/route.ts',
|
||||
'src/app/api/asset-hub/generate-image/route.ts',
|
||||
'src/app/api/asset-hub/locations/[locationId]/route.ts',
|
||||
'src/app/api/asset-hub/locations/route.ts',
|
||||
'src/app/api/asset-hub/modify-image/route.ts',
|
||||
'src/app/api/asset-hub/picker/route.ts',
|
||||
'src/app/api/asset-hub/reference-to-character/route.ts',
|
||||
'src/app/api/asset-hub/select-image/route.ts',
|
||||
'src/app/api/asset-hub/undo-image/route.ts',
|
||||
'src/app/api/asset-hub/update-asset-label/route.ts',
|
||||
'src/app/api/asset-hub/upload-image/route.ts',
|
||||
'src/app/api/asset-hub/upload-temp/route.ts',
|
||||
'src/app/api/asset-hub/voice-design/route.ts',
|
||||
'src/app/api/asset-hub/voices/[id]/route.ts',
|
||||
'src/app/api/asset-hub/voices/route.ts',
|
||||
'src/app/api/asset-hub/voices/upload/route.ts',
|
||||
'src/app/api/auth/[...nextauth]/route.ts',
|
||||
'src/app/api/auth/register/route.ts',
|
||||
'src/app/api/cos/image/route.ts',
|
||||
'src/app/api/files/[...path]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/analyze/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/assets/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character-voice/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/confirm-selection/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/cleanup-unselected-images/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/clips/[clipId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/clips/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/copy-from-global/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-images/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-videos/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/download-voices/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/editor/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/[episodeId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/batch/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/split-by-markers/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-character-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/location/confirm-selection/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel-link/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/panel/select-candidate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/photography-plan/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/select-character-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/select-location-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/speaker-voice/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/storyboard-group/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/storyboards/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/undo-regenerate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-appearance/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-asset-label/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-location/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/update-prompt/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/upload-asset-image/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/video-proxy/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/video-urls/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',
|
||||
'src/app/api/novel-promotion/[projectId]/voice-lines/route.ts',
|
||||
'src/app/api/projects/[projectId]/assets/route.ts',
|
||||
'src/app/api/projects/[projectId]/costs/route.ts',
|
||||
'src/app/api/projects/[projectId]/data/route.ts',
|
||||
'src/app/api/projects/[projectId]/route.ts',
|
||||
'src/app/api/projects/route.ts',
|
||||
'src/app/api/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/api-config/test-connection/route.ts',
|
||||
'src/app/api/user/balance/route.ts',
|
||||
'src/app/api/user/costs/details/route.ts',
|
||||
'src/app/api/user/costs/route.ts',
|
||||
'src/app/api/user/models/route.ts',
|
||||
'src/app/api/user/transactions/route.ts',
|
||||
] as const
|
||||
|
||||
function resolveCategory(routeFile: string): RouteCategory {
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'asset-hub'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'novel-promotion'
|
||||
if (routeFile.startsWith('src/app/api/projects/')) return 'projects'
|
||||
if (routeFile.startsWith('src/app/api/tasks/') || routeFile === 'src/app/api/task-target-states/route.ts') return 'tasks'
|
||||
if (routeFile.startsWith('src/app/api/user/') || routeFile === 'src/app/api/user-preference/route.ts') return 'user'
|
||||
if (routeFile.startsWith('src/app/api/auth/')) return 'auth'
|
||||
if (routeFile.startsWith('src/app/api/system/')) return 'system'
|
||||
return 'infra'
|
||||
}
|
||||
|
||||
function resolveContractGroup(routeFile: string): RouteContractGroup {
|
||||
if (
|
||||
routeFile.includes('/ai-')
|
||||
|| routeFile.includes('/analyze')
|
||||
|| routeFile.includes('/story-to-script-stream/')
|
||||
|| routeFile.includes('/script-to-storyboard-stream/')
|
||||
|| routeFile.includes('/screenplay-conversion/')
|
||||
|| routeFile.includes('/reference-to-character/')
|
||||
|| routeFile.includes('/character-profile/')
|
||||
|| routeFile.endsWith('/clips/route.ts')
|
||||
|| routeFile.endsWith('/episodes/split/route.ts')
|
||||
|| routeFile.endsWith('/voice-analyze/route.ts')
|
||||
) {
|
||||
return 'llm-observe-routes'
|
||||
}
|
||||
if (
|
||||
routeFile.endsWith('/generate-image/route.ts')
|
||||
|| routeFile.endsWith('/generate-video/route.ts')
|
||||
|| routeFile.endsWith('/modify-image/route.ts')
|
||||
|| routeFile.endsWith('/voice-design/route.ts')
|
||||
|| routeFile.endsWith('/insert-panel/route.ts')
|
||||
|| routeFile.endsWith('/lip-sync/route.ts')
|
||||
|| routeFile.endsWith('/modify-asset-image/route.ts')
|
||||
|| routeFile.endsWith('/modify-storyboard-image/route.ts')
|
||||
|| routeFile.endsWith('/panel-variant/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-group/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-panel-image/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-single-image/route.ts')
|
||||
|| routeFile.endsWith('/regenerate-storyboard-text/route.ts')
|
||||
|| routeFile.endsWith('/voice-generate/route.ts')
|
||||
) {
|
||||
return 'direct-submit-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/asset-hub/')) return 'crud-asset-hub-routes'
|
||||
if (routeFile.startsWith('src/app/api/novel-promotion/')) return 'crud-novel-promotion-routes'
|
||||
if (
|
||||
routeFile.startsWith('src/app/api/tasks/')
|
||||
|| routeFile === 'src/app/api/task-target-states/route.ts'
|
||||
|| routeFile === 'src/app/api/sse/route.ts'
|
||||
) {
|
||||
return 'task-infra-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/projects/') || routeFile.startsWith('src/app/api/user/')) {
|
||||
return 'user-project-routes'
|
||||
}
|
||||
if (routeFile.startsWith('src/app/api/auth/')) return 'auth-routes'
|
||||
return 'infra-routes'
|
||||
}
|
||||
|
||||
export const ROUTE_CATALOG: ReadonlyArray<RouteCatalogEntry> = ROUTE_FILES.map((routeFile) => ({
|
||||
routeFile,
|
||||
category: resolveCategory(routeFile),
|
||||
contractGroup: resolveContractGroup(routeFile),
|
||||
}))
|
||||
|
||||
export const ROUTE_COUNT = ROUTE_CATALOG.length
|
||||
58
tests/contracts/task-type-catalog.ts
Normal file
58
tests/contracts/task-type-catalog.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
|
||||
export type TaskTestLayer = 'unit-helper' | 'worker-unit' | 'api-contract' | 'chain'
|
||||
|
||||
export type TaskTypeCoverageEntry = {
|
||||
taskType: TaskType
|
||||
owner: string
|
||||
layers: ReadonlyArray<TaskTestLayer>
|
||||
}
|
||||
|
||||
const TASK_TYPE_OWNER_MAP = {
|
||||
[TASK_TYPE.IMAGE_PANEL]: 'tests/unit/worker/panel-image-task-handler.test.ts',
|
||||
[TASK_TYPE.IMAGE_CHARACTER]: 'tests/unit/worker/character-image-task-handler.test.ts',
|
||||
[TASK_TYPE.IMAGE_LOCATION]: 'tests/unit/worker/location-image-task-handler.test.ts',
|
||||
[TASK_TYPE.VIDEO_PANEL]: 'tests/unit/worker/video-worker.test.ts',
|
||||
[TASK_TYPE.LIP_SYNC]: 'tests/unit/worker/video-worker.test.ts',
|
||||
[TASK_TYPE.VOICE_LINE]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_VOICE_DESIGN]: 'tests/unit/worker/voice-worker.test.ts',
|
||||
[TASK_TYPE.REGENERATE_STORYBOARD_TEXT]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.INSERT_PANEL]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.PANEL_VARIANT]: 'tests/unit/worker/panel-variant-task-handler.test.ts',
|
||||
[TASK_TYPE.MODIFY_ASSET_IMAGE]: 'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
[TASK_TYPE.REGENERATE_GROUP]: 'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_IMAGE]: 'tests/unit/worker/asset-hub-image-suffix.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_MODIFY]: 'tests/unit/worker/modify-image-reference-description.test.ts',
|
||||
[TASK_TYPE.ANALYZE_NOVEL]: 'tests/unit/worker/analyze-novel.test.ts',
|
||||
[TASK_TYPE.STORY_TO_SCRIPT_RUN]: 'tests/unit/worker/story-to-script.test.ts',
|
||||
[TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN]: 'tests/unit/worker/script-to-storyboard.test.ts',
|
||||
[TASK_TYPE.CLIPS_BUILD]: 'tests/unit/worker/clips-build.test.ts',
|
||||
[TASK_TYPE.SCREENPLAY_CONVERT]: 'tests/unit/worker/screenplay-convert.test.ts',
|
||||
[TASK_TYPE.VOICE_ANALYZE]: 'tests/unit/worker/voice-analyze.test.ts',
|
||||
[TASK_TYPE.ANALYZE_GLOBAL]: 'tests/unit/worker/analyze-global.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'tests/unit/worker/shot-ai-prompt-appearance.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'tests/unit/worker/shot-ai-prompt-location.test.ts',
|
||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'tests/unit/worker/shot-ai-prompt-shot.test.ts',
|
||||
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'tests/unit/worker/shot-ai-variants.test.ts',
|
||||
[TASK_TYPE.AI_CREATE_CHARACTER]: 'tests/unit/worker/shot-ai-tasks.test.ts',
|
||||
[TASK_TYPE.AI_CREATE_LOCATION]: 'tests/unit/worker/shot-ai-tasks.test.ts',
|
||||
[TASK_TYPE.REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',
|
||||
[TASK_TYPE.CHARACTER_PROFILE_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',
|
||||
[TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM]: 'tests/unit/worker/character-profile.test.ts',
|
||||
[TASK_TYPE.EPISODE_SPLIT_LLM]: 'tests/unit/worker/episode-split.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER]: 'tests/unit/worker/asset-hub-ai-design.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'tests/unit/worker/asset-hub-ai-design.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'tests/unit/worker/asset-hub-ai-modify.test.ts',
|
||||
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'tests/unit/worker/reference-to-character.test.ts',
|
||||
} as const satisfies Record<TaskType, string>
|
||||
|
||||
export const TASK_TYPE_CATALOG: ReadonlyArray<TaskTypeCoverageEntry> = (Object.values(TASK_TYPE) as TaskType[])
|
||||
.map((taskType) => ({
|
||||
taskType,
|
||||
owner: TASK_TYPE_OWNER_MAP[taskType],
|
||||
layers: ['worker-unit', 'api-contract', 'chain'],
|
||||
}))
|
||||
|
||||
export const TASK_TYPE_COUNT = TASK_TYPE_CATALOG.length
|
||||
105
tests/contracts/tasktype-behavior-matrix.ts
Normal file
105
tests/contracts/tasktype-behavior-matrix.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { TASK_TYPE_CATALOG } from './task-type-catalog'
|
||||
import type { TaskType } from '@/lib/task/types'
|
||||
|
||||
export type TaskTypeBehaviorMatrixEntry = {
|
||||
taskType: TaskType
|
||||
caseId: string
|
||||
workerTest: string
|
||||
chainTest: string
|
||||
apiContractTest: string
|
||||
}
|
||||
|
||||
function resolveChainTestByTaskType(taskType: TaskType): string {
|
||||
if (taskType === 'video_panel' || taskType === 'lip_sync') {
|
||||
return 'tests/integration/chain/video.chain.test.ts'
|
||||
}
|
||||
if (taskType === 'voice_line' || taskType === 'voice_design' || taskType === 'asset_hub_voice_design') {
|
||||
return 'tests/integration/chain/voice.chain.test.ts'
|
||||
}
|
||||
if (
|
||||
taskType === 'analyze_novel'
|
||||
|| taskType === 'story_to_script_run'
|
||||
|| taskType === 'script_to_storyboard_run'
|
||||
|| taskType === 'clips_build'
|
||||
|| taskType === 'screenplay_convert'
|
||||
|| taskType === 'voice_analyze'
|
||||
|| taskType === 'analyze_global'
|
||||
|| taskType === 'ai_modify_appearance'
|
||||
|| taskType === 'ai_modify_location'
|
||||
|| taskType === 'ai_modify_shot_prompt'
|
||||
|| taskType === 'analyze_shot_variants'
|
||||
|| taskType === 'ai_create_character'
|
||||
|| taskType === 'ai_create_location'
|
||||
|| taskType === 'reference_to_character'
|
||||
|| taskType === 'character_profile_confirm'
|
||||
|| taskType === 'character_profile_batch_confirm'
|
||||
|| taskType === 'episode_split_llm'
|
||||
|| taskType === 'asset_hub_ai_design_character'
|
||||
|| taskType === 'asset_hub_ai_design_location'
|
||||
|| taskType === 'asset_hub_ai_modify_character'
|
||||
|| taskType === 'asset_hub_ai_modify_location'
|
||||
|| taskType === 'asset_hub_reference_to_character'
|
||||
) {
|
||||
return 'tests/integration/chain/text.chain.test.ts'
|
||||
}
|
||||
return 'tests/integration/chain/image.chain.test.ts'
|
||||
}
|
||||
|
||||
function resolveApiContractByTaskType(taskType: TaskType): string {
|
||||
if (
|
||||
taskType === 'analyze_novel'
|
||||
|| taskType === 'story_to_script_run'
|
||||
|| taskType === 'script_to_storyboard_run'
|
||||
|| taskType === 'clips_build'
|
||||
|| taskType === 'screenplay_convert'
|
||||
|| taskType === 'voice_analyze'
|
||||
|| taskType === 'analyze_global'
|
||||
|| taskType === 'ai_modify_appearance'
|
||||
|| taskType === 'ai_modify_location'
|
||||
|| taskType === 'ai_modify_shot_prompt'
|
||||
|| taskType === 'analyze_shot_variants'
|
||||
|| taskType === 'ai_create_character'
|
||||
|| taskType === 'ai_create_location'
|
||||
|| taskType === 'reference_to_character'
|
||||
|| taskType === 'character_profile_confirm'
|
||||
|| taskType === 'character_profile_batch_confirm'
|
||||
|| taskType === 'episode_split_llm'
|
||||
|| taskType === 'asset_hub_ai_design_character'
|
||||
|| taskType === 'asset_hub_ai_design_location'
|
||||
|| taskType === 'asset_hub_ai_modify_character'
|
||||
|| taskType === 'asset_hub_ai_modify_location'
|
||||
|| taskType === 'asset_hub_reference_to_character'
|
||||
) {
|
||||
return 'tests/integration/api/contract/llm-observe-routes.test.ts'
|
||||
}
|
||||
if (
|
||||
taskType === 'image_panel'
|
||||
|| taskType === 'image_character'
|
||||
|| taskType === 'image_location'
|
||||
|| taskType === 'video_panel'
|
||||
|| taskType === 'lip_sync'
|
||||
|| taskType === 'voice_line'
|
||||
|| taskType === 'voice_design'
|
||||
|| taskType === 'asset_hub_voice_design'
|
||||
|| taskType === 'insert_panel'
|
||||
|| taskType === 'panel_variant'
|
||||
|| taskType === 'modify_asset_image'
|
||||
|| taskType === 'regenerate_group'
|
||||
|| taskType === 'asset_hub_image'
|
||||
|| taskType === 'asset_hub_modify'
|
||||
|| taskType === 'regenerate_storyboard_text'
|
||||
) {
|
||||
return 'tests/integration/api/contract/direct-submit-routes.test.ts'
|
||||
}
|
||||
return 'tests/integration/api/contract/task-infra-routes.test.ts'
|
||||
}
|
||||
|
||||
export const TASKTYPE_BEHAVIOR_MATRIX: ReadonlyArray<TaskTypeBehaviorMatrixEntry> = TASK_TYPE_CATALOG.map((entry) => ({
|
||||
taskType: entry.taskType,
|
||||
caseId: `TASKTYPE:${entry.taskType}`,
|
||||
workerTest: entry.owner,
|
||||
chainTest: resolveChainTestByTaskType(entry.taskType),
|
||||
apiContractTest: resolveApiContractByTaskType(entry.taskType),
|
||||
}))
|
||||
|
||||
export const TASKTYPE_BEHAVIOR_COUNT = TASKTYPE_BEHAVIOR_MATRIX.length
|
||||
6
tests/fixtures/billing/cases.json
vendored
Normal file
6
tests/fixtures/billing/cases.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"textModel": "anthropic/claude-sonnet-4",
|
||||
"imageModel": "seedream",
|
||||
"videoModel": "doubao-seedance-1-0-pro-fast-251015",
|
||||
"voiceSeconds": 5
|
||||
}
|
||||
23
tests/helpers/assertions.ts
Normal file
23
tests/helpers/assertions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect } from 'vitest'
|
||||
import { prisma } from './prisma'
|
||||
import { toMoneyNumber } from '@/lib/billing/money'
|
||||
|
||||
export async function expectBalance(userId: string, params: {
|
||||
balance: number
|
||||
frozenAmount: number
|
||||
totalSpent: number
|
||||
}) {
|
||||
const row = await prisma.userBalance.findUnique({ where: { userId } })
|
||||
expect(row).toBeTruthy()
|
||||
expect(toMoneyNumber(row!.balance)).toBeCloseTo(params.balance, 8)
|
||||
expect(toMoneyNumber(row!.frozenAmount)).toBeCloseTo(params.frozenAmount, 8)
|
||||
expect(toMoneyNumber(row!.totalSpent)).toBeCloseTo(params.totalSpent, 8)
|
||||
}
|
||||
|
||||
export async function expectNoNegativeLedger(userId: string) {
|
||||
const row = await prisma.userBalance.findUnique({ where: { userId } })
|
||||
expect(row).toBeTruthy()
|
||||
expect(toMoneyNumber(row!.balance)).toBeGreaterThanOrEqual(0)
|
||||
expect(toMoneyNumber(row!.frozenAmount)).toBeGreaterThanOrEqual(0)
|
||||
expect(toMoneyNumber(row!.totalSpent)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
132
tests/helpers/auth.ts
Normal file
132
tests/helpers/auth.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
type SessionUser = {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
type SessionPayload = {
|
||||
user: SessionUser
|
||||
}
|
||||
|
||||
type MockAuthState = {
|
||||
session: SessionPayload | null
|
||||
projectAuthMode: 'allow' | 'forbidden' | 'not_found'
|
||||
}
|
||||
|
||||
const defaultSession: SessionPayload = {
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
name: 'test-user',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
}
|
||||
|
||||
let state: MockAuthState = {
|
||||
session: defaultSession,
|
||||
projectAuthMode: 'allow',
|
||||
}
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
function forbiddenResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Forbidden',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
function notFoundResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Project not found',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
export function installAuthMocks() {
|
||||
vi.doMock('@/lib/api-auth', () => ({
|
||||
isErrorResponse: (value: unknown) => value instanceof NextResponse,
|
||||
requireUserAuth: async () => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
return { session: state.session }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
|
||||
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
||||
return {
|
||||
session: state.session,
|
||||
project: { id: projectId, userId: state.session.user.id, name: 'project' },
|
||||
novelData: { id: 'novel-data-id' },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!state.session) return unauthorizedResponse()
|
||||
if (state.projectAuthMode === 'forbidden') return forbiddenResponse()
|
||||
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
||||
return {
|
||||
session: state.session,
|
||||
project: { id: projectId, userId: state.session.user.id, name: 'project' },
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function mockAuthenticated(userId: string) {
|
||||
state = {
|
||||
...state,
|
||||
session: {
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function mockUnauthenticated() {
|
||||
state = {
|
||||
...state,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mockProjectAuth(mode: 'allow' | 'forbidden' | 'not_found') {
|
||||
state = {
|
||||
...state,
|
||||
projectAuthMode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAuthMockState() {
|
||||
state = {
|
||||
session: defaultSession,
|
||||
projectAuthMode: 'allow',
|
||||
}
|
||||
vi.doUnmock('@/lib/api-auth')
|
||||
}
|
||||
68
tests/helpers/billing-fixtures.ts
Normal file
68
tests/helpers/billing-fixtures.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { TaskBillingInfo, TaskType } from '@/lib/task/types'
|
||||
import { TASK_STATUS } from '@/lib/task/types'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function createTestUser() {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
name: `billing_user_${suffix}`,
|
||||
email: `billing_${suffix}@example.com`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTestProject(userId: string) {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return await prisma.project.create({
|
||||
data: {
|
||||
name: `Billing Project ${suffix}`,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function seedBalance(userId: string, balance: number) {
|
||||
return await prisma.userBalance.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
balance,
|
||||
frozenAmount: 0,
|
||||
totalSpent: 0,
|
||||
},
|
||||
update: {
|
||||
balance,
|
||||
frozenAmount: 0,
|
||||
totalSpent: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createQueuedTask(params: {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
type: TaskType
|
||||
targetType: string
|
||||
targetId: string
|
||||
billingInfo?: TaskBillingInfo | null
|
||||
payload?: Record<string, unknown> | null
|
||||
}) {
|
||||
return await prisma.task.create({
|
||||
data: {
|
||||
id: params.id,
|
||||
userId: params.userId,
|
||||
projectId: params.projectId,
|
||||
type: params.type,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
status: TASK_STATUS.QUEUED,
|
||||
billingInfo: (params.billingInfo ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
payload: (params.payload ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
queuedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
60
tests/helpers/db-reset.ts
Normal file
60
tests/helpers/db-reset.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function resetBillingState() {
|
||||
await prisma.balanceTransaction.deleteMany()
|
||||
await prisma.balanceFreeze.deleteMany()
|
||||
await prisma.usageCost.deleteMany()
|
||||
await prisma.taskEvent.deleteMany()
|
||||
await prisma.task.deleteMany()
|
||||
await prisma.userBalance.deleteMany()
|
||||
await prisma.project.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.userPreference.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetTaskState() {
|
||||
await prisma.taskEvent.deleteMany()
|
||||
await prisma.task.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetAssetHubState() {
|
||||
await prisma.globalCharacterAppearance.deleteMany()
|
||||
await prisma.globalCharacter.deleteMany()
|
||||
await prisma.globalLocationImage.deleteMany()
|
||||
await prisma.globalLocation.deleteMany()
|
||||
await prisma.globalVoice.deleteMany()
|
||||
await prisma.globalAssetFolder.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetNovelPromotionState() {
|
||||
await prisma.novelPromotionVoiceLine.deleteMany()
|
||||
await prisma.novelPromotionPanel.deleteMany()
|
||||
await prisma.supplementaryPanel.deleteMany()
|
||||
await prisma.novelPromotionStoryboard.deleteMany()
|
||||
await prisma.novelPromotionShot.deleteMany()
|
||||
await prisma.novelPromotionClip.deleteMany()
|
||||
await prisma.characterAppearance.deleteMany()
|
||||
await prisma.locationImage.deleteMany()
|
||||
await prisma.novelPromotionCharacter.deleteMany()
|
||||
await prisma.novelPromotionLocation.deleteMany()
|
||||
await prisma.videoEditorProject.deleteMany()
|
||||
await prisma.novelPromotionEpisode.deleteMany()
|
||||
await prisma.novelPromotionProject.deleteMany()
|
||||
}
|
||||
|
||||
export async function resetSystemState() {
|
||||
await resetTaskState()
|
||||
await resetAssetHubState()
|
||||
await resetNovelPromotionState()
|
||||
await prisma.usageCost.deleteMany()
|
||||
await prisma.project.deleteMany()
|
||||
await prisma.userPreference.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.userBalance.deleteMany()
|
||||
await prisma.balanceFreeze.deleteMany()
|
||||
await prisma.balanceTransaction.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
}
|
||||
26
tests/helpers/fakes/llm.ts
Normal file
26
tests/helpers/fakes/llm.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
type CompletionResult = {
|
||||
text: string
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
const state: { nextText: string; nextReasoning: string } = {
|
||||
nextText: '{"ok":true}',
|
||||
nextReasoning: '',
|
||||
}
|
||||
|
||||
export function configureFakeLLM(result: CompletionResult) {
|
||||
state.nextText = result.text
|
||||
state.nextReasoning = result.reasoning || ''
|
||||
}
|
||||
|
||||
export function resetFakeLLM() {
|
||||
state.nextText = '{"ok":true}'
|
||||
state.nextReasoning = ''
|
||||
}
|
||||
|
||||
export async function fakeChatCompletion() {
|
||||
return {
|
||||
output_text: state.nextText,
|
||||
reasoning: state.nextReasoning,
|
||||
}
|
||||
}
|
||||
37
tests/helpers/fakes/media.ts
Normal file
37
tests/helpers/fakes/media.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const state: {
|
||||
nextImageUrl: string
|
||||
nextVideoUrl: string
|
||||
nextAudioUrl: string
|
||||
} = {
|
||||
nextImageUrl: 'images/fake-image.jpg',
|
||||
nextVideoUrl: 'video/fake-video.mp4',
|
||||
nextAudioUrl: 'voice/fake-audio.mp3',
|
||||
}
|
||||
|
||||
export function configureFakeMedia(params: {
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
audioUrl?: string
|
||||
}) {
|
||||
if (params.imageUrl) state.nextImageUrl = params.imageUrl
|
||||
if (params.videoUrl) state.nextVideoUrl = params.videoUrl
|
||||
if (params.audioUrl) state.nextAudioUrl = params.audioUrl
|
||||
}
|
||||
|
||||
export function resetFakeMedia() {
|
||||
state.nextImageUrl = 'images/fake-image.jpg'
|
||||
state.nextVideoUrl = 'video/fake-video.mp4'
|
||||
state.nextAudioUrl = 'voice/fake-audio.mp3'
|
||||
}
|
||||
|
||||
export async function fakeGenerateImage() {
|
||||
return { success: true, imageUrl: state.nextImageUrl }
|
||||
}
|
||||
|
||||
export async function fakeGenerateVideo() {
|
||||
return { success: true, videoUrl: state.nextVideoUrl }
|
||||
}
|
||||
|
||||
export async function fakeGenerateAudio() {
|
||||
return { success: true, audioUrl: state.nextAudioUrl }
|
||||
}
|
||||
35
tests/helpers/fakes/providers.ts
Normal file
35
tests/helpers/fakes/providers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const providerState: {
|
||||
falApiKey: string
|
||||
googleApiKey: string
|
||||
openrouterApiKey: string
|
||||
} = {
|
||||
falApiKey: 'fake-fal-key',
|
||||
googleApiKey: 'fake-google-key',
|
||||
openrouterApiKey: 'fake-openrouter-key',
|
||||
}
|
||||
|
||||
export function configureFakeProviders(params: {
|
||||
falApiKey?: string
|
||||
googleApiKey?: string
|
||||
openrouterApiKey?: string
|
||||
}) {
|
||||
if (params.falApiKey) providerState.falApiKey = params.falApiKey
|
||||
if (params.googleApiKey) providerState.googleApiKey = params.googleApiKey
|
||||
if (params.openrouterApiKey) providerState.openrouterApiKey = params.openrouterApiKey
|
||||
}
|
||||
|
||||
export function resetFakeProviders() {
|
||||
providerState.falApiKey = 'fake-fal-key'
|
||||
providerState.googleApiKey = 'fake-google-key'
|
||||
providerState.openrouterApiKey = 'fake-openrouter-key'
|
||||
}
|
||||
|
||||
export function getFakeProviderConfig(provider: 'fal' | 'google' | 'openrouter') {
|
||||
if (provider === 'fal') {
|
||||
return { apiKey: providerState.falApiKey }
|
||||
}
|
||||
if (provider === 'google') {
|
||||
return { apiKey: providerState.googleApiKey }
|
||||
}
|
||||
return { apiKey: providerState.openrouterApiKey }
|
||||
}
|
||||
99
tests/helpers/fixtures.ts
Normal file
99
tests/helpers/fixtures.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
function suffix() {
|
||||
return randomUUID().slice(0, 8)
|
||||
}
|
||||
|
||||
export async function createFixtureUser() {
|
||||
const id = suffix()
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
name: `user_${id}`,
|
||||
email: `user_${id}@example.com`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureProject(userId: string, mode: 'novel-promotion' | 'general' = 'novel-promotion') {
|
||||
const id = suffix()
|
||||
return await prisma.project.create({
|
||||
data: {
|
||||
userId,
|
||||
mode,
|
||||
name: `project_${id}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureNovelProject(projectId: string) {
|
||||
return await prisma.novelPromotionProject.create({
|
||||
data: {
|
||||
projectId,
|
||||
analysisModel: 'openrouter::anthropic/claude-sonnet-4',
|
||||
characterModel: 'fal::banana/character',
|
||||
locationModel: 'fal::banana/location',
|
||||
storyboardModel: 'fal::banana/storyboard',
|
||||
editModel: 'fal::banana/edit',
|
||||
videoModel: 'fal::seedance/video',
|
||||
videoRatio: '9:16',
|
||||
imageResolution: '2K',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalCharacter(userId: string, folderId: string | null = null) {
|
||||
const id = suffix()
|
||||
return await prisma.globalCharacter.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `character_${id}`,
|
||||
...(folderId ? { folderId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalCharacterAppearance(characterId: string, appearanceIndex = 0) {
|
||||
return await prisma.globalCharacterAppearance.create({
|
||||
data: {
|
||||
characterId,
|
||||
appearanceIndex,
|
||||
changeReason: 'default',
|
||||
imageUrls: JSON.stringify(['images/test-0.jpg']),
|
||||
selectedIndex: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalLocation(userId: string, folderId: string | null = null) {
|
||||
const id = suffix()
|
||||
return await prisma.globalLocation.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `location_${id}`,
|
||||
...(folderId ? { folderId } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureGlobalLocationImage(locationId: string, imageIndex = 0) {
|
||||
return await prisma.globalLocationImage.create({
|
||||
data: {
|
||||
locationId,
|
||||
imageIndex,
|
||||
imageUrl: `images/location-${suffix()}.jpg`,
|
||||
isSelected: imageIndex === 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createFixtureEpisode(novelPromotionProjectId: string, episodeNumber = 1) {
|
||||
return await prisma.novelPromotionEpisode.create({
|
||||
data: {
|
||||
novelPromotionProjectId,
|
||||
episodeNumber,
|
||||
name: `Episode ${episodeNumber}`,
|
||||
novelText: 'test novel text',
|
||||
},
|
||||
})
|
||||
}
|
||||
72
tests/helpers/mock-query-client.ts
Normal file
72
tests/helpers/mock-query-client.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { QueryKey } from '@tanstack/react-query'
|
||||
|
||||
interface QueryFilter {
|
||||
queryKey: QueryKey
|
||||
exact?: boolean
|
||||
}
|
||||
|
||||
type Updater<T> = T | ((previous: T | undefined) => T | undefined)
|
||||
|
||||
interface StoredQueryEntry {
|
||||
queryKey: QueryKey
|
||||
data: unknown
|
||||
}
|
||||
|
||||
function isPrefixQueryKey(target: QueryKey, prefix: QueryKey): boolean {
|
||||
if (prefix.length > target.length) return false
|
||||
return prefix.every((value, index) => Object.is(value, target[index]))
|
||||
}
|
||||
|
||||
function keyOf(queryKey: QueryKey): string {
|
||||
return JSON.stringify(queryKey)
|
||||
}
|
||||
|
||||
export class MockQueryClient {
|
||||
private readonly queryMap = new Map<string, StoredQueryEntry>()
|
||||
|
||||
async cancelQueries(filters: QueryFilter): Promise<void> {
|
||||
void filters
|
||||
}
|
||||
|
||||
seedQuery<T>(queryKey: QueryKey, data: T | undefined) {
|
||||
this.queryMap.set(keyOf(queryKey), {
|
||||
queryKey,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
getQueryData<T>(queryKey: QueryKey): T | undefined {
|
||||
const entry = this.queryMap.get(keyOf(queryKey))
|
||||
return entry?.data as T | undefined
|
||||
}
|
||||
|
||||
setQueryData<T>(queryKey: QueryKey, updater: Updater<T | undefined>) {
|
||||
const previous = this.getQueryData<T>(queryKey)
|
||||
const next = typeof updater === 'function'
|
||||
? (updater as (prev: T | undefined) => T | undefined)(previous)
|
||||
: updater
|
||||
this.seedQuery(queryKey, next)
|
||||
}
|
||||
|
||||
getQueriesData<T>(filters: QueryFilter): Array<[QueryKey, T | undefined]> {
|
||||
const matched: Array<[QueryKey, T | undefined]> = []
|
||||
for (const { queryKey, data } of this.queryMap.values()) {
|
||||
const isMatch = filters.exact
|
||||
? keyOf(filters.queryKey) === keyOf(queryKey)
|
||||
: isPrefixQueryKey(queryKey, filters.queryKey)
|
||||
if (!isMatch) continue
|
||||
matched.push([queryKey, data as T | undefined])
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
setQueriesData<T>(
|
||||
filters: QueryFilter,
|
||||
updater: (previous: T | undefined) => T | undefined,
|
||||
) {
|
||||
const matches = this.getQueriesData<T>(filters)
|
||||
matches.forEach(([queryKey, previous]) => {
|
||||
this.seedQuery(queryKey, updater(previous))
|
||||
})
|
||||
}
|
||||
}
|
||||
6
tests/helpers/prisma.ts
Normal file
6
tests/helpers/prisma.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { loadTestEnv } from '../setup/env'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
loadTestEnv()
|
||||
|
||||
export { prisma }
|
||||
62
tests/helpers/request.ts
Normal file
62
tests/helpers/request.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
type HeaderMap = Record<string, string>
|
||||
type QueryMap = Record<string, string | number | boolean>
|
||||
|
||||
function toJsonBody(body: unknown): string | undefined {
|
||||
if (body === undefined) return undefined
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
function appendQuery(url: URL, query?: QueryMap) {
|
||||
if (!query) return
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMockRequest(params: {
|
||||
path: string
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: HeaderMap
|
||||
query?: QueryMap
|
||||
}) {
|
||||
const url = new URL(params.path, 'http://localhost:3000')
|
||||
appendQuery(url, params.query)
|
||||
const jsonBody = toJsonBody(params.body)
|
||||
|
||||
const headers: HeaderMap = {
|
||||
...(params.headers || {}),
|
||||
}
|
||||
if (jsonBody !== undefined && !headers['content-type']) {
|
||||
headers['content-type'] = 'application/json'
|
||||
}
|
||||
|
||||
return new NextRequest(url, {
|
||||
method: params.method,
|
||||
headers,
|
||||
...(jsonBody !== undefined ? { body: jsonBody } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function callRoute<TContext>(
|
||||
handler: (req: NextRequest, ctx: TContext) => Promise<Response>,
|
||||
params: {
|
||||
path: string
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: HeaderMap
|
||||
query?: QueryMap
|
||||
context: TContext
|
||||
},
|
||||
) {
|
||||
const req = buildMockRequest({
|
||||
path: params.path,
|
||||
method: params.method,
|
||||
body: params.body,
|
||||
headers: params.headers,
|
||||
query: params.query,
|
||||
})
|
||||
return await handler(req, params.context)
|
||||
}
|
||||
396
tests/integration/api/contract/crud-routes.test.ts
Normal file
396
tests/integration/api/contract/crud-routes.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type RouteMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: false,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
globalAssetFolder: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionLocation: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
locationImage: {
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
novelPromotionClip: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: 'novel-promotion' },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/cos', () => ({
|
||||
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
|
||||
}))
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
function resolveParamValue(paramName: string): string {
|
||||
const key = paramName.toLowerCase()
|
||||
if (key.includes('project')) return 'project-1'
|
||||
if (key.includes('character')) return 'character-1'
|
||||
if (key.includes('location')) return 'location-1'
|
||||
if (key.includes('appearance')) return '0'
|
||||
if (key.includes('episode')) return 'episode-1'
|
||||
if (key.includes('storyboard')) return 'storyboard-1'
|
||||
if (key.includes('panel')) return 'panel-1'
|
||||
if (key.includes('clip')) return 'clip-1'
|
||||
if (key.includes('folder')) return 'folder-1'
|
||||
if (key === 'id') return 'id-1'
|
||||
return `${paramName}-1`
|
||||
}
|
||||
|
||||
function toApiPath(routeFile: string): { path: string; params: Record<string, string> } {
|
||||
const withoutPrefix = routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
const path = withoutPrefix.replace(/\[([^\]]+)\]/g, (_full, paramName: string) => {
|
||||
const value = resolveParamValue(paramName)
|
||||
params[paramName] = value
|
||||
return value
|
||||
})
|
||||
return { path, params }
|
||||
}
|
||||
|
||||
function buildGenericBody() {
|
||||
return {
|
||||
id: 'id-1',
|
||||
name: 'Name',
|
||||
type: 'character',
|
||||
userInstruction: 'instruction',
|
||||
characterId: 'character-1',
|
||||
locationId: 'location-1',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'modify prompt',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelId: 'panel-1',
|
||||
panelIndex: 0,
|
||||
episodeId: 'episode-1',
|
||||
content: 'x'.repeat(140),
|
||||
voicePrompt: 'voice prompt',
|
||||
previewText: 'preview text',
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
referenceImageUrls: ['https://example.com/ref.png'],
|
||||
lineId: 'line-1',
|
||||
audioModel: 'fal::audio-model',
|
||||
videoModel: 'fal::video-model',
|
||||
insertAfterPanelId: 'panel-1',
|
||||
sourcePanelId: 'panel-2',
|
||||
variant: { video_prompt: 'variant prompt' },
|
||||
currentDescription: 'description',
|
||||
modifyInstruction: 'instruction',
|
||||
currentPrompt: 'prompt',
|
||||
all: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeRouteMethod(
|
||||
routeFile: string,
|
||||
method: RouteMethod,
|
||||
): Promise<Response> {
|
||||
const { path, params } = toApiPath(routeFile)
|
||||
const modulePath = toModuleImportPath(routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const handler = mod[method] as ((req: Request, ctx?: RouteContext) => Promise<Response>) | undefined
|
||||
if (!handler) {
|
||||
throw new Error(`Route ${routeFile} missing method ${method}`)
|
||||
}
|
||||
const req = buildMockRequest({
|
||||
path,
|
||||
method,
|
||||
...(method === 'GET' || method === 'DELETE' ? {} : { body: buildGenericBody() }),
|
||||
})
|
||||
return await handler(req, { params: Promise.resolve(params) })
|
||||
}
|
||||
|
||||
describe('api contract - crud routes (behavior)', () => {
|
||||
const routes = ROUTE_CATALOG.filter(
|
||||
(entry) => entry.contractGroup === 'crud-asset-hub-routes' || entry.contractGroup === 'crud-novel-promotion-routes',
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = false
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
prismaMock.globalAssetFolder.findUnique.mockResolvedValue({
|
||||
id: 'folder-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
prismaMock.globalCharacter.update.mockResolvedValue({
|
||||
id: 'character-1',
|
||||
name: 'Alice',
|
||||
userId: 'user-1',
|
||||
appearances: [],
|
||||
})
|
||||
prismaMock.globalCharacter.delete.mockResolvedValue({ id: 'character-1' })
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
characterId: 'character-1',
|
||||
imageUrls: JSON.stringify(['cos/char-0.png', 'cos/char-1.png']),
|
||||
imageUrl: null,
|
||||
selectedIndex: null,
|
||||
character: { id: 'character-1', name: 'Alice' },
|
||||
})
|
||||
prismaMock.characterAppearance.update.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'cos/char-1.png',
|
||||
})
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{ id: 'img-0', imageIndex: 0, imageUrl: 'cos/loc-0.png' },
|
||||
{ id: 'img-1', imageIndex: 1, imageUrl: 'cos/loc-1.png' },
|
||||
],
|
||||
})
|
||||
prismaMock.locationImage.updateMany.mockResolvedValue({ count: 2 })
|
||||
prismaMock.locationImage.update.mockResolvedValue({
|
||||
id: 'img-1',
|
||||
imageIndex: 1,
|
||||
imageUrl: 'cos/loc-1.png',
|
||||
isSelected: true,
|
||||
})
|
||||
prismaMock.novelPromotionLocation.update.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
selectedImageId: 'img-1',
|
||||
})
|
||||
prismaMock.novelPromotionClip.update.mockResolvedValue({
|
||||
id: 'clip-1',
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
})
|
||||
})
|
||||
|
||||
it('crud route group exists', () => {
|
||||
expect(routes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('all crud route methods reject unauthenticated requests (no 2xx pass-through)', async () => {
|
||||
const methods: ReadonlyArray<RouteMethod> = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE']
|
||||
let checkedMethodCount = 0
|
||||
|
||||
for (const entry of routes) {
|
||||
const modulePath = toModuleImportPath(entry.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
for (const method of methods) {
|
||||
if (typeof mod[method] !== 'function') continue
|
||||
checkedMethodCount += 1
|
||||
const res = await invokeRouteMethod(entry.routeFile, method)
|
||||
expect(res.status, `${entry.routeFile}#${method} should reject unauthenticated`).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status, `${entry.routeFile}#${method} should not be server-error on auth gate`).toBeLessThan(500)
|
||||
}
|
||||
}
|
||||
|
||||
expect(checkedMethodCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('PATCH /asset-hub/characters/[characterId] writes normalized fields to prisma.globalCharacter.update', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: ' Alice ',
|
||||
aliases: ['A'],
|
||||
profileConfirmed: true,
|
||||
folderId: 'folder-1',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.globalCharacter.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'character-1' },
|
||||
data: expect.objectContaining({
|
||||
name: 'Alice',
|
||||
aliases: ['A'],
|
||||
profileConfirmed: true,
|
||||
folderId: 'folder-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('DELETE /asset-hub/characters/[characterId] deletes owned character and blocks non-owner', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/asset-hub/characters/[characterId]/route')
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
|
||||
id: 'character-1',
|
||||
userId: 'user-1',
|
||||
})
|
||||
const okReq = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'DELETE',
|
||||
})
|
||||
const okRes = await mod.DELETE(okReq, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(okRes.status).toBe(200)
|
||||
expect(prismaMock.globalCharacter.delete).toHaveBeenCalledWith({ where: { id: 'character-1' } })
|
||||
|
||||
prismaMock.globalCharacter.findUnique.mockResolvedValueOnce({
|
||||
id: 'character-1',
|
||||
userId: 'other-user',
|
||||
})
|
||||
const forbiddenReq = buildMockRequest({
|
||||
path: '/api/asset-hub/characters/character-1',
|
||||
method: 'DELETE',
|
||||
})
|
||||
const forbiddenRes = await mod.DELETE(forbiddenReq, { params: Promise.resolve({ characterId: 'character-1' }) })
|
||||
expect(forbiddenRes.status).toBe(403)
|
||||
})
|
||||
|
||||
it('POST /novel-promotion/[projectId]/select-character-image writes selectedIndex and imageUrl key', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/select-character-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/select-character-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
selectedIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'cos/char-1.png',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await res.json() as { success: boolean; selectedIndex: number; imageUrl: string }
|
||||
expect(payload).toEqual({
|
||||
success: true,
|
||||
selectedIndex: 1,
|
||||
imageUrl: 'https://signed.example/cos/char-1.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /novel-promotion/[projectId]/select-location-image toggles selected state and selectedImageId', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/select-location-image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/select-location-image',
|
||||
method: 'POST',
|
||||
body: {
|
||||
locationId: 'location-1',
|
||||
selectedIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.locationImage.updateMany).toHaveBeenCalledWith({
|
||||
where: { locationId: 'location-1' },
|
||||
data: { isSelected: false },
|
||||
})
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { locationId_imageIndex: { locationId: 'location-1', imageIndex: 1 } },
|
||||
data: { isSelected: true },
|
||||
})
|
||||
expect(prismaMock.novelPromotionLocation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-1' },
|
||||
data: { selectedImageId: 'img-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('PATCH /novel-promotion/[projectId]/clips/[clipId] writes provided editable fields', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/clips/[clipId]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/clips/clip-1',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.PATCH(req, {
|
||||
params: Promise.resolve({ projectId: 'project-1', clipId: 'clip-1' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-1' },
|
||||
data: {
|
||||
characters: JSON.stringify(['Alice']),
|
||||
location: 'Old Town',
|
||||
content: 'clip content',
|
||||
screenplay: JSON.stringify({ scenes: [{ id: 1 }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
469
tests/integration/api/contract/direct-submit-routes.test.ts
Normal file
469
tests/integration/api/contract/direct-submit-routes.test.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
projectMode: 'novel-promotion' | 'other'
|
||||
}
|
||||
|
||||
type SubmitResult = {
|
||||
taskId: string
|
||||
async: true
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
type DirectRouteCase = {
|
||||
routeFile: string
|
||||
body: Record<string, unknown>
|
||||
params?: Record<string, string>
|
||||
expectedTaskType: TaskType
|
||||
expectedTargetType: string
|
||||
expectedProjectId: string
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: true,
|
||||
projectMode: 'novel-promotion',
|
||||
}))
|
||||
|
||||
const submitTaskMock = vi.hoisted(() => vi.fn<[], Promise<SubmitResult>>())
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
editModel: 'img::edit',
|
||||
})),
|
||||
buildImageBillingPayloadFromUserConfig: vi.fn((input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'img::character',
|
||||
locationModel: 'img::location',
|
||||
editModel: 'img::edit',
|
||||
storyboardModel: 'img::storyboard',
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({
|
||||
resolution: '1024x1024',
|
||||
})),
|
||||
}))
|
||||
|
||||
const hasOutputMock = vi.hoisted(() => ({
|
||||
hasGlobalCharacterOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationOutput: vi.fn(async () => false),
|
||||
hasGlobalCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasGlobalLocationImageOutput: vi.fn(async () => false),
|
||||
hasCharacterAppearanceOutput: vi.fn(async () => false),
|
||||
hasLocationImageOutput: vi.fn(async () => false),
|
||||
hasPanelLipSyncOutput: vi.fn(async () => false),
|
||||
hasPanelImageOutput: vi.fn(async () => false),
|
||||
hasPanelVideoOutput: vi.fn(async () => false),
|
||||
hasVoiceLineAudioOutput: vi.fn(async () => false),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findFirst: vi.fn(async () => ({ id: 'panel-1' })),
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async ({ where }: { where?: { id?: string } }) => {
|
||||
const id = where?.id || 'panel-1'
|
||||
if (id === 'panel-src') {
|
||||
return {
|
||||
id,
|
||||
panelIndex: 1,
|
||||
shotType: 'wide',
|
||||
cameraMove: 'static',
|
||||
description: 'source description',
|
||||
videoPrompt: 'source video prompt',
|
||||
location: 'source location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
if (id === 'panel-ins') {
|
||||
return {
|
||||
id,
|
||||
panelIndex: 2,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'push',
|
||||
description: 'insert description',
|
||||
videoPrompt: 'insert video prompt',
|
||||
location: 'insert location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
panelIndex: 0,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
description: 'panel description',
|
||||
videoPrompt: 'panel prompt',
|
||||
location: 'panel location',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}),
|
||||
update: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'project-data-1',
|
||||
characters: [
|
||||
{ name: 'Narrator', customVoiceUrl: 'https://voice.example/narrator.mp3' },
|
||||
],
|
||||
})),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'episode-1',
|
||||
speakerVoices: '{}',
|
||||
})),
|
||||
},
|
||||
novelPromotionVoiceLine: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 'line-1', speaker: 'Narrator', content: 'hello world voice line' },
|
||||
]),
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 'line-1',
|
||||
speaker: 'Narrator',
|
||||
content: 'hello world voice line',
|
||||
})),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: (tx: {
|
||||
novelPromotionPanel: {
|
||||
findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
create: (args: unknown) => Promise<{ id: string; panelIndex: number }>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionPanel: {
|
||||
findMany: async () => [],
|
||||
update: async () => ({}),
|
||||
create: async () => ({ id: 'panel-created', panelIndex: 3 }),
|
||||
},
|
||||
}
|
||||
return await fn(tx)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/task/submitter', () => ({
|
||||
submitTask: submitTaskMock,
|
||||
}))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/task/has-output', () => hasOutputMock)
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/qwen-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',
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
function toApiPath(routeFile: string): string {
|
||||
return routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
.replace('[projectId]', 'project-1')
|
||||
}
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
const DIRECT_CASES: ReadonlyArray<DirectRouteCase> = [
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/generate-image/route.ts',
|
||||
body: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/modify-image/route.ts',
|
||||
body: {
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
modifyPrompt: 'sharpen details',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 0,
|
||||
extraImageUrls: ['https://example.com/ref-a.png'],
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_MODIFY,
|
||||
expectedTargetType: 'GlobalCharacterAppearance',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/voice-design/route.ts',
|
||||
body: { voicePrompt: 'female calm narrator', previewText: '你好世界' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
expectedTargetType: 'GlobalAssetHubVoiceDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/generate-video/route.ts',
|
||||
body: { videoModel: 'fal::video-model', storyboardId: 'storyboard-1', panelIndex: 0 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VIDEO_PANEL,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/insert-panel/route.ts',
|
||||
body: { storyboardId: 'storyboard-1', insertAfterPanelId: 'panel-ins' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.INSERT_PANEL,
|
||||
expectedTargetType: 'NovelPromotionStoryboard',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/lip-sync/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncModel: 'fal::lip-model',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.LIP_SYNC,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-asset-image/route.ts',
|
||||
body: {
|
||||
type: 'character',
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'enhance texture',
|
||||
extraImageUrls: ['https://example.com/ref-b.png'],
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/modify-storyboard-image/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
modifyPrompt: 'increase contrast',
|
||||
extraImageUrls: ['https://example.com/ref-c.png'],
|
||||
selectedAssets: [{ imageUrl: 'https://example.com/ref-d.png' }],
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/panel-variant/route.ts',
|
||||
body: {
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'new prompt', description: 'variant desc' },
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.PANEL_VARIANT,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-group/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REGENERATE_GROUP,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-panel-image/route.ts',
|
||||
body: { panelId: 'panel-1', count: 1 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_PANEL,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-single-image/route.ts',
|
||||
body: { type: 'character', id: 'character-1', appearanceId: 'appearance-1', imageIndex: 0 },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.IMAGE_CHARACTER,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/regenerate-storyboard-text/route.ts',
|
||||
body: { storyboardId: 'storyboard-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REGENERATE_STORYBOARD_TEXT,
|
||||
expectedTargetType: 'NovelPromotionStoryboard',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-design/route.ts',
|
||||
body: { voicePrompt: 'warm female voice', previewText: 'This is preview text' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_DESIGN,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-generate/route.ts',
|
||||
body: { episodeId: 'episode-1', lineId: 'line-1', audioModel: 'fal::audio-model' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_LINE,
|
||||
expectedTargetType: 'NovelPromotionVoiceLine',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
]
|
||||
|
||||
async function invokePostRoute(routeCase: DirectRouteCase): Promise<Response> {
|
||||
const modulePath = toModuleImportPath(routeCase.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
||||
const req = buildMockRequest({
|
||||
path: toApiPath(routeCase.routeFile),
|
||||
method: 'POST',
|
||||
body: routeCase.body,
|
||||
})
|
||||
return await post(req, { params: Promise.resolve(routeCase.params || {}) })
|
||||
}
|
||||
|
||||
describe('api contract - direct submit routes (behavior)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
authState.projectMode = 'novel-promotion'
|
||||
let seq = 0
|
||||
submitTaskMock.mockImplementation(async () => ({
|
||||
taskId: `task-${++seq}`,
|
||||
async: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps expected coverage size', () => {
|
||||
expect(DIRECT_CASES.length).toBe(16)
|
||||
})
|
||||
|
||||
for (const routeCase of DIRECT_CASES) {
|
||||
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
|
||||
authState.authenticated = false
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(401)
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it(`${routeCase.routeFile} -> submits task with expected contract when authenticated`, async () => {
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(200)
|
||||
expect(submitTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: routeCase.expectedTaskType,
|
||||
targetType: routeCase.expectedTargetType,
|
||||
projectId: routeCase.expectedProjectId,
|
||||
userId: 'user-1',
|
||||
}))
|
||||
|
||||
const submitArg = submitTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
|
||||
expect(submitArg?.type).toBe(routeCase.expectedTaskType)
|
||||
expect(submitArg?.targetType).toBe(routeCase.expectedTargetType)
|
||||
expect(submitArg?.projectId).toBe(routeCase.expectedProjectId)
|
||||
expect(submitArg?.userId).toBe('user-1')
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
const isVoiceGenerateRoute = routeCase.routeFile.endsWith('/voice-generate/route.ts')
|
||||
if (isVoiceGenerateRoute) {
|
||||
expect(json.success).toBe(true)
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
} else {
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
362
tests/integration/api/contract/llm-observe-routes.test.ts
Normal file
362
tests/integration/api/contract/llm-observe-routes.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskType } from '@/lib/task/types'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
type AuthState = {
|
||||
authenticated: boolean
|
||||
projectMode: 'novel-promotion' | 'other'
|
||||
}
|
||||
|
||||
type LLMRouteCase = {
|
||||
routeFile: string
|
||||
body: Record<string, unknown>
|
||||
params?: Record<string, string>
|
||||
expectedTaskType: TaskType
|
||||
expectedTargetType: string
|
||||
expectedProjectId: string
|
||||
}
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<Record<string, string>>
|
||||
}
|
||||
|
||||
const authState = vi.hoisted<AuthState>(() => ({
|
||||
authenticated: true,
|
||||
projectMode: 'novel-promotion',
|
||||
}))
|
||||
|
||||
const maybeSubmitLLMTaskMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new Response(
|
||||
JSON.stringify({ taskId: 'task-1', async: true }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)),
|
||||
)
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'global-character-1',
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
globalLocation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'global-location-1',
|
||||
userId: 'user-1',
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
requireProjectAuth: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
requireProjectAuthLight: async (projectId: string) => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return {
|
||||
session: { user: { id: 'user-1' } },
|
||||
project: { id: projectId, userId: 'user-1', mode: authState.projectMode },
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/llm-observe/route-task', () => ({
|
||||
maybeSubmitLLMTask: maybeSubmitLLMTaskMock,
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
function toApiPath(routeFile: string): string {
|
||||
return routeFile
|
||||
.replace(/^src\/app/, '')
|
||||
.replace(/\/route\.ts$/, '')
|
||||
.replace('[projectId]', 'project-1')
|
||||
}
|
||||
|
||||
function toModuleImportPath(routeFile: string): string {
|
||||
return `@/${routeFile.replace(/^src\//, '').replace(/\.ts$/, '')}`
|
||||
}
|
||||
|
||||
const ROUTE_CASES: ReadonlyArray<LLMRouteCase> = [
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-design-character/route.ts',
|
||||
body: { userInstruction: 'design a heroic character' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER,
|
||||
expectedTargetType: 'GlobalAssetHubCharacterDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-design-location/route.ts',
|
||||
body: { userInstruction: 'design a noir city location' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,
|
||||
expectedTargetType: 'GlobalAssetHubLocationDesign',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-modify-character/route.ts',
|
||||
body: {
|
||||
characterId: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'make the outfit darker',
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/ai-modify-location/route.ts',
|
||||
body: {
|
||||
locationId: 'global-location-1',
|
||||
imageIndex: 0,
|
||||
currentDescription: 'old location desc',
|
||||
modifyInstruction: 'add more fog',
|
||||
},
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,
|
||||
expectedTargetType: 'GlobalLocation',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/asset-hub/reference-to-character/route.ts',
|
||||
body: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
expectedTaskType: TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
expectedTargetType: 'GlobalCharacter',
|
||||
expectedProjectId: 'global-asset-hub',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-character/route.ts',
|
||||
body: { userInstruction: 'create a rebel hero' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_CREATE_CHARACTER,
|
||||
expectedTargetType: 'NovelPromotionCharacterDesign',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-create-location/route.ts',
|
||||
body: { userInstruction: 'create a mountain temple' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_CREATE_LOCATION,
|
||||
expectedTargetType: 'NovelPromotionLocationDesign',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-appearance/route.ts',
|
||||
body: {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old appearance',
|
||||
modifyInstruction: 'add armor',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||
expectedTargetType: 'CharacterAppearance',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-location/route.ts',
|
||||
body: {
|
||||
locationId: 'location-1',
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'add rain',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_LOCATION,
|
||||
expectedTargetType: 'NovelPromotionLocation',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/ai-modify-shot-prompt/route.ts',
|
||||
body: {
|
||||
panelId: 'panel-1',
|
||||
currentPrompt: 'old prompt',
|
||||
modifyInstruction: 'more dramatic angle',
|
||||
},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-global/route.ts',
|
||||
body: {},
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_GLOBAL,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze-shot-variants/route.ts',
|
||||
body: { panelId: 'panel-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
|
||||
expectedTargetType: 'NovelPromotionPanel',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/analyze/route.ts',
|
||||
body: { episodeId: 'episode-1', content: 'Analyze this chapter' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.ANALYZE_NOVEL,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/batch-confirm/route.ts',
|
||||
body: { items: ['character-1', 'character-2'] },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/character-profile/confirm/route.ts',
|
||||
body: { characterId: 'character-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CHARACTER_PROFILE_CONFIRM,
|
||||
expectedTargetType: 'NovelPromotionCharacter',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/clips/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.CLIPS_BUILD,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/episodes/split/route.ts',
|
||||
body: { content: 'x'.repeat(120) },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/reference-to-character/route.ts',
|
||||
body: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
expectedTargetType: 'NovelPromotionProject',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/screenplay-conversion/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.SCREENPLAY_CONVERT,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/story-to-script-stream/route.ts',
|
||||
body: { episodeId: 'episode-1', content: 'story text' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
{
|
||||
routeFile: 'src/app/api/novel-promotion/[projectId]/voice-analyze/route.ts',
|
||||
body: { episodeId: 'episode-1' },
|
||||
params: { projectId: 'project-1' },
|
||||
expectedTaskType: TASK_TYPE.VOICE_ANALYZE,
|
||||
expectedTargetType: 'NovelPromotionEpisode',
|
||||
expectedProjectId: 'project-1',
|
||||
},
|
||||
]
|
||||
|
||||
async function invokePostRoute(routeCase: LLMRouteCase): Promise<Response> {
|
||||
const modulePath = toModuleImportPath(routeCase.routeFile)
|
||||
const mod = await import(modulePath)
|
||||
const post = mod.POST as (request: Request, context?: RouteContext) => Promise<Response>
|
||||
const req = buildMockRequest({
|
||||
path: toApiPath(routeCase.routeFile),
|
||||
method: 'POST',
|
||||
body: routeCase.body,
|
||||
})
|
||||
return await post(req, { params: Promise.resolve(routeCase.params || {}) })
|
||||
}
|
||||
|
||||
describe('api contract - llm observe routes (behavior)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
authState.projectMode = 'novel-promotion'
|
||||
maybeSubmitLLMTaskMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ taskId: 'task-1', async: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps expected coverage size', () => {
|
||||
expect(ROUTE_CASES.length).toBe(22)
|
||||
})
|
||||
|
||||
for (const routeCase of ROUTE_CASES) {
|
||||
it(`${routeCase.routeFile} -> returns 401 when unauthenticated`, async () => {
|
||||
authState.authenticated = false
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(401)
|
||||
expect(maybeSubmitLLMTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it(`${routeCase.routeFile} -> submits llm task with expected contract when authenticated`, async () => {
|
||||
const res = await invokePostRoute(routeCase)
|
||||
expect(res.status).toBe(200)
|
||||
expect(maybeSubmitLLMTaskMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: routeCase.expectedTaskType,
|
||||
targetType: routeCase.expectedTargetType,
|
||||
projectId: routeCase.expectedProjectId,
|
||||
userId: 'user-1',
|
||||
}))
|
||||
|
||||
const callArg = maybeSubmitLLMTaskMock.mock.calls.at(-1)?.[0] as Record<string, unknown> | undefined
|
||||
expect(callArg?.type).toBe(routeCase.expectedTaskType)
|
||||
expect(callArg?.targetType).toBe(routeCase.expectedTargetType)
|
||||
expect(callArg?.projectId).toBe(routeCase.expectedProjectId)
|
||||
expect(callArg?.userId).toBe('user-1')
|
||||
|
||||
const json = await res.json() as Record<string, unknown>
|
||||
expect(json.async).toBe(true)
|
||||
expect(typeof json.taskId).toBe('string')
|
||||
})
|
||||
}
|
||||
})
|
||||
446
tests/integration/api/contract/task-infra-routes.test.ts
Normal file
446
tests/integration/api/contract/task-infra-routes.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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<Record<string, string>>
|
||||
}
|
||||
|
||||
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(async () => []))
|
||||
const listTaskLifecycleEventsMock = vi.hoisted(() => vi.fn(async () => []))
|
||||
const addChannelListenerMock = vi.hoisted(() => vi.fn(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)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = true
|
||||
subscriberState.listener = null
|
||||
|
||||
queryTasksMock.mockResolvedValue([baseTask])
|
||||
dismissFailedTasksMock.mockResolvedValue(1)
|
||||
getTaskByIdMock.mockResolvedValue(baseTask)
|
||||
cancelTaskMock.mockResolvedValue({
|
||||
task: {
|
||||
...baseTask,
|
||||
status: TASK_STATUS.FAILED,
|
||||
errorCode: 'TASK_CANCELLED',
|
||||
errorMessage: 'Task cancelled by user',
|
||||
},
|
||||
cancelled: true,
|
||||
})
|
||||
queryTaskTargetStatesMock.mockResolvedValue([
|
||||
{
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
active: true,
|
||||
status: TASK_STATUS.PROCESSING,
|
||||
taskId: 'task-1',
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
addChannelListenerMock.mockImplementation(async (_channel: string, listener: (message: string) => void) => {
|
||||
subscriberState.listener = listener
|
||||
return async () => undefined
|
||||
})
|
||||
listTaskLifecycleEventsMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('GET /api/tasks: unauthenticated -> 401; authenticated -> 200 with caller-owned tasks', async () => {
|
||||
const { GET } = await import('@/app/api/tasks/route')
|
||||
|
||||
authState.authenticated = false
|
||||
const unauthorizedReq = buildMockRequest({
|
||||
path: '/api/tasks',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1', limit: 20 },
|
||||
})
|
||||
const unauthorizedRes = await GET(unauthorizedReq)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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 = [
|
||||
{
|
||||
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',
|
||||
stepId: 'clip_1_phase1',
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 3,
|
||||
message: 'running',
|
||||
},
|
||||
},
|
||||
]
|
||||
listTaskLifecycleEventsMock.mockResolvedValueOnce(replayEvents)
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/tasks/task-1',
|
||||
method: 'GET',
|
||||
query: { includeEvents: '1', eventsLimit: '1200' },
|
||||
})
|
||||
const res = await route.GET(req, { params: Promise.resolve({ taskId: 'task-1' }) })
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const payload = await res.json() as { task: TaskRecord; events: Array<Record<string, unknown>> }
|
||||
expect(payload.task.id).toBe('task-1')
|
||||
expect(payload.events).toHaveLength(1)
|
||||
expect(payload.events[0]?.id).toBe('11')
|
||||
expect(listTaskLifecycleEventsMock).toHaveBeenCalledWith('task-1', 1200)
|
||||
})
|
||||
|
||||
it('DELETE /api/tasks/[taskId]: cancellation publishes cancelled event payload', async () => {
|
||||
const { DELETE } = await import('@/app/api/tasks/[taskId]/route')
|
||||
|
||||
const req = buildMockRequest({ path: '/api/tasks/task-1', method: 'DELETE' })
|
||||
const res = await DELETE(req, { params: Promise.resolve({ taskId: 'task-1' }) } as RouteContext)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
expect(removeTaskJobMock).toHaveBeenCalledWith('task-1')
|
||||
expect(publishTaskEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
payload: expect.objectContaining({
|
||||
cancelled: true,
|
||||
stage: 'cancelled',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('GET /api/sse: missing projectId -> 400; unauthenticated with projectId -> 401', async () => {
|
||||
const { GET } = await import('@/app/api/sse/route')
|
||||
|
||||
const invalidReq = buildMockRequest({ path: '/api/sse', method: 'GET' })
|
||||
const invalidRes = await GET(invalidReq)
|
||||
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)
|
||||
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: 'created' },
|
||||
},
|
||||
])
|
||||
|
||||
const req = buildMockRequest({
|
||||
path: '/api/sse',
|
||||
method: 'GET',
|
||||
query: { projectId: 'project-1' },
|
||||
headers: { 'last-event-id': '3' },
|
||||
})
|
||||
const res = await GET(req)
|
||||
|
||||
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)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const listener = subscriberState.listener
|
||||
expect(listener).toBeTruthy()
|
||||
|
||||
listener!(JSON.stringify({
|
||||
id: '11',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: { lifecycleType: 'processing', progress: 60 },
|
||||
}))
|
||||
listener!(JSON.stringify({
|
||||
id: '12',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: null,
|
||||
payload: { lifecycleType: 'completed', progress: 100 },
|
||||
}))
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
expect(reader).toBeTruthy()
|
||||
const chunk1 = await reader!.read()
|
||||
const chunk2 = await reader!.read()
|
||||
const merged = `${new TextDecoder().decode(chunk1.value)}${new TextDecoder().decode(chunk2.value)}`
|
||||
|
||||
expect(merged).toContain('"lifecycleType":"processing"')
|
||||
expect(merged).toContain('"lifecycleType":"completed"')
|
||||
expect(merged).toContain('"taskId":"task-1"')
|
||||
await reader!.cancel()
|
||||
})
|
||||
})
|
||||
35
tests/integration/api/helpers/call-route.ts
Normal file
35
tests/integration/api/helpers/call-route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
type RouteParams = Record<string, string>
|
||||
type HeaderMap = Record<string, string>
|
||||
|
||||
type RouteHandler = (
|
||||
req: NextRequest,
|
||||
ctx?: { params: Promise<RouteParams> },
|
||||
) => Promise<Response>
|
||||
|
||||
export async function callRoute(
|
||||
handler: RouteHandler,
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
options?: { headers?: HeaderMap; params?: RouteParams; query?: Record<string, string> },
|
||||
) {
|
||||
const url = new URL('http://localhost:3000/api/test')
|
||||
if (options?.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body)
|
||||
const req = new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(payload ? { 'content-type': 'application/json' } : {}),
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
...(payload ? { body: payload } : {}),
|
||||
})
|
||||
const context = { params: Promise.resolve(options?.params || {}) }
|
||||
return await handler(req, context)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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(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(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',
|
||||
generateFromReference: true,
|
||||
referenceImageUrl: 'https://example.com/ref.png',
|
||||
customDescription: '冷静,黑发',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await mod.POST(req)
|
||||
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
|
||||
referenceImageUrls?: string[]
|
||||
appearanceId?: string
|
||||
characterId?: string
|
||||
}
|
||||
|
||||
expect(forwarded.locale).toBe('zh')
|
||||
expect(forwarded.meta?.locale).toBe('zh')
|
||||
expect(forwarded.customDescription).toBe('冷静,黑发')
|
||||
expect(forwarded.referenceImageUrls).toEqual(['https://example.com/ref.png'])
|
||||
expect(forwarded.characterId).toBe('character-1')
|
||||
expect(forwarded.appearanceId).toBe('appearance-1')
|
||||
})
|
||||
|
||||
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)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
45
tests/integration/api/specific/characters-post.test.ts
Normal file
45
tests/integration/api/specific/characters-post.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
86
tests/integration/billing/api-contract.integration.test.ts
Normal file
86
tests/integration/billing/api-contract.integration.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { apiHandler } from '@/lib/api-errors'
|
||||
import { calcText } from '@/lib/billing/cost'
|
||||
import { withTextBilling } from '@/lib/billing/service'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
describe('billing/api contract integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('returns 402 payload when balance is insufficient', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 0)
|
||||
|
||||
const route = apiHandler(async () => {
|
||||
await withTextBilling(
|
||||
user.id,
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
500,
|
||||
{ projectId: project.id, action: 'api_contract_insufficient' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'x-request-id': 'req_insufficient' },
|
||||
})
|
||||
const response = await route(req, { params: Promise.resolve({}) })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(402)
|
||||
expect(body?.error?.code).toBe('INSUFFICIENT_BALANCE')
|
||||
expect(typeof body?.required).toBe('number')
|
||||
expect(typeof body?.available).toBe('number')
|
||||
})
|
||||
|
||||
it('rejects duplicate retry with same request id and prevents duplicate charge', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 5)
|
||||
|
||||
const route = apiHandler(async () => {
|
||||
await withTextBilling(
|
||||
user.id,
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
500,
|
||||
{ projectId: project.id, action: 'api_contract_dedupe' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
return NextResponse.json({ ok: true })
|
||||
})
|
||||
|
||||
const req1 = new NextRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'x-request-id': 'same_request_id' },
|
||||
})
|
||||
const req2 = new NextRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: { 'x-request-id': 'same_request_id' },
|
||||
})
|
||||
|
||||
const resp1 = await route(req1, { params: Promise.resolve({}) })
|
||||
const resp2 = await route(req2, { params: Promise.resolve({}) })
|
||||
const body2 = await resp2.json()
|
||||
|
||||
expect(resp1.status).toBe(200)
|
||||
expect(resp2.status).toBe(409)
|
||||
expect(body2?.error?.code).toBe('CONFLICT')
|
||||
expect(String(body2?.error?.message || '')).toContain('duplicate billing request already confirmed')
|
||||
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
const expectedCharge = calcText('anthropic/claude-sonnet-4', 1000, 500)
|
||||
expect(balance?.totalSpent).toBeCloseTo(expectedCharge, 8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
183
tests/integration/billing/ledger.integration.test.ts
Normal file
183
tests/integration/billing/ledger.integration.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
confirmChargeWithRecord,
|
||||
freezeBalance,
|
||||
getBalance,
|
||||
recordShadowUsage,
|
||||
rollbackFreeze,
|
||||
} from '@/lib/billing/ledger'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
describe('billing/ledger integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('freezes balance when enough funds exist', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_ok' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(7, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(3, 8)
|
||||
})
|
||||
|
||||
it('returns null freeze id when balance is insufficient', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 1)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'freeze_no_money' })
|
||||
expect(freezeId).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses same freeze record with the same idempotency key', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const first = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
|
||||
const second = await freezeBalance(user.id, 2, { idempotencyKey: 'idem_key' })
|
||||
|
||||
expect(first).toBeTruthy()
|
||||
expect(second).toBe(first)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(8, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(2, 8)
|
||||
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('supports partial confirmation and refunds difference', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 3, { idempotencyKey: 'confirm_partial' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const confirmed = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 2,
|
||||
unit: 'second',
|
||||
},
|
||||
{ chargedAmount: 2 },
|
||||
)
|
||||
expect(confirmed).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(8, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(2, 8)
|
||||
expect(await prisma.usageCost.count()).toBe(1)
|
||||
})
|
||||
|
||||
it('is idempotent when confirm is called repeatedly', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'confirm_idem' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const first = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'image',
|
||||
model: 'seedream',
|
||||
quantity: 1,
|
||||
unit: 'image',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
const second = await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'image',
|
||||
model: 'seedream',
|
||||
quantity: 1,
|
||||
unit: 'image',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
|
||||
expect(first).toBe(true)
|
||||
expect(second).toBe(true)
|
||||
expect(await prisma.balanceTransaction.count({ where: { freezeId: freezeId! } })).toBe(1)
|
||||
})
|
||||
|
||||
it('rolls back pending freeze and restores funds', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 4, { idempotencyKey: 'rollback_ok' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
const rolled = await rollbackFreeze(freezeId!)
|
||||
expect(rolled).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(10, 8)
|
||||
expect(balance.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
|
||||
it('returns false when trying to rollback a non-pending freeze', async () => {
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const freezeId = await freezeBalance(user.id, 2, { idempotencyKey: 'rollback_after_confirm' })
|
||||
expect(freezeId).toBeTruthy()
|
||||
|
||||
await confirmChargeWithRecord(
|
||||
freezeId!,
|
||||
{
|
||||
projectId: project.id,
|
||||
action: 'integration_confirm',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 5,
|
||||
unit: 'second',
|
||||
},
|
||||
{ chargedAmount: 1 },
|
||||
)
|
||||
|
||||
const rolled = await rollbackFreeze(freezeId!)
|
||||
expect(rolled).toBe(false)
|
||||
})
|
||||
|
||||
it('records shadow usage as audit transaction without balance change', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 5)
|
||||
|
||||
const ok = await recordShadowUsage(user.id, {
|
||||
projectId: 'asset-hub',
|
||||
action: 'shadow_test',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1200,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
metadata: { source: 'test' },
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const balance = await getBalance(user.id)
|
||||
expect(balance.balance).toBeCloseTo(5, 8)
|
||||
expect(balance.totalSpent).toBeCloseTo(0, 8)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
|
||||
})
|
||||
})
|
||||
137
tests/integration/billing/service.integration.test.ts
Normal file
137
tests/integration/billing/service.integration.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { calcVoice } from '@/lib/billing/cost'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { prepareTaskBilling, rollbackTaskBilling, settleTaskBilling } from '@/lib/billing/service'
|
||||
import { TASK_TYPE, type TaskBillingInfo } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
function expectBillableInfo(info: TaskBillingInfo | null | undefined): Extract<TaskBillingInfo, { billable: true }> {
|
||||
expect(info?.billable).toBe(true)
|
||||
if (!info || !info.billable) {
|
||||
throw new Error('Expected billable task billing info')
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
describe('billing/service integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('marks task billing as skipped in OFF mode', async () => {
|
||||
process.env.BILLING_MODE = 'OFF'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const result = await prepareTaskBilling({
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
})
|
||||
|
||||
expect(result?.billable).toBe(true)
|
||||
expect((result as TaskBillingInfo & { status: string }).status).toBe('skipped')
|
||||
})
|
||||
|
||||
it('records shadow audit in SHADOW mode and does not consume balance', async () => {
|
||||
process.env.BILLING_MODE = 'SHADOW'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
expect(prepared.status).toBe('quoted')
|
||||
|
||||
const settled = expectBillableInfo(await settleTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: prepared,
|
||||
}, {
|
||||
result: { actualDurationSeconds: 2 },
|
||||
}))
|
||||
|
||||
expect(settled.status).toBe('settled')
|
||||
expect(settled.chargedCost).toBe(0)
|
||||
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||
expect(balance?.totalSpent).toBeCloseTo(0, 8)
|
||||
expect(await prisma.balanceTransaction.count({ where: { type: 'shadow_consume' } })).toBe(1)
|
||||
})
|
||||
|
||||
it('freezes and settles in ENFORCE mode with actual usage', async () => {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
expect(prepared.status).toBe('frozen')
|
||||
expect(prepared.freezeId).toBeTruthy()
|
||||
|
||||
const settled = expectBillableInfo(await settleTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: prepared,
|
||||
}, {
|
||||
result: { actualDurationSeconds: 2 },
|
||||
}))
|
||||
|
||||
expect(settled.status).toBe('settled')
|
||||
expect(settled.chargedCost).toBeCloseTo(calcVoice(2), 8)
|
||||
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.totalSpent).toBeCloseTo(calcVoice(2), 8)
|
||||
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
|
||||
it('rolls back frozen billing in ENFORCE mode', async () => {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const info = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })!
|
||||
const taskId = randomUUID()
|
||||
const prepared = expectBillableInfo(await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: info,
|
||||
}))
|
||||
|
||||
const rolled = expectBillableInfo(await rollbackTaskBilling({
|
||||
id: taskId,
|
||||
billingInfo: prepared,
|
||||
}))
|
||||
|
||||
expect(rolled.status).toBe('rolled_back')
|
||||
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||
})
|
||||
})
|
||||
78
tests/integration/billing/submitter.integration.test.ts
Normal file
78
tests/integration/billing/submitter.integration.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ApiError } from '@/lib/api-errors'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { submitTask } from '@/lib/task/submitter'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
vi.mock('@/lib/task/queues', () => ({
|
||||
addTaskJob: vi.fn(async () => ({ id: 'mock-job' })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
describe('billing/submitter integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
})
|
||||
|
||||
it('builds billing info server-side for billable task submission', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const result = await submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-a',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-a',
|
||||
payload: { maxSeconds: 5 },
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const task = await prisma.task.findUnique({ where: { id: result.taskId } })
|
||||
expect(task).toBeTruthy()
|
||||
const billing = task?.billingInfo as { billable?: boolean; source?: string } | null
|
||||
expect(billing?.billable).toBe(true)
|
||||
expect(billing?.source).toBe('task')
|
||||
})
|
||||
|
||||
it('marks task as failed when balance is insufficient', async () => {
|
||||
const user = await createTestUser()
|
||||
await seedBalance(user.id, 0)
|
||||
|
||||
const billingInfo = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 10 })
|
||||
expect(billingInfo?.billable).toBe(true)
|
||||
|
||||
await expect(
|
||||
submitTask({
|
||||
userId: user.id,
|
||||
locale: 'en',
|
||||
projectId: 'project-b',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-b',
|
||||
payload: { maxSeconds: 10 },
|
||||
billingInfo,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' } satisfies Pick<ApiError, 'code'>)
|
||||
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
expect(task).toBeTruthy()
|
||||
expect(task?.status).toBe('failed')
|
||||
expect(task?.errorCode).toBe('INSUFFICIENT_BALANCE')
|
||||
})
|
||||
})
|
||||
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal file
136
tests/integration/billing/worker-lifecycle.integration.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Job } from 'bullmq'
|
||||
import { UnrecoverableError } from 'bullmq'
|
||||
import { prepareTaskBilling } from '@/lib/billing/service'
|
||||
import { buildDefaultTaskBillingInfo } from '@/lib/billing/task-policy'
|
||||
import { TaskTerminatedError } from '@/lib/task/errors'
|
||||
import { withTaskLifecycle } from '@/lib/workers/shared'
|
||||
import { TASK_TYPE, type TaskBillingInfo, type TaskJobData } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createQueuedTask, createTestProject, createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||
|
||||
vi.mock('@/lib/task/publisher', () => ({
|
||||
publishTaskEvent: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
async function createPreparedVoiceTask() {
|
||||
process.env.BILLING_MODE = 'ENFORCE'
|
||||
const user = await createTestUser()
|
||||
const project = await createTestProject(user.id)
|
||||
await seedBalance(user.id, 10)
|
||||
|
||||
const taskId = randomUUID()
|
||||
const raw = buildDefaultTaskBillingInfo(TASK_TYPE.VOICE_LINE, { maxSeconds: 5 })
|
||||
if (!raw || !raw.billable) {
|
||||
throw new Error('failed to build billing info fixture')
|
||||
}
|
||||
const prepared = await prepareTaskBilling({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
billingInfo: raw,
|
||||
})
|
||||
|
||||
const billingInfo = prepared as TaskBillingInfo
|
||||
await createQueuedTask({
|
||||
id: taskId,
|
||||
userId: user.id,
|
||||
projectId: project.id,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-1',
|
||||
billingInfo,
|
||||
})
|
||||
|
||||
const jobData: TaskJobData = {
|
||||
taskId,
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'en',
|
||||
projectId: project.id,
|
||||
targetType: 'VoiceLine',
|
||||
targetId: 'line-1',
|
||||
billingInfo,
|
||||
userId: user.id,
|
||||
payload: {},
|
||||
}
|
||||
|
||||
const job = {
|
||||
data: jobData,
|
||||
queueName: 'voice',
|
||||
opts: {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2_000,
|
||||
},
|
||||
},
|
||||
attemptsMade: 0,
|
||||
} as unknown as Job<TaskJobData>
|
||||
|
||||
return { taskId, user, project, job }
|
||||
}
|
||||
|
||||
describe('billing/worker lifecycle integration', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('settles billing and marks task completed on success', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await withTaskLifecycle(fixture.job, async () => ({ actualDurationSeconds: 2 }))
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('completed')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect(billing?.billable).toBe(true)
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
|
||||
})
|
||||
|
||||
it('rolls back billing and marks task failed on error', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new Error('worker failed')
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnrecoverableError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('failed')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
})
|
||||
|
||||
it('keeps task active for queue retry on retryable worker error', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new TypeError('terminated')
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TypeError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
expect(task?.status).toBe('processing')
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('frozen')
|
||||
})
|
||||
|
||||
it('rolls back billing on cancellation path', async () => {
|
||||
const fixture = await createPreparedVoiceTask()
|
||||
|
||||
await expect(
|
||||
withTaskLifecycle(fixture.job, async () => {
|
||||
throw new TaskTerminatedError(fixture.taskId)
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnrecoverableError)
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: fixture.taskId } })
|
||||
const billing = task?.billingInfo as TaskBillingInfo
|
||||
expect((billing as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
expect(task?.status).not.toBe('failed')
|
||||
})
|
||||
})
|
||||
182
tests/integration/chain/image.chain.test.ts
Normal file
182
tests/integration/chain/image.chain.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getUserModels: vi.fn(async () => ({
|
||||
characterModel: 'model-character-1',
|
||||
locationModel: 'model-location-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/global-character-generated.png'),
|
||||
parseJsonStringArray: vi.fn(() => [] as string[]),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - image queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('image tasks are enqueued into image queue with jobId=taskId', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
options: expect.objectContaining({ jobId: 'task-image-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('modify asset image task also routes to image queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-2',
|
||||
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
payload: { appearanceId: 'appearance-1', modifyPrompt: 'make it cleaner' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.jobName).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.MODIFY_ASSET_IMAGE)
|
||||
})
|
||||
|
||||
it('queued image job payload can be consumed by worker handler and persist image fields', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleAssetHubImageTask } = await import('@/lib/workers/handlers/asset-hub-image-task-handler')
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: '黑发,风衣',
|
||||
descriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-image-chain-worker-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload: { type: 'character', id: 'global-character-1', appearanceIndex: 0 },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.IMAGE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.ASSET_HUB_IMAGE)
|
||||
|
||||
const result = await handleAssetHubImageTask(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
type: 'character',
|
||||
appearanceId: 'appearance-1',
|
||||
imageCount: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.globalCharacterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-1' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify(['cos/global-character-generated.png']),
|
||||
imageUrl: 'cos/global-character-generated.png',
|
||||
selectedIndex: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
185
tests/integration/chain/text.chain.test.ts
Normal file
185
tests/integration/chain/text.chain.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => JSON.stringify({
|
||||
episodes: [
|
||||
{
|
||||
number: 1,
|
||||
title: '第一集',
|
||||
summary: '开端',
|
||||
startMarker: 'START_MARKER',
|
||||
endMarker: 'END_MARKER',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({ analysisModel: 'llm::analysis-1' })),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'run-1' })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({ flush: vi.fn(async () => undefined) })),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
|
||||
buildPrompt: vi.fn(() => 'episode-split-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createTextMarkerMatcher: (content: string) => ({
|
||||
matchMarker: (marker: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(marker, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
return { startIndex, endIndex: startIndex + marker.length }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - text queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
})
|
||||
|
||||
it('text tasks are enqueued into text queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-1',
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
options: expect.objectContaining({ jobId: 'task-text-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('explicit priority is preserved for text queue jobs', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-2',
|
||||
type: TASK_TYPE.REFERENCE_TO_CHARACTER,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { referenceImageUrl: 'https://example.com/ref.png' },
|
||||
userId: 'user-1',
|
||||
}, { priority: 7 })
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.options).toEqual(expect.objectContaining({ priority: 7, jobId: 'task-text-2' }))
|
||||
})
|
||||
|
||||
it('queued text job payload can be consumed by text handler and resolve episode boundaries', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { handleEpisodeSplitTask } = await import('@/lib/workers/handlers/episode-split')
|
||||
|
||||
const content = [
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'START_MARKER',
|
||||
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于链路测试验证。',
|
||||
'END_MARKER',
|
||||
'后置内容用于确保边界外还有文本,并继续补足长度。',
|
||||
].join('')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-text-chain-worker-1',
|
||||
type: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { content },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.TEXT) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.EPISODE_SPLIT_LLM)
|
||||
|
||||
const result = await handleEpisodeSplitTask(toJob(queued!))
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.episodes).toHaveLength(1)
|
||||
expect(result.episodes[0]?.title).toBe('第一集')
|
||||
expect(result.episodes[0]?.content).toContain('START_MARKER')
|
||||
expect(result.episodes[0]?.content).toContain('END_MARKER')
|
||||
})
|
||||
})
|
||||
189
tests/integration/chain/video.chain.test.ts
Normal file
189
tests/integration/chain/video.chain.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
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 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' })),
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - video queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
videoUrl: 'cos/base-video.mp4',
|
||||
})
|
||||
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
|
||||
id: 'line-1',
|
||||
audioUrl: 'cos/line-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VIDEO_PANEL is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-1',
|
||||
type: TASK_TYPE.VIDEO_PANEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { videoModel: 'fal::video-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VIDEO_PANEL,
|
||||
options: expect.objectContaining({ jobId: 'task-video-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('LIP_SYNC is enqueued into video queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-2',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
})
|
||||
|
||||
it('queued video job payload can be consumed by video worker and persist lipSyncVideoUrl', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVideoWorker } = await import('@/lib/workers/video.worker')
|
||||
createVideoWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-video-chain-worker-1',
|
||||
type: TASK_TYPE.LIP_SYNC,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload: { voiceLineId: 'line-1', lipSyncModel: 'fal::lipsync-model' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VIDEO) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.LIP_SYNC)
|
||||
|
||||
const result = await processor!(toJob(queued!)) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
voiceLineId: 'line-1',
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
})
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
|
||||
lipSyncTaskId: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
172
tests/integration/chain/voice.chain.test.ts
Normal file
172
tests/integration/chain/voice.chain.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type AddCall = {
|
||||
jobName: string
|
||||
data: TaskJobData
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
const queueState = vi.hoisted(() => ({
|
||||
addCallsByQueue: new Map<string, AddCall[]>(),
|
||||
}))
|
||||
|
||||
const workerState = vi.hoisted(() => ({
|
||||
processor: null as ((job: Job<TaskJobData>) => Promise<unknown>) | null,
|
||||
}))
|
||||
|
||||
const voiceMock = vi.hoisted(() => ({
|
||||
generateVoiceLine: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
withTaskLifecycle: vi.fn(async (job: Job<TaskJobData>, handler: (j: Job<TaskJobData>) => Promise<unknown>) => await handler(job)),
|
||||
}))
|
||||
|
||||
const voiceDesignMock = vi.hoisted(() => ({
|
||||
handleVoiceDesignTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('bullmq', () => ({
|
||||
Queue: class {
|
||||
private readonly queueName: string
|
||||
|
||||
constructor(queueName: string) {
|
||||
this.queueName = queueName
|
||||
}
|
||||
|
||||
async add(jobName: string, data: TaskJobData, options: Record<string, unknown>) {
|
||||
const list = queueState.addCallsByQueue.get(this.queueName) || []
|
||||
list.push({ jobName, data, options })
|
||||
queueState.addCallsByQueue.set(this.queueName, list)
|
||||
return { id: data.taskId }
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
return null
|
||||
}
|
||||
},
|
||||
Worker: class {
|
||||
constructor(_name: string, processor: (job: Job<TaskJobData>) => Promise<unknown>) {
|
||||
workerState.processor = processor
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
|
||||
vi.mock('@/lib/voice/generate-voice-line', () => ({
|
||||
generateVoiceLine: voiceMock.generateVoiceLine,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
withTaskLifecycle: workerMock.withTaskLifecycle,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/voice-design', () => ({
|
||||
handleVoiceDesignTask: voiceDesignMock.handleVoiceDesignTask,
|
||||
}))
|
||||
|
||||
function toJob(data: TaskJobData): Job<TaskJobData> {
|
||||
return { data } as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('chain contract - voice queue behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queueState.addCallsByQueue.clear()
|
||||
workerState.processor = null
|
||||
voiceMock.generateVoiceLine.mockResolvedValue({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
voiceDesignMock.handleVoiceDesignTask.mockResolvedValue({
|
||||
presetId: 'voice-design-1',
|
||||
previewAudioUrl: 'cos/preview-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('VOICE_LINE is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: { lineId: 'line-1', episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual(expect.objectContaining({
|
||||
jobName: TASK_TYPE.VOICE_LINE,
|
||||
options: expect.objectContaining({ jobId: 'task-voice-1', priority: 0 }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('ASSET_HUB_VOICE_DESIGN is enqueued into voice queue', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-2',
|
||||
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalAssetHubVoiceDesign',
|
||||
targetId: 'voice-design-1',
|
||||
payload: { voicePrompt: 'female calm narrator' },
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.data.type).toBe(TASK_TYPE.ASSET_HUB_VOICE_DESIGN)
|
||||
})
|
||||
|
||||
it('queued voice job payload can be consumed by voice worker and forwarded with concrete params', async () => {
|
||||
const { addTaskJob, QUEUE_NAME } = await import('@/lib/task/queues')
|
||||
const { createVoiceWorker } = await import('@/lib/workers/voice.worker')
|
||||
createVoiceWorker()
|
||||
const processor = workerState.processor
|
||||
expect(processor).toBeTruthy()
|
||||
|
||||
await addTaskJob({
|
||||
taskId: 'task-voice-chain-worker-1',
|
||||
type: TASK_TYPE.VOICE_LINE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionVoiceLine',
|
||||
targetId: 'line-1',
|
||||
payload: {
|
||||
lineId: 'line-1',
|
||||
episodeId: 'episode-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
},
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
const calls = queueState.addCallsByQueue.get(QUEUE_NAME.VOICE) || []
|
||||
const queued = calls[0]?.data
|
||||
expect(queued?.type).toBe(TASK_TYPE.VOICE_LINE)
|
||||
|
||||
const result = await processor!(toJob(queued!))
|
||||
expect(result).toEqual({
|
||||
lineId: 'line-1',
|
||||
audioUrl: 'cos/voice-line-1.mp3',
|
||||
})
|
||||
expect(voiceMock.generateVoiceLine).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
lineId: 'line-1',
|
||||
userId: 'user-1',
|
||||
audioModel: 'fal::voice-model',
|
||||
})
|
||||
})
|
||||
})
|
||||
72
tests/setup/env.ts
Normal file
72
tests/setup/env.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
let loaded = false
|
||||
|
||||
function parseEnvLine(line: string) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
const idx = trimmed.indexOf('=')
|
||||
if (idx <= 0) return null
|
||||
const key = trimmed.slice(0, idx).trim()
|
||||
if (!key) return null
|
||||
const rawValue = trimmed.slice(idx + 1).trim()
|
||||
const unquoted =
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"'))
|
||||
|| (rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
? rawValue.slice(1, -1)
|
||||
: rawValue
|
||||
return { key, value: unquoted }
|
||||
}
|
||||
|
||||
export function loadTestEnv() {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
const mutableEnv = process.env as Record<string, string | undefined>
|
||||
const setIfMissing = (key: string, value: string) => {
|
||||
if (!mutableEnv[key]) {
|
||||
mutableEnv[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = path.resolve(process.cwd(), '.env.test')
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf8')
|
||||
for (const line of content.split('\n')) {
|
||||
const pair = parseEnvLine(line)
|
||||
if (!pair) continue
|
||||
if (mutableEnv[pair.key] === undefined) {
|
||||
mutableEnv[pair.key] = pair.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIfMissing('NODE_ENV', 'test')
|
||||
setIfMissing('BILLING_MODE', 'OFF')
|
||||
setIfMissing('DATABASE_URL', 'mysql://root:root@127.0.0.1:3307/waoowaoo_test')
|
||||
setIfMissing('REDIS_HOST', '127.0.0.1')
|
||||
setIfMissing('REDIS_PORT', '6380')
|
||||
}
|
||||
|
||||
loadTestEnv()
|
||||
|
||||
if (process.env.ALLOW_TEST_NETWORK !== '1' && typeof globalThis.fetch === 'function') {
|
||||
const originalFetch = globalThis.fetch
|
||||
const allowHosts = new Set(['localhost', '127.0.0.1'])
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const rawUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
const parsed = new URL(rawUrl, 'http://localhost')
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
if (!allowHosts.has(parsed.hostname)) {
|
||||
throw new Error(`Network blocked in tests: ${parsed.hostname}`)
|
||||
}
|
||||
}
|
||||
return await originalFetch(input, init)
|
||||
}) as typeof fetch
|
||||
}
|
||||
99
tests/setup/global-setup.ts
Normal file
99
tests/setup/global-setup.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import mysql from 'mysql2/promise'
|
||||
import Redis from 'ioredis'
|
||||
import { loadTestEnv } from './env'
|
||||
import { runGlobalTeardown } from './global-teardown'
|
||||
|
||||
function parseDbUrl(dbUrl: string) {
|
||||
const url = new URL(dbUrl)
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ''),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMysql(maxAttempts = 180) {
|
||||
const db = parseDbUrl(process.env.DATABASE_URL || '')
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
user: db.user,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
connectTimeout: 5_000,
|
||||
})
|
||||
await conn.query('SELECT 1')
|
||||
await conn.end()
|
||||
return
|
||||
} catch {
|
||||
await sleep(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('MySQL test service did not become ready in time')
|
||||
}
|
||||
|
||||
async function waitForRedis(maxAttempts = 60) {
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number(process.env.REDIS_PORT || '6380'),
|
||||
maxRetriesPerRequest: 1,
|
||||
lazyConnect: true,
|
||||
})
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
if (redis.status !== 'ready') {
|
||||
await redis.connect()
|
||||
}
|
||||
const pong = await redis.ping()
|
||||
if (pong === 'PONG') return
|
||||
} catch {
|
||||
await sleep(1_000)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
throw new Error('Redis test service did not become ready in time')
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
loadTestEnv()
|
||||
|
||||
const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'
|
||||
if (!shouldBootstrap) {
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml up -d --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
await waitForMysql()
|
||||
await waitForRedis()
|
||||
|
||||
execSync('npx prisma db push --skip-generate --schema prisma/schema.prisma', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
return async () => {
|
||||
await runGlobalTeardown()
|
||||
}
|
||||
}
|
||||
15
tests/setup/global-teardown.ts
Normal file
15
tests/setup/global-teardown.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { loadTestEnv } from './env'
|
||||
|
||||
export async function runGlobalTeardown() {
|
||||
loadTestEnv()
|
||||
|
||||
const shouldBootstrap = process.env.BILLING_TEST_BOOTSTRAP === '1' || process.env.SYSTEM_TEST_BOOTSTRAP === '1'
|
||||
if (!shouldBootstrap) return
|
||||
if (process.env.BILLING_TEST_KEEP_SERVICES === '1') return
|
||||
|
||||
execSync('docker compose -f docker-compose.test.yml down -v --remove-orphans', {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
}
|
||||
37
tests/unit/api-config/preset-coming-soon.test.ts
Normal file
37
tests/unit/api-config/preset-coming-soon.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PRESET_MODELS,
|
||||
encodeModelKey,
|
||||
isPresetComingSoonModel,
|
||||
isPresetComingSoonModelKey,
|
||||
} from '@/app/[locale]/profile/components/api-config/types'
|
||||
|
||||
describe('api-config preset coming soon', () => {
|
||||
it('registers Nano Banana 2 under Google AI Studio presets', () => {
|
||||
const model = PRESET_MODELS.find(
|
||||
(entry) => entry.provider === 'google' && entry.modelId === 'gemini-3.1-flash-image-preview',
|
||||
)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.name).toBe('Nano Banana 2')
|
||||
})
|
||||
|
||||
it('registers Seedance 2.0 as a coming-soon preset model', () => {
|
||||
const model = PRESET_MODELS.find(
|
||||
(entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128',
|
||||
)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.name).toContain('待上线')
|
||||
})
|
||||
|
||||
it('recognizes coming-soon model by provider/modelId and modelKey', () => {
|
||||
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')
|
||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true)
|
||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark normal preset models as coming soon', () => {
|
||||
const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215')
|
||||
expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)
|
||||
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
65
tests/unit/billing/cost-error-branches.test.ts
Normal file
65
tests/unit/billing/cost-error-branches.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const lookupMock = vi.hoisted(() => ({
|
||||
resolveBuiltinPricing: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/model-pricing/lookup', () => ({
|
||||
resolveBuiltinPricing: lookupMock.resolveBuiltinPricing,
|
||||
}))
|
||||
|
||||
import { calcImage, calcText, calcVideo, calcVoice } from '@/lib/billing/cost'
|
||||
|
||||
describe('billing/cost error branches', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('throws ambiguous pricing error when catalog has multiple candidates', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockReturnValue({
|
||||
status: 'ambiguous_model',
|
||||
apiType: 'image',
|
||||
modelId: 'shared-model',
|
||||
candidates: [
|
||||
{
|
||||
apiType: 'image',
|
||||
provider: 'p1',
|
||||
modelId: 'shared-model',
|
||||
pricing: { mode: 'flat', flatAmount: 1 },
|
||||
},
|
||||
{
|
||||
apiType: 'image',
|
||||
provider: 'p2',
|
||||
modelId: 'shared-model',
|
||||
pricing: { mode: 'flat', flatAmount: 1 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(() => calcImage('shared-model', 1)).toThrow('Ambiguous image pricing modelId')
|
||||
})
|
||||
|
||||
it('throws unknown model when catalog returns not_configured', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockReturnValue({
|
||||
status: 'not_configured',
|
||||
})
|
||||
|
||||
expect(() => calcImage('provider::missing-image-model', 1)).toThrow('Unknown image model pricing')
|
||||
})
|
||||
|
||||
it('normalizes invalid numeric inputs to zero before pricing', () => {
|
||||
lookupMock.resolveBuiltinPricing.mockImplementation(
|
||||
(input: { selections?: { tokenType?: 'input' | 'output' } }) => {
|
||||
if (input.selections?.tokenType === 'input') return { status: 'resolved', amount: 2 }
|
||||
if (input.selections?.tokenType === 'output') return { status: 'resolved', amount: 4 }
|
||||
return { status: 'resolved', amount: 3 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(calcText('text-model', Number.NaN, 1_000_000)).toBeCloseTo(4, 8)
|
||||
expect(calcText('text-model', 1_000_000, Number.NaN)).toBeCloseTo(2, 8)
|
||||
expect(calcImage('image-model', Number.NaN)).toBe(0)
|
||||
expect(calcVideo('video-model', '720p', Number.NaN)).toBe(0)
|
||||
expect(calcVoice(Number.NaN)).toBe(0)
|
||||
})
|
||||
})
|
||||
126
tests/unit/billing/cost.test.ts
Normal file
126
tests/unit/billing/cost.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
USD_TO_CNY,
|
||||
calcImage,
|
||||
calcLipSync,
|
||||
calcText,
|
||||
calcVideo,
|
||||
calcVoice,
|
||||
calcVoiceDesign,
|
||||
} from '@/lib/billing/cost'
|
||||
|
||||
describe('billing/cost', () => {
|
||||
it('calculates text cost by known model price table', () => {
|
||||
const cost = calcText('anthropic/claude-sonnet-4', 1_000_000, 1_000_000)
|
||||
expect(cost).toBeCloseTo((3 + 15) * USD_TO_CNY, 8)
|
||||
})
|
||||
|
||||
it('throws when text model pricing is unknown', () => {
|
||||
expect(() => calcText('unknown-model', 500_000, 250_000)).toThrow('Unknown text model pricing')
|
||||
})
|
||||
|
||||
it('throws when image model pricing is unknown', () => {
|
||||
expect(() => calcImage('missing-image-model', 3)).toThrow('Unknown image model pricing')
|
||||
})
|
||||
|
||||
it('supports resolution-aware video pricing', () => {
|
||||
const cost720 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '720p', 2)
|
||||
const cost1080 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '1080p', 2)
|
||||
expect(cost720).toBeCloseTo(0.86, 8)
|
||||
expect(cost1080).toBeCloseTo(2.06, 8)
|
||||
expect(() => calcVideo('doubao-seedance-1-0-pro-fast-251015', '2k', 1)).toThrow('Unsupported video resolution pricing')
|
||||
expect(() => calcVideo('unknown-video-model', '720p', 1)).toThrow('Unknown video model pricing')
|
||||
})
|
||||
|
||||
it('scales ark video pricing by selected duration when tiers omit duration', () => {
|
||||
const shortDuration = calcVideo('doubao-seedance-1-0-pro-250528', '480p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '480p',
|
||||
duration: 2,
|
||||
})
|
||||
const longDuration = calcVideo('doubao-seedance-1-0-pro-250528', '1080p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '1080p',
|
||||
duration: 12,
|
||||
})
|
||||
|
||||
expect(shortDuration).toBeCloseTo(0.292, 8)
|
||||
expect(longDuration).toBeCloseTo(8.808, 8)
|
||||
})
|
||||
|
||||
it('uses Ark 1.5 official default generateAudio=true when audio is omitted', () => {
|
||||
const defaultAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
})
|
||||
const muteAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
generateAudio: false,
|
||||
})
|
||||
|
||||
expect(defaultAudio).toBeCloseTo(1.73, 8)
|
||||
expect(muteAudio).toBeCloseTo(0.86, 8)
|
||||
})
|
||||
|
||||
it('supports Ark Seedance 1.0 Lite i2v pricing and duration scaling', () => {
|
||||
const shortDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '480p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '480p',
|
||||
duration: 2,
|
||||
})
|
||||
const longDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '1080p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '1080p',
|
||||
duration: 12,
|
||||
})
|
||||
|
||||
expect(shortDuration).toBeCloseTo(0.196, 8)
|
||||
expect(longDuration).toBeCloseTo(5.88, 8)
|
||||
})
|
||||
|
||||
it('rejects unsupported Ark capability values before pricing', () => {
|
||||
expect(() => calcVideo('doubao-seedance-1-0-lite-i2v-250428', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
duration: 1,
|
||||
})).toThrow('Unsupported video capability pricing')
|
||||
})
|
||||
|
||||
it('supports minimax capability-aware video pricing', () => {
|
||||
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '768p',
|
||||
duration: 6,
|
||||
})
|
||||
const hailuoFirstLast = calcVideo('minimax-hailuo-02', '768p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '768p',
|
||||
duration: 10,
|
||||
})
|
||||
const t2v = calcVideo('t2v-01', '720p', 1, {
|
||||
generationMode: 'normal',
|
||||
resolution: '720p',
|
||||
duration: 6,
|
||||
})
|
||||
|
||||
expect(hailuoNormal).toBeCloseTo(2.0, 8)
|
||||
expect(hailuoFirstLast).toBeCloseTo(4.0, 8)
|
||||
expect(t2v).toBeCloseTo(3.0, 8)
|
||||
expect(() => calcVideo('minimax-hailuo-02', '512p', 1, {
|
||||
generationMode: 'firstlastframe',
|
||||
resolution: '512p',
|
||||
duration: 6,
|
||||
})).toThrow('Unsupported video capability pricing')
|
||||
})
|
||||
|
||||
it('returns deterministic fixed costs for call-based APIs', () => {
|
||||
expect(calcVoiceDesign()).toBeGreaterThan(0)
|
||||
expect(calcLipSync()).toBeGreaterThan(0)
|
||||
expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calculates voice costs from quantities', () => {
|
||||
expect(calcVoice(30)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
135
tests/unit/billing/ledger-extra.test.ts
Normal file
135
tests/unit/billing/ledger-extra.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}))
|
||||
|
||||
import { addBalance, recordShadowUsage } from '@/lib/billing/ledger'
|
||||
|
||||
function buildTxStub() {
|
||||
return {
|
||||
userBalance: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
balanceTransaction: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('billing/ledger extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false when addBalance amount is invalid', async () => {
|
||||
const result = await addBalance('u1', 0)
|
||||
expect(result).toBe(false)
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds recharge balance with string reason', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.userBalance.upsert.mockResolvedValue({ balance: 8.5 })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await addBalance('u1', 5, 'manual recharge')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.findFirst).not.toHaveBeenCalled()
|
||||
expect(tx.userBalance.upsert).toHaveBeenCalledTimes(1)
|
||||
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'u1',
|
||||
type: 'recharge',
|
||||
amount: 5,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('supports idempotent addBalance and short-circuits duplicate key', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.balanceTransaction.findFirst.mockResolvedValue({ id: 'existing_tx' })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await addBalance('u1', 3, {
|
||||
type: 'adjust',
|
||||
reason: 'admin adjust',
|
||||
idempotencyKey: 'idem_1',
|
||||
operatorId: 'op_1',
|
||||
externalOrderId: 'order_1',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.findFirst).toHaveBeenCalledTimes(1)
|
||||
expect(tx.userBalance.upsert).not.toHaveBeenCalled()
|
||||
expect(tx.balanceTransaction.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when transaction throws in addBalance', async () => {
|
||||
prismaMock.$transaction.mockRejectedValue(new Error('db error'))
|
||||
|
||||
const result = await addBalance('u1', 2, 'x')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('records shadow usage consume log on success', async () => {
|
||||
const tx = buildTxStub()
|
||||
tx.userBalance.upsert.mockResolvedValue({ balance: 11.2 })
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
|
||||
await callback(tx)
|
||||
})
|
||||
|
||||
const result = await recordShadowUsage('u1', {
|
||||
projectId: 'p1',
|
||||
action: 'analyze',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
metadata: { trace: 'abc' },
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'u1',
|
||||
type: 'shadow_consume',
|
||||
amount: 0,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns false when recordShadowUsage transaction fails', async () => {
|
||||
prismaMock.$transaction.mockRejectedValue(new Error('shadow failed'))
|
||||
|
||||
const result = await recordShadowUsage('u1', {
|
||||
projectId: 'p1',
|
||||
action: 'analyze',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
cost: 0.25,
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
22
tests/unit/billing/mode.test.ts
Normal file
22
tests/unit/billing/mode.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getBillingMode, getBootBillingEnabled } from '@/lib/billing/mode'
|
||||
|
||||
describe('billing/mode', () => {
|
||||
it('falls back to OFF when env is missing', async () => {
|
||||
delete process.env.BILLING_MODE
|
||||
await expect(getBillingMode()).resolves.toBe('OFF')
|
||||
expect(getBootBillingEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes lower-case env mode', async () => {
|
||||
process.env.BILLING_MODE = 'enforce'
|
||||
await expect(getBillingMode()).resolves.toBe('ENFORCE')
|
||||
expect(getBootBillingEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to OFF when env mode is invalid', async () => {
|
||||
process.env.BILLING_MODE = 'invalid'
|
||||
await expect(getBillingMode()).resolves.toBe('OFF')
|
||||
expect(getBootBillingEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
79
tests/unit/billing/runtime-usage.test.ts
Normal file
79
tests/unit/billing/runtime-usage.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { recordTextUsage, withTextUsageCollection } from '@/lib/billing/runtime-usage'
|
||||
|
||||
describe('billing/runtime-usage', () => {
|
||||
it('ignores records outside of collection scope', () => {
|
||||
expect(() => {
|
||||
recordTextUsage({
|
||||
model: 'm',
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('collects and normalizes token usage', async () => {
|
||||
const { textUsage } = await withTextUsageCollection(async () => {
|
||||
recordTextUsage({
|
||||
model: 'test-model',
|
||||
inputTokens: 10.9,
|
||||
outputTokens: -2,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
expect(textUsage).toEqual([
|
||||
{
|
||||
model: 'test-model',
|
||||
inputTokens: 10,
|
||||
outputTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to empty usage when store is unavailable at read time', async () => {
|
||||
const getStoreSpy = vi.spyOn(AsyncLocalStorage.prototype, 'getStore')
|
||||
getStoreSpy.mockReturnValueOnce(undefined as never)
|
||||
|
||||
const payload = await withTextUsageCollection(async () => ({ ok: true }))
|
||||
|
||||
expect(payload).toEqual({ result: { ok: true }, textUsage: [] })
|
||||
getStoreSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('normalizes NaN and zero token values to zero', async () => {
|
||||
const { textUsage } = await withTextUsageCollection(async () => {
|
||||
recordTextUsage({
|
||||
model: 'nan-model',
|
||||
inputTokens: Number.NaN,
|
||||
outputTokens: 0,
|
||||
})
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
expect(textUsage).toEqual([
|
||||
{
|
||||
model: 'nan-model',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('isolates concurrent async local storage contexts', async () => {
|
||||
const [left, right] = await Promise.all([
|
||||
withTextUsageCollection(async () => {
|
||||
recordTextUsage({ model: 'left', inputTokens: 1, outputTokens: 2 })
|
||||
return 'left'
|
||||
}),
|
||||
withTextUsageCollection(async () => {
|
||||
recordTextUsage({ model: 'right', inputTokens: 3, outputTokens: 4 })
|
||||
return 'right'
|
||||
}),
|
||||
])
|
||||
|
||||
expect(left.textUsage).toEqual([{ model: 'left', inputTokens: 1, outputTokens: 2 }])
|
||||
expect(right.textUsage).toEqual([{ model: 'right', inputTokens: 3, outputTokens: 4 }])
|
||||
})
|
||||
})
|
||||
442
tests/unit/billing/service.test.ts
Normal file
442
tests/unit/billing/service.test.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { calcText, calcVoice } from '@/lib/billing/cost'
|
||||
import type { TaskBillingInfo } from '@/lib/task/types'
|
||||
|
||||
const ledgerMock = vi.hoisted(() => ({
|
||||
confirmChargeWithRecord: vi.fn(),
|
||||
freezeBalance: vi.fn(),
|
||||
getBalance: vi.fn(),
|
||||
getFreezeByIdempotencyKey: vi.fn(),
|
||||
increasePendingFreezeAmount: vi.fn(),
|
||||
recordShadowUsage: vi.fn(),
|
||||
rollbackFreeze: vi.fn(),
|
||||
}))
|
||||
|
||||
const modeMock = vi.hoisted(() => ({
|
||||
getBillingMode: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/ledger', () => ledgerMock)
|
||||
vi.mock('@/lib/billing/mode', () => modeMock)
|
||||
|
||||
import { BillingOperationError, InsufficientBalanceError } from '@/lib/billing/errors'
|
||||
import {
|
||||
handleBillingError,
|
||||
prepareTaskBilling,
|
||||
rollbackTaskBilling,
|
||||
settleTaskBilling,
|
||||
withTextBilling,
|
||||
withVoiceBilling,
|
||||
} from '@/lib/billing/service'
|
||||
|
||||
describe('billing/service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValue(true)
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_1')
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0 })
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue(null)
|
||||
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(true)
|
||||
ledgerMock.recordShadowUsage.mockResolvedValue(true)
|
||||
ledgerMock.rollbackFreeze.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('returns raw execution result in OFF mode', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('OFF')
|
||||
|
||||
const result = await withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.confirmChargeWithRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records shadow usage in SHADOW mode without freezing', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('SHADOW')
|
||||
|
||||
const result = await withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.recordShadowUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue(null)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.01 })
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => ({ ok: true }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
})
|
||||
|
||||
it('rolls back freeze when execution throws', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_rollback')
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1' },
|
||||
async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('boom')
|
||||
|
||||
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_rollback')
|
||||
})
|
||||
|
||||
it('expands freeze and charges actual voice usage when actual exceeds quoted', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice')
|
||||
|
||||
await withVoiceBilling(
|
||||
'u1',
|
||||
5,
|
||||
{ projectId: 'p1', action: 'voice_gen' },
|
||||
async () => ({ actualDurationSeconds: 50 }),
|
||||
)
|
||||
|
||||
const confirmCall = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)
|
||||
expect(confirmCall).toBeTruthy()
|
||||
const chargedAmount = confirmCall?.[2]?.chargedAmount as number
|
||||
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
|
||||
expect(chargedAmount).toBeCloseTo(calcVoice(50), 8)
|
||||
})
|
||||
|
||||
it('fails and rolls back when overage freeze expansion cannot be covered', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice_low_balance')
|
||||
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(false)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
|
||||
|
||||
await expect(
|
||||
withVoiceBilling(
|
||||
'u1',
|
||||
5,
|
||||
{ projectId: 'p1', action: 'voice_gen' },
|
||||
async () => ({ actualDurationSeconds: 50 }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
|
||||
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_voice_low_balance')
|
||||
})
|
||||
|
||||
it('rejects duplicate sync billing key when freeze is already confirmed', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
|
||||
id: 'freeze_confirmed',
|
||||
userId: 'u1',
|
||||
amount: 0.5,
|
||||
status: 'confirmed',
|
||||
})
|
||||
const execute = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-1' },
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow('duplicate billing request already confirmed')
|
||||
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate sync billing key when freeze is pending', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
|
||||
id: 'freeze_pending',
|
||||
userId: 'u1',
|
||||
amount: 0.5,
|
||||
status: 'pending',
|
||||
})
|
||||
const execute = vi.fn(async () => ({ ok: true }))
|
||||
|
||||
await expect(
|
||||
withTextBilling(
|
||||
'u1',
|
||||
'anthropic/claude-sonnet-4',
|
||||
1000,
|
||||
1000,
|
||||
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-2' },
|
||||
execute,
|
||||
),
|
||||
).rejects.toThrow('duplicate billing request is already in progress')
|
||||
|
||||
expect(execute).not.toHaveBeenCalled()
|
||||
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps insufficient balance error to 402 response payload', async () => {
|
||||
const response = handleBillingError(new InsufficientBalanceError(1.2, 0.3))
|
||||
expect(response).toBeTruthy()
|
||||
expect(response?.status).toBe(402)
|
||||
const body = await response?.json()
|
||||
expect(body?.code).toBe('INSUFFICIENT_BALANCE')
|
||||
expect(body?.required).toBeCloseTo(1.2, 8)
|
||||
expect(body?.available).toBeCloseTo(0.3, 8)
|
||||
})
|
||||
|
||||
it('returns null for non-billing errors', () => {
|
||||
expect(handleBillingError(new Error('x'))).toBeNull()
|
||||
expect(handleBillingError('x')).toBeNull()
|
||||
})
|
||||
|
||||
describe('task billing lifecycle helpers', () => {
|
||||
function buildTaskInfo(overrides: Partial<Extract<TaskBillingInfo, { billable: true }>> = {}): Extract<TaskBillingInfo, { billable: true }> {
|
||||
return {
|
||||
billable: true,
|
||||
source: 'task',
|
||||
taskType: 'voice_line',
|
||||
apiType: 'voice',
|
||||
model: 'index-tts2',
|
||||
quantity: 5,
|
||||
unit: 'second',
|
||||
maxFrozenCost: calcVoice(5),
|
||||
action: 'voice_line_generate',
|
||||
metadata: { foo: 'bar' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
|
||||
const off = await prepareTaskBilling({
|
||||
id: 'task_off',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
expect((off as Extract<TaskBillingInfo, { billable: true }>).status).toBe('skipped')
|
||||
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
|
||||
const shadow = await prepareTaskBilling({
|
||||
id: 'task_shadow',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
expect((shadow as Extract<TaskBillingInfo, { billable: true }>).status).toBe('quoted')
|
||||
|
||||
modeMock.getBillingMode.mockResolvedValueOnce('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValueOnce('freeze_task_1')
|
||||
const enforce = await prepareTaskBilling({
|
||||
id: 'task_enforce',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
})
|
||||
const enforceInfo = enforce as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(enforceInfo.status).toBe('frozen')
|
||||
expect(enforceInfo.freezeId).toBe('freeze_task_1')
|
||||
})
|
||||
|
||||
it('prepareTaskBilling throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
|
||||
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
|
||||
ledgerMock.freezeBalance.mockResolvedValue(null)
|
||||
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
|
||||
|
||||
await expect(
|
||||
prepareTaskBilling({
|
||||
id: 'task_no_balance',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InsufficientBalanceError)
|
||||
})
|
||||
|
||||
it('settleTaskBilling handles SHADOW and non-ENFORCE snapshots', async () => {
|
||||
const shadowSettled = await settleTaskBilling({
|
||||
id: 'task_shadow_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'SHADOW', status: 'quoted' }),
|
||||
})
|
||||
const shadowInfo = shadowSettled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(shadowInfo.status).toBe('settled')
|
||||
expect(shadowInfo.chargedCost).toBe(0)
|
||||
expect(ledgerMock.recordShadowUsage).toHaveBeenCalled()
|
||||
|
||||
const offSettled = await settleTaskBilling({
|
||||
id: 'task_off_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'OFF', status: 'quoted' }),
|
||||
})
|
||||
const offInfo = offSettled as Extract<TaskBillingInfo, { billable: true }>
|
||||
expect(offInfo.status).toBe('settled')
|
||||
expect(offInfo.chargedCost).toBe(0)
|
||||
})
|
||||
|
||||
it('settleTaskBilling handles ENFORCE success/failure branches', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_enforce_settle',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),
|
||||
})
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
|
||||
|
||||
const missingFreeze = await settleTaskBilling({
|
||||
id: 'task_enforce_no_freeze',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),
|
||||
})
|
||||
expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
|
||||
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
|
||||
await expect(
|
||||
settleTaskBilling({
|
||||
id: 'task_enforce_confirm_fail',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),
|
||||
}),
|
||||
).rejects.toThrow('confirm failed')
|
||||
})
|
||||
|
||||
it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
|
||||
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
|
||||
|
||||
await expect(
|
||||
settleTaskBilling({
|
||||
id: 'task_confirm_and_rollback_fail',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'BillingOperationError',
|
||||
code: 'BILLING_CONFIRM_FAILED',
|
||||
})
|
||||
})
|
||||
|
||||
it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(
|
||||
new BillingOperationError(
|
||||
'BILLING_INVALID_FREEZE',
|
||||
'invalid freeze',
|
||||
{ reason: 'status_mismatch' },
|
||||
),
|
||||
)
|
||||
|
||||
let thrown: unknown = null
|
||||
try {
|
||||
await settleTaskBilling({
|
||||
id: 'task_confirm_billing_error',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(BillingOperationError)
|
||||
const billingError = thrown as BillingOperationError
|
||||
expect(billingError.code).toBe('BILLING_INVALID_FREEZE')
|
||||
expect(billingError.details).toMatchObject({
|
||||
reason: 'status_mismatch',
|
||||
taskId: 'task_confirm_billing_error',
|
||||
freezeId: 'freeze_billing_error',
|
||||
})
|
||||
})
|
||||
|
||||
it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_enforce_overage',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),
|
||||
}, {
|
||||
result: { actualDurationSeconds: 50 },
|
||||
})
|
||||
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
|
||||
expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
|
||||
})
|
||||
|
||||
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
|
||||
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
|
||||
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
|
||||
billable: true,
|
||||
source: 'task',
|
||||
taskType: 'analyze_novel',
|
||||
apiType: 'text',
|
||||
model: 'anthropic/claude-sonnet-4',
|
||||
quantity: 1000,
|
||||
unit: 'token',
|
||||
maxFrozenCost: quoted,
|
||||
action: 'analyze_novel',
|
||||
modeSnapshot: 'ENFORCE',
|
||||
status: 'frozen',
|
||||
freezeId: 'freeze_text_zero',
|
||||
}
|
||||
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
|
||||
|
||||
const settled = await settleTaskBilling({
|
||||
id: 'task_text_zero_usage',
|
||||
userId: 'u1',
|
||||
projectId: 'p1',
|
||||
billingInfo: textBillingInfo,
|
||||
}, {
|
||||
textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],
|
||||
})
|
||||
|
||||
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)
|
||||
const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }
|
||||
expect(recordParams.model).toBe('openai/gpt-5')
|
||||
})
|
||||
|
||||
it('rollbackTaskBilling handles success and fallback branches', async () => {
|
||||
const rolledBack = await rollbackTaskBilling({
|
||||
id: 'task_rb_ok',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),
|
||||
})
|
||||
expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
|
||||
|
||||
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
|
||||
const rollbackFailed = await rollbackTaskBilling({
|
||||
id: 'task_rb_fail',
|
||||
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),
|
||||
})
|
||||
expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
82
tests/unit/billing/task-policy.test.ts
Normal file
82
tests/unit/billing/task-policy.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing/task-policy'
|
||||
import type { TaskBillingInfo } from '@/lib/task/types'
|
||||
|
||||
function expectBillableInfo(info: TaskBillingInfo | null): Extract<TaskBillingInfo, { billable: true }> {
|
||||
expect(info).toBeTruthy()
|
||||
expect(info?.billable).toBe(true)
|
||||
if (!info || !info.billable) {
|
||||
throw new Error('Expected billable task billing info')
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
describe('billing/task-policy', () => {
|
||||
const billingPayload = {
|
||||
analysisModel: 'anthropic/claude-sonnet-4',
|
||||
imageModel: 'seedream',
|
||||
videoModel: 'doubao-seedance-1-5-pro-251215',
|
||||
} as const
|
||||
|
||||
it('builds TaskBillingInfo for every billable task type', () => {
|
||||
for (const taskType of Object.values(TASK_TYPE)) {
|
||||
if (!isBillableTaskType(taskType)) continue
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(taskType, billingPayload))
|
||||
expect(info.taskType).toBe(taskType)
|
||||
expect(info.maxFrozenCost).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for a non-billable task type', () => {
|
||||
const fake = 'not_billable' as unknown as (typeof TASK_TYPE)[keyof typeof TASK_TYPE]
|
||||
expect(isBillableTaskType(fake)).toBe(false)
|
||||
expect(buildDefaultTaskBillingInfo(fake, {})).toBeNull()
|
||||
})
|
||||
|
||||
it('builds text billing info from explicit model payload', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
analysisModel: 'anthropic/claude-sonnet-4',
|
||||
}))
|
||||
expect(info.apiType).toBe('text')
|
||||
expect(info.model).toBe('anthropic/claude-sonnet-4')
|
||||
expect(info.quantity).toBe(4200)
|
||||
})
|
||||
|
||||
it('returns null for missing required models in text/image/video tasks', () => {
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {})).toBeNull()
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {})).toBeNull()
|
||||
expect(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {})).toBeNull()
|
||||
})
|
||||
|
||||
it('honors candidateCount/count for image tasks', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {
|
||||
candidateCount: 4,
|
||||
imageModel: 'seedream4',
|
||||
}))
|
||||
expect(info.apiType).toBe('image')
|
||||
expect(info.quantity).toBe(4)
|
||||
expect(info.model).toBe('seedream4')
|
||||
})
|
||||
|
||||
it('builds video billing info from firstLastFrame.flModel', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {
|
||||
firstLastFrame: {
|
||||
flModel: 'doubao-seedance-1-0-pro-250528',
|
||||
},
|
||||
duration: 8,
|
||||
}))
|
||||
expect(info.apiType).toBe('video')
|
||||
expect(info.model).toBe('doubao-seedance-1-0-pro-250528')
|
||||
expect(info.quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('uses explicit lip sync model from payload', () => {
|
||||
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, {
|
||||
lipSyncModel: 'vidu::vidu-lipsync',
|
||||
}))
|
||||
expect(info.apiType).toBe('lip-sync')
|
||||
expect(info.model).toBe('vidu::vidu-lipsync')
|
||||
expect(info.quantity).toBe(1)
|
||||
})
|
||||
})
|
||||
92
tests/unit/generators/fal-video-kling-presets.test.ts
Normal file
92
tests/unit/generators/fal-video-kling-presets.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
|
||||
}))
|
||||
|
||||
const asyncSubmitMock = vi.hoisted(() => ({
|
||||
submitFalTask: vi.fn(async () => 'req_kling_1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
|
||||
|
||||
import { FalVideoGenerator } from '@/lib/generators/fal'
|
||||
|
||||
type KlingModelCase = {
|
||||
modelId: string
|
||||
endpoint: string
|
||||
imageField: 'image_url' | 'start_image_url'
|
||||
}
|
||||
|
||||
const KLING_MODEL_CASES: KlingModelCase[] = [
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
|
||||
imageField: 'image_url',
|
||||
},
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v3/standard/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v3/standard/image-to-video',
|
||||
imageField: 'start_image_url',
|
||||
},
|
||||
{
|
||||
modelId: 'fal-ai/kling-video/v3/pro/image-to-video',
|
||||
endpoint: 'fal-ai/kling-video/v3/pro/image-to-video',
|
||||
imageField: 'start_image_url',
|
||||
},
|
||||
]
|
||||
|
||||
describe('FalVideoGenerator kling presets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'fal-key' })
|
||||
asyncSubmitMock.submitFalTask.mockResolvedValue('req_kling_1')
|
||||
})
|
||||
|
||||
it.each(KLING_MODEL_CASES)('submits $modelId to expected endpoint and payload', async ({ modelId, endpoint, imageField }) => {
|
||||
const generator = new FalVideoGenerator()
|
||||
const result = await generator.generate({
|
||||
userId: 'user-1',
|
||||
imageUrl: 'https://example.com/start.png',
|
||||
prompt: 'test prompt',
|
||||
options: {
|
||||
modelId,
|
||||
duration: 5,
|
||||
aspectRatio: '16:9',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.endpoint).toBe(endpoint)
|
||||
expect(result.requestId).toBe('req_kling_1')
|
||||
expect(result.externalId).toBe(`FAL:VIDEO:${endpoint}:req_kling_1`)
|
||||
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'fal')
|
||||
|
||||
const submitCall = asyncSubmitMock.submitFalTask.mock.calls.at(0)
|
||||
expect(submitCall).toBeTruthy()
|
||||
if (!submitCall) {
|
||||
throw new Error('submitFalTask should be called')
|
||||
}
|
||||
|
||||
expect(submitCall[0]).toBe(endpoint)
|
||||
expect(submitCall[2]).toBe('fal-key')
|
||||
|
||||
const payload = submitCall[1] as Record<string, unknown>
|
||||
expect(payload.prompt).toBe('test prompt')
|
||||
expect(payload.duration).toBe('5')
|
||||
|
||||
if (imageField === 'image_url') {
|
||||
expect(payload.image_url).toBe('https://example.com/start.png')
|
||||
expect(payload.start_image_url).toBeUndefined()
|
||||
expect(payload.negative_prompt).toBe('blur, distort, and low quality')
|
||||
expect(payload.cfg_scale).toBe(0.5)
|
||||
return
|
||||
}
|
||||
|
||||
expect(payload.start_image_url).toBe('https://example.com/start.png')
|
||||
expect(payload.image_url).toBeUndefined()
|
||||
expect(payload.aspect_ratio).toBe('16:9')
|
||||
expect(payload.generate_audio).toBe(false)
|
||||
})
|
||||
})
|
||||
63
tests/unit/helpers/logging-core.test.ts
Normal file
63
tests/unit/helpers/logging-core.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('logging core suppression', () => {
|
||||
let originalLogLevel: string | undefined
|
||||
let originalUnifiedEnabled: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
originalLogLevel = process.env.LOG_LEVEL
|
||||
originalUnifiedEnabled = process.env.LOG_UNIFIED_ENABLED
|
||||
process.env.LOG_LEVEL = 'INFO'
|
||||
process.env.LOG_UNIFIED_ENABLED = 'true'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env.LOG_LEVEL
|
||||
} else {
|
||||
process.env.LOG_LEVEL = originalLogLevel
|
||||
}
|
||||
if (originalUnifiedEnabled === undefined) {
|
||||
delete process.env.LOG_UNIFIED_ENABLED
|
||||
} else {
|
||||
process.env.LOG_UNIFIED_ENABLED = originalUnifiedEnabled
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('suppresses worker.progress.stream logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress.stream',
|
||||
message: 'worker stream chunk',
|
||||
details: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
},
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps non-suppressed logs', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const { createScopedLogger } = await import('@/lib/logging/core')
|
||||
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
|
||||
|
||||
logger.info({
|
||||
action: 'worker.progress',
|
||||
message: 'worker progress update',
|
||||
})
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls[0]?.[0])) as { action?: string; message?: string }
|
||||
expect(payload.action).toBe('worker.progress')
|
||||
expect(payload.message).toBe('worker progress update')
|
||||
})
|
||||
})
|
||||
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
addCharacterPromptSuffix,
|
||||
CHARACTER_PROMPT_SUFFIX,
|
||||
removeCharacterPromptSuffix,
|
||||
} from '@/lib/constants'
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
if (!target) return 0
|
||||
return input.split(target).length - 1
|
||||
}
|
||||
|
||||
describe('character prompt suffix regression', () => {
|
||||
it('appends suffix when generating prompt', () => {
|
||||
const basePrompt = 'A brave knight in silver armor'
|
||||
const generated = addCharacterPromptSuffix(basePrompt)
|
||||
|
||||
expect(generated).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(generated, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
})
|
||||
|
||||
it('removes suffix text from prompt', () => {
|
||||
const basePrompt = 'A calm detective with short black hair'
|
||||
const withSuffix = addCharacterPromptSuffix(basePrompt)
|
||||
const removed = removeCharacterPromptSuffix(withSuffix)
|
||||
|
||||
expect(removed).not.toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removed).toContain(basePrompt)
|
||||
})
|
||||
|
||||
it('uses suffix as full prompt when base prompt is empty', () => {
|
||||
expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(removeCharacterPromptSuffix('')).toBe('')
|
||||
})
|
||||
})
|
||||
210
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
210
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
|
||||
|
||||
type MockEvent = {
|
||||
id: string
|
||||
type: string
|
||||
taskId: string
|
||||
projectId: string
|
||||
userId: string
|
||||
ts: string
|
||||
taskType: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
episodeId: string | null
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
function buildLifecycleEvent(payload: Record<string, unknown>): MockEvent {
|
||||
return {
|
||||
id: '1',
|
||||
type: 'task.lifecycle',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'script_to_storyboard_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function buildStreamEvent(payload: Record<string, unknown>): MockEvent {
|
||||
return {
|
||||
id: 'stream-1',
|
||||
type: 'task.stream',
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
ts: new Date().toISOString(),
|
||||
taskType: 'script_to_storyboard_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCondition(condition: () => boolean, timeoutMs = 1000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (condition()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('condition not met before timeout')
|
||||
}
|
||||
|
||||
describe('recovered run subscription', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
if (originalFetch) {
|
||||
globalThis.fetch = originalFetch
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
}
|
||||
})
|
||||
|
||||
it('replays task lifecycle events for external mode to recover stage steps', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
events: [
|
||||
buildLifecycleEvent({
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'clip_1_phase1',
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 4,
|
||||
message: 'running',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const pollTaskTerminalState = vi.fn(async () => null)
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
projectId: 'project-1',
|
||||
storageScopeKey: 'episode-1',
|
||||
taskId: 'task-1',
|
||||
eventSourceMode: 'external',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
pollTaskTerminalState,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/tasks/task-1?includeEvents=1&eventsLimit=5000',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.start',
|
||||
runId: 'task-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
}))
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('settles external recovery when replay hits terminal lifecycle event', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
events: [
|
||||
buildLifecycleEvent({
|
||||
lifecycleType: 'task.failed',
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const pollTaskTerminalState = vi.fn(async () => null)
|
||||
const onSettled = vi.fn()
|
||||
|
||||
subscribeRecoveredRun({
|
||||
projectId: 'project-1',
|
||||
storageScopeKey: 'episode-1',
|
||||
taskId: 'task-1',
|
||||
eventSourceMode: 'external',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
pollTaskTerminalState,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => onSettled.mock.calls.length === 1 && applyAndCapture.mock.calls.length > 0)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'run.error',
|
||||
runId: 'task-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('replays persisted stream events so refresh keeps prior output', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
events: [
|
||||
buildLifecycleEvent({
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'clip_1_phase1',
|
||||
stepTitle: '分镜规划',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
message: 'running',
|
||||
}),
|
||||
buildStreamEvent({
|
||||
stepId: 'clip_1_phase1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
lane: 'main',
|
||||
seq: 1,
|
||||
delta: '旧输出',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const applyAndCapture = vi.fn()
|
||||
const pollTaskTerminalState = vi.fn(async () => null)
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const cleanup = subscribeRecoveredRun({
|
||||
projectId: 'project-1',
|
||||
storageScopeKey: 'episode-1',
|
||||
taskId: 'task-1',
|
||||
eventSourceMode: 'external',
|
||||
taskStreamTimeoutMs: 10_000,
|
||||
applyAndCapture,
|
||||
pollTaskTerminalState,
|
||||
onSettled,
|
||||
})
|
||||
|
||||
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
|
||||
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
|
||||
event: 'step.chunk',
|
||||
runId: 'task-1',
|
||||
stepId: 'clip_1_phase1',
|
||||
textDelta: '旧输出',
|
||||
}))
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseReferenceImages, readBoolean, readString } from '@/lib/workers/handlers/reference-to-character-helpers'
|
||||
|
||||
describe('reference-to-character helpers', () => {
|
||||
it('parses and trims single reference image', () => {
|
||||
expect(parseReferenceImages({ referenceImageUrl: ' https://x/a.png ' })).toEqual(['https://x/a.png'])
|
||||
})
|
||||
|
||||
it('parses multi reference images and truncates to max 5', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
'https://x/6.png',
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
'https://x/1.png',
|
||||
'https://x/2.png',
|
||||
'https://x/3.png',
|
||||
'https://x/4.png',
|
||||
'https://x/5.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters empty values', () => {
|
||||
expect(
|
||||
parseReferenceImages({
|
||||
referenceImageUrls: [' ', '\n', 'https://x/ok.png'],
|
||||
}),
|
||||
).toEqual(['https://x/ok.png'])
|
||||
})
|
||||
|
||||
it('readString trims and normalizes invalid values', () => {
|
||||
expect(readString(' abc ')).toBe('abc')
|
||||
expect(readString(1)).toBe('')
|
||||
expect(readString(null)).toBe('')
|
||||
})
|
||||
|
||||
it('readBoolean supports boolean/number/string flags', () => {
|
||||
expect(readBoolean(true)).toBe(true)
|
||||
expect(readBoolean(1)).toBe(true)
|
||||
expect(readBoolean('true')).toBe(true)
|
||||
expect(readBoolean('YES')).toBe(true)
|
||||
expect(readBoolean('on')).toBe(true)
|
||||
expect(readBoolean('0')).toBe(false)
|
||||
expect(readBoolean(false)).toBe(false)
|
||||
expect(readBoolean(0)).toBe(false)
|
||||
})
|
||||
})
|
||||
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import {
|
||||
parseSyncFlag,
|
||||
resolveDisplayMode,
|
||||
resolvePositiveInteger,
|
||||
shouldRunSyncTask,
|
||||
} from '@/lib/llm-observe/route-task'
|
||||
|
||||
function buildRequest(path: string, headers?: Record<string, string>) {
|
||||
return new NextRequest(new URL(path, 'http://localhost'), {
|
||||
method: 'POST',
|
||||
headers: headers || {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('route-task helpers', () => {
|
||||
it('parseSyncFlag supports boolean-like values', () => {
|
||||
expect(parseSyncFlag(true)).toBe(true)
|
||||
expect(parseSyncFlag(1)).toBe(true)
|
||||
expect(parseSyncFlag('1')).toBe(true)
|
||||
expect(parseSyncFlag('true')).toBe(true)
|
||||
expect(parseSyncFlag('yes')).toBe(true)
|
||||
expect(parseSyncFlag('on')).toBe(true)
|
||||
expect(parseSyncFlag('false')).toBe(false)
|
||||
expect(parseSyncFlag(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when internal task header exists', () => {
|
||||
const req = buildRequest('/api/test', { 'x-internal-task-id': 'task-1' })
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when body sync flag exists', () => {
|
||||
const req = buildRequest('/api/test')
|
||||
expect(shouldRunSyncTask(req, { sync: 'true' })).toBe(true)
|
||||
})
|
||||
|
||||
it('shouldRunSyncTask true when query sync flag exists', () => {
|
||||
const req = buildRequest('/api/test?sync=1')
|
||||
expect(shouldRunSyncTask(req, {})).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveDisplayMode falls back to default on invalid value', () => {
|
||||
expect(resolveDisplayMode('detail', 'loading')).toBe('detail')
|
||||
expect(resolveDisplayMode('loading', 'detail')).toBe('loading')
|
||||
expect(resolveDisplayMode('invalid', 'loading')).toBe('loading')
|
||||
})
|
||||
|
||||
it('resolvePositiveInteger returns safe integer fallback', () => {
|
||||
expect(resolvePositiveInteger(2.9, 1)).toBe(2)
|
||||
expect(resolvePositiveInteger('9', 1)).toBe(9)
|
||||
expect(resolvePositiveInteger('0', 7)).toBe(7)
|
||||
expect(resolvePositiveInteger('abc', 7)).toBe(7)
|
||||
})
|
||||
})
|
||||
274
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
274
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||
import { applyRunStreamEvent, getStageOutput } from '@/lib/query/hooks/run-stream/state-machine'
|
||||
|
||||
function applySequence(events: RunStreamEvent[]) {
|
||||
let state = null
|
||||
for (const event of events) {
|
||||
state = applyRunStreamEvent(state, event)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
describe('run stream state-machine', () => {
|
||||
it('marks unfinished steps as failed when run.error arrives', () => {
|
||||
const runId = 'run-1'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'step-a',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-b',
|
||||
stepTitle: 'B',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'ok',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.error',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'failed',
|
||||
message: 'exception TypeError: fetch failed sending request',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.status).toBe('failed')
|
||||
expect(state?.stepsById['step-a']?.errorMessage).toContain('fetch failed')
|
||||
expect(state?.stepsById['step-b']?.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('returns readable error output for failed step without stream text', () => {
|
||||
const output = getStageOutput({
|
||||
id: 'step-failed',
|
||||
attempt: 1,
|
||||
title: 'failed',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
status: 'failed',
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: 'exception TypeError: fetch failed sending request',
|
||||
updatedAt: Date.now(),
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(output).toContain('【错误】')
|
||||
expect(output).toContain('fetch failed sending request')
|
||||
})
|
||||
|
||||
it('merges retry attempts into one step instead of duplicating stage entries', () => {
|
||||
const runId = 'run-2'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'first-attempt',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_x_phase1_r2',
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'retry-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepOrder).toEqual(['clip_x_phase1'])
|
||||
expect(state?.stepsById['clip_x_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_x_phase1']?.textOutput).toBe('retry-output')
|
||||
})
|
||||
|
||||
it('resets step output when a higher stepAttempt starts and ignores stale lower attempt chunks', () => {
|
||||
const runId = 'run-3'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:01.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'old-output',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
stepTitle: 'A',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 1,
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: 'should-be-ignored',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.200Z',
|
||||
status: 'running',
|
||||
stepId: 'clip_y_phase1',
|
||||
stepAttempt: 2,
|
||||
lane: 'text',
|
||||
seq: 1,
|
||||
textDelta: 'new-output',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.stepsById['clip_y_phase1']?.attempt).toBe(2)
|
||||
expect(state?.stepsById['clip_y_phase1']?.textOutput).toBe('new-output')
|
||||
})
|
||||
|
||||
it('reopens completed step when late chunk arrives, then finalizes on run.complete', () => {
|
||||
const runId = 'run-4'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.start',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'analyze_characters',
|
||||
stepTitle: 'characters',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'partial',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.chunk',
|
||||
ts: '2026-02-26T23:00:02.100Z',
|
||||
status: 'running',
|
||||
stepId: 'analyze_characters',
|
||||
lane: 'text',
|
||||
seq: 2,
|
||||
textDelta: '-tail',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'run.complete',
|
||||
ts: '2026-02-26T23:00:03.000Z',
|
||||
status: 'completed',
|
||||
payload: { ok: true },
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.status).toBe('completed')
|
||||
expect(state?.stepsById['analyze_characters']?.textOutput).toBe('partial-tail')
|
||||
})
|
||||
|
||||
it('moves activeStepId to the latest step when no step is running', () => {
|
||||
const runId = 'run-5'
|
||||
const state = applySequence([
|
||||
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:01.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-1',
|
||||
stepTitle: 'step 1',
|
||||
stepIndex: 1,
|
||||
stepTotal: 2,
|
||||
text: 'a',
|
||||
},
|
||||
{
|
||||
runId,
|
||||
event: 'step.complete',
|
||||
ts: '2026-02-26T23:00:02.000Z',
|
||||
status: 'completed',
|
||||
stepId: 'step-2',
|
||||
stepTitle: 'step 2',
|
||||
stepIndex: 2,
|
||||
stepTotal: 2,
|
||||
text: 'b',
|
||||
},
|
||||
])
|
||||
|
||||
expect(state?.activeStepId).toBe('step-2')
|
||||
})
|
||||
})
|
||||
102
tests/unit/helpers/run-stream-view.test.ts
Normal file
102
tests/unit/helpers/run-stream-view.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveRunStreamView } from '@/lib/query/hooks/run-stream/run-stream-view'
|
||||
import type { RunState, RunStepState } from '@/lib/query/hooks/run-stream/types'
|
||||
|
||||
function buildStep(overrides: Partial<RunStepState> = {}): RunStepState {
|
||||
return {
|
||||
id: 'step-1',
|
||||
attempt: 1,
|
||||
title: 'step',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
status: 'running',
|
||||
textOutput: '',
|
||||
reasoningOutput: '',
|
||||
textLength: 0,
|
||||
reasoningLength: 0,
|
||||
message: '',
|
||||
errorMessage: '',
|
||||
updatedAt: Date.now(),
|
||||
seqByLane: {
|
||||
text: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRunState(overrides: Partial<RunState> = {}): RunState {
|
||||
const baseStep = buildStep()
|
||||
return {
|
||||
runId: 'run-1',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
terminalAt: null,
|
||||
errorMessage: '',
|
||||
summary: null,
|
||||
payload: null,
|
||||
stepsById: {
|
||||
[baseStep.id]: baseStep,
|
||||
},
|
||||
stepOrder: [baseStep.id],
|
||||
activeStepId: baseStep.id,
|
||||
selectedStepId: baseStep.id,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('run stream view', () => {
|
||||
it('keeps console visible for recovered running state', () => {
|
||||
const state = buildRunState({
|
||||
status: 'running',
|
||||
terminalAt: null,
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it('shows run error in output when run failed and selected step has no output', () => {
|
||||
const state = buildRunState({
|
||||
status: 'failed',
|
||||
errorMessage: 'exception TypeError: fetch failed sending request',
|
||||
stepsById: {
|
||||
'step-1': buildStep({ status: 'running' }),
|
||||
},
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.outputText).toContain('【错误】')
|
||||
expect(view.outputText).toContain('fetch failed sending request')
|
||||
})
|
||||
|
||||
it('shows run error in output when run failed before any step starts', () => {
|
||||
const state = buildRunState({
|
||||
status: 'failed',
|
||||
errorMessage: 'NETWORK_ERROR',
|
||||
stepsById: {},
|
||||
stepOrder: [],
|
||||
activeStepId: null,
|
||||
selectedStepId: null,
|
||||
})
|
||||
|
||||
const view = deriveRunStreamView({
|
||||
runState: state,
|
||||
isLiveRunning: false,
|
||||
clock: Date.now(),
|
||||
})
|
||||
|
||||
expect(view.outputText).toBe('【错误】\nNETWORK_ERROR')
|
||||
})
|
||||
})
|
||||
83
tests/unit/helpers/task-state-service.test.ts
Normal file
83
tests/unit/helpers/task-state-service.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
asBoolean,
|
||||
asNonEmptyString,
|
||||
asObject,
|
||||
buildIdleState,
|
||||
pairKey,
|
||||
resolveTargetState,
|
||||
toProgress,
|
||||
} from '@/lib/task/state-service'
|
||||
|
||||
describe('task state service helpers', () => {
|
||||
it('normalizes primitive parsing helpers', () => {
|
||||
expect(pairKey('A', 'B')).toBe('A:B')
|
||||
expect(asObject({ ok: true })).toEqual({ ok: true })
|
||||
expect(asObject(['x'])).toBeNull()
|
||||
expect(asNonEmptyString(' x ')).toBe('x')
|
||||
expect(asNonEmptyString(' ')).toBeNull()
|
||||
expect(asBoolean(true)).toBe(true)
|
||||
expect(asBoolean('true')).toBeNull()
|
||||
expect(toProgress(101)).toBe(100)
|
||||
expect(toProgress(-5)).toBe(0)
|
||||
expect(toProgress(Number.NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('builds idle state when no tasks found', () => {
|
||||
const idle = buildIdleState({ targetType: 'GlobalCharacter', targetId: 'c1' })
|
||||
expect(idle.phase).toBe('idle')
|
||||
expect(idle.runningTaskId).toBeNull()
|
||||
expect(idle.lastError).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves processing state from active task', () => {
|
||||
const state = resolveTargetState(
|
||||
{ targetType: 'GlobalCharacter', targetId: 'c1' },
|
||||
[
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'asset_hub_image',
|
||||
status: 'processing',
|
||||
progress: 42,
|
||||
payload: {
|
||||
stage: 'image_generating',
|
||||
stageLabel: 'Generating',
|
||||
ui: { intent: 'create', hasOutputAtStart: false },
|
||||
},
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(state.phase).toBe('processing')
|
||||
expect(state.runningTaskId).toBe('task-1')
|
||||
expect(state.progress).toBe(42)
|
||||
expect(state.stage).toBe('image_generating')
|
||||
expect(state.stageLabel).toBe('Generating')
|
||||
})
|
||||
|
||||
it('resolves failed state and normalizes error', () => {
|
||||
const state = resolveTargetState(
|
||||
{ targetType: 'GlobalCharacter', targetId: 'c1' },
|
||||
[
|
||||
{
|
||||
id: 'task-2',
|
||||
type: 'asset_hub_image',
|
||||
status: 'failed',
|
||||
progress: 100,
|
||||
payload: { ui: { intent: 'modify', hasOutputAtStart: true } },
|
||||
errorCode: 'INVALID_PARAMS',
|
||||
errorMessage: 'bad input',
|
||||
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(state.phase).toBe('failed')
|
||||
expect(state.runningTaskId).toBeNull()
|
||||
expect(state.lastError?.code).toBe('INVALID_PARAMS')
|
||||
expect(state.lastError?.message).toBe('bad input')
|
||||
})
|
||||
})
|
||||
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal file
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'
|
||||
import { normalizeTaskPayload } from '@/lib/task/submitter'
|
||||
|
||||
describe('task submitter helpers', () => {
|
||||
it('fills default flow metadata when payload misses flow fields', () => {
|
||||
const type = TASK_TYPE.AI_CREATE_CHARACTER
|
||||
const flow = getTaskFlowMeta(type)
|
||||
const normalized = normalizeTaskPayload(type, {})
|
||||
|
||||
expect(normalized.flowId).toBe(flow.flowId)
|
||||
expect(normalized.flowStageIndex).toBe(flow.flowStageIndex)
|
||||
expect(normalized.flowStageTotal).toBe(flow.flowStageTotal)
|
||||
expect(normalized.flowStageTitle).toBe(flow.flowStageTitle)
|
||||
expect(normalized.meta).toMatchObject({
|
||||
flowId: flow.flowId,
|
||||
flowStageIndex: flow.flowStageIndex,
|
||||
flowStageTotal: flow.flowStageTotal,
|
||||
flowStageTitle: flow.flowStageTitle,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes negative stage values', () => {
|
||||
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
flowId: 'flow-a',
|
||||
flowStageIndex: -9,
|
||||
flowStageTotal: -1,
|
||||
flowStageTitle: ' title ',
|
||||
meta: {},
|
||||
})
|
||||
|
||||
expect(normalized.flowId).toBe('flow-a')
|
||||
expect(normalized.flowStageIndex).toBeGreaterThanOrEqual(1)
|
||||
expect(normalized.flowStageTotal).toBeGreaterThanOrEqual(normalized.flowStageIndex)
|
||||
expect(normalized.flowStageTitle).toBe('title')
|
||||
})
|
||||
|
||||
it('prefers payload meta flow values when valid', () => {
|
||||
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
|
||||
flowId: 'outer-flow',
|
||||
flowStageIndex: 1,
|
||||
flowStageTotal: 2,
|
||||
flowStageTitle: 'Outer',
|
||||
meta: {
|
||||
flowId: 'meta-flow',
|
||||
flowStageIndex: 3,
|
||||
flowStageTotal: 7,
|
||||
flowStageTitle: 'Meta',
|
||||
},
|
||||
})
|
||||
|
||||
const meta = normalized.meta as Record<string, unknown>
|
||||
expect(meta.flowId).toBe('meta-flow')
|
||||
expect(meta.flowStageIndex).toBe(3)
|
||||
expect(meta.flowStageTotal).toBe(7)
|
||||
expect(meta.flowStageTitle).toBe('Meta')
|
||||
})
|
||||
})
|
||||
66
tests/unit/model-capabilities/video-effective.test.ts
Normal file
66
tests/unit/model-capabilities/video-effective.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeVideoGenerationSelections,
|
||||
resolveEffectiveVideoCapabilityDefinitions,
|
||||
resolveEffectiveVideoCapabilityFields,
|
||||
} from '@/lib/model-capabilities/video-effective'
|
||||
import type { VideoPricingTier } from '@/lib/model-pricing/video-tier'
|
||||
|
||||
const GOOGLE_VEO_TIERS: VideoPricingTier[] = [
|
||||
{ when: { resolution: '720p', duration: 4 } },
|
||||
{ when: { resolution: '720p', duration: 6 } },
|
||||
{ when: { resolution: '720p', duration: 8 } },
|
||||
{ when: { resolution: '1080p', duration: 8 } },
|
||||
{ when: { resolution: '4k', duration: 8 } },
|
||||
]
|
||||
|
||||
describe('model-capabilities/video-effective', () => {
|
||||
it('derives capability definitions from pricing tiers', () => {
|
||||
const definitions = resolveEffectiveVideoCapabilityDefinitions({
|
||||
pricingTiers: GOOGLE_VEO_TIERS,
|
||||
})
|
||||
const byField = new Map(definitions.map((item) => [item.field, item.options]))
|
||||
|
||||
expect(byField.get('resolution')).toEqual(['720p', '1080p', '4k'])
|
||||
expect(byField.get('duration')).toEqual([4, 6, 8])
|
||||
})
|
||||
|
||||
it('keeps pinned field and adjusts the linked field to nearest supported combo', () => {
|
||||
const definitions = resolveEffectiveVideoCapabilityDefinitions({
|
||||
pricingTiers: GOOGLE_VEO_TIERS,
|
||||
})
|
||||
|
||||
const normalized = normalizeVideoGenerationSelections({
|
||||
definitions,
|
||||
pricingTiers: GOOGLE_VEO_TIERS,
|
||||
selection: {
|
||||
resolution: '1080p',
|
||||
duration: 4,
|
||||
},
|
||||
pinnedFields: ['resolution'],
|
||||
})
|
||||
|
||||
expect(normalized).toEqual({
|
||||
resolution: '1080p',
|
||||
duration: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('filters dependent options by current selection', () => {
|
||||
const definitions = resolveEffectiveVideoCapabilityDefinitions({
|
||||
pricingTiers: GOOGLE_VEO_TIERS,
|
||||
})
|
||||
const fields = resolveEffectiveVideoCapabilityFields({
|
||||
definitions,
|
||||
pricingTiers: GOOGLE_VEO_TIERS,
|
||||
selection: {
|
||||
resolution: '1080p',
|
||||
},
|
||||
})
|
||||
const durationField = fields.find((field) => field.field === 'duration')
|
||||
|
||||
expect(durationField?.options).toEqual([8])
|
||||
expect(durationField?.value).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal file
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createAIDataModalDraftState,
|
||||
mergeAIDataModalDraftStateByDirty,
|
||||
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useAIDataModalState'
|
||||
|
||||
describe('useAIDataModalState optimistic sync helpers', () => {
|
||||
it('keeps dirty fields when server data refreshes', () => {
|
||||
const localDraft = createAIDataModalDraftState({
|
||||
initialShotType: 'Close-up',
|
||||
initialCameraMove: 'Push in',
|
||||
initialDescription: 'user typing draft',
|
||||
initialVideoPrompt: 'prompt-a',
|
||||
initialPhotographyRules: null,
|
||||
initialActingNotes: null,
|
||||
})
|
||||
|
||||
const serverDraft = createAIDataModalDraftState({
|
||||
initialShotType: 'Wide',
|
||||
initialCameraMove: 'Pan left',
|
||||
initialDescription: 'server-updated-desc',
|
||||
initialVideoPrompt: 'prompt-b',
|
||||
initialPhotographyRules: null,
|
||||
initialActingNotes: null,
|
||||
})
|
||||
|
||||
const merged = mergeAIDataModalDraftStateByDirty(
|
||||
localDraft,
|
||||
serverDraft,
|
||||
new Set(['description']),
|
||||
)
|
||||
|
||||
expect(merged.description).toBe('user typing draft')
|
||||
expect(merged.shotType).toBe('Wide')
|
||||
expect(merged.cameraMove).toBe('Pan left')
|
||||
expect(merged.videoPrompt).toBe('prompt-b')
|
||||
})
|
||||
|
||||
it('syncs non-dirty nested fields from server', () => {
|
||||
const localDraft = createAIDataModalDraftState({
|
||||
initialShotType: 'A',
|
||||
initialCameraMove: 'B',
|
||||
initialDescription: 'C',
|
||||
initialVideoPrompt: 'D',
|
||||
initialPhotographyRules: {
|
||||
scene_summary: 'local scene',
|
||||
lighting: {
|
||||
direction: 'front',
|
||||
quality: 'soft',
|
||||
},
|
||||
characters: [{
|
||||
name: 'hero',
|
||||
screen_position: 'left',
|
||||
posture: 'standing',
|
||||
facing: 'camera',
|
||||
}],
|
||||
depth_of_field: 'deep',
|
||||
color_tone: 'warm',
|
||||
},
|
||||
initialActingNotes: [{
|
||||
name: 'hero',
|
||||
acting: 'smile',
|
||||
}],
|
||||
})
|
||||
|
||||
const serverDraft = createAIDataModalDraftState({
|
||||
initialShotType: 'A2',
|
||||
initialCameraMove: 'B2',
|
||||
initialDescription: 'C2',
|
||||
initialVideoPrompt: 'D2',
|
||||
initialPhotographyRules: {
|
||||
scene_summary: 'server scene',
|
||||
lighting: {
|
||||
direction: 'back',
|
||||
quality: 'hard',
|
||||
},
|
||||
characters: [{
|
||||
name: 'hero',
|
||||
screen_position: 'center',
|
||||
posture: 'running',
|
||||
facing: 'right',
|
||||
}],
|
||||
depth_of_field: 'shallow',
|
||||
color_tone: 'cool',
|
||||
},
|
||||
initialActingNotes: [{
|
||||
name: 'hero',
|
||||
acting: 'angry',
|
||||
}],
|
||||
})
|
||||
|
||||
const merged = mergeAIDataModalDraftStateByDirty(
|
||||
localDraft,
|
||||
serverDraft,
|
||||
new Set(['videoPrompt']),
|
||||
)
|
||||
|
||||
expect(merged.videoPrompt).toBe('D')
|
||||
expect(merged.photographyRules?.scene_summary).toBe('server scene')
|
||||
expect(merged.photographyRules?.lighting.direction).toBe('back')
|
||||
expect(merged.actingNotes[0]?.acting).toBe('angry')
|
||||
})
|
||||
})
|
||||
169
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal file
169
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalCharacter, GlobalLocation } from '@/lib/query/hooks/useGlobalAssets'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { MockQueryClient } from '../../helpers/mock-query-client'
|
||||
|
||||
let queryClient = new MockQueryClient()
|
||||
const useQueryClientMock = vi.fn(() => queryClient)
|
||||
const useMutationMock = vi.fn((options: unknown) => options)
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useRef: <T,>(value: T) => ({ current: value }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
useMutation: (options: unknown) => useMutationMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
|
||||
'@/lib/query/mutations/mutation-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
requestJsonWithError: vi.fn(),
|
||||
requestVoidWithError: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/query/mutations/asset-hub-mutations-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/asset-hub-mutations-shared')>(
|
||||
'@/lib/query/mutations/asset-hub-mutations-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
invalidateGlobalCharacters: vi.fn(),
|
||||
invalidateGlobalLocations: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
useSelectCharacterImage,
|
||||
} from '@/lib/query/mutations/asset-hub-character-mutations'
|
||||
import { useDeleteLocation as useDeleteAssetHubLocation } from '@/lib/query/mutations/asset-hub-location-mutations'
|
||||
|
||||
interface SelectCharacterMutation {
|
||||
onMutate: (variables: {
|
||||
characterId: string
|
||||
appearanceIndex: number
|
||||
imageIndex: number | null
|
||||
}) => Promise<unknown>
|
||||
onError: (error: unknown, variables: unknown, context: unknown) => void
|
||||
}
|
||||
|
||||
interface DeleteLocationMutation {
|
||||
onMutate: (locationId: string) => Promise<unknown>
|
||||
onError: (error: unknown, locationId: string, context: unknown) => void
|
||||
}
|
||||
|
||||
function buildGlobalCharacter(selectedIndex: number | null): GlobalCharacter {
|
||||
return {
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
folderId: 'folder-1',
|
||||
customVoiceUrl: null,
|
||||
appearances: [{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'default',
|
||||
description: null,
|
||||
descriptionSource: null,
|
||||
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
|
||||
imageUrls: ['img-0', 'img-1', 'img-2'],
|
||||
selectedIndex,
|
||||
previousImageUrl: null,
|
||||
previousImageUrls: [],
|
||||
imageTaskRunning: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function buildGlobalLocation(id: string): GlobalLocation {
|
||||
return {
|
||||
id,
|
||||
name: `Location ${id}`,
|
||||
summary: null,
|
||||
folderId: 'folder-1',
|
||||
images: [{
|
||||
id: `${id}-img-0`,
|
||||
imageIndex: 0,
|
||||
description: null,
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
isSelected: true,
|
||||
imageTaskRunning: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('asset hub optimistic mutations', () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new MockQueryClient()
|
||||
useQueryClientMock.mockClear()
|
||||
useMutationMock.mockClear()
|
||||
})
|
||||
|
||||
it('updates all character query caches optimistically and ignores stale rollback', async () => {
|
||||
const allCharactersKey = queryKeys.globalAssets.characters()
|
||||
const folderCharactersKey = queryKeys.globalAssets.characters('folder-1')
|
||||
queryClient.seedQuery(allCharactersKey, [buildGlobalCharacter(0)])
|
||||
queryClient.seedQuery(folderCharactersKey, [buildGlobalCharacter(0)])
|
||||
|
||||
const mutation = useSelectCharacterImage() as unknown as SelectCharacterMutation
|
||||
const firstVariables = {
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 1,
|
||||
}
|
||||
const secondVariables = {
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 2,
|
||||
}
|
||||
|
||||
const firstContext = await mutation.onMutate(firstVariables)
|
||||
const afterFirstAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
|
||||
const afterFirstFolder = queryClient.getQueryData<GlobalCharacter[]>(folderCharactersKey)
|
||||
expect(afterFirstAll?.[0]?.appearances[0]?.selectedIndex).toBe(1)
|
||||
expect(afterFirstFolder?.[0]?.appearances[0]?.selectedIndex).toBe(1)
|
||||
|
||||
const secondContext = await mutation.onMutate(secondVariables)
|
||||
const afterSecondAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
|
||||
expect(afterSecondAll?.[0]?.appearances[0]?.selectedIndex).toBe(2)
|
||||
|
||||
mutation.onError(new Error('first failed'), firstVariables, firstContext)
|
||||
const afterStaleError = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
|
||||
expect(afterStaleError?.[0]?.appearances[0]?.selectedIndex).toBe(2)
|
||||
|
||||
mutation.onError(new Error('second failed'), secondVariables, secondContext)
|
||||
const afterLatestRollback = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
|
||||
expect(afterLatestRollback?.[0]?.appearances[0]?.selectedIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('optimistically removes location and restores on error', async () => {
|
||||
const allLocationsKey = queryKeys.globalAssets.locations()
|
||||
const folderLocationsKey = queryKeys.globalAssets.locations('folder-1')
|
||||
queryClient.seedQuery(allLocationsKey, [buildGlobalLocation('loc-1'), buildGlobalLocation('loc-2')])
|
||||
queryClient.seedQuery(folderLocationsKey, [buildGlobalLocation('loc-1')])
|
||||
|
||||
const mutation = useDeleteAssetHubLocation() as unknown as DeleteLocationMutation
|
||||
const context = await mutation.onMutate('loc-1')
|
||||
|
||||
const afterDeleteAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
|
||||
const afterDeleteFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
|
||||
expect(afterDeleteAll?.map((item) => item.id)).toEqual(['loc-2'])
|
||||
expect(afterDeleteFolder).toEqual([])
|
||||
|
||||
mutation.onError(new Error('delete failed'), 'loc-1', context)
|
||||
|
||||
const rolledBackAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
|
||||
const rolledBackFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
|
||||
expect(rolledBackAll?.map((item) => item.id)).toEqual(['loc-1', 'loc-2'])
|
||||
expect(rolledBackFolder?.map((item) => item.id)).toEqual(['loc-1'])
|
||||
})
|
||||
})
|
||||
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal file
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
serializeStructuredJsonField,
|
||||
syncPanelCharacterDependentJson,
|
||||
} from '@/lib/novel-promotion/panel-ai-data-sync'
|
||||
|
||||
describe('panel ai data sync helpers', () => {
|
||||
it('removes deleted character from acting notes and photography rules', () => {
|
||||
const synced = syncPanelCharacterDependentJson({
|
||||
characters: [
|
||||
{ name: '楚江锴/当朝皇帝', appearance: '初始形象' },
|
||||
{ name: '燕画乔/魏画乔', appearance: '初始形象' },
|
||||
],
|
||||
removeIndex: 0,
|
||||
actingNotesJson: JSON.stringify([
|
||||
{ name: '楚江锴/当朝皇帝', acting: '紧握手腕' },
|
||||
{ name: '燕画乔/魏画乔', acting: '本能后退' },
|
||||
]),
|
||||
photographyRulesJson: JSON.stringify({
|
||||
lighting: {
|
||||
direction: '侧逆光',
|
||||
quality: '硬光',
|
||||
},
|
||||
characters: [
|
||||
{ name: '楚江锴/当朝皇帝', screen_position: 'left' },
|
||||
{ name: '燕画乔/魏画乔', screen_position: 'right' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(synced.characters).toEqual([{ name: '燕画乔/魏画乔', appearance: '初始形象' }])
|
||||
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
|
||||
{ name: '燕画乔/魏画乔', acting: '本能后退' },
|
||||
])
|
||||
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
|
||||
lighting: {
|
||||
direction: '侧逆光',
|
||||
quality: '硬光',
|
||||
},
|
||||
characters: [
|
||||
{ name: '燕画乔/魏画乔', screen_position: 'right' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps notes by character name when another appearance of same name remains', () => {
|
||||
const synced = syncPanelCharacterDependentJson({
|
||||
characters: [
|
||||
{ name: '顾娘子/顾盼之', appearance: '素衣' },
|
||||
{ name: '顾娘子/顾盼之', appearance: '华服' },
|
||||
],
|
||||
removeIndex: 1,
|
||||
actingNotesJson: JSON.stringify([
|
||||
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
|
||||
]),
|
||||
photographyRulesJson: JSON.stringify({
|
||||
characters: [
|
||||
{ name: '顾娘子/顾盼之', screen_position: 'center' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
|
||||
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
|
||||
])
|
||||
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
|
||||
characters: [
|
||||
{ name: '顾娘子/顾盼之', screen_position: 'center' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('supports double-serialized JSON string inputs', () => {
|
||||
const actingNotes = JSON.stringify([{ name: '甲', acting: '动作' }])
|
||||
const doubleSerialized = JSON.stringify(actingNotes)
|
||||
expect(serializeStructuredJsonField(doubleSerialized, 'actingNotes')).toBe(actingNotes)
|
||||
})
|
||||
|
||||
it('throws on malformed acting notes to avoid silent fallback', () => {
|
||||
expect(() => syncPanelCharacterDependentJson({
|
||||
characters: [{ name: '甲', appearance: '初始形象' }],
|
||||
removeIndex: 0,
|
||||
actingNotesJson: '[{"name":"甲","acting":"动作"}, {"acting":"缺少名字"}]',
|
||||
photographyRulesJson: null,
|
||||
})).toThrowError('actingNotes item.name must be a non-empty string')
|
||||
})
|
||||
})
|
||||
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal file
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'
|
||||
import {
|
||||
PanelSaveCoordinator,
|
||||
type PanelSaveState,
|
||||
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-save-coordinator'
|
||||
|
||||
function buildSnapshot(description: string): PanelEditData {
|
||||
return {
|
||||
id: 'panel-1',
|
||||
panelIndex: 0,
|
||||
panelNumber: 1,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'push',
|
||||
description,
|
||||
location: null,
|
||||
characters: [],
|
||||
srtStart: null,
|
||||
srtEnd: null,
|
||||
duration: null,
|
||||
videoPrompt: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('PanelSaveCoordinator', () => {
|
||||
it('keeps single-flight and only flushes the latest snapshot after burst edits', async () => {
|
||||
const savedDescriptions: string[] = []
|
||||
let releaseFirstAttempt: () => void = () => {}
|
||||
const firstAttemptGate = new Promise<void>((resolve) => {
|
||||
releaseFirstAttempt = () => resolve()
|
||||
})
|
||||
let attempts = 0
|
||||
|
||||
const coordinator = new PanelSaveCoordinator({
|
||||
onSavingChange: () => {},
|
||||
onStateChange: () => {},
|
||||
runSave: async ({ snapshot }) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
await firstAttemptGate
|
||||
}
|
||||
savedDescriptions.push(snapshot.description ?? '')
|
||||
},
|
||||
resolveErrorMessage: () => 'save failed',
|
||||
})
|
||||
|
||||
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v1'))
|
||||
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v2'))
|
||||
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v3'))
|
||||
|
||||
releaseFirstAttempt()
|
||||
await firstRun
|
||||
|
||||
expect(savedDescriptions).toEqual(['v1', 'v3'])
|
||||
})
|
||||
|
||||
it('marks error on failure and clears unsaved state after retry success', async () => {
|
||||
const stateByPanel = new Map<string, PanelSaveState>()
|
||||
let attemptCount = 0
|
||||
|
||||
const coordinator = new PanelSaveCoordinator({
|
||||
onSavingChange: () => {},
|
||||
onStateChange: (panelId, state) => {
|
||||
stateByPanel.set(panelId, state)
|
||||
},
|
||||
runSave: async () => {
|
||||
attemptCount += 1
|
||||
if (attemptCount === 1) {
|
||||
throw new Error('network timeout')
|
||||
}
|
||||
},
|
||||
resolveErrorMessage: (error) => (error instanceof Error ? error.message : 'unknown'),
|
||||
})
|
||||
|
||||
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('draft text'))
|
||||
await firstRun
|
||||
expect(stateByPanel.get('panel-1')).toEqual({
|
||||
status: 'error',
|
||||
errorMessage: 'network timeout',
|
||||
})
|
||||
|
||||
const retryRun = coordinator.retry('panel-1', buildSnapshot('draft text'))
|
||||
await retryRun
|
||||
expect(stateByPanel.get('panel-1')).toEqual({
|
||||
status: 'idle',
|
||||
errorMessage: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal file
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Character, Location, Project } from '@/types/project'
|
||||
import type { ProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { MockQueryClient } from '../../helpers/mock-query-client'
|
||||
|
||||
let queryClient = new MockQueryClient()
|
||||
const useQueryClientMock = vi.fn(() => queryClient)
|
||||
const useMutationMock = vi.fn((options: unknown) => options)
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useRef: <T,>(value: T) => ({ current: value }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => useQueryClientMock(),
|
||||
useMutation: (options: unknown) => useMutationMock(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
|
||||
'@/lib/query/mutations/mutation-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
requestJsonWithError: vi.fn(),
|
||||
requestVoidWithError: vi.fn(),
|
||||
invalidateQueryTemplates: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
useDeleteProjectCharacter,
|
||||
useSelectProjectCharacterImage,
|
||||
} from '@/lib/query/mutations/character-base-mutations'
|
||||
|
||||
interface SelectProjectCharacterMutation {
|
||||
onMutate: (variables: {
|
||||
characterId: string
|
||||
appearanceId: string
|
||||
imageIndex: number | null
|
||||
}) => Promise<unknown>
|
||||
onError: (error: unknown, variables: unknown, context: unknown) => void
|
||||
}
|
||||
|
||||
interface DeleteProjectCharacterMutation {
|
||||
onMutate: (characterId: string) => Promise<unknown>
|
||||
onError: (error: unknown, characterId: string, context: unknown) => void
|
||||
}
|
||||
|
||||
function buildCharacter(selectedIndex: number | null): Character {
|
||||
return {
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
appearances: [{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'default',
|
||||
description: null,
|
||||
descriptions: null,
|
||||
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
|
||||
imageUrls: ['img-0', 'img-1', 'img-2'],
|
||||
previousImageUrl: null,
|
||||
previousImageUrls: [],
|
||||
previousDescription: null,
|
||||
previousDescriptions: null,
|
||||
selectedIndex,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssets(selectedIndex: number | null): ProjectAssetsData {
|
||||
return {
|
||||
characters: [buildCharacter(selectedIndex)],
|
||||
locations: [] as Location[],
|
||||
}
|
||||
}
|
||||
|
||||
function buildProject(selectedIndex: number | null): Project {
|
||||
return {
|
||||
novelPromotionData: {
|
||||
characters: [buildCharacter(selectedIndex)],
|
||||
locations: [],
|
||||
},
|
||||
} as unknown as Project
|
||||
}
|
||||
|
||||
describe('project asset optimistic mutations', () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new MockQueryClient()
|
||||
useQueryClientMock.mockClear()
|
||||
useMutationMock.mockClear()
|
||||
})
|
||||
|
||||
it('optimistically selects project character image and ignores stale rollback', async () => {
|
||||
const projectId = 'project-1'
|
||||
const assetsKey = queryKeys.projectAssets.all(projectId)
|
||||
const projectKey = queryKeys.projectData(projectId)
|
||||
queryClient.seedQuery(assetsKey, buildAssets(0))
|
||||
queryClient.seedQuery(projectKey, buildProject(0))
|
||||
|
||||
const mutation = useSelectProjectCharacterImage(projectId) as unknown as SelectProjectCharacterMutation
|
||||
const firstVariables = {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
imageIndex: 1,
|
||||
}
|
||||
const secondVariables = {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
imageIndex: 2,
|
||||
}
|
||||
|
||||
const firstContext = await mutation.onMutate(firstVariables)
|
||||
const afterFirst = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(afterFirst?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
|
||||
|
||||
const secondContext = await mutation.onMutate(secondVariables)
|
||||
const afterSecond = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(afterSecond?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
|
||||
|
||||
mutation.onError(new Error('first failed'), firstVariables, firstContext)
|
||||
const afterStaleError = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(afterStaleError?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
|
||||
|
||||
mutation.onError(new Error('second failed'), secondVariables, secondContext)
|
||||
const afterLatestRollback = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(afterLatestRollback?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('optimistically deletes project character and restores on error', async () => {
|
||||
const projectId = 'project-1'
|
||||
const assetsKey = queryKeys.projectAssets.all(projectId)
|
||||
const projectKey = queryKeys.projectData(projectId)
|
||||
queryClient.seedQuery(assetsKey, buildAssets(0))
|
||||
queryClient.seedQuery(projectKey, buildProject(0))
|
||||
|
||||
const mutation = useDeleteProjectCharacter(projectId) as unknown as DeleteProjectCharacterMutation
|
||||
const context = await mutation.onMutate('character-1')
|
||||
|
||||
const afterDeleteAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(afterDeleteAssets?.characters).toHaveLength(0)
|
||||
|
||||
const afterDeleteProject = queryClient.getQueryData<Project>(projectKey)
|
||||
expect(afterDeleteProject?.novelPromotionData?.characters ?? []).toHaveLength(0)
|
||||
|
||||
mutation.onError(new Error('delete failed'), 'character-1', context)
|
||||
|
||||
const rolledBackAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
|
||||
expect(rolledBackAssets?.characters).toHaveLength(1)
|
||||
expect(rolledBackAssets?.characters[0]?.id).toBe('character-1')
|
||||
})
|
||||
})
|
||||
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal file
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE } from '@/lib/task/types'
|
||||
|
||||
type InvalidateArg = { queryKey?: readonly unknown[]; exact?: boolean }
|
||||
|
||||
type EffectCleanup = (() => void) | void
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
queryClient: {
|
||||
invalidateQueries: vi.fn(async (_arg?: InvalidateArg) => undefined),
|
||||
},
|
||||
effectCleanup: null as EffectCleanup,
|
||||
scheduledTimers: [] as Array<() => void>,
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
applyTaskLifecycleToOverlay: vi.fn(),
|
||||
}))
|
||||
|
||||
class FakeEventSource {
|
||||
static OPEN = 1
|
||||
static instances: FakeEventSource[] = []
|
||||
|
||||
readonly url: string
|
||||
readyState = FakeEventSource.OPEN
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
private listeners = new Map<string, Set<EventListener>>()
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
FakeEventSource.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: EventListener) {
|
||||
const set = this.listeners.get(type) || new Set<EventListener>()
|
||||
set.add(handler)
|
||||
this.listeners.set(type, set)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, handler: EventListener) {
|
||||
const set = this.listeners.get(type)
|
||||
if (!set) return
|
||||
set.delete(handler)
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown) {
|
||||
const event = { data: JSON.stringify(payload) } as MessageEvent
|
||||
if (this.onmessage) this.onmessage(event)
|
||||
const set = this.listeners.get(type)
|
||||
if (!set) return
|
||||
for (const handler of set) {
|
||||
handler(event as unknown as Event)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 2
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo: <T,>(factory: () => T) => factory(),
|
||||
useRef: <T,>(value: T) => ({ current: value }),
|
||||
useEffect: (effect: () => EffectCleanup) => {
|
||||
runtime.effectCleanup = effect()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => runtime.queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/query/task-target-overlay', () => overlayMock)
|
||||
|
||||
function hasInvalidation(predicate: (arg: InvalidateArg) => boolean) {
|
||||
return runtime.queryClient.invalidateQueries.mock.calls.some((call) => {
|
||||
const arg = (call[0] || {}) as InvalidateArg
|
||||
return predicate(arg)
|
||||
})
|
||||
}
|
||||
|
||||
describe('sse invalidation behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtime.effectCleanup = null
|
||||
runtime.scheduledTimers = []
|
||||
FakeEventSource.instances = []
|
||||
|
||||
;(globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource
|
||||
;(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = {
|
||||
setTimeout: ((cb: () => void) => {
|
||||
runtime.scheduledTimers.push(cb)
|
||||
return runtime.scheduledTimers.length as unknown as ReturnType<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout,
|
||||
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
|
||||
}
|
||||
})
|
||||
|
||||
it('PROCESSING(progress 数值) 不触发 target-state invalidation;COMPLETED 触发', async () => {
|
||||
const { useSSE } = await import('@/lib/query/hooks/useSSE')
|
||||
|
||||
useSSE({
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const source = FakeEventSource.instances[0]
|
||||
expect(source).toBeTruthy()
|
||||
|
||||
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
|
||||
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
|
||||
taskId: 'task-1',
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: 'episode-1',
|
||||
payload: {
|
||||
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
|
||||
progress: 32,
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasInvalidation((arg) => {
|
||||
const key = arg.queryKey || []
|
||||
return Array.isArray(key) && key[0] === 'task-target-states'
|
||||
})).toBe(false)
|
||||
|
||||
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
|
||||
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
|
||||
taskId: 'task-1',
|
||||
taskType: 'IMAGE_CHARACTER',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
episodeId: 'episode-1',
|
||||
payload: {
|
||||
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
|
||||
},
|
||||
})
|
||||
|
||||
for (const cb of runtime.scheduledTimers) cb()
|
||||
|
||||
expect(hasInvalidation((arg) => {
|
||||
const key = arg.queryKey || []
|
||||
return Array.isArray(key)
|
||||
&& key[0] === queryKeys.tasks.targetStatesAll('project-1')[0]
|
||||
&& key[1] === 'project-1'
|
||||
&& arg.exact === false
|
||||
})).toBe(true)
|
||||
|
||||
expect(overlayMock.applyTaskLifecycleToOverlay).toHaveBeenCalledWith(
|
||||
runtime.queryClient,
|
||||
expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
123
tests/unit/optimistic/task-target-state-map.test.ts
Normal file
123
tests/unit/optimistic/task-target-state-map.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
useQueryCalls: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
const overlayNow = new Date().toISOString()
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useMemo: <T,>(factory: () => T) => factory(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: (options: Record<string, unknown>) => {
|
||||
runtime.useQueryCalls.push(options)
|
||||
|
||||
const queryKey = (options.queryKey || []) as unknown[]
|
||||
const first = queryKey[0]
|
||||
if (first === 'task-target-states-overlay') {
|
||||
return {
|
||||
data: {
|
||||
'CharacterAppearance:appearance-1': {
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
phase: 'processing',
|
||||
runningTaskId: 'task-ov-1',
|
||||
runningTaskType: 'IMAGE_CHARACTER',
|
||||
intent: 'process',
|
||||
hasOutputAtStart: false,
|
||||
progress: 50,
|
||||
stage: 'generate',
|
||||
stageLabel: '生成中',
|
||||
updatedAt: overlayNow,
|
||||
lastError: null,
|
||||
expiresAt: Date.now() + 30_000,
|
||||
},
|
||||
'NovelPromotionPanel:panel-1': {
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
phase: 'queued',
|
||||
runningTaskId: 'task-ov-2',
|
||||
runningTaskType: 'LIP_SYNC',
|
||||
intent: 'process',
|
||||
hasOutputAtStart: null,
|
||||
progress: null,
|
||||
stage: null,
|
||||
stageLabel: null,
|
||||
updatedAt: overlayNow,
|
||||
lastError: null,
|
||||
expiresAt: Date.now() + 30_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
phase: 'idle',
|
||||
runningTaskId: null,
|
||||
runningTaskType: null,
|
||||
intent: 'process',
|
||||
hasOutputAtStart: null,
|
||||
progress: null,
|
||||
stage: null,
|
||||
stageLabel: null,
|
||||
lastError: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
phase: 'processing',
|
||||
runningTaskId: 'task-api-panel',
|
||||
runningTaskType: 'IMAGE_PANEL',
|
||||
intent: 'process',
|
||||
hasOutputAtStart: null,
|
||||
progress: 10,
|
||||
stage: 'api',
|
||||
stageLabel: 'API处理中',
|
||||
lastError: null,
|
||||
updatedAt: overlayNow,
|
||||
},
|
||||
] as TaskTargetState[],
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('task target state map behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
runtime.useQueryCalls = []
|
||||
})
|
||||
|
||||
it('keeps polling disabled and merges overlay only when rules match', async () => {
|
||||
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
|
||||
|
||||
const result = useTaskTargetStateMap('project-1', [
|
||||
{ targetType: 'CharacterAppearance', targetId: 'appearance-1', types: ['IMAGE_CHARACTER'] },
|
||||
{ targetType: 'NovelPromotionPanel', targetId: 'panel-1', types: ['IMAGE_PANEL'] },
|
||||
])
|
||||
|
||||
const firstCall = runtime.useQueryCalls[0]
|
||||
expect(firstCall?.refetchInterval).toBe(false)
|
||||
|
||||
const appearance = result.getState('CharacterAppearance', 'appearance-1')
|
||||
expect(appearance?.phase).toBe('processing')
|
||||
expect(appearance?.runningTaskType).toBe('IMAGE_CHARACTER')
|
||||
expect(appearance?.runningTaskId).toBe('task-ov-1')
|
||||
|
||||
const panel = result.getState('NovelPromotionPanel', 'panel-1')
|
||||
expect(panel?.phase).toBe('processing')
|
||||
expect(panel?.runningTaskType).toBe('IMAGE_PANEL')
|
||||
expect(panel?.runningTaskId).toBe('task-api-panel')
|
||||
})
|
||||
})
|
||||
21
tests/unit/task/async-poll-external-id.test.ts
Normal file
21
tests/unit/task/async-poll-external-id.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatExternalId, parseExternalId } from '@/lib/async-poll'
|
||||
|
||||
describe('async poll externalId contract', () => {
|
||||
it('parses standard FAL externalId with endpoint', () => {
|
||||
const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')
|
||||
expect(parsed.provider).toBe('FAL')
|
||||
expect(parsed.type).toBe('VIDEO')
|
||||
expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')
|
||||
expect(parsed.requestId).toBe('req_123')
|
||||
})
|
||||
|
||||
it('rejects legacy non-standard externalId formats', () => {
|
||||
expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)
|
||||
expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)
|
||||
})
|
||||
|
||||
it('requires endpoint when formatting FAL externalId', () => {
|
||||
expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)
|
||||
})
|
||||
})
|
||||
60
tests/unit/task/error-message.test.ts
Normal file
60
tests/unit/task/error-message.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'
|
||||
|
||||
describe('task error message normalization', () => {
|
||||
it('maps TASK_CANCELLED to unified cancelled message', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
errorCode: 'TASK_CANCELLED',
|
||||
errorMessage: 'whatever',
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.code).toBe('CONFLICT')
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
|
||||
it('keeps cancelled semantics from normalized task error details', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'Task cancelled by user',
|
||||
details: { cancelled: true, originalCode: 'TASK_CANCELLED' },
|
||||
},
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.code).toBe('CONFLICT')
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
|
||||
it('extracts nested error message from payload', () => {
|
||||
const message = resolveTaskErrorMessage({
|
||||
error: {
|
||||
details: {
|
||||
message: 'provider failed',
|
||||
},
|
||||
},
|
||||
}, 'fallback')
|
||||
expect(message).toBe('provider failed')
|
||||
})
|
||||
|
||||
it('supports flat error/details string payload', () => {
|
||||
expect(resolveTaskErrorMessage({
|
||||
error: 'provider failed',
|
||||
}, 'fallback')).toBe('provider failed')
|
||||
|
||||
expect(resolveTaskErrorMessage({
|
||||
details: 'provider failed',
|
||||
}, 'fallback')).toBe('provider failed')
|
||||
})
|
||||
|
||||
it('uses fallback when payload has no structured error', () => {
|
||||
expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('recognizes cancelled semantics from message-only payload', () => {
|
||||
const summary = resolveTaskErrorSummary({
|
||||
message: 'Task cancelled by user',
|
||||
})
|
||||
expect(summary.cancelled).toBe(true)
|
||||
expect(summary.message).toBe('Task cancelled by user')
|
||||
})
|
||||
})
|
||||
23
tests/unit/task/intent.test.ts
Normal file
23
tests/unit/task/intent.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { resolveTaskIntent } from '@/lib/task/intent'
|
||||
|
||||
describe('resolveTaskIntent', () => {
|
||||
it('maps generate task types', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
|
||||
})
|
||||
|
||||
it('maps regenerate and modify task types', () => {
|
||||
expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')
|
||||
expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')
|
||||
})
|
||||
|
||||
it('falls back to process for unknown types', () => {
|
||||
expect(resolveTaskIntent('unknown_type')).toBe('process')
|
||||
expect(resolveTaskIntent(null)).toBe('process')
|
||||
expect(resolveTaskIntent(undefined)).toBe('process')
|
||||
})
|
||||
})
|
||||
65
tests/unit/task/llm-observe-contract.test.ts
Normal file
65
tests/unit/task/llm-observe-contract.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'
|
||||
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
|
||||
describe('llm observe task contract', () => {
|
||||
it('maps AI_CREATE tasks to standard llm policy', () => {
|
||||
const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)
|
||||
const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)
|
||||
|
||||
expect(characterPolicy.consoleEnabled).toBe(true)
|
||||
expect(characterPolicy.displayMode).toBe('loading')
|
||||
expect(characterPolicy.captureReasoning).toBe(true)
|
||||
|
||||
expect(locationPolicy.consoleEnabled).toBe(true)
|
||||
expect(locationPolicy.displayMode).toBe('loading')
|
||||
expect(locationPolicy.captureReasoning).toBe(true)
|
||||
})
|
||||
|
||||
it('maps story/script run tasks to long-flow stage metadata', () => {
|
||||
const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)
|
||||
const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
|
||||
|
||||
expect(storyMeta.flowId).toBe('novel_promotion_generation')
|
||||
expect(storyMeta.flowStageIndex).toBe(1)
|
||||
expect(storyMeta.flowStageTotal).toBe(2)
|
||||
|
||||
expect(scriptMeta.flowId).toBe('novel_promotion_generation')
|
||||
expect(scriptMeta.flowStageIndex).toBe(2)
|
||||
expect(scriptMeta.flowStageTotal).toBe(2)
|
||||
})
|
||||
|
||||
it('maps AI_CREATE tasks to dedicated single-stage flows', () => {
|
||||
const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)
|
||||
const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)
|
||||
|
||||
expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')
|
||||
expect(characterMeta.flowStageIndex).toBe(1)
|
||||
expect(characterMeta.flowStageTotal).toBe(1)
|
||||
|
||||
expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')
|
||||
expect(locationMeta.flowStageIndex).toBe(1)
|
||||
expect(locationMeta.flowStageTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a stable two-stage pipeline for story/script flow', () => {
|
||||
const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
|
||||
const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)
|
||||
expect(stageTaskTypes).toEqual([
|
||||
TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to single-stage metadata for unknown task type', () => {
|
||||
const meta = getTaskFlowMeta('unknown_task_type')
|
||||
const pipeline = getTaskPipeline('unknown_task_type')
|
||||
|
||||
expect(meta.flowId).toBe('single:unknown_task_type')
|
||||
expect(meta.flowStageIndex).toBe(1)
|
||||
expect(meta.flowStageTotal).toBe(1)
|
||||
expect(pipeline.stages).toHaveLength(1)
|
||||
expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')
|
||||
})
|
||||
})
|
||||
22
tests/unit/task/normalize-error.test.ts
Normal file
22
tests/unit/task/normalize-error.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeAnyError } from '@/lib/errors/normalize'
|
||||
|
||||
describe('normalizeAnyError network termination mapping', () => {
|
||||
it('maps undici terminated TypeError to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new TypeError('terminated'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('maps socket hang up TypeError to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new TypeError('socket hang up'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('maps wrapped terminated message to NETWORK_ERROR', () => {
|
||||
const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))
|
||||
expect(normalized.code).toBe('NETWORK_ERROR')
|
||||
expect(normalized.retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
38
tests/unit/task/presentation.test.ts
Normal file
38
tests/unit/task/presentation.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
|
||||
describe('resolveTaskPresentationState', () => {
|
||||
it('uses overlay mode when running and has output', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'regenerate',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
expect(state.isRunning).toBe(true)
|
||||
expect(state.mode).toBe('overlay')
|
||||
expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')
|
||||
})
|
||||
|
||||
it('uses placeholder mode when running and no output', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'queued',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
expect(state.mode).toBe('placeholder')
|
||||
expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')
|
||||
})
|
||||
|
||||
it('maps failed state to failed label', () => {
|
||||
const state = resolveTaskPresentationState({
|
||||
phase: 'failed',
|
||||
intent: 'modify',
|
||||
resource: 'video',
|
||||
hasOutput: true,
|
||||
})
|
||||
expect(state.isError).toBe(true)
|
||||
expect(state.labelKey).toBe('taskStatus.failed.video')
|
||||
})
|
||||
})
|
||||
207
tests/unit/task/publisher.replay.test.ts
Normal file
207
tests/unit/task/publisher.replay.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const taskEventFindManyMock = vi.hoisted(() => vi.fn(async () => []))
|
||||
const taskEventCreateMock = vi.hoisted(() => vi.fn(async () => null))
|
||||
const taskFindManyMock = vi.hoisted(() => vi.fn(async () => []))
|
||||
const redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
taskEvent: {
|
||||
findMany: taskEventFindManyMock,
|
||||
create: taskEventCreateMock,
|
||||
},
|
||||
task: {
|
||||
findMany: taskFindManyMock,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
redis: {
|
||||
publish: redisPublishMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'
|
||||
|
||||
describe('task publisher replay', () => {
|
||||
beforeEach(() => {
|
||||
taskEventFindManyMock.mockReset()
|
||||
taskEventCreateMock.mockReset()
|
||||
taskFindManyMock.mockReset()
|
||||
redisPublishMock.mockReset()
|
||||
})
|
||||
|
||||
it('replays persisted lifecycle + stream rows in chronological order', async () => {
|
||||
taskEventFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 12,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 2,
|
||||
lane: 'main',
|
||||
delta: 'world',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:02.000Z'),
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.processing',
|
||||
payload: {
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'step-1',
|
||||
stepTitle: '阶段1',
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:01.000Z'),
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.ignored',
|
||||
payload: {},
|
||||
createdAt: new Date('2026-02-27T00:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
taskFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'script_to_storyboard_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
},
|
||||
])
|
||||
|
||||
const events = await listTaskLifecycleEvents('task-1', 50)
|
||||
|
||||
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { taskId: 'task-1' },
|
||||
orderBy: { id: 'desc' },
|
||||
take: 50,
|
||||
}))
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((event) => event.id)).toEqual(['11', '12'])
|
||||
expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])
|
||||
expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')
|
||||
})
|
||||
|
||||
it('persists stream rows when persist=true', async () => {
|
||||
taskEventCreateMock.mockResolvedValueOnce({
|
||||
id: 99,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
delta: 'hello',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:03.000Z'),
|
||||
})
|
||||
redisPublishMock.mockResolvedValueOnce(1)
|
||||
|
||||
const message = await publishTaskStreamEvent({
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
taskType: 'story_to_script_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
delta: 'hello',
|
||||
},
|
||||
},
|
||||
persist: true,
|
||||
})
|
||||
|
||||
expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
taskId: 'task-1',
|
||||
eventType: 'task.stream',
|
||||
}),
|
||||
}))
|
||||
expect(redisPublishMock).toHaveBeenCalledTimes(1)
|
||||
expect(message?.id).toBe('99')
|
||||
expect(message?.type).toBe('task.stream')
|
||||
})
|
||||
|
||||
it('replays lifecycle + stream rows in listEventsAfter', async () => {
|
||||
taskEventFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 101,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.stream',
|
||||
payload: {
|
||||
stepId: 'step-1',
|
||||
stream: {
|
||||
kind: 'text',
|
||||
seq: 3,
|
||||
lane: 'main',
|
||||
delta: 'chunk',
|
||||
},
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:03.000Z'),
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
taskId: 'task-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
eventType: 'task.processing',
|
||||
payload: {
|
||||
lifecycleType: 'task.processing',
|
||||
stepId: 'step-1',
|
||||
},
|
||||
createdAt: new Date('2026-02-27T00:00:04.000Z'),
|
||||
},
|
||||
])
|
||||
taskFindManyMock.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'story_to_script_run',
|
||||
targetType: 'episode',
|
||||
targetId: 'episode-1',
|
||||
episodeId: 'episode-1',
|
||||
},
|
||||
])
|
||||
|
||||
const events = await listEventsAfter('project-1', 100, 20)
|
||||
|
||||
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: {
|
||||
projectId: 'project-1',
|
||||
id: { gt: 100 },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}))
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events.map((event) => event.id)).toEqual(['101', '102'])
|
||||
expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])
|
||||
expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')
|
||||
})
|
||||
})
|
||||
146
tests/unit/worker/analyze-global.test.ts
Normal file
146
tests/unit/worker/analyze-global.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => '{"ok":true}'),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const parseMock = vi.hoisted(() => ({
|
||||
chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),
|
||||
safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),
|
||||
safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
createAnalyzeGlobalStats: vi.fn((totalChunks: number) => ({
|
||||
totalChunks,
|
||||
processedChunks: 0,
|
||||
newCharacters: 0,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 0,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
})),
|
||||
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number } }) => {
|
||||
args.stats.newCharacters += 1
|
||||
args.stats.newLocations += 1
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({
|
||||
CHUNK_SIZE: 3000,
|
||||
chunkContent: parseMock.chunkContent,
|
||||
parseAliases: vi.fn(() => []),
|
||||
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
safeParseCharactersResponse: parseMock.safeParseCharactersResponse,
|
||||
safeParseLocationsResponse: parseMock.safeParseLocationsResponse,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({
|
||||
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l' })),
|
||||
buildAnalyzeGlobalPrompts: vi.fn(() => ({
|
||||
characterPrompt: 'character prompt',
|
||||
locationPrompt: 'location prompt',
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({
|
||||
createAnalyzeGlobalStats: persistMock.createAnalyzeGlobalStats,
|
||||
persistAnalyzeGlobalChunk: persistMock.persistAnalyzeGlobalChunk,
|
||||
}))
|
||||
|
||||
import { handleAnalyzeGlobalTask } from '@/lib/workers/handlers/analyze-global'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-analyze-global-1',
|
||||
type: TASK_TYPE.ANALYZE_GLOBAL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'np-project-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker analyze-global behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
globalAssetText: '全局设定',
|
||||
characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary' }],
|
||||
episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('no analyzable content -> explicit error', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
globalAssetText: '',
|
||||
characters: [],
|
||||
locations: [],
|
||||
episodes: [{ id: 'ep-1', name: '第一集', novelText: '' }],
|
||||
})
|
||||
|
||||
await expect(handleAnalyzeGlobalTask(buildJob())).rejects.toThrow('没有可分析的内容')
|
||||
})
|
||||
|
||||
it('success path -> persists every chunk and returns stats summary', async () => {
|
||||
const result = await handleAnalyzeGlobalTask(buildJob())
|
||||
|
||||
expect(parseMock.chunkContent).toHaveBeenCalled()
|
||||
expect(persistMock.persistAnalyzeGlobalChunk).toHaveBeenCalledTimes(2)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stats: {
|
||||
totalChunks: 2,
|
||||
newCharacters: 2,
|
||||
updatedCharacters: 0,
|
||||
newLocations: 2,
|
||||
skippedCharacters: 0,
|
||||
skippedLocations: 0,
|
||||
totalCharacters: 1,
|
||||
totalLocations: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
197
tests/unit/worker/analyze-novel.test.ts
Normal file
197
tests/unit/worker/analyze-novel.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionEpisode: { findFirst: vi.fn() },
|
||||
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
|
||||
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },
|
||||
locationImage: { create: vi.fn(async () => ({})) },
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
getArtStylePrompt: vi.fn(() => 'cinematic style'),
|
||||
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'char',
|
||||
NP_SELECT_LOCATION: 'loc',
|
||||
},
|
||||
buildPrompt: vi.fn(() => 'analysis-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAnalyzeNovelTask } from '@/lib/workers/handlers/analyze-novel'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-analyze-novel-1',
|
||||
type: TASK_TYPE.ANALYZE_NOVEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'np-project-1',
|
||||
payload: {},
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker analyze-novel behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
mode: 'novel-promotion',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
artStyle: 'cinematic',
|
||||
globalAssetText: '全局设定文本',
|
||||
characters: [{ id: 'char-existing', name: '已有角色' }],
|
||||
locations: [{ id: 'loc-existing', name: '已有场景', summary: 'old' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValue({
|
||||
novelText: '首集内容',
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
characters: [
|
||||
{
|
||||
name: '新角色',
|
||||
aliases: ['别名A'],
|
||||
role_level: 'main',
|
||||
personality_tags: ['冷静'],
|
||||
visual_keywords: ['黑发'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
locations: [
|
||||
{
|
||||
name: '新地点',
|
||||
summary: '雨夜街道',
|
||||
descriptions: ['雨夜街道 [SUFFIX]'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('no global text and no episode text -> explicit error', async () => {
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
artStyle: 'cinematic',
|
||||
globalAssetText: '',
|
||||
characters: [],
|
||||
locations: [],
|
||||
})
|
||||
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValueOnce({ novelText: '' })
|
||||
|
||||
await expect(handleAnalyzeNovelTask(buildJob())).rejects.toThrow('请先填写全局资产设定或剧本内容')
|
||||
})
|
||||
|
||||
it('success path -> creates character/location and persists cleaned location descriptions', async () => {
|
||||
const result = await handleAnalyzeNovelTask(buildJob())
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
characters: [{ id: 'char-new-1' }],
|
||||
locations: [{ id: 'loc-new-1' }],
|
||||
characterCount: 1,
|
||||
locationCount: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
name: '新角色',
|
||||
aliases: JSON.stringify(['别名A']),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionLocation.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
name: '新地点',
|
||||
summary: '雨夜街道',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.locationImage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||
where: { id: 'np-project-1' },
|
||||
data: { artStylePrompt: 'cinematic style' },
|
||||
})
|
||||
|
||||
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
60,
|
||||
expect.objectContaining({
|
||||
stepId: 'analyze_characters',
|
||||
done: true,
|
||||
output: expect.stringContaining('"characters"'),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
70,
|
||||
expect.objectContaining({
|
||||
stepId: 'analyze_locations',
|
||||
done: true,
|
||||
output: expect.stringContaining('"locations"'),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
96
tests/unit/worker/asset-hub-ai-design.test.ts
Normal file
96
tests/unit/worker/asset-hub-ai-design.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const assetUtilsMock = vi.hoisted(() => ({
|
||||
aiDesign: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/asset-utils', () => assetUtilsMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
|
||||
import { handleAssetHubAIDesignTask } from '@/lib/workers/handlers/asset-hub-ai-design'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-ai-design-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker asset-hub-ai-design behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-default' })
|
||||
assetUtilsMock.aiDesign.mockResolvedValue({
|
||||
success: true,
|
||||
prompt: 'generated prompt',
|
||||
})
|
||||
})
|
||||
|
||||
it('missing userInstruction -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {})
|
||||
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('userInstruction is required')
|
||||
})
|
||||
|
||||
it('unsupported task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, { userInstruction: 'design a hero' })
|
||||
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('Unsupported asset hub ai design task type')
|
||||
})
|
||||
|
||||
it('success uses payload analysisModel override and character assetType', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {
|
||||
userInstruction: ' design a heroic character ',
|
||||
analysisModel: ' llm::analysis-override ',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIDesignTask(job)
|
||||
|
||||
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
analysisModel: 'llm::analysis-override',
|
||||
userInstruction: 'design a heroic character',
|
||||
assetType: 'character',
|
||||
projectId: 'global-asset-hub',
|
||||
skipBilling: true,
|
||||
}))
|
||||
expect(result).toEqual({ prompt: 'generated prompt' })
|
||||
})
|
||||
|
||||
it('location type success -> passes location assetType', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION, {
|
||||
userInstruction: 'design a rainy alley',
|
||||
})
|
||||
|
||||
await handleAssetHubAIDesignTask(job)
|
||||
|
||||
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
|
||||
assetType: 'location',
|
||||
analysisModel: 'llm::analysis-default',
|
||||
}))
|
||||
})
|
||||
})
|
||||
145
tests/unit/worker/asset-hub-ai-modify.test.ts
Normal file
145
tests/unit/worker/asset-hub-ai-modify.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
const streamContextMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => {
|
||||
const flush = vi.fn(async () => undefined)
|
||||
return {
|
||||
flush,
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
onStage: vi.fn(),
|
||||
onChunk: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
flush,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamContextMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
|
||||
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_CHARACTER_MODIFY: 'np_character_modify',
|
||||
NP_LOCATION_MODIFY: 'np_location_modify',
|
||||
},
|
||||
buildPrompt: vi.fn((_args: unknown) => 'final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAssetHubAIModifyTask } from '@/lib/workers/handlers/asset-hub-ai-modify'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-ai-modify-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'global-asset-hub',
|
||||
episodeId: null,
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker asset-hub-ai-modify behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-1' })
|
||||
llmMock.chatCompletion.mockResolvedValue({ id: 'completion-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('{"prompt":"modified description"}')
|
||||
})
|
||||
|
||||
it('missing analysisModel in user config -> explicit error', async () => {
|
||||
configMock.getUserModelConfig.mockResolvedValueOnce({ analysisModel: '' })
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old',
|
||||
modifyInstruction: 'new',
|
||||
})
|
||||
|
||||
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('请先在用户配置中设置分析模型')
|
||||
})
|
||||
|
||||
it('unsupported type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old',
|
||||
modifyInstruction: 'new',
|
||||
})
|
||||
|
||||
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('Unsupported task type')
|
||||
})
|
||||
|
||||
it('character success -> parses JSON prompt and returns modifiedDescription', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
|
||||
characterId: 'char-1',
|
||||
currentDescription: 'old character description',
|
||||
modifyInstruction: 'add armor details',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIModifyTask(job)
|
||||
|
||||
expect(llmMock.chatCompletion).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'llm::analysis-1',
|
||||
[{ role: 'user', content: 'final-prompt' }],
|
||||
expect.objectContaining({
|
||||
projectId: 'asset-hub',
|
||||
action: 'ai_modify_character',
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedDescription: 'modified description',
|
||||
})
|
||||
expect(llmStreamMock.flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('location success -> requires locationName and returns modifiedDescription', async () => {
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION, {
|
||||
locationId: 'loc-1',
|
||||
locationName: 'Old Town',
|
||||
currentDescription: 'old location description',
|
||||
modifyInstruction: 'add more fog',
|
||||
})
|
||||
|
||||
const result = await handleAssetHubAIModifyTask(job)
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedDescription: 'modified description',
|
||||
})
|
||||
})
|
||||
})
|
||||
103
tests/unit/worker/asset-hub-image-suffix.test.ts
Normal file
103
tests/unit/worker/asset-hub-image-suffix.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const workersUtilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getUserModels: vi.fn(async () => ({
|
||||
characterModel: 'character-model-1',
|
||||
locationModel: 'location-model-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/generated-character.png'),
|
||||
parseJsonStringArray: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
parseJsonStringArray: sharedMock.parseJsonStringArray,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleAssetHubImageTask } from '@/lib/workers/handlers/asset-hub-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-asset-hub-image-1',
|
||||
type: TASK_TYPE.ASSET_HUB_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'global-character-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function countOccurrences(input: string, target: string) {
|
||||
if (!target) return 0
|
||||
return input.split(target).length - 1
|
||||
}
|
||||
|
||||
describe('asset hub character image prompt suffix regression', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: '主角,黑发,冷静',
|
||||
descriptions: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps character prompt suffix in actual generation prompt', async () => {
|
||||
const job = buildJob({
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
})
|
||||
|
||||
await handleAssetHubImageTask(job)
|
||||
|
||||
const callArg = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as { prompt?: string } | undefined
|
||||
const prompt = callArg?.prompt || ''
|
||||
|
||||
expect(prompt).toContain('主角,黑发,冷静')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)
|
||||
})
|
||||
})
|
||||
120
tests/unit/worker/character-image-task-handler.test.ts
Normal file
120
tests/unit/worker/character-image-task-handler.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ characterModel: 'image-model-1', artStyle: 'noir' })),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-primary-ref']),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionCharacter: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/character-generated-0.png'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleCharacterImageTask } from '@/lib/workers/handlers/character-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'appearance-2'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-character-image-1',
|
||||
type: TASK_TYPE.IMAGE_CHARACTER,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker character-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-2',
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 1,
|
||||
descriptions: JSON.stringify(['角色描述A']),
|
||||
description: '角色描述A',
|
||||
imageUrls: JSON.stringify([]),
|
||||
selectedIndex: 0,
|
||||
imageUrl: null,
|
||||
changeReason: '战斗形态',
|
||||
character: { name: 'Hero' },
|
||||
})
|
||||
|
||||
prismaMock.characterAppearance.findFirst.mockResolvedValue({
|
||||
imageUrl: 'cos/primary.png',
|
||||
imageUrls: JSON.stringify(['cos/primary.png']),
|
||||
})
|
||||
})
|
||||
|
||||
it('characterModel not configured -> explicit error', async () => {
|
||||
utilsMock.getProjectModels.mockResolvedValueOnce({ characterModel: '', artStyle: 'noir' })
|
||||
await expect(handleCharacterImageTask(buildJob({}))).rejects.toThrow('Character model not configured')
|
||||
})
|
||||
|
||||
it('success path -> uses primary appearance as reference and persists imageUrls', async () => {
|
||||
const job = buildJob({ imageIndex: 0 })
|
||||
const result = await handleCharacterImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
appearanceId: 'appearance-2',
|
||||
imageCount: 1,
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
})
|
||||
|
||||
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
|
||||
prompt: string
|
||||
options?: { referenceImages?: string[]; aspectRatio?: string }
|
||||
}
|
||||
|
||||
expect(generationInput.prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(generationInput.options).toEqual(expect.objectContaining({
|
||||
referenceImages: ['normalized-primary-ref'],
|
||||
aspectRatio: '3:2',
|
||||
}))
|
||||
|
||||
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
|
||||
where: { id: 'appearance-2' },
|
||||
data: {
|
||||
imageUrls: JSON.stringify(['cos/character-generated-0.png']),
|
||||
imageUrl: 'cos/character-generated-0.png',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
174
tests/unit/worker/character-profile.test.ts
Normal file
174
tests/unit/worker/character-profile.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
resolveProjectModel: vi.fn(async () => ({
|
||||
id: 'project-1',
|
||||
novelPromotionData: {
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/types/character-profile', () => ({
|
||||
validateProfileData: vi.fn(() => true),
|
||||
stringifyProfileData: vi.fn((value: unknown) => JSON.stringify(value)),
|
||||
}))
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/character-profile-helpers', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/character-profile-helpers')>(
|
||||
'@/lib/workers/handlers/character-profile-helpers',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
resolveProjectModel: helperMock.resolveProjectModel,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_CHARACTER_VISUAL: 'np_agent_character_visual' },
|
||||
buildPrompt: vi.fn(() => 'character-visual-prompt'),
|
||||
}))
|
||||
|
||||
import { handleCharacterProfileTask } from '@/lib/workers/handlers/character-profile'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-character-profile-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'NovelPromotionCharacter',
|
||||
targetId: 'character-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker character-profile behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify({
|
||||
characters: [
|
||||
{
|
||||
appearances: [
|
||||
{
|
||||
change_reason: '默认形象',
|
||||
descriptions: ['黑发,冷静,风衣'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
prismaMock.novelPromotionCharacter.findFirst.mockImplementation(async (args: { where: { id: string } }) => ({
|
||||
id: args.where.id,
|
||||
name: args.where.id === 'character-2' ? 'Villain' : 'Hero',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
}))
|
||||
|
||||
prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'character-1',
|
||||
name: 'Hero',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
},
|
||||
{
|
||||
id: 'character-2',
|
||||
name: 'Villain',
|
||||
profileData: JSON.stringify({ archetype: 'antagonist' }),
|
||||
profileConfirmed: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('unsupported task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.AI_CREATE_CHARACTER, {})
|
||||
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
|
||||
})
|
||||
|
||||
it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
characterId: 'character-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: '默认形象',
|
||||
description: '黑发,冷静,风衣',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'character-1' },
|
||||
data: { profileConfirmed: true },
|
||||
})
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
character: expect.objectContaining({
|
||||
id: 'character-1',
|
||||
profileConfirmed: true,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('batch confirm -> loops through all unconfirmed characters and returns count', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM, {})
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
count: 2,
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
157
tests/unit/worker/clips-build.test.ts
Normal file
157
tests/unit/worker/clips-build.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
novelPromotionClip: {
|
||||
deleteMany: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ id: 'clip-row-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_CLIP: 'np_agent_clip' },
|
||||
buildPrompt: vi.fn(() => 'clip-split-prompt'),
|
||||
}))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
|
||||
createClipContentMatcher: (content: string) => ({
|
||||
matchBoundary: (start: string, end: string, fromIndex = 0) => {
|
||||
const startIndex = content.indexOf(start, fromIndex)
|
||||
if (startIndex === -1) return null
|
||||
const endStart = content.indexOf(end, startIndex)
|
||||
if (endStart === -1) return null
|
||||
return {
|
||||
startIndex,
|
||||
endIndex: endStart + end.length,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { handleClipsBuildTask } from '@/lib/workers/handlers/clips-build'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-clips-build-1',
|
||||
type: TASK_TYPE.CLIPS_BUILD,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker clips-build behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
name: '第一集',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'A START one END B START two END C',
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
start: 'START one',
|
||||
end: 'END',
|
||||
summary: 'first clip',
|
||||
location: 'Old Town',
|
||||
characters: ['Hero'],
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleClipsBuildTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> creates clip row with concrete boundaries and characters payload', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleClipsBuildTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
count: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionClip.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
episodeId: 'episode-1',
|
||||
startText: 'START one',
|
||||
endText: 'END',
|
||||
summary: 'first clip',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify(['Hero']),
|
||||
content: 'START one END',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('AI boundaries cannot be matched -> explicit boundary error', async () => {
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
start: 'NOT_FOUND_START',
|
||||
end: 'NOT_FOUND_END',
|
||||
summary: 'bad clip',
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
await expect(handleClipsBuildTask(job)).rejects.toThrow('split_clips boundary matching failed')
|
||||
})
|
||||
})
|
||||
127
tests/unit/worker/episode-split.test.ts
Normal file
127
tests/unit/worker/episode-split.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmClientMock = 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 configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
analysisModel: 'llm::analysis-model',
|
||||
})),
|
||||
}))
|
||||
|
||||
const internalStreamMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => ({
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'stream-1' })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
flush: vi.fn(async () => {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
|
||||
buildPrompt: vi.fn(() => 'EPISODE_SPLIT_PROMPT'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmClientMock)
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => internalStreamMock)
|
||||
vi.mock('@/lib/workers/shared', () => sharedMock)
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => llmStreamMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptMock)
|
||||
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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import { handleEpisodeSplitTask } from '@/lib/workers/handlers/episode-split'
|
||||
|
||||
function buildJob(content: string): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-episode-split-1',
|
||||
type: TASK_TYPE.EPISODE_SPLIT_LLM,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'NovelPromotionProject',
|
||||
targetId: 'project-1',
|
||||
payload: { content },
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker episode-split', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when content is too short', async () => {
|
||||
const job = buildJob('short text')
|
||||
await expect(handleEpisodeSplitTask(job)).rejects.toThrow('文本太短,至少需要 100 字')
|
||||
})
|
||||
|
||||
it('returns matched episodes when ai boundaries are valid', async () => {
|
||||
const content = [
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
|
||||
'START_MARKER',
|
||||
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于单元测试验证。',
|
||||
'END_MARKER',
|
||||
'后置内容用于确保边界外还有文本,并继续补足长度。',
|
||||
].join('')
|
||||
|
||||
const job = buildJob(content)
|
||||
const result = await handleEpisodeSplitTask(job)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.episodes).toHaveLength(1)
|
||||
expect(result.episodes[0]?.number).toBe(1)
|
||||
expect(result.episodes[0]?.title).toBe('第一集')
|
||||
expect(result.episodes[0]?.content).toContain('START_MARKER')
|
||||
expect(result.episodes[0]?.content).toContain('END_MARKER')
|
||||
})
|
||||
})
|
||||
179
tests/unit/worker/image-task-handlers-core.test.ts
Normal file
179
tests/unit/worker/image-task-handlers-core.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
|
||||
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
|
||||
stripLabelBar: vi.fn(async () => 'required-reference-image'),
|
||||
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
|
||||
withLabelBar: vi.fn(async (source: unknown) => source),
|
||||
}))
|
||||
|
||||
const outboundImageMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),
|
||||
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-required-reference'),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
resolveNovelData: vi.fn(async () => ({ videoRatio: '16:9' })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
|
||||
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,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function readUpdateData(arg: unknown): Record<string, unknown> {
|
||||
if (!arg || typeof arg !== 'object') return {}
|
||||
const data = (arg as { data?: unknown }).data
|
||||
if (!data || typeof data !== 'object') return {}
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('worker image-task-handlers-core', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when modify task payload is incomplete', async () => {
|
||||
const job = buildJob({})
|
||||
await expect(handleModifyAssetImageTask(job)).rejects.toThrow('modify task missing type/modifyPrompt')
|
||||
})
|
||||
|
||||
it('updates location image with expected generation options and persistence payload', async () => {
|
||||
prismaMock.locationImage.findUnique.mockResolvedValue({
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageUrl: 'cos/location-old.png',
|
||||
location: { name: 'Old Town' },
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: 'location',
|
||||
locationImageId: 'location-image-1',
|
||||
modifyPrompt: 'add heavy rain',
|
||||
extraImageUrls: [' https://example.com/location-ref.png '],
|
||||
generationOptions: { resolution: '1536x1024' },
|
||||
})
|
||||
|
||||
const result = await handleModifyAssetImageTask(job)
|
||||
expect(result).toEqual({
|
||||
type: 'location',
|
||||
locationImageId: 'location-image-1',
|
||||
imageUrl: 'cos/new-image.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '1:1',
|
||||
resolution: '1536x1024',
|
||||
referenceImages: ['required-reference-image', 'normalized-reference-image'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const updateArg = prismaMock.locationImage.update.mock.calls.at(-1)?.[0]
|
||||
const updateData = readUpdateData(updateArg)
|
||||
expect(updateData.previousImageUrl).toBe('cos/location-old.png')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('updates storyboard panel image and keeps candidateImages reset', async () => {
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
imageUrl: 'cos/panel-old.png',
|
||||
previousImageUrl: null,
|
||||
})
|
||||
|
||||
const job = buildJob({
|
||||
type: 'storyboard',
|
||||
panelId: 'panel-1',
|
||||
modifyPrompt: 'cinematic backlight',
|
||||
selectedAssets: [{ imageUrl: 'https://example.com/asset-ref.png' }],
|
||||
extraImageUrls: ['https://example.com/extra-ref.png'],
|
||||
generationOptions: { resolution: '2048x1152' },
|
||||
})
|
||||
|
||||
const result = await handleModifyAssetImageTask(job)
|
||||
expect(result).toEqual({
|
||||
type: 'storyboard',
|
||||
panelId: 'panel-1',
|
||||
imageUrl: 'cos/new-image.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '16:9',
|
||||
resolution: '2048x1152',
|
||||
referenceImages: [
|
||||
'base64-required-reference',
|
||||
'normalized-reference-image',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const updateArg = prismaMock.novelPromotionPanel.update.mock.calls.at(-1)?.[0]
|
||||
const updateData = readUpdateData(updateArg)
|
||||
expect(updateData.previousImageUrl).toBe('cos/panel-old.png')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
expect(updateData.candidateImages).toBeNull()
|
||||
})
|
||||
})
|
||||
32
tests/unit/worker/llm-proxy.test.ts
Normal file
32
tests/unit/worker/llm-proxy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
import { handleLLMProxyTask, isLLMProxyTaskType } from '@/lib/workers/handlers/llm-proxy'
|
||||
|
||||
function buildJob(type: TaskJobData['type']): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-llm-proxy-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: { episodeId: 'episode-1' },
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker llm-proxy behavior', () => {
|
||||
it('current route map has no enabled proxy task type', () => {
|
||||
expect(isLLMProxyTaskType(TASK_TYPE.STORY_TO_SCRIPT_RUN)).toBe(false)
|
||||
expect(isLLMProxyTaskType(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)).toBe(false)
|
||||
})
|
||||
|
||||
it('unsupported proxy task type -> explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.STORY_TO_SCRIPT_RUN)
|
||||
await expect(handleLLMProxyTask(job)).rejects.toThrow('Unsupported llm proxy task type')
|
||||
})
|
||||
})
|
||||
131
tests/unit/worker/llm-stream.test.ts
Normal file
131
tests/unit/worker/llm-stream.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Job } from 'bullmq'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const reportTaskStreamChunkMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const isTaskActiveMock = vi.hoisted(() => vi.fn(async () => true))
|
||||
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
reportTaskStreamChunk: reportTaskStreamChunkMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: assertTaskActiveMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/task/service', () => ({
|
||||
isTaskActive: isTaskActiveMock,
|
||||
}))
|
||||
|
||||
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from '@/lib/workers/handlers/llm-stream'
|
||||
|
||||
function buildJob(): Job<TaskJobData> {
|
||||
const data: TaskJobData = {
|
||||
taskId: 'task-1',
|
||||
type: 'story_to_script_run',
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload: {},
|
||||
trace: null,
|
||||
}
|
||||
return {
|
||||
data,
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('createWorkerLLMStreamCallbacks', () => {
|
||||
beforeEach(() => {
|
||||
reportTaskProgressMock.mockReset()
|
||||
reportTaskStreamChunkMock.mockReset()
|
||||
assertTaskActiveMock.mockReset()
|
||||
isTaskActiveMock.mockReset()
|
||||
isTaskActiveMock.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('publishes final step output on onComplete for replay recovery', async () => {
|
||||
const job = buildJob()
|
||||
const context = createWorkerLLMStreamContext(job, 'story_to_script')
|
||||
const callbacks = createWorkerLLMStreamCallbacks(job, context)
|
||||
|
||||
callbacks.onStage({
|
||||
stage: 'streaming',
|
||||
provider: 'ark',
|
||||
step: {
|
||||
id: 'screenplay_clip_1',
|
||||
attempt: 2,
|
||||
title: 'progress.streamStep.screenplayConversion',
|
||||
index: 1,
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
callbacks.onComplete('final screenplay text', {
|
||||
id: 'screenplay_clip_1',
|
||||
attempt: 2,
|
||||
title: 'progress.streamStep.screenplayConversion',
|
||||
index: 1,
|
||||
total: 1,
|
||||
})
|
||||
await callbacks.flush()
|
||||
|
||||
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
|
||||
const payload = call[2] as Record<string, unknown> | undefined
|
||||
return payload?.stage === 'worker_llm_complete'
|
||||
})
|
||||
|
||||
expect(finalProgressCall).toBeDefined()
|
||||
const payload = finalProgressCall?.[2] as Record<string, unknown>
|
||||
expect(payload.done).toBe(true)
|
||||
expect(payload.output).toBe('final screenplay text')
|
||||
expect(payload.stepId).toBe('screenplay_clip_1')
|
||||
expect(payload.stepAttempt).toBe(2)
|
||||
expect(payload.stepTitle).toBe('progress.streamStep.screenplayConversion')
|
||||
expect(payload.stepIndex).toBe(1)
|
||||
expect(payload.stepTotal).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps completion payload bound to provided step under interleaved steps', async () => {
|
||||
const job = buildJob()
|
||||
const context = createWorkerLLMStreamContext(job, 'story_to_script')
|
||||
const callbacks = createWorkerLLMStreamCallbacks(job, context)
|
||||
|
||||
callbacks.onChunk({
|
||||
kind: 'text',
|
||||
delta: 'A-',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
step: { id: 'analyze_characters', attempt: 1, title: 'A', index: 1, total: 2 },
|
||||
})
|
||||
callbacks.onChunk({
|
||||
kind: 'text',
|
||||
delta: 'B-',
|
||||
seq: 1,
|
||||
lane: 'main',
|
||||
step: { id: 'analyze_locations', attempt: 1, title: 'B', index: 2, total: 2 },
|
||||
})
|
||||
callbacks.onComplete('characters-final', {
|
||||
id: 'analyze_characters',
|
||||
attempt: 1,
|
||||
title: 'A',
|
||||
index: 1,
|
||||
total: 2,
|
||||
})
|
||||
await callbacks.flush()
|
||||
|
||||
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
|
||||
const payload = call[2] as Record<string, unknown> | undefined
|
||||
return payload?.stage === 'worker_llm_complete'
|
||||
})
|
||||
|
||||
expect(finalProgressCall).toBeDefined()
|
||||
const payload = finalProgressCall?.[2] as Record<string, unknown>
|
||||
expect(payload.stepId).toBe('analyze_characters')
|
||||
expect(payload.stepTitle).toBe('A')
|
||||
expect(payload.output).toBe('characters-final')
|
||||
})
|
||||
})
|
||||
109
tests/unit/worker/location-image-task-handler.test.ts
Normal file
109
tests/unit/worker/location-image-task-handler.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ locationModel: 'location-model-1', artStyle: 'anime' })),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionLocation: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
generateLabeledImageToCos: vi.fn(async () => 'cos/location-generated-1.png'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||
'@/lib/workers/handlers/image-task-handler-shared',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
|
||||
}
|
||||
})
|
||||
|
||||
import { handleLocationImageTask } from '@/lib/workers/handlers/location-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'location-image-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-location-image-1',
|
||||
type: TASK_TYPE.IMAGE_LOCATION,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: null,
|
||||
targetType: 'LocationImage',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker location-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.locationImage.findUnique.mockResolvedValue({
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
location: { name: 'Old Town' },
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
|
||||
id: 'location-1',
|
||||
name: 'Old Town',
|
||||
images: [
|
||||
{
|
||||
id: 'location-image-1',
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('locationModel missing -> explicit error', async () => {
|
||||
utilsMock.getProjectModels.mockResolvedValueOnce({ locationModel: '', artStyle: 'anime' })
|
||||
await expect(handleLocationImageTask(buildJob({}))).rejects.toThrow('Location model not configured')
|
||||
})
|
||||
|
||||
it('success path -> generates and persists concrete location image url', async () => {
|
||||
const result = await handleLocationImageTask(buildJob({ imageIndex: 0 }))
|
||||
|
||||
expect(result).toEqual({
|
||||
updated: 1,
|
||||
locationIds: ['location-1'],
|
||||
})
|
||||
|
||||
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '雨夜街道',
|
||||
label: 'Old Town',
|
||||
targetId: 'location-image-1',
|
||||
options: expect.objectContaining({ aspectRatio: '1:1' }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
|
||||
where: { id: 'location-image-1' },
|
||||
data: { imageUrl: 'cos/location-generated-1.png' },
|
||||
})
|
||||
})
|
||||
})
|
||||
183
tests/unit/worker/modify-image-reference-description.test.ts
Normal file
183
tests/unit/worker/modify-image-reference-description.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
|
||||
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
|
||||
stripLabelBar: vi.fn(async () => 'required-reference-image'),
|
||||
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
|
||||
withLabelBar: vi.fn(async (source: unknown) => source),
|
||||
}))
|
||||
|
||||
const outboundImageMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),
|
||||
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-reference'),
|
||||
}))
|
||||
|
||||
const llmClientMock = vi.hoisted(() => ({
|
||||
chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),
|
||||
getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),
|
||||
}))
|
||||
|
||||
const promptMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: {
|
||||
CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',
|
||||
},
|
||||
buildPrompt: vi.fn(() => 'vision-prompt-template'),
|
||||
}))
|
||||
|
||||
const loggerWarnMock = vi.hoisted(() => vi.fn())
|
||||
const loggingMock = vi.hoisted(() => ({
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
warn: loggerWarnMock,
|
||||
})),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
characterAppearance: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
locationImage: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
globalCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
globalLocation: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
globalLocationImage: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
|
||||
vi.mock('@/lib/llm-client', () => llmClientMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptMock)
|
||||
vi.mock('@/lib/logging/core', () => loggingMock)
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
|
||||
import { handleAssetHubModifyTask } from '@/lib/workers/handlers/asset-hub-modify-task-handler'
|
||||
|
||||
function buildJob(type: TaskType, payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function getUpdateData(callArg: unknown): Record<string, unknown> {
|
||||
if (!callArg || typeof callArg !== 'object') return {}
|
||||
const maybeData = (callArg as { data?: unknown }).data
|
||||
if (!maybeData || typeof maybeData !== 'object') return {}
|
||||
return maybeData as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('modify image with references writes real description', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.characterAppearance.findUnique.mockResolvedValue({
|
||||
id: 'appearance-1',
|
||||
imageUrls: JSON.stringify(['cos/original-image.png']),
|
||||
imageUrl: 'cos/original-image.png',
|
||||
selectedIndex: 0,
|
||||
changeReason: 'base',
|
||||
description: 'old description',
|
||||
character: { name: 'Hero' },
|
||||
})
|
||||
|
||||
prismaMock.globalCharacter.findFirst.mockResolvedValue({
|
||||
id: 'global-character-1',
|
||||
name: 'Hero',
|
||||
appearances: [
|
||||
{
|
||||
id: 'global-appearance-1',
|
||||
appearanceIndex: 0,
|
||||
changeReason: 'base',
|
||||
imageUrl: 'cos/original-global.png',
|
||||
imageUrls: JSON.stringify(['cos/original-global.png']),
|
||||
selectedIndex: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('updates character appearance description from vision output in project modify handler', async () => {
|
||||
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
|
||||
type: 'character',
|
||||
appearanceId: 'appearance-1',
|
||||
modifyPrompt: 'enhance details',
|
||||
extraImageUrls: [' https://ref.example/a.png '],
|
||||
})
|
||||
|
||||
await handleModifyAssetImageTask(job)
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '3:2',
|
||||
referenceImages: ['required-reference-image', 'normalized-reference-image'],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const updateArg = prismaMock.characterAppearance.update.mock.calls.at(-1)?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
|
||||
expect(updateData.previousDescription).toBe('old description')
|
||||
expect(updateData.imageUrl).toBe('cos/new-image.png')
|
||||
})
|
||||
|
||||
it('updates asset-hub character description from vision output when reference image exists', async () => {
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-image.png')
|
||||
|
||||
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
|
||||
type: 'character',
|
||||
id: 'global-character-1',
|
||||
appearanceIndex: 0,
|
||||
imageIndex: 0,
|
||||
modifyPrompt: 'make it sharper',
|
||||
extraImageUrls: ['https://ref.example/b.png'],
|
||||
})
|
||||
|
||||
await handleAssetHubModifyTask(job)
|
||||
|
||||
const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1)?.[0]
|
||||
const updateData = getUpdateData(updateArg)
|
||||
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
|
||||
expect(updateData.imageUrl).toBe('cos/new-global-image.png')
|
||||
expect(updateData.imageUrls).toBe(JSON.stringify(['cos/new-global-image.png']))
|
||||
})
|
||||
})
|
||||
187
tests/unit/worker/panel-image-task-handler.test.ts
Normal file
187
tests/unit/worker/panel-image-task-handler.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'cinematic' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(),
|
||||
uploadImageSourceToCos: vi.fn(),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-1.png']),
|
||||
resolveNovelData: vi.fn(async () => ({
|
||||
videoRatio: '16:9',
|
||||
characters: [],
|
||||
locations: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
|
||||
vi.mock('@/lib/logging/core', () => ({
|
||||
logInfo: vi.fn(),
|
||||
createScopedLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
event: vi.fn(),
|
||||
child: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
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,
|
||||
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },
|
||||
buildPrompt: vi.fn(() => 'panel-image-prompt'),
|
||||
}))
|
||||
|
||||
import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, targetId = 'panel-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-panel-image-1',
|
||||
type: TASK_TYPE.IMAGE_PANEL,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId,
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker panel-image-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'hero close-up',
|
||||
videoPrompt: 'dramatic',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
|
||||
srtSegment: '台词片段',
|
||||
photographyRules: null,
|
||||
actingNotes: null,
|
||||
sketchImageUrl: null,
|
||||
imageUrl: null,
|
||||
})
|
||||
|
||||
utilsMock.resolveImageSourceFromGeneration
|
||||
.mockResolvedValueOnce('generated-source-1')
|
||||
.mockResolvedValueOnce('generated-source-2')
|
||||
|
||||
utilsMock.uploadImageSourceToCos
|
||||
.mockResolvedValueOnce('cos/panel-candidate-1.png')
|
||||
.mockResolvedValueOnce('cos/panel-candidate-2.png')
|
||||
})
|
||||
|
||||
it('missing panelId -> explicit error', async () => {
|
||||
const job = buildJob({}, '')
|
||||
await expect(handlePanelImageTask(job)).rejects.toThrow('panelId missing')
|
||||
})
|
||||
|
||||
it('first generation -> persists main image and candidate list', async () => {
|
||||
const job = buildJob({ candidateCount: 2 })
|
||||
const result = await handlePanelImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
candidateCount: 2,
|
||||
imageUrl: 'cos/panel-candidate-1.png',
|
||||
})
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
modelId: 'storyboard-model-1',
|
||||
prompt: 'panel-image-prompt',
|
||||
options: expect.objectContaining({
|
||||
referenceImages: ['normalized-ref-1'],
|
||||
aspectRatio: '16:9',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
imageUrl: 'cos/panel-candidate-1.png',
|
||||
candidateImages: JSON.stringify(['cos/panel-candidate-1.png', 'cos/panel-candidate-2.png']),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('regeneration branch -> keeps old image in previousImageUrl and stores candidates only', async () => {
|
||||
utilsMock.resolveImageSourceFromGeneration.mockReset()
|
||||
utilsMock.uploadImageSourceToCos.mockReset()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce({
|
||||
id: 'panel-1',
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'hero close-up',
|
||||
videoPrompt: 'dramatic',
|
||||
location: 'Old Town',
|
||||
characters: '[]',
|
||||
srtSegment: null,
|
||||
photographyRules: null,
|
||||
actingNotes: null,
|
||||
sketchImageUrl: null,
|
||||
imageUrl: 'cos/panel-old.png',
|
||||
})
|
||||
|
||||
utilsMock.resolveImageSourceFromGeneration.mockResolvedValueOnce('generated-source-regen')
|
||||
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/panel-regenerated.png')
|
||||
|
||||
const job = buildJob({ candidateCount: 1 })
|
||||
const result = await handlePanelImageTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-1',
|
||||
candidateCount: 1,
|
||||
imageUrl: null,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-1' },
|
||||
data: {
|
||||
previousImageUrl: 'cos/panel-old.png',
|
||||
candidateImages: JSON.stringify(['cos/panel-regenerated.png']),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
143
tests/unit/worker/panel-variant-task-handler.test.ts
Normal file
143
tests/unit/worker/panel-variant-task-handler.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
const utilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'cinematic' })),
|
||||
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-variant-source'),
|
||||
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
|
||||
uploadImageSourceToCos: vi.fn(async () => 'cos/panel-variant-new.png'),
|
||||
}))
|
||||
|
||||
const sharedMock = vi.hoisted(() => ({
|
||||
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']),
|
||||
resolveNovelData: vi.fn(async () => ({
|
||||
videoRatio: '16:9',
|
||||
characters: [{ name: 'Hero', introduction: '主角' }],
|
||||
locations: [{ name: 'Old Town' }],
|
||||
})),
|
||||
}))
|
||||
|
||||
const outboundMock = vi.hoisted(() => ({
|
||||
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/workers/utils', () => utilsMock)
|
||||
vi.mock('@/lib/media/outbound-image', () => outboundMock)
|
||||
vi.mock('@/lib/logging/core', () => ({ logInfo: vi.fn() }))
|
||||
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,
|
||||
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
|
||||
resolveNovelData: sharedMock.resolveNovelData,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' },
|
||||
buildPrompt: vi.fn(() => 'panel-variant-prompt'),
|
||||
}))
|
||||
|
||||
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-panel-variant-1',
|
||||
type: TASK_TYPE.PANEL_VARIANT,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-new',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker panel-variant-task-handler behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionPanel.findUnique.mockImplementation(async (args: { where: { id: string } }) => {
|
||||
if (args.where.id === 'panel-new') {
|
||||
return {
|
||||
id: 'panel-new',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: null,
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
|
||||
}
|
||||
}
|
||||
if (args.where.id === 'panel-source') {
|
||||
return {
|
||||
id: 'panel-source',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: 'cos/panel-source.png',
|
||||
description: 'source description',
|
||||
shotType: 'medium',
|
||||
cameraMove: 'pan',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero' }]),
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('missing source/new panel ids -> explicit error', async () => {
|
||||
const job = buildJob({})
|
||||
await expect(handlePanelVariantTask(job)).rejects.toThrow('panel_variant missing newPanelId/sourcePanelId')
|
||||
})
|
||||
|
||||
it('success path -> includes source panel image in referenceImages and persists new image', async () => {
|
||||
const payload = {
|
||||
newPanelId: 'panel-new',
|
||||
sourcePanelId: 'panel-source',
|
||||
variant: {
|
||||
title: '雨夜版本',
|
||||
description: '加强雨夜氛围',
|
||||
},
|
||||
}
|
||||
|
||||
const result = await handlePanelVariantTask(buildJob(payload))
|
||||
|
||||
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
modelId: 'storyboard-model-1',
|
||||
prompt: 'panel-variant-prompt',
|
||||
options: expect.objectContaining({
|
||||
aspectRatio: '16:9',
|
||||
referenceImages: [
|
||||
'normalized:https://signed.example/cos/panel-source.png',
|
||||
'normalized:https://signed.example/ref-character.png',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'panel-new' },
|
||||
data: { imageUrl: 'cos/panel-variant-new.png' },
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
panelId: 'panel-new',
|
||||
storyboardId: 'storyboard-1',
|
||||
imageUrl: 'cos/panel-variant-new.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
215
tests/unit/worker/reference-to-character.test.ts
Normal file
215
tests/unit/worker/reference-to-character.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CHARACTER_PROMPT_SUFFIX, CHARACTER_IMAGE_BANANA_RATIO } from '@/lib/constants'
|
||||
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
|
||||
|
||||
const sharpMock = vi.hoisted(() =>
|
||||
vi.fn(() => {
|
||||
const chain = {
|
||||
metadata: vi.fn(async () => ({ width: 2160, height: 2160 })),
|
||||
extend: vi.fn(() => chain),
|
||||
composite: vi.fn(() => chain),
|
||||
jpeg: vi.fn(() => chain),
|
||||
toBuffer: vi.fn(async () => Buffer.from('processed-image')),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
)
|
||||
|
||||
const generatorApiMock = vi.hoisted(() => ({
|
||||
generateImage: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageUrl: 'https://example.com/generated.jpg',
|
||||
async: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
const asyncSubmitMock = vi.hoisted(() => ({
|
||||
queryFalStatus: vi.fn(async () => ({ completed: false, failed: false, resultUrl: null })),
|
||||
}))
|
||||
|
||||
const arkApiMock = vi.hoisted(() => ({
|
||||
fetchWithTimeoutAndRetry: vi.fn(async () => ({
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})),
|
||||
}))
|
||||
|
||||
const apiConfigMock = vi.hoisted(() => ({
|
||||
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
|
||||
}))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getUserModelConfig: vi.fn(async () => ({
|
||||
characterModel: 'character-model-1',
|
||||
analysisModel: 'analysis-model-1',
|
||||
})),
|
||||
}))
|
||||
|
||||
const llmClientMock = vi.hoisted(() => ({
|
||||
chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),
|
||||
getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),
|
||||
}))
|
||||
|
||||
const cosMock = vi.hoisted(() => {
|
||||
let keyIndex = 0
|
||||
return {
|
||||
generateUniqueKey: vi.fn(() => `reference-key-${++keyIndex}.jpg`),
|
||||
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
|
||||
uploadToCOS: vi.fn(async (_buffer: Buffer, key: string) => `cos/${key}`),
|
||||
}
|
||||
})
|
||||
|
||||
const fontsMock = vi.hoisted(() => ({
|
||||
initializeFonts: vi.fn(async () => {}),
|
||||
createLabelSVG: vi.fn(async () => Buffer.from('<svg />')),
|
||||
}))
|
||||
|
||||
const workersSharedMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const workersUtilsMock = vi.hoisted(() => ({
|
||||
assertTaskActive: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const promptI18nMock = vi.hoisted(() => ({
|
||||
PROMPT_IDS: {
|
||||
CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',
|
||||
CHARACTER_REFERENCE_TO_SHEET: 'character_reference_to_sheet',
|
||||
},
|
||||
buildPrompt: vi.fn((input: { promptId: string }) => (
|
||||
input.promptId === 'character_reference_to_sheet'
|
||||
? 'BASE_REFERENCE_PROMPT'
|
||||
: 'ANALYSIS_PROMPT'
|
||||
)),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
globalCharacterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
characterAppearance: {
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sharp', () => ({
|
||||
default: sharpMock,
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/generator-api', () => generatorApiMock)
|
||||
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
|
||||
vi.mock('@/lib/ark-api', () => arkApiMock)
|
||||
vi.mock('@/lib/api-config', () => apiConfigMock)
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/llm-client', () => llmClientMock)
|
||||
vi.mock('@/lib/cos', () => cosMock)
|
||||
vi.mock('@/lib/fonts', () => fontsMock)
|
||||
vi.mock('@/lib/workers/shared', () => workersSharedMock)
|
||||
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => promptI18nMock)
|
||||
|
||||
import { handleReferenceToCharacterTask } from '@/lib/workers/handlers/reference-to-character'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, type: TaskType): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
targetType: 'GlobalCharacter',
|
||||
targetId: 'target-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function readGenerateCall(index: number) {
|
||||
const call = generatorApiMock.generateImage.mock.calls[index]
|
||||
if (!call) {
|
||||
return {
|
||||
prompt: '',
|
||||
options: {} as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
const prompt = typeof call[2] === 'string' ? call[2] : ''
|
||||
const options = (typeof call[3] === 'object' && call[3]) ? call[3] as Record<string, unknown> : {}
|
||||
return { prompt, options }
|
||||
}
|
||||
|
||||
describe('worker reference-to-character', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails fast when reference images are missing', async () => {
|
||||
const job = buildJob({}, TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER)
|
||||
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Missing referenceImageUrl or referenceImageUrls')
|
||||
})
|
||||
|
||||
it('fails fast on unsupported task type', async () => {
|
||||
const job = buildJob(
|
||||
{ referenceImageUrl: 'https://example.com/ref.png' },
|
||||
'unsupported-task' as TaskType,
|
||||
)
|
||||
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Unsupported task type')
|
||||
})
|
||||
|
||||
it('uses suffix prompt and disables reference-image injection when customDescription is provided', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: ['https://example.com/ref-a.png', 'https://example.com/ref-b.png'],
|
||||
customDescription: '冷静黑发角色',
|
||||
characterName: 'Hero',
|
||||
},
|
||||
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
const result = await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ success: true }))
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('冷静黑发角色')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
|
||||
expect(options.referenceImages).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps three-view suffix in template flow and writes extracted description in background mode', async () => {
|
||||
const job = buildJob(
|
||||
{
|
||||
referenceImageUrls: [' https://example.com/ref-a.png ', 'https://example.com/ref-b.png'],
|
||||
isBackgroundJob: true,
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
characterName: 'Hero',
|
||||
},
|
||||
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
)
|
||||
|
||||
const result = await handleReferenceToCharacterTask(job)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
|
||||
|
||||
const { prompt, options } = readGenerateCall(0)
|
||||
expect(prompt).toContain('BASE_REFERENCE_PROMPT')
|
||||
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
|
||||
expect(options.referenceImages).toEqual(['https://example.com/ref-a.png', 'https://example.com/ref-b.png'])
|
||||
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
|
||||
|
||||
const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls[0]?.[0] as {
|
||||
data?: Record<string, unknown>
|
||||
where?: Record<string, unknown>
|
||||
} | undefined
|
||||
const updateData = updateArg?.data || {}
|
||||
expect(updateArg?.where).toEqual({ id: 'appearance-1' })
|
||||
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
|
||||
expect(typeof updateData.imageUrls).toBe('string')
|
||||
expect(updateData.imageUrl).toMatch(/^cos\/reference-key-\d+\.jpg$/)
|
||||
})
|
||||
})
|
||||
141
tests/unit/worker/screenplay-convert.test.ts
Normal file
141
tests/unit/worker/screenplay-convert.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
novelPromotionClip: { update: vi.fn(async () => ({})) },
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
|
||||
getCompletionContent: vi.fn(() => '{"scenes":[{"index":1}]}'),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const helpersMock = vi.hoisted(() => ({
|
||||
parseScreenplayPayload: vi.fn(() => ({ scenes: [{ index: 1 }] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters introduction'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
|
||||
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/screenplay-convert-helpers', () => ({
|
||||
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
parseScreenplayPayload: helpersMock.parseScreenplayPayload,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion' },
|
||||
getPromptTemplate: vi.fn(() => 'screenplay-template-{clip_content}-{clip_id}'),
|
||||
}))
|
||||
|
||||
import { handleScreenplayConvertTask } from '@/lib/workers/handlers/screenplay-convert'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-screenplay-1',
|
||||
type: TASK_TYPE.SCREENPLAY_CONVERT,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker screenplay-convert behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
mode: 'novel-promotion',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ name: 'Hero' }],
|
||||
locations: [{ name: 'Old Town' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: 'clip 1 content',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> writes screenplay json to clip row', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleScreenplayConvertTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
total: 1,
|
||||
successCount: 1,
|
||||
failCount: 0,
|
||||
totalScenes: 1,
|
||||
}))
|
||||
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-1' },
|
||||
data: {
|
||||
screenplay: JSON.stringify({
|
||||
scenes: [{ index: 1 }],
|
||||
clip_id: 'clip-1',
|
||||
original_text: 'clip 1 content',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('clip parse failed -> throws partial failure error with code prefix', async () => {
|
||||
helpersMock.parseScreenplayPayload.mockImplementation(() => {
|
||||
throw new Error('invalid screenplay payload')
|
||||
})
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('SCREENPLAY_CONVERT_PARTIAL_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runScriptToStoryboardOrchestrator } from '@/lib/novel-promotion/script-to-storyboard/orchestrator'
|
||||
|
||||
describe('script-to-storyboard orchestrator retry', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.NP_SCRIPT_TO_STORYBOARD_CONCURRENCY
|
||||
})
|
||||
|
||||
it('retries retryable step failures up to 3 attempts', async () => {
|
||||
const attemptsByAction = new Map<string, number>()
|
||||
const phase1Metas: Array<{ stepId: string; stepAttempt?: number }> = []
|
||||
const runStep = vi.fn(async (meta, _prompt, action: string) => {
|
||||
attemptsByAction.set(action, (attemptsByAction.get(action) || 0) + 1)
|
||||
|
||||
if (action === 'storyboard_phase1_plan') {
|
||||
phase1Metas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
|
||||
const attempt = attemptsByAction.get(action) || 0
|
||||
if (attempt < 3) {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'storyboard_phase2_cinematography') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }
|
||||
}
|
||||
if (action === 'storyboard_phase2_acting') {
|
||||
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
|
||||
}
|
||||
return {
|
||||
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
|
||||
reasoning: '',
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
expect(runStep).toHaveBeenCalled()
|
||||
expect(attemptsByAction.get('storyboard_phase1_plan')).toBe(3)
|
||||
expect(phase1Metas).toEqual([
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: undefined },
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: 2 },
|
||||
{ stepId: 'clip_clip-1_phase1', stepAttempt: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry non-retryable step failure', async () => {
|
||||
let callCount = 0
|
||||
const runStep = vi.fn(async () => {
|
||||
callCount += 1
|
||||
throw new Error('SENSITIVE_CONTENT: blocked')
|
||||
})
|
||||
|
||||
await expect(
|
||||
runScriptToStoryboardOrchestrator({
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: '文本',
|
||||
characters: JSON.stringify([{ name: '角色A' }]),
|
||||
location: '场景A',
|
||||
screenplay: null,
|
||||
},
|
||||
],
|
||||
novelPromotionData: {
|
||||
characters: [{ name: '角色A', appearances: [] }],
|
||||
locations: [{ name: '场景A', images: [] }],
|
||||
},
|
||||
promptTemplates: {
|
||||
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
|
||||
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
|
||||
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
|
||||
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('SENSITIVE_CONTENT')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
})
|
||||
297
tests/unit/worker/script-to-storyboard.test.ts
Normal file
297
tests/unit/worker/script-to-storyboard.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
type VoiceLineInput = {
|
||||
lineIndex: number
|
||||
speaker: string
|
||||
content: string
|
||||
emotionStrength: number
|
||||
matchedPanel: {
|
||||
storyboardId: string
|
||||
panelIndex: number
|
||||
}
|
||||
}
|
||||
|
||||
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
|
||||
const chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ responseId: 'resp-1' })))
|
||||
const getCompletionPartsMock = vi.hoisted(() => vi.fn(() => ({ text: 'voice lines json', reasoning: '' })))
|
||||
const resolveProjectModelCapabilityGenerationOptionsMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ reasoningEffort: 'high' })),
|
||||
)
|
||||
const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
clipPanels: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
panels: [
|
||||
{
|
||||
panelIndex: 1,
|
||||
shotType: 'close-up',
|
||||
cameraMove: 'static',
|
||||
description: 'panel desc',
|
||||
videoPrompt: 'panel prompt',
|
||||
location: 'room',
|
||||
characters: ['Narrator'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalPanelCount: 1,
|
||||
totalStepCount: 4,
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
const parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())
|
||||
const persistStoryboardsAndPanelsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
const txState = vi.hoisted(() => ({
|
||||
createdRows: [] as Array<Record<string, unknown>>,
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
novelPromotionEpisode: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
|
||||
vi.mock('@/lib/llm-client', () => ({
|
||||
chatCompletion: chatCompletionMock,
|
||||
getCompletionParts: getCompletionPartsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/config-service', () => ({
|
||||
resolveProjectModelCapabilityGenerationOptions: resolveProjectModelCapabilityGenerationOptionsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/semantic', () => ({
|
||||
logAIAnalysis: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logging/file-writer', () => ({
|
||||
onProjectNameAvailable: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/constants', () => ({
|
||||
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: reportTaskProgressMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: assertTaskActiveMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', () => ({
|
||||
runScriptToStoryboardOrchestrator: runScriptToStoryboardOrchestratorMock,
|
||||
JsonParseError: class JsonParseError extends Error {
|
||||
rawText: string
|
||||
|
||||
constructor(message: string, rawText: string) {
|
||||
super(message)
|
||||
this.name = 'JsonParseError'
|
||||
this.rawText = rawText
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_STORYBOARD_PLAN: 'plan',
|
||||
NP_AGENT_CINEMATOGRAPHER: 'cinematographer',
|
||||
NP_AGENT_ACTING_DIRECTION: 'acting',
|
||||
NP_AGENT_STORYBOARD_DETAIL: 'detail',
|
||||
NP_VOICE_ANALYSIS: 'voice-analysis',
|
||||
},
|
||||
getPromptTemplate: vi.fn(() => 'prompt-template'),
|
||||
buildPrompt: vi.fn(() => 'voice-analysis-prompt'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({
|
||||
asJsonRecord: (value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
},
|
||||
buildStoryboardJson: vi.fn(() => '[]'),
|
||||
parseEffort: vi.fn(() => null),
|
||||
parseTemperature: vi.fn(() => 0.7),
|
||||
parseVoiceLinesJson: parseVoiceLinesJsonMock,
|
||||
persistStoryboardsAndPanels: persistStoryboardsAndPanelsMock,
|
||||
toPositiveInt: (value: unknown) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
||||
const n = Math.floor(value)
|
||||
return n > 0 ? n : null
|
||||
},
|
||||
}))
|
||||
|
||||
import { handleScriptToStoryboardTask } from '@/lib/workers/handlers/script-to-storyboard'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
function baseVoiceRows(): VoiceLineInput[] {
|
||||
return [
|
||||
{
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanel: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('worker script-to-storyboard behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
txState.createdRows = []
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
mode: 'novel-promotion',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-model',
|
||||
characters: [{ id: 'char-1', name: 'Narrator' }],
|
||||
locations: [{ id: 'loc-1', name: 'Office' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'A complete chapter text for voice analyze.',
|
||||
clips: [
|
||||
{
|
||||
id: 'clip-1',
|
||||
content: 'clip content',
|
||||
characters: JSON.stringify(['Narrator']),
|
||||
location: 'Office',
|
||||
screenplay: 'Screenplay text',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
|
||||
novelPromotionVoiceLine: {
|
||||
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
|
||||
create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionVoiceLine: {
|
||||
deleteMany: async () => undefined,
|
||||
create: async (args: { data: Record<string, unknown>; select: { id: boolean } }) => {
|
||||
txState.createdRows.push(args.data)
|
||||
return { id: `voice-${txState.createdRows.length}` }
|
||||
},
|
||||
},
|
||||
}
|
||||
return await fn(tx)
|
||||
})
|
||||
|
||||
persistStoryboardsAndPanelsMock.mockResolvedValue([
|
||||
{
|
||||
storyboardId: 'storyboard-1',
|
||||
panels: [{ id: 'panel-1', panelIndex: 1 }],
|
||||
},
|
||||
])
|
||||
|
||||
parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())
|
||||
})
|
||||
|
||||
it('缺少 episodeId -> 显式失败', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleScriptToStoryboardTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('成功路径: 写入 voice line 时包含 matchedPanel 映射后的 panelId', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
storyboardCount: 1,
|
||||
panelCount: 1,
|
||||
voiceLineCount: 1,
|
||||
})
|
||||
|
||||
expect(txState.createdRows).toHaveLength(1)
|
||||
expect(txState.createdRows[0]).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
lineIndex: 1,
|
||||
speaker: 'Narrator',
|
||||
content: 'Hello world',
|
||||
emotionStrength: 0.8,
|
||||
matchedPanelId: 'panel-1',
|
||||
matchedStoryboardId: 'storyboard-1',
|
||||
matchedPanelIndex: 1,
|
||||
}))
|
||||
})
|
||||
|
||||
it('voice 解析失败后会重试一次再成功', async () => {
|
||||
parseVoiceLinesJsonMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('invalid voice json')
|
||||
})
|
||||
.mockImplementationOnce(() => baseVoiceRows())
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1' })
|
||||
const result = await handleScriptToStoryboardTask(job)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
episodeId: 'episode-1',
|
||||
voiceLineCount: 1,
|
||||
}))
|
||||
expect(chatCompletionMock).toHaveBeenCalledTimes(2)
|
||||
expect(parseVoiceLinesJsonMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
92
tests/unit/worker/shot-ai-prompt-appearance.test.ts
Normal file
92
tests/unit/worker/shot-ai-prompt-appearance.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_CHARACTER_MODIFY: 'np_character_modify' },
|
||||
buildPrompt: vi.fn(() => 'appearance-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyAppearanceTask } from '@/lib/workers/handlers/shot-ai-prompt-appearance'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-appearance-1',
|
||||
type: TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'CharacterAppearance',
|
||||
targetId: 'appearance-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-appearance behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('{"prompt":"updated appearance description"}')
|
||||
})
|
||||
|
||||
it('missing characterId -> explicit error', async () => {
|
||||
const job = buildJob({
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'new style',
|
||||
})
|
||||
|
||||
await expect(handleModifyAppearanceTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('characterId is required')
|
||||
})
|
||||
|
||||
it('success -> returns modifiedDescription and rawResponse', async () => {
|
||||
const payload = {
|
||||
characterId: 'character-1',
|
||||
appearanceId: 'appearance-1',
|
||||
currentDescription: 'old desc',
|
||||
modifyInstruction: 'new style',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyAppearanceTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_appearance',
|
||||
prompt: 'appearance-final-prompt',
|
||||
}))
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
modifiedDescription: 'updated appearance description',
|
||||
rawResponse: '{"prompt":"updated appearance description"}',
|
||||
}))
|
||||
})
|
||||
})
|
||||
101
tests/unit/worker/shot-ai-prompt-location.test.ts
Normal file
101
tests/unit/worker/shot-ai-prompt-location.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
requireProjectLocation: vi.fn(),
|
||||
persistLocationDescription: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_LOCATION_MODIFY: 'np_location_modify' },
|
||||
buildPrompt: vi.fn(() => 'location-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyLocationTask } from '@/lib/workers/handlers/shot-ai-prompt-location'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-location-1',
|
||||
type: TASK_TYPE.AI_MODIFY_LOCATION,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionLocation',
|
||||
targetId: 'location-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-location behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('{"prompt":"updated location description"}')
|
||||
persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })
|
||||
})
|
||||
|
||||
it('missing locationId -> explicit error', async () => {
|
||||
const payload = {
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'new style',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleModifyLocationTask(job, payload)).rejects.toThrow('locationId is required')
|
||||
})
|
||||
|
||||
it('success -> persists modifiedDescription with computed imageIndex', async () => {
|
||||
const payload = {
|
||||
locationId: 'location-1',
|
||||
imageIndex: 2,
|
||||
currentDescription: 'old location',
|
||||
modifyInstruction: 'add fog',
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyLocationTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_location',
|
||||
prompt: 'location-final-prompt',
|
||||
}))
|
||||
expect(persistMock.persistLocationDescription).toHaveBeenCalledWith({
|
||||
locationId: 'location-1',
|
||||
imageIndex: 2,
|
||||
modifiedDescription: 'updated location description',
|
||||
})
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
modifiedDescription: 'updated location description',
|
||||
location: { id: 'location-1', images: [] },
|
||||
}))
|
||||
})
|
||||
})
|
||||
90
tests/unit/worker/shot-ai-prompt-shot.test.ts
Normal file
90
tests/unit/worker/shot-ai-prompt-shot.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
runShotPromptCompletion: vi.fn(),
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
|
||||
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
|
||||
}))
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: runtimeMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: runtimeMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_IMAGE_PROMPT_MODIFY: 'np_image_prompt_modify' },
|
||||
buildPrompt: vi.fn(() => 'shot-final-prompt'),
|
||||
}))
|
||||
|
||||
import { handleModifyShotPromptTask } from '@/lib/workers/handlers/shot-ai-prompt-shot'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-prompt-1',
|
||||
type: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-prompt-shot behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
|
||||
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('{"image_prompt":"updated image prompt","video_prompt":"updated video prompt"}')
|
||||
})
|
||||
|
||||
it('missing currentPrompt -> explicit error', async () => {
|
||||
const payload = { modifyInstruction: 'new angle' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleModifyShotPromptTask(job, payload)).rejects.toThrow('currentPrompt is required')
|
||||
})
|
||||
|
||||
it('success -> returns modified image/video prompts and passes referencedAssets', async () => {
|
||||
const payload = {
|
||||
currentPrompt: 'old image prompt',
|
||||
currentVideoPrompt: 'old video prompt',
|
||||
modifyInstruction: 'new camera movement',
|
||||
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
|
||||
}
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleModifyShotPromptTask(job, payload)
|
||||
|
||||
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'ai_modify_shot_prompt',
|
||||
prompt: 'shot-final-prompt',
|
||||
}))
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
modifiedImagePrompt: 'updated image prompt',
|
||||
modifiedVideoPrompt: 'updated video prompt',
|
||||
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
80
tests/unit/worker/shot-ai-tasks.test.ts
Normal file
80
tests/unit/worker/shot-ai-tasks.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const handlersMock = vi.hoisted(() => ({
|
||||
handleModifyAppearanceTask: vi.fn(),
|
||||
handleModifyLocationTask: vi.fn(),
|
||||
handleModifyShotPromptTask: vi.fn(),
|
||||
handleAnalyzeShotVariantsTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-prompt', () => ({
|
||||
handleModifyAppearanceTask: handlersMock.handleModifyAppearanceTask,
|
||||
handleModifyLocationTask: handlersMock.handleModifyLocationTask,
|
||||
handleModifyShotPromptTask: handlersMock.handleModifyShotPromptTask,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-variants', () => ({
|
||||
handleAnalyzeShotVariantsTask: handlersMock.handleAnalyzeShotVariantsTask,
|
||||
}))
|
||||
|
||||
import { handleShotAITask } from '@/lib/workers/handlers/shot-ai-tasks'
|
||||
|
||||
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-1',
|
||||
type,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-tasks behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
handlersMock.handleModifyAppearanceTask.mockResolvedValue({ type: 'appearance' })
|
||||
handlersMock.handleModifyLocationTask.mockResolvedValue({ type: 'location' })
|
||||
handlersMock.handleModifyShotPromptTask.mockResolvedValue({ type: 'shot-prompt' })
|
||||
handlersMock.handleAnalyzeShotVariantsTask.mockResolvedValue({ type: 'variants' })
|
||||
})
|
||||
|
||||
it('AI_MODIFY_APPEARANCE -> routes to appearance handler with payload', async () => {
|
||||
const payload = { characterId: 'char-1', appearanceId: 'app-1' }
|
||||
const job = buildJob(TASK_TYPE.AI_MODIFY_APPEARANCE, payload)
|
||||
|
||||
const result = await handleShotAITask(job)
|
||||
|
||||
expect(result).toEqual({ type: 'appearance' })
|
||||
expect(handlersMock.handleModifyAppearanceTask).toHaveBeenCalledWith(job, payload)
|
||||
})
|
||||
|
||||
it('AI_MODIFY_LOCATION / AI_MODIFY_SHOT_PROMPT / ANALYZE_SHOT_VARIANTS route correctly', async () => {
|
||||
const locationPayload = { locationId: 'loc-1' }
|
||||
const locationJob = buildJob(TASK_TYPE.AI_MODIFY_LOCATION, locationPayload)
|
||||
await handleShotAITask(locationJob)
|
||||
expect(handlersMock.handleModifyLocationTask).toHaveBeenCalledWith(locationJob, locationPayload)
|
||||
|
||||
const shotPayload = { currentPrompt: 'old prompt', modifyInstruction: 'new angle' }
|
||||
const shotJob = buildJob(TASK_TYPE.AI_MODIFY_SHOT_PROMPT, shotPayload)
|
||||
await handleShotAITask(shotJob)
|
||||
expect(handlersMock.handleModifyShotPromptTask).toHaveBeenCalledWith(shotJob, shotPayload)
|
||||
|
||||
const variantPayload = { panelId: 'panel-1' }
|
||||
const variantJob = buildJob(TASK_TYPE.ANALYZE_SHOT_VARIANTS, variantPayload)
|
||||
await handleShotAITask(variantJob)
|
||||
expect(handlersMock.handleAnalyzeShotVariantsTask).toHaveBeenCalledWith(variantJob, variantPayload)
|
||||
})
|
||||
|
||||
it('unsupported type -> throws explicit error', async () => {
|
||||
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {})
|
||||
await expect(handleShotAITask(job)).rejects.toThrow('Unsupported shot AI task type')
|
||||
})
|
||||
})
|
||||
147
tests/unit/worker/shot-ai-variants.test.ts
Normal file
147
tests/unit/worker/shot-ai-variants.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
chatCompletionWithVision: vi.fn(),
|
||||
getCompletionContent: vi.fn(),
|
||||
}))
|
||||
|
||||
const cosMock = vi.hoisted(() => ({
|
||||
getSignedUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const streamCtxMock = vi.hoisted(() => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const llmStreamMock = vi.hoisted(() => {
|
||||
const flush = vi.fn(async () => undefined)
|
||||
return {
|
||||
flush,
|
||||
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||
onStage: vi.fn(),
|
||||
onChunk: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
flush,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
const persistMock = vi.hoisted(() => ({
|
||||
resolveAnalysisModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => llmMock)
|
||||
vi.mock('@/lib/cos', () => cosMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamCtxMock)
|
||||
vi.mock('@/lib/workers/shared', () => ({
|
||||
reportTaskProgress: workerMock.reportTaskProgress,
|
||||
}))
|
||||
vi.mock('@/lib/workers/utils', () => ({
|
||||
assertTaskActive: workerMock.assertTaskActive,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
|
||||
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_ANALYSIS: 'np_agent_shot_variant_analysis' },
|
||||
buildPrompt: vi.fn(() => 'shot-variants-prompt'),
|
||||
}))
|
||||
|
||||
import { handleAnalyzeShotVariantsTask } from '@/lib/workers/handlers/shot-ai-variants'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-shot-variants-1',
|
||||
type: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId: 'episode-1',
|
||||
targetType: 'NovelPromotionPanel',
|
||||
targetId: 'panel-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker shot-ai-variants behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis-1' })
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
|
||||
id: 'panel-1',
|
||||
panelNumber: 3,
|
||||
imageUrl: 'images/panel-1.png',
|
||||
description: 'panel desc',
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
location: 'Old Town',
|
||||
characters: JSON.stringify([{ name: 'Hero', appearance: 'black coat' }]),
|
||||
})
|
||||
cosMock.getSignedUrl.mockReturnValue('https://signed.example/panel-1.png')
|
||||
llmMock.chatCompletionWithVision.mockResolvedValue({ id: 'vision-1' })
|
||||
llmMock.getCompletionContent.mockReturnValue('[{"name":"v1"},{"name":"v2"},{"name":"v3"}]')
|
||||
})
|
||||
|
||||
it('panel not found -> explicit error', async () => {
|
||||
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)
|
||||
const job = buildJob({ panelId: 'panel-404' })
|
||||
|
||||
await expect(handleAnalyzeShotVariantsTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('Panel not found')
|
||||
})
|
||||
|
||||
it('success -> returns suggestions and signed panel image', async () => {
|
||||
const payload = { panelId: 'panel-1' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
const result = await handleAnalyzeShotVariantsTask(job, payload)
|
||||
|
||||
expect(llmMock.chatCompletionWithVision).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'llm::analysis-1',
|
||||
'shot-variants-prompt',
|
||||
['https://signed.example/panel-1.png'],
|
||||
expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
action: 'analyze_shot_variants',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
suggestions: [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }],
|
||||
panelInfo: expect.objectContaining({
|
||||
panelNumber: 3,
|
||||
imageUrl: 'https://signed.example/panel-1.png',
|
||||
}),
|
||||
}))
|
||||
expect(llmStreamMock.flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suggestions fewer than 3 -> explicit error', async () => {
|
||||
llmMock.getCompletionContent.mockReturnValueOnce('[{"name":"only-one"}]')
|
||||
const payload = { panelId: 'panel-1' }
|
||||
const job = buildJob(payload)
|
||||
|
||||
await expect(handleAnalyzeShotVariantsTask(job, payload)).rejects.toThrow('生成的变体数量不足')
|
||||
})
|
||||
})
|
||||
90
tests/unit/worker/story-to-script-orchestrator.retry.test.ts
Normal file
90
tests/unit/worker/story-to-script-orchestrator.retry.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runStoryToScriptOrchestrator } from '@/lib/novel-promotion/story-to-script/orchestrator'
|
||||
|
||||
describe('story-to-script orchestrator retry', () => {
|
||||
it('retries retryable step failure up to 3 attempts', async () => {
|
||||
const actionCalls = new Map<string, number>()
|
||||
const characterMetas: Array<{ stepId: string; stepAttempt?: number }> = []
|
||||
const runStep = vi.fn(async (meta, _prompt, action: string) => {
|
||||
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
|
||||
|
||||
if (action === 'analyze_characters') {
|
||||
characterMetas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
|
||||
const count = actionCalls.get(action) || 0
|
||||
if (count < 3) {
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'analyze_locations') {
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
}
|
||||
if (action === 'split_clips') {
|
||||
return {
|
||||
text: JSON.stringify([
|
||||
{
|
||||
start: '甲在门口',
|
||||
end: '乙回答',
|
||||
summary: '片段摘要',
|
||||
location: '地点A',
|
||||
characters: ['甲'],
|
||||
},
|
||||
]),
|
||||
reasoning: '',
|
||||
}
|
||||
}
|
||||
return { text: JSON.stringify({ scenes: [{ id: 1 }] }), reasoning: '' }
|
||||
})
|
||||
|
||||
const result = await runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
})
|
||||
|
||||
expect(result.summary.clipCount).toBe(1)
|
||||
expect(actionCalls.get('analyze_characters')).toBe(3)
|
||||
expect(characterMetas).toEqual([
|
||||
{ stepId: 'analyze_characters', stepAttempt: undefined },
|
||||
{ stepId: 'analyze_characters', stepAttempt: 2 },
|
||||
{ stepId: 'analyze_characters', stepAttempt: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not retry non-retryable failures', async () => {
|
||||
const actionCalls = new Map<string, number>()
|
||||
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
|
||||
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
|
||||
if (action === 'analyze_characters') {
|
||||
throw new Error('SENSITIVE_CONTENT: blocked')
|
||||
}
|
||||
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
runStoryToScriptOrchestrator({
|
||||
content: '甲在门口。乙回答。',
|
||||
baseCharacters: [],
|
||||
baseLocations: [],
|
||||
baseCharacterIntroductions: [],
|
||||
promptTemplates: {
|
||||
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
|
||||
locationPromptTemplate: '{input} {locations_lib_name}',
|
||||
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
|
||||
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
|
||||
},
|
||||
runStep,
|
||||
}),
|
||||
).rejects.toThrow('SENSITIVE_CONTENT')
|
||||
|
||||
expect(actionCalls.get('analyze_characters')).toBe(1)
|
||||
})
|
||||
})
|
||||
190
tests/unit/worker/story-to-script.test.ts
Normal file
190
tests/unit/worker/story-to-script.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
project: { findUnique: vi.fn() },
|
||||
novelPromotionProject: { findUnique: vi.fn() },
|
||||
novelPromotionEpisode: { findUnique: vi.fn() },
|
||||
novelPromotionClip: { update: vi.fn(async () => ({})) },
|
||||
}))
|
||||
|
||||
const workerMock = vi.hoisted(() => ({
|
||||
reportTaskProgress: vi.fn(async () => undefined),
|
||||
assertTaskActive: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({ reasoningEffort: 'high' })),
|
||||
}))
|
||||
|
||||
const orchestratorMock = vi.hoisted(() => ({
|
||||
runStoryToScriptOrchestrator: vi.fn(),
|
||||
}))
|
||||
|
||||
const helperMock = vi.hoisted(() => ({
|
||||
persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),
|
||||
persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),
|
||||
persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/llm-client', () => ({
|
||||
chatCompletion: vi.fn(),
|
||||
getCompletionParts: vi.fn(() => ({ text: '', reasoning: '' })),
|
||||
}))
|
||||
vi.mock('@/lib/config-service', () => configMock)
|
||||
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||
}))
|
||||
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
|
||||
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))
|
||||
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
|
||||
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
|
||||
vi.mock('@/lib/novel-promotion/story-to-script/orchestrator', () => orchestratorMock)
|
||||
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),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'a',
|
||||
NP_SELECT_LOCATION: 'b',
|
||||
NP_AGENT_CLIP: 'c',
|
||||
NP_SCREENPLAY_CONVERSION: 'd',
|
||||
},
|
||||
getPromptTemplate: vi.fn(() => 'prompt-template'),
|
||||
}))
|
||||
vi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({
|
||||
asString: (value: unknown) => (typeof value === 'string' ? value : ''),
|
||||
parseEffort: vi.fn(() => null),
|
||||
parseTemperature: vi.fn(() => 0.7),
|
||||
persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,
|
||||
persistAnalyzedLocations: helperMock.persistAnalyzedLocations,
|
||||
persistClips: helperMock.persistClips,
|
||||
resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,
|
||||
}))
|
||||
|
||||
import { handleStoryToScriptTask } from '@/lib/workers/handlers/story-to-script'
|
||||
|
||||
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
|
||||
return {
|
||||
data: {
|
||||
taskId: 'task-story-to-script-1',
|
||||
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
locale: 'zh',
|
||||
projectId: 'project-1',
|
||||
episodeId,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-1',
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
},
|
||||
} as unknown as Job<TaskJobData>
|
||||
}
|
||||
|
||||
describe('worker story-to-script behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
name: 'Project One',
|
||||
mode: 'novel-promotion',
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
|
||||
id: 'np-project-1',
|
||||
analysisModel: 'llm::analysis-1',
|
||||
characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],
|
||||
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town' }],
|
||||
})
|
||||
|
||||
prismaMock.novelPromotionEpisode.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 'episode-1',
|
||||
novelPromotionProjectId: 'np-project-1',
|
||||
novelText: 'episode text',
|
||||
})
|
||||
.mockResolvedValueOnce({ id: 'episode-1' })
|
||||
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({
|
||||
analyzedCharacters: [{ name: 'New Hero' }],
|
||||
analyzedLocations: [{ name: 'Market' }],
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
|
||||
screenplayResults: [
|
||||
{
|
||||
clipId: 'clip-1',
|
||||
success: true,
|
||||
screenplay: { scenes: [{ shot: 'close-up' }] },
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 1,
|
||||
screenplayFailedCount: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('missing episodeId -> explicit error', async () => {
|
||||
const job = buildJob({}, null)
|
||||
await expect(handleStoryToScriptTask(job)).rejects.toThrow('episodeId is required')
|
||||
})
|
||||
|
||||
it('success path -> persists clips and screenplay with concrete fields', async () => {
|
||||
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
|
||||
const result = await handleStoryToScriptTask(job)
|
||||
|
||||
expect(result).toEqual({
|
||||
episodeId: 'episode-1',
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 1,
|
||||
screenplayFailedCount: 0,
|
||||
persistedCharacters: 1,
|
||||
persistedLocations: 1,
|
||||
persistedClips: 1,
|
||||
})
|
||||
|
||||
expect(helperMock.persistClips).toHaveBeenCalledWith({
|
||||
episodeId: 'episode-1',
|
||||
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
|
||||
where: { id: 'clip-row-1' },
|
||||
data: {
|
||||
screenplay: JSON.stringify({ scenes: [{ shot: 'close-up' }] }),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('orchestrator partial failure summary -> throws explicit error', async () => {
|
||||
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({
|
||||
analyzedCharacters: [],
|
||||
analyzedLocations: [],
|
||||
clipList: [],
|
||||
screenplayResults: [
|
||||
{
|
||||
clipId: 'clip-3',
|
||||
success: false,
|
||||
error: 'bad screenplay json',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
clipCount: 1,
|
||||
screenplaySuccessCount: 0,
|
||||
screenplayFailedCount: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
|
||||
await expect(handleStoryToScriptTask(job)).rejects.toThrow('STORY_TO_SCRIPT_PARTIAL_FAILED')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user