feat:Strengthen the testing framework
This commit is contained in:
20
package.json
20
package.json
@@ -52,6 +52,7 @@
|
|||||||
"check:test-route-coverage": "node scripts/guards/test-route-coverage-guard.mjs",
|
"check:test-route-coverage": "node scripts/guards/test-route-coverage-guard.mjs",
|
||||||
"check:test-tasktype-coverage": "node scripts/guards/test-tasktype-coverage-guard.mjs",
|
"check:test-tasktype-coverage": "node scripts/guards/test-tasktype-coverage-guard.mjs",
|
||||||
"check:test-behavior-quality": "node scripts/guards/test-behavior-quality-guard.mjs",
|
"check:test-behavior-quality": "node scripts/guards/test-behavior-quality-guard.mjs",
|
||||||
|
"check:changed-test-impact": "node scripts/guards/changed-file-test-impact-guard.mjs",
|
||||||
"check:test-behavior-route-coverage": "node scripts/guards/test-behavior-route-coverage-guard.mjs",
|
"check:test-behavior-route-coverage": "node scripts/guards/test-behavior-route-coverage-guard.mjs",
|
||||||
"check:test-behavior-tasktype-coverage": "node scripts/guards/test-behavior-tasktype-coverage-guard.mjs",
|
"check:test-behavior-tasktype-coverage": "node scripts/guards/test-behavior-tasktype-coverage-guard.mjs",
|
||||||
"check:test-coverage-guards": "npm run check:test-behavior-quality && npm run check:test-route-coverage && npm run check:test-tasktype-coverage && npm run check:test-behavior-route-coverage && npm run check:test-behavior-tasktype-coverage",
|
"check:test-coverage-guards": "npm run check:test-behavior-quality && npm run check:test-route-coverage && npm run check:test-tasktype-coverage && npm run check:test-behavior-route-coverage && npm run check:test-behavior-tasktype-coverage",
|
||||||
@@ -71,21 +72,28 @@
|
|||||||
"test:billing:integration": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/billing",
|
"test:billing:integration": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/billing",
|
||||||
"test:billing:concurrency": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/concurrency/billing",
|
"test:billing:concurrency": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/concurrency/billing",
|
||||||
"test:billing:coverage": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run --coverage tests/unit/billing tests/integration/billing tests/concurrency/billing",
|
"test:billing:coverage": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run --coverage tests/unit/billing tests/integration/billing tests/concurrency/billing",
|
||||||
"test:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:no-api-direct-llm-call && npm run check:test-coverage-guards && npm run check:requirements-matrix && npm run check:locale-navigation",
|
"test:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:no-api-direct-llm-call && npm run check:test-coverage-guards && npm run check:changed-test-impact && npm run check:requirements-matrix && npm run check:locale-navigation",
|
||||||
"test:unit:all": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit",
|
"test:unit:all": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit",
|
||||||
"test:integration:api": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/api",
|
"test:integration:api": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/api",
|
||||||
|
"test:integration:provider": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/provider",
|
||||||
"test:integration:chain": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/chain",
|
"test:integration:chain": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/chain",
|
||||||
|
"test:integration:task": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/task",
|
||||||
|
"test:system": "cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run tests/system",
|
||||||
|
"test:regression:cases": "cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run tests/regression",
|
||||||
"test:behavior:unit": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic tests/unit/guards",
|
"test:behavior:unit": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic tests/unit/guards",
|
||||||
"test:behavior:api": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/api/contract",
|
"test:behavior:api": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/api/contract",
|
||||||
|
"test:behavior:provider": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/provider",
|
||||||
"test:behavior:chain": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/chain",
|
"test:behavior:chain": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/chain",
|
||||||
"test:behavior:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:test-coverage-guards && npm run check:requirements-matrix",
|
"test:behavior:guards": "npm run check:api-handler && npm run check:image-reference-normalization && npm run check:task-submit-compensation && npm run check:test-coverage-guards && npm run check:requirements-matrix",
|
||||||
"test:behavior:full": "npm run test:behavior:guards && npm run test:behavior:unit && npm run test:behavior:api && npm run test:behavior:chain",
|
"test:behavior:full": "npm run test:behavior:guards && npm run test:behavior:unit && npm run test:behavior:api && npm run test:behavior:provider && npm run test:behavior:chain",
|
||||||
"test:regression": "npm run test:guards && npm run test:unit:all && npm run test:billing:integration && npm run test:integration:api && npm run test:integration:chain",
|
"test:coverage:core-baseline": "cross-env SYSTEM_TEST_BOOTSTRAP=1 vitest run --config vitest.core-coverage.config.ts tests/unit tests/integration/api tests/integration/provider tests/integration/chain tests/integration/task tests/system tests/regression",
|
||||||
"test:pr": "bash scripts/test-regression-runner.sh npm run test:regression",
|
"test:all": "npm run test:guards && npm run test:unit:all && npm run test:billing:integration && npm run test:billing:concurrency && npm run test:integration:api && npm run test:integration:provider && npm run test:integration:chain && npm run test:integration:task && npm run test:system && npm run test:regression:cases",
|
||||||
|
"test:regression": "npm run test:all",
|
||||||
|
"test:pr": "bash scripts/test-regression-runner.sh npm run test:all",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint:all": "npm run lint -- .",
|
"lint:all": "npm run lint -- .",
|
||||||
"verify:commit": "npm run lint:all && npm run typecheck && npm run test:behavior:full",
|
"verify:commit": "npm run lint:all && npm run typecheck && npm run test:all",
|
||||||
"verify:push": "npm run lint:all && npm run typecheck && npm run test:regression && npm run build",
|
"verify:push": "npm run lint:all && npm run typecheck && npm run test:all && npm run build",
|
||||||
"migrate:image-urls-contract": "tsx scripts/migrate-image-urls-contract.ts",
|
"migrate:image-urls-contract": "tsx scripts/migrate-image-urls-contract.ts",
|
||||||
"migrate:model-config-contract": "tsx scripts/migrations/migrate-model-config-contract.ts",
|
"migrate:model-config-contract": "tsx scripts/migrations/migrate-model-config-contract.ts",
|
||||||
"migrate:capability-selections": "tsx scripts/migrations/migrate-capability-selections.ts",
|
"migrate:capability-selections": "tsx scripts/migrations/migrate-capability-selections.ts",
|
||||||
|
|||||||
106
scripts/guards/changed-file-test-impact-guard.mjs
Normal file
106
scripts/guards/changed-file-test-impact-guard.mjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
{
|
||||||
|
name: 'api',
|
||||||
|
source: /^src\/app\/api\//,
|
||||||
|
tests: [/^tests\/integration\/api\/contract\//, /^tests\/system\//, /^tests\/regression\//],
|
||||||
|
message: 'changing src/app/api/** requires a matching contract, system, or regression test change',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'worker',
|
||||||
|
source: /^src\/lib\/workers\//,
|
||||||
|
tests: [/^tests\/unit\/worker\//, /^tests\/system\//, /^tests\/regression\//],
|
||||||
|
message: 'changing src/lib/workers/** requires a matching worker, system, or regression test change',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'task',
|
||||||
|
source: /^src\/lib\/task\//,
|
||||||
|
tests: [/^tests\/unit\/task\//, /^tests\/system\//, /^tests\/regression\//],
|
||||||
|
message: 'changing src/lib/task/** requires a matching task, system, or regression test change',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'media',
|
||||||
|
source: /^src\/lib\/media\//,
|
||||||
|
tests: [/^tests\/unit\//, /^tests\/system\//, /^tests\/regression\//],
|
||||||
|
message: 'changing src/lib/media/** requires a matching unit, system, or regression test change',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'provider',
|
||||||
|
source: /^src\/lib\/(generator-api|generators|model-gateway|lipsync|providers)\//,
|
||||||
|
tests: [/^tests\/unit\/(providers|model-gateway|llm)\//, /^tests\/integration\/provider\//, /^tests\/system\//, /^tests\/regression\//],
|
||||||
|
message: 'changing provider/gateway code requires provider contract, system, or regression test change',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function normalizeChangedFiles(rawFiles) {
|
||||||
|
return rawFiles
|
||||||
|
.flatMap((item) => item.split(/[\n,]/))
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readGitChangedFiles() {
|
||||||
|
try {
|
||||||
|
const output = execSync('git diff --name-only --cached', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
})
|
||||||
|
return normalizeChangedFiles([output])
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectChangedFiles(changedFiles) {
|
||||||
|
const changed = normalizeChangedFiles(changedFiles)
|
||||||
|
const changedTests = changed.filter((file) => file.startsWith('tests/'))
|
||||||
|
const violations = []
|
||||||
|
|
||||||
|
for (const rule of RULES) {
|
||||||
|
const impactedSources = changed.filter((file) => rule.source.test(file))
|
||||||
|
if (impactedSources.length === 0) continue
|
||||||
|
const hasMatchingTestChange = changedTests.some((file) => rule.tests.some((pattern) => pattern.test(file)))
|
||||||
|
if (!hasMatchingTestChange) {
|
||||||
|
violations.push(`${rule.name}: ${rule.message}; sources=${impactedSources.join(',')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(violations) {
|
||||||
|
console.error('\n[changed-file-test-impact-guard] Missing matching test changes')
|
||||||
|
for (const violation of violations) {
|
||||||
|
console.error(` - ${violation}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli() {
|
||||||
|
const inputFiles = process.argv.slice(2)
|
||||||
|
const changedFiles = inputFiles.length > 0
|
||||||
|
? normalizeChangedFiles(inputFiles)
|
||||||
|
: normalizeChangedFiles([process.env.TEST_IMPACT_CHANGED_FILES || '', ...readGitChangedFiles()])
|
||||||
|
|
||||||
|
if (changedFiles.length === 0) {
|
||||||
|
console.log('[changed-file-test-impact-guard] SKIP no changed files detected')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = inspectChangedFiles(changedFiles)
|
||||||
|
if (violations.length > 0) {
|
||||||
|
fail(violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[changed-file-test-impact-guard] OK files=${changedFiles.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryHref = process.argv[1] ? pathToFileURL(process.argv[1]).href : null
|
||||||
|
if (entryHref && import.meta.url === entryHref) {
|
||||||
|
runCli()
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import path from 'path'
|
|||||||
const root = process.cwd()
|
const root = process.cwd()
|
||||||
const targetDirs = [
|
const targetDirs = [
|
||||||
path.join(root, 'tests', 'integration', 'api', 'contract'),
|
path.join(root, 'tests', 'integration', 'api', 'contract'),
|
||||||
|
path.join(root, 'tests', 'integration', 'provider'),
|
||||||
path.join(root, 'tests', 'integration', 'chain'),
|
path.join(root, 'tests', 'integration', 'chain'),
|
||||||
|
path.join(root, 'tests', 'system'),
|
||||||
|
path.join(root, 'tests', 'regression'),
|
||||||
]
|
]
|
||||||
|
|
||||||
function fail(title, details = []) {
|
function fail(title, details = []) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||||||
|
import { buildFalQueueUrl } from '@/lib/providers/fal/base-url'
|
||||||
/**
|
/**
|
||||||
* 异步任务提交工具
|
* 异步任务提交工具
|
||||||
*
|
*
|
||||||
@@ -24,7 +25,7 @@ export async function submitFalTask(endpoint: string, input: Record<string, unkn
|
|||||||
throw new Error('请配置 FAL API Key')
|
throw new Error('请配置 FAL API Key')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`https://queue.fal.run/${endpoint}`, {
|
const response = await fetch(buildFalQueueUrl(endpoint), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -93,7 +94,7 @@ export async function queryFalStatus(endpoint: string, requestId: string, apiKey
|
|||||||
_ulogInfo(`[FAL Status] 解析端点 ${endpoint} -> ${baseEndpoint} (忽略路径: ${parsed.path})`)
|
_ulogInfo(`[FAL Status] 解析端点 ${endpoint} -> ${baseEndpoint} (忽略路径: ${parsed.path})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusUrl = `https://queue.fal.run/${baseEndpoint}/requests/${requestId}/status?logs=0`
|
const statusUrl = buildFalQueueUrl(`${baseEndpoint}/requests/${requestId}/status?logs=0`)
|
||||||
|
|
||||||
// FAL 状态查询使用 GET 方法
|
// FAL 状态查询使用 GET 方法
|
||||||
const response = await fetch(statusUrl, {
|
const response = await fetch(statusUrl, {
|
||||||
@@ -122,7 +123,7 @@ export async function queryFalStatus(endpoint: string, requestId: string, apiKey
|
|||||||
// 优先使用返回的 response_url,如果没有则构建 URL
|
// 优先使用返回的 response_url,如果没有则构建 URL
|
||||||
// 注意:获取结果必须使用完整的原始端点(包括 /edit 等路径),而不是 baseEndpoint
|
// 注意:获取结果必须使用完整的原始端点(包括 /edit 等路径),而不是 baseEndpoint
|
||||||
// 否则 FAL 会把请求当作新任务处理,导致 422 错误(缺少 image_urls 等必需参数)
|
// 否则 FAL 会把请求当作新任务处理,导致 422 错误(缺少 image_urls 等必需参数)
|
||||||
const resultUrl = data.response_url || `https://queue.fal.run/${endpoint}/requests/${requestId}`
|
const resultUrl = data.response_url || buildFalQueueUrl(`${endpoint}/requests/${requestId}`)
|
||||||
_ulogInfo(`[FAL Status] 任务已完成,获取结果: ${resultUrl}`)
|
_ulogInfo(`[FAL Status] 任务已完成,获取结果: ${resultUrl}`)
|
||||||
|
|
||||||
const resultResponse = await fetch(resultUrl, {
|
const resultResponse = await fetch(resultUrl, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { logInternal } from './logging/semantic'
|
import { logInternal } from './logging/semantic'
|
||||||
|
import { buildFalQueueUrl } from '@/lib/providers/fal/base-url'
|
||||||
|
|
||||||
export interface TaskStatus {
|
export interface TaskStatus {
|
||||||
status: 'pending' | 'completed' | 'failed'
|
status: 'pending' | 'completed' | 'failed'
|
||||||
@@ -51,7 +52,7 @@ export async function queryBananaTaskStatus(requestId: string, apiKey: string):
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const statusResponse = await fetch(
|
const statusResponse = await fetch(
|
||||||
`https://queue.fal.run/fal-ai/nano-banana-pro/requests/${requestId}/status`,
|
buildFalQueueUrl(`fal-ai/nano-banana-pro/requests/${requestId}/status`),
|
||||||
{
|
{
|
||||||
headers: { 'Authorization': `Key ${apiKey}` },
|
headers: { 'Authorization': `Key ${apiKey}` },
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
@@ -68,7 +69,7 @@ export async function queryBananaTaskStatus(requestId: string, apiKey: string):
|
|||||||
if (data.status === 'COMPLETED') {
|
if (data.status === 'COMPLETED') {
|
||||||
// 获取结果
|
// 获取结果
|
||||||
const resultResponse = await fetch(
|
const resultResponse = await fetch(
|
||||||
`https://queue.fal.run/fal-ai/nano-banana-pro/requests/${requestId}`,
|
buildFalQueueUrl(`fal-ai/nano-banana-pro/requests/${requestId}`),
|
||||||
{
|
{
|
||||||
headers: { 'Authorization': `Key ${apiKey}` },
|
headers: { 'Authorization': `Key ${apiKey}` },
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { getProviderConfig } from '@/lib/api-config'
|
import { getProviderConfig } from '@/lib/api-config'
|
||||||
import { submitFalTask } from '@/lib/async-submit'
|
import { submitFalTask } from '@/lib/async-submit'
|
||||||
import { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'
|
import { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image'
|
||||||
|
import { buildFalQueueUrl } from '@/lib/providers/fal/base-url'
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 图像模型端点映射(modelId → FAL 端点前缀)
|
// 图像模型端点映射(modelId → FAL 端点前缀)
|
||||||
@@ -146,7 +147,7 @@ export class FalImageGenerator extends BaseImageGenerator {
|
|||||||
logger.info({
|
logger.info({
|
||||||
message: 'FAL image request body summary',
|
message: 'FAL image request body summary',
|
||||||
details: {
|
details: {
|
||||||
url: `https://queue.fal.run/${endpoint}`,
|
url: buildFalQueueUrl(endpoint),
|
||||||
promptLength: prompt.length,
|
promptLength: prompt.length,
|
||||||
imageUrlsCount: hasReferenceImages ? (body.image_urls as string[]).length : 0,
|
imageUrlsCount: hasReferenceImages ? (body.image_urls as string[]).length : 0,
|
||||||
resolution: body.resolution ?? null,
|
resolution: body.resolution ?? null,
|
||||||
@@ -156,7 +157,7 @@ export class FalImageGenerator extends BaseImageGenerator {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 提交异步任务
|
// 提交异步任务
|
||||||
const submitResponse = await fetch(`https://queue.fal.run/${endpoint}`, {
|
const submitResponse = await fetch(buildFalQueueUrl(endpoint), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
16
src/lib/providers/fal/base-url.ts
Normal file
16
src/lib/providers/fal/base-url.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const DEFAULT_FAL_QUEUE_BASE_URL = 'https://queue.fal.run'
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFalQueueBaseUrl(): string {
|
||||||
|
const override = process.env.FAL_QUEUE_BASE_URL?.trim()
|
||||||
|
if (!override) return DEFAULT_FAL_QUEUE_BASE_URL
|
||||||
|
return normalizeBaseUrl(override)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFalQueueUrl(path: string): string {
|
||||||
|
const normalizedPath = path.replace(/^\/+/, '')
|
||||||
|
return `${resolveFalQueueBaseUrl()}/${normalizedPath}`
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
- `tests/integration/api/contract/**/*.test.ts`
|
- `tests/integration/api/contract/**/*.test.ts`
|
||||||
|
- `tests/integration/provider/**/*.test.ts`
|
||||||
- `tests/integration/chain/**/*.test.ts`
|
- `tests/integration/chain/**/*.test.ts`
|
||||||
|
- `tests/system/**/*.test.ts`
|
||||||
|
- `tests/regression/**/*.test.ts`
|
||||||
- `tests/unit/worker/**/*.test.ts`
|
- `tests/unit/worker/**/*.test.ts`
|
||||||
|
|
||||||
## Must-have
|
## Must-have
|
||||||
@@ -23,3 +26,4 @@
|
|||||||
## Regression rule
|
## Regression rule
|
||||||
- One historical bug must map to at least one dedicated regression test case.
|
- One historical bug must map to at least one dedicated regression test case.
|
||||||
- Bug fix without matching behavior regression test is incomplete.
|
- Bug fix without matching behavior regression test is incomplete.
|
||||||
|
- Provider or gateway protocol changes must add a provider contract test or update an existing localhost fake-provider scenario.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||||
'tests/unit/worker/image-task-handlers-core.test.ts',
|
'tests/unit/worker/image-task-handlers-core.test.ts',
|
||||||
'tests/integration/chain/image.chain.test.ts',
|
'tests/integration/chain/image.chain.test.ts',
|
||||||
|
'tests/system/generate-image.system.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||||
'tests/unit/worker/video-worker.test.ts',
|
'tests/unit/worker/video-worker.test.ts',
|
||||||
'tests/integration/chain/video.chain.test.ts',
|
'tests/integration/chain/video.chain.test.ts',
|
||||||
|
'tests/system/generate-video.system.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +68,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
tests: [
|
tests: [
|
||||||
'tests/unit/novel-promotion/insert-panel-user-input.test.ts',
|
'tests/unit/novel-promotion/insert-panel-user-input.test.ts',
|
||||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||||
|
'tests/system/text-workflow.system.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,6 +81,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
'tests/integration/api/specific/panel-variant-route.test.ts',
|
'tests/integration/api/specific/panel-variant-route.test.ts',
|
||||||
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
'tests/integration/api/contract/direct-submit-routes.test.ts',
|
||||||
'tests/unit/worker/panel-variant-task-handler.test.ts',
|
'tests/unit/worker/panel-variant-task-handler.test.ts',
|
||||||
|
'tests/regression/panel-variant-cross-storyboard.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,6 +94,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
'tests/integration/api/contract/llm-observe-routes.test.ts',
|
'tests/integration/api/contract/llm-observe-routes.test.ts',
|
||||||
'tests/unit/worker/script-to-storyboard.test.ts',
|
'tests/unit/worker/script-to-storyboard.test.ts',
|
||||||
'tests/integration/chain/text.chain.test.ts',
|
'tests/integration/chain/text.chain.test.ts',
|
||||||
|
'tests/system/text-workflow.system.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,9 +106,36 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
|||||||
tests: [
|
tests: [
|
||||||
'tests/unit/helpers/task-state-service.test.ts',
|
'tests/unit/helpers/task-state-service.test.ts',
|
||||||
'tests/integration/api/contract/task-infra-routes.test.ts',
|
'tests/integration/api/contract/task-infra-routes.test.ts',
|
||||||
|
'tests/integration/task/create-task-dedupe.integration.test.ts',
|
||||||
'tests/unit/optimistic/sse-invalidation.test.ts',
|
'tests/unit/optimistic/sse-invalidation.test.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'REQ-PROVIDER-PROTOCOL-CONTRACT',
|
||||||
|
feature: 'Provider protocol contract',
|
||||||
|
userValue: '外部 provider 请求格式、轮询状态和错误分类保持稳定',
|
||||||
|
risk: 'provider 协议漂移导致系统链路仅在真实调用时失败',
|
||||||
|
priority: 'P0',
|
||||||
|
tests: [
|
||||||
|
'tests/integration/provider/fal-provider.contract.test.ts',
|
||||||
|
'tests/integration/provider/openai-compat-provider.contract.test.ts',
|
||||||
|
'tests/unit/task/async-poll-external-id.test.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REQ-TASK-DEDUPE-COMPENSATION',
|
||||||
|
feature: 'Task dedupe and enqueue compensation',
|
||||||
|
userValue: '重复提交不会卡死,队列失败不会留下脏冻结或孤儿任务',
|
||||||
|
risk: '重复任务、孤儿 dedupeKey、enqueue 失败后冻结金额未回滚',
|
||||||
|
priority: 'P0',
|
||||||
|
tests: [
|
||||||
|
'tests/integration/task/create-task-dedupe.integration.test.ts',
|
||||||
|
'tests/integration/billing/submitter.integration.test.ts',
|
||||||
|
'tests/regression/task-dedupe-recovery.test.ts',
|
||||||
|
'tests/regression/task-enqueue-billing-rollback.test.ts',
|
||||||
|
'tests/unit/worker/user-concurrency-gate.test.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'REQ-API-CONFIG-TUTORIAL-PORTAL',
|
id: 'REQ-API-CONFIG-TUTORIAL-PORTAL',
|
||||||
feature: 'API config tutorial modal layering',
|
feature: 'API config tutorial modal layering',
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function installAuthMocks() {
|
|||||||
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
if (state.projectAuthMode === 'not_found') return notFoundResponse()
|
||||||
return {
|
return {
|
||||||
session: state.session,
|
session: state.session,
|
||||||
project: { id: projectId, userId: state.session.user.id, name: 'project' },
|
project: { id: projectId, userId: state.session.user.id, name: 'project', mode: 'novel-promotion' },
|
||||||
novelData: { id: 'novel-data-id' },
|
novelData: { id: 'novel-data-id' },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
193
tests/helpers/fakes/scenario-server.ts
Normal file
193
tests/helpers/fakes/scenario-server.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import http, { type IncomingMessage, type ServerResponse } from 'node:http'
|
||||||
|
|
||||||
|
export type FakeScenarioMode =
|
||||||
|
| 'success'
|
||||||
|
| 'queued_then_success'
|
||||||
|
| 'retryable_error_then_success'
|
||||||
|
| 'fatal_error'
|
||||||
|
| 'malformed_response'
|
||||||
|
| 'timeout'
|
||||||
|
|
||||||
|
export type FakeResponseSpec = {
|
||||||
|
status: number
|
||||||
|
headers?: Record<string, string>
|
||||||
|
body?: string | Buffer | Record<string, unknown> | unknown[] | null
|
||||||
|
delayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FakeRequestRecord = {
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
query: string
|
||||||
|
bodyText: string
|
||||||
|
headers: Record<string, string | string[] | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteKey = `${Uppercase<string>} ${string}`
|
||||||
|
|
||||||
|
type RouteScenario = {
|
||||||
|
mode: FakeScenarioMode
|
||||||
|
submitResponse?: FakeResponseSpec
|
||||||
|
pollSequence?: FakeResponseSpec[]
|
||||||
|
errorCode?: string
|
||||||
|
delayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeKey(method: string, path: string): RouteKey {
|
||||||
|
return `${method.toUpperCase()} ${path}` as RouteKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers: IncomingMessage['headers']): Record<string, string | string[] | undefined> {
|
||||||
|
return Object.fromEntries(Object.entries(headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBodyText(chunks: Buffer[]): string {
|
||||||
|
if (chunks.length === 0) return ''
|
||||||
|
return Buffer.concat(chunks).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonBody(body: FakeResponseSpec['body']): body is Record<string, unknown> | unknown[] | null {
|
||||||
|
return body === null || Array.isArray(body) || (!!body && typeof body === 'object' && !Buffer.isBuffer(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeResponse(
|
||||||
|
res: ServerResponse,
|
||||||
|
spec: FakeResponseSpec,
|
||||||
|
inheritedDelayMs: number | undefined,
|
||||||
|
) {
|
||||||
|
const delayMs = spec.delayMs ?? inheritedDelayMs ?? 0
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { ...(spec.headers || {}) }
|
||||||
|
if (isJsonBody(spec.body) && !headers['content-type']) {
|
||||||
|
headers['content-type'] = 'application/json'
|
||||||
|
}
|
||||||
|
res.writeHead(spec.status, headers)
|
||||||
|
|
||||||
|
if (spec.body === undefined) {
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(spec.body)) {
|
||||||
|
res.end(spec.body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isJsonBody(spec.body)) {
|
||||||
|
res.end(JSON.stringify(spec.body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.end(spec.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startScenarioServer() {
|
||||||
|
const requests = new Map<RouteKey, FakeRequestRecord[]>()
|
||||||
|
const routes = new Map<RouteKey, { queue: FakeResponseSpec[]; mode: FakeScenarioMode; delayMs?: number }>()
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
||||||
|
const key = routeKey(req.method || 'GET', url.pathname)
|
||||||
|
const entry = routes.get(key)
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
const bodyText = toBodyText(chunks)
|
||||||
|
const history = requests.get(key) || []
|
||||||
|
history.push({
|
||||||
|
method: (req.method || 'GET').toUpperCase(),
|
||||||
|
path: url.pathname,
|
||||||
|
query: url.search,
|
||||||
|
bodyText,
|
||||||
|
headers: normalizeHeaders(req.headers),
|
||||||
|
})
|
||||||
|
requests.set(key, history)
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
await writeResponse(res, {
|
||||||
|
status: 404,
|
||||||
|
body: { error: 'SCENARIO_ROUTE_NOT_FOUND', path: url.pathname },
|
||||||
|
}, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = entry.queue.length > 1 ? entry.queue.shift() : entry.queue[0]
|
||||||
|
if (!next) {
|
||||||
|
await writeResponse(res, {
|
||||||
|
status: 500,
|
||||||
|
body: { error: 'SCENARIO_DEPLETED', path: url.pathname, mode: entry.mode },
|
||||||
|
}, entry.delayMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeResponse(res, next, entry.delayMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve())
|
||||||
|
server.once('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('SCENARIO_SERVER_ADDRESS_INVALID')
|
||||||
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
defineScenario(input: {
|
||||||
|
method: string
|
||||||
|
path: string
|
||||||
|
mode: FakeScenarioMode
|
||||||
|
submitResponse?: FakeResponseSpec
|
||||||
|
pollSequence?: FakeResponseSpec[]
|
||||||
|
errorCode?: string
|
||||||
|
delayMs?: number
|
||||||
|
}) {
|
||||||
|
const key = routeKey(input.method, input.path)
|
||||||
|
const queue: FakeResponseSpec[] = []
|
||||||
|
if (input.submitResponse) {
|
||||||
|
queue.push(input.submitResponse)
|
||||||
|
}
|
||||||
|
if (input.pollSequence && input.pollSequence.length > 0) {
|
||||||
|
queue.push(...input.pollSequence)
|
||||||
|
}
|
||||||
|
if (queue.length === 0) {
|
||||||
|
throw new Error(`SCENARIO_EMPTY_QUEUE: ${key}`)
|
||||||
|
}
|
||||||
|
const scenario: RouteScenario = {
|
||||||
|
mode: input.mode,
|
||||||
|
submitResponse: input.submitResponse,
|
||||||
|
pollSequence: input.pollSequence,
|
||||||
|
errorCode: input.errorCode,
|
||||||
|
delayMs: input.delayMs,
|
||||||
|
}
|
||||||
|
routes.set(key, {
|
||||||
|
queue,
|
||||||
|
mode: scenario.mode,
|
||||||
|
delayMs: scenario.delayMs,
|
||||||
|
})
|
||||||
|
requests.delete(key)
|
||||||
|
},
|
||||||
|
getRequests(method: string, path: string): FakeRequestRecord[] {
|
||||||
|
return [...(requests.get(routeKey(method, path)) || [])]
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
routes.clear()
|
||||||
|
requests.clear()
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
8
tests/hidden/README.md
Normal file
8
tests/hidden/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Hidden Acceptance Reserve
|
||||||
|
|
||||||
|
This directory is intentionally reserved for private acceptance and regression suites that are not exposed to code-writing agents.
|
||||||
|
|
||||||
|
Public repo rules:
|
||||||
|
- Do not add executable public tests here.
|
||||||
|
- Keep provider/system helper interfaces stable so private CI can mount hidden evals without patching production code.
|
||||||
|
- Private hidden suites should target the same route entrypoints and fake-provider hooks used by `tests/system/**`.
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
type RouteParams = Record<string, string>
|
type RouteParamValue = string | string[] | undefined
|
||||||
|
type RouteParams = Record<string, RouteParamValue>
|
||||||
type HeaderMap = Record<string, string>
|
type HeaderMap = Record<string, string>
|
||||||
|
|
||||||
type RouteHandler = (
|
type RouteHandler<TParams extends RouteParams = RouteParams> = (
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
ctx?: { params: Promise<RouteParams> },
|
ctx: { params: Promise<TParams> },
|
||||||
) => Promise<Response>
|
) => Promise<Response>
|
||||||
|
|
||||||
export async function callRoute(
|
export async function callRoute<TParams extends RouteParams>(
|
||||||
handler: RouteHandler,
|
handler: RouteHandler<TParams>,
|
||||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
options?: { headers?: HeaderMap; params?: RouteParams; query?: Record<string, string> },
|
options?: { headers?: HeaderMap; params?: TParams; query?: Record<string, string> },
|
||||||
) {
|
) {
|
||||||
const url = new URL('http://localhost:3000/api/test')
|
const url = new URL('http://localhost:3000/api/test')
|
||||||
if (options?.query) {
|
if (options?.query) {
|
||||||
@@ -30,6 +31,6 @@ export async function callRoute(
|
|||||||
},
|
},
|
||||||
...(payload ? { body: payload } : {}),
|
...(payload ? { body: payload } : {}),
|
||||||
})
|
})
|
||||||
const context = { params: Promise.resolve(options?.params || {}) }
|
const context = { params: Promise.resolve((options?.params || {}) as TParams) }
|
||||||
return await handler(req, context)
|
return await handler(req, context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ import { prisma } from '../../helpers/prisma'
|
|||||||
import { resetBillingState } from '../../helpers/db-reset'
|
import { resetBillingState } from '../../helpers/db-reset'
|
||||||
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
import { createTestUser, seedBalance } from '../../helpers/billing-fixtures'
|
||||||
|
|
||||||
|
const queueState = vi.hoisted(() => ({
|
||||||
|
mode: 'success' as 'success' | 'fail',
|
||||||
|
errorMessage: 'queue add failed',
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/task/queues', () => ({
|
vi.mock('@/lib/task/queues', () => ({
|
||||||
addTaskJob: vi.fn(async () => ({ id: 'mock-job' })),
|
addTaskJob: vi.fn(async () => {
|
||||||
|
if (queueState.mode === 'fail') {
|
||||||
|
throw new Error(queueState.errorMessage)
|
||||||
|
}
|
||||||
|
return { id: 'mock-job' }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/task/publisher', () => ({
|
vi.mock('@/lib/task/publisher', () => ({
|
||||||
@@ -19,6 +29,8 @@ describe('billing/submitter integration', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetBillingState()
|
await resetBillingState()
|
||||||
process.env.BILLING_MODE = 'ENFORCE'
|
process.env.BILLING_MODE = 'ENFORCE'
|
||||||
|
queueState.mode = 'success'
|
||||||
|
queueState.errorMessage = 'queue add failed'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('builds billing info server-side for billable task submission', async () => {
|
it('builds billing info server-side for billable task submission', async () => {
|
||||||
@@ -127,4 +139,46 @@ describe('billing/submitter integration', () => {
|
|||||||
expect(task?.errorCode).toBe('INVALID_PARAMS')
|
expect(task?.errorCode).toBe('INVALID_PARAMS')
|
||||||
expect(task?.errorMessage).toContain('missing server-generated billingInfo')
|
expect(task?.errorMessage).toContain('missing server-generated billingInfo')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rolls back billing freeze and marks task failed when queue enqueue fails', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
await seedBalance(user.id, 10)
|
||||||
|
queueState.mode = 'fail'
|
||||||
|
queueState.errorMessage = 'queue unavailable'
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
submitTask({
|
||||||
|
userId: user.id,
|
||||||
|
locale: 'en',
|
||||||
|
projectId: 'project-e',
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
targetType: 'VoiceLine',
|
||||||
|
targetId: 'line-e',
|
||||||
|
payload: { maxSeconds: 6 },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' } satisfies Pick<ApiError, 'code'>)
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||||
|
|
||||||
|
expect(task).toBeTruthy()
|
||||||
|
expect(task?.status).toBe('failed')
|
||||||
|
expect(task?.errorCode).toBe('ENQUEUE_FAILED')
|
||||||
|
expect(task?.errorMessage).toContain('queue unavailable')
|
||||||
|
expect(task?.billingInfo).toMatchObject({
|
||||||
|
billable: true,
|
||||||
|
status: 'rolled_back',
|
||||||
|
})
|
||||||
|
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||||
|
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||||
|
expect(await prisma.balanceFreeze.count()).toBe(1)
|
||||||
|
const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
|
||||||
|
expect(freeze?.status).toBe('rolled_back')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
154
tests/integration/provider/fal-provider.contract.test.ts
Normal file
154
tests/integration/provider/fal-provider.contract.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { queryFalStatus, submitFalTask } from '@/lib/async-submit'
|
||||||
|
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
|
||||||
|
|
||||||
|
describe('provider contract - fal queue', () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = await startScenarioServer()
|
||||||
|
process.env.FAL_QUEUE_BASE_URL = `${server.baseUrl}/fal`
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
delete process.env.FAL_QUEUE_BASE_URL
|
||||||
|
await server?.close()
|
||||||
|
server = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits the expected auth header and json payload', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/fal/fal-ai/nano-banana-pro',
|
||||||
|
mode: 'success',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { request_id: 'req_image_1' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestId = await submitFalTask(
|
||||||
|
'fal-ai/nano-banana-pro',
|
||||||
|
{
|
||||||
|
prompt: 'generate image',
|
||||||
|
image_urls: ['data:image/png;base64,AAAA'],
|
||||||
|
},
|
||||||
|
'fal-key-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(requestId).toBe('req_image_1')
|
||||||
|
const requests = server!.getRequests('POST', '/fal/fal-ai/nano-banana-pro')
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.headers.authorization).toBe('Key fal-key-1')
|
||||||
|
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
|
||||||
|
prompt: 'generate image',
|
||||||
|
image_urls: ['data:image/png;base64,AAAA'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats transient status failure as pending and completes after retry', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fal/fal-ai/veo3.1/requests/req_video_1/status',
|
||||||
|
mode: 'retryable_error_then_success',
|
||||||
|
pollSequence: [
|
||||||
|
{ status: 503, body: { error: 'upstream unavailable' } },
|
||||||
|
{ status: 200, body: { status: 'COMPLETED' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fal/fal-ai/veo3.1/fast/image-to-video/requests/req_video_1',
|
||||||
|
mode: 'success',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
video: { url: 'https://cdn.local/video.mp4' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
||||||
|
const second = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
||||||
|
|
||||||
|
expect(first).toEqual({
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
completed: false,
|
||||||
|
failed: false,
|
||||||
|
})
|
||||||
|
expect(second).toEqual({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completed: true,
|
||||||
|
failed: false,
|
||||||
|
resultUrl: 'https://cdn.local/video.mp4',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a failed status response as failed with explicit provider error', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fal/fal-ai/veo3.1/requests/req_failed/status',
|
||||||
|
mode: 'fatal_error',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
status: 'FAILED',
|
||||||
|
error: 'content moderation failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_failed', 'fal-key-3')
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'FAILED',
|
||||||
|
completed: false,
|
||||||
|
failed: true,
|
||||||
|
error: 'content moderation failed',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails explicitly when submit response is malformed', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/fal/fal-ai/nano-banana-pro',
|
||||||
|
mode: 'malformed_response',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { ok: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
submitFalTask('fal-ai/nano-banana-pro', { prompt: 'bad response' }, 'fal-key-4'),
|
||||||
|
).rejects.toThrow('FAL未返回request_id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats completed result without media url as failed', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media/status',
|
||||||
|
mode: 'queued_then_success',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'COMPLETED' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media',
|
||||||
|
mode: 'malformed_response',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { images: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await queryFalStatus('fal-ai/nano-banana-pro', 'req_no_media', 'fal-key-5')
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completed: true,
|
||||||
|
failed: false,
|
||||||
|
resultUrl: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'
|
||||||
|
import { pollAsyncTask } from '@/lib/async-poll'
|
||||||
|
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
|
||||||
|
|
||||||
|
const getProviderConfigMock = vi.hoisted(() => vi.fn())
|
||||||
|
const getUserModelsMock = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-config', () => ({
|
||||||
|
getProviderConfig: getProviderConfigMock,
|
||||||
|
getUserModels: getUserModelsMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function encode(value: string): string {
|
||||||
|
return Buffer.from(value, 'utf8').toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('provider contract - openai compatible media template', () => {
|
||||||
|
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = await startScenarioServer()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
getProviderConfigMock.mockResolvedValue({
|
||||||
|
id: 'openai-compatible:provider-local',
|
||||||
|
apiKey: 'sk-local',
|
||||||
|
baseUrl: `${server.baseUrl}/compat/v1`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server?.close()
|
||||||
|
server = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders create request against provider baseUrl and returns OCOMPAT externalId', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/compat/v1/video/create',
|
||||||
|
mode: 'success',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'queued', task_id: 'task_local_1' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateVideoViaOpenAICompatTemplate({
|
||||||
|
userId: 'user-local',
|
||||||
|
providerId: 'openai-compatible:provider-local',
|
||||||
|
modelId: 'veo-local',
|
||||||
|
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||||
|
imageUrl: 'data:image/png;base64,AAAA',
|
||||||
|
prompt: 'animate this frame',
|
||||||
|
options: {
|
||||||
|
duration: 5,
|
||||||
|
aspectRatio: '9:16',
|
||||||
|
},
|
||||||
|
profile: 'openai-compatible',
|
||||||
|
template: {
|
||||||
|
version: 1,
|
||||||
|
mediaType: 'video',
|
||||||
|
mode: 'async',
|
||||||
|
create: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/video/create',
|
||||||
|
bodyTemplate: {
|
||||||
|
model: '{{model}}',
|
||||||
|
prompt: '{{prompt}}',
|
||||||
|
image: '{{image}}',
|
||||||
|
duration: '{{duration}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||||
|
response: {
|
||||||
|
taskIdPath: '$.task_id',
|
||||||
|
statusPath: '$.status',
|
||||||
|
},
|
||||||
|
polling: {
|
||||||
|
intervalMs: 1000,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
doneStates: ['done'],
|
||||||
|
failStates: ['failed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
async: true,
|
||||||
|
requestId: 'task_local_1',
|
||||||
|
externalId: `OCOMPAT:VIDEO:b64_${encode('openai-compatible:provider-local')}:${encode('veo-local')}:task_local_1`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const requests = server!.getRequests('POST', '/compat/v1/video/create')
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.headers.authorization).toBe('Bearer sk-local')
|
||||||
|
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
|
||||||
|
model: 'veo-local',
|
||||||
|
prompt: 'animate this frame',
|
||||||
|
image: 'data:image/png;base64,AAAA',
|
||||||
|
duration: 5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls localhost provider status and falls back to content endpoint when output url is missing', async () => {
|
||||||
|
getUserModelsMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||||
|
modelId: 'veo-local',
|
||||||
|
name: 'Local Veo',
|
||||||
|
type: 'video',
|
||||||
|
provider: 'openai-compatible:provider-local',
|
||||||
|
price: 0,
|
||||||
|
compatMediaTemplate: {
|
||||||
|
version: 1,
|
||||||
|
mediaType: 'video',
|
||||||
|
mode: 'async',
|
||||||
|
create: { method: 'POST', path: '/video/create' },
|
||||||
|
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||||
|
content: { method: 'GET', path: '/video/content/{{task_id}}' },
|
||||||
|
response: {
|
||||||
|
statusPath: '$.status',
|
||||||
|
},
|
||||||
|
polling: {
|
||||||
|
intervalMs: 1000,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
doneStates: ['done'],
|
||||||
|
failStates: ['failed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/compat/v1/video/status/task_local_2',
|
||||||
|
mode: 'queued_then_success',
|
||||||
|
pollSequence: [
|
||||||
|
{ status: 200, body: { status: 'running' } },
|
||||||
|
{ status: 200, body: { status: 'done' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = await pollAsyncTask(
|
||||||
|
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
|
||||||
|
'user-local',
|
||||||
|
)
|
||||||
|
const second = await pollAsyncTask(
|
||||||
|
`OCOMPAT:VIDEO:${encode('openai-compatible:provider-local')}:${encode('openai-compatible:provider-local::veo-local')}:task_local_2`,
|
||||||
|
'user-local',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(first).toEqual({ status: 'pending' })
|
||||||
|
expect(second).toEqual({
|
||||||
|
status: 'completed',
|
||||||
|
resultUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
|
||||||
|
videoUrl: `${server!.baseUrl}/compat/v1/video/content/task_local_2`,
|
||||||
|
downloadHeaders: {
|
||||||
|
Authorization: 'Bearer sk-local',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails explicitly when async create response omits task id', async () => {
|
||||||
|
server!.defineScenario({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/compat/v1/video/create',
|
||||||
|
mode: 'malformed_response',
|
||||||
|
submitResponse: {
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'queued' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
generateVideoViaOpenAICompatTemplate({
|
||||||
|
userId: 'user-local',
|
||||||
|
providerId: 'openai-compatible:provider-local',
|
||||||
|
modelId: 'veo-local',
|
||||||
|
modelKey: 'openai-compatible:provider-local::veo-local',
|
||||||
|
imageUrl: 'data:image/png;base64,AAAA',
|
||||||
|
prompt: 'bad create payload',
|
||||||
|
profile: 'openai-compatible',
|
||||||
|
template: {
|
||||||
|
version: 1,
|
||||||
|
mediaType: 'video',
|
||||||
|
mode: 'async',
|
||||||
|
create: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/video/create',
|
||||||
|
bodyTemplate: { prompt: '{{prompt}}' },
|
||||||
|
},
|
||||||
|
status: { method: 'GET', path: '/video/status/{{task_id}}' },
|
||||||
|
response: {
|
||||||
|
taskIdPath: '$.task_id',
|
||||||
|
statusPath: '$.status',
|
||||||
|
},
|
||||||
|
polling: {
|
||||||
|
intervalMs: 1000,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
doneStates: ['done'],
|
||||||
|
failStates: ['failed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND')
|
||||||
|
})
|
||||||
|
})
|
||||||
150
tests/integration/task/create-task-dedupe.integration.test.ts
Normal file
150
tests/integration/task/create-task-dedupe.integration.test.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
|
||||||
|
import { createTask } from '@/lib/task/service'
|
||||||
|
import { prisma } from '../../helpers/prisma'
|
||||||
|
import { resetBillingState } from '../../helpers/db-reset'
|
||||||
|
import { createTestProject, createTestUser } from '../../helpers/billing-fixtures'
|
||||||
|
|
||||||
|
const reconcileMock = vi.hoisted(() => ({
|
||||||
|
isJobAlive: vi.fn(async () => true),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/task/reconcile', () => reconcileMock)
|
||||||
|
|
||||||
|
describe('task service dedupe + orphan recovery', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetBillingState()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
reconcileMock.isJobAlive.mockResolvedValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes to an active task when dedupeKey matches and queue job is alive', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
const project = await createTestProject(user.id)
|
||||||
|
const existing = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
targetType: 'NovelPromotionVoiceLine',
|
||||||
|
targetId: 'line-1',
|
||||||
|
status: TASK_STATUS.QUEUED,
|
||||||
|
payload: {
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
lineId: 'line-1',
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'voice_line:line-1',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createTask({
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
targetType: 'NovelPromotionVoiceLine',
|
||||||
|
targetId: 'line-1',
|
||||||
|
payload: {
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
lineId: 'line-1',
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'voice_line:line-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deduped).toBe(true)
|
||||||
|
expect(result.task.id).toBe(existing.id)
|
||||||
|
expect(reconcileMock.isJobAlive).toHaveBeenCalledWith(existing.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails orphaned active task and creates a replacement when queue job is missing', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
const project = await createTestProject(user.id)
|
||||||
|
const existing = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VIDEO_PANEL,
|
||||||
|
targetType: 'NovelPromotionPanel',
|
||||||
|
targetId: 'panel-1',
|
||||||
|
status: TASK_STATUS.QUEUED,
|
||||||
|
payload: {
|
||||||
|
storyboardId: 'storyboard-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'video_panel:panel-1',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reconcileMock.isJobAlive.mockResolvedValue(false)
|
||||||
|
|
||||||
|
const result = await createTask({
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VIDEO_PANEL,
|
||||||
|
targetType: 'NovelPromotionPanel',
|
||||||
|
targetId: 'panel-1',
|
||||||
|
payload: {
|
||||||
|
storyboardId: 'storyboard-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'video_panel:panel-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deduped).toBe(false)
|
||||||
|
expect(result.task.id).not.toBe(existing.id)
|
||||||
|
|
||||||
|
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
|
||||||
|
expect(failedExisting).toMatchObject({
|
||||||
|
status: TASK_STATUS.FAILED,
|
||||||
|
errorCode: 'RECONCILE_ORPHAN',
|
||||||
|
dedupeKey: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails locale-less active task and replaces it instead of deduping forever', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
const project = await createTestProject(user.id)
|
||||||
|
const existing = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||||
|
targetType: 'NovelPromotionEpisode',
|
||||||
|
targetId: 'episode-1',
|
||||||
|
status: TASK_STATUS.QUEUED,
|
||||||
|
payload: {
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
},
|
||||||
|
dedupeKey: 'script_to_storyboard_run:episode-1',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await createTask({
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||||
|
targetType: 'NovelPromotionEpisode',
|
||||||
|
targetId: 'episode-1',
|
||||||
|
payload: {
|
||||||
|
episodeId: 'episode-1',
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'script_to_storyboard_run:episode-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.deduped).toBe(false)
|
||||||
|
expect(result.task.id).not.toBe(existing.id)
|
||||||
|
|
||||||
|
const failedExisting = await prisma.task.findUnique({ where: { id: existing.id } })
|
||||||
|
expect(failedExisting).toMatchObject({
|
||||||
|
status: TASK_STATUS.FAILED,
|
||||||
|
errorCode: 'TASK_LOCALE_REQUIRED',
|
||||||
|
dedupeKey: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
50
tests/regression/panel-variant-cross-storyboard.test.ts
Normal file
50
tests/regression/panel-variant-cross-storyboard.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { callRoute } from '../integration/api/helpers/call-route'
|
||||||
|
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||||
|
import { resetSystemState } from '../helpers/db-reset'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { seedMinimalDomainState } from '../system/helpers/seed'
|
||||||
|
|
||||||
|
describe('regression - panel variant cross storyboard safety', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetSystemState()
|
||||||
|
installAuthMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sourcePanelId from another storyboard -> explicit invalid params and no dirty panel', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
|
||||||
|
const beforeCount = await prisma.novelPromotionPanel.count({
|
||||||
|
where: { storyboardId: seeded.storyboard.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
storyboardId: seeded.storyboard.id,
|
||||||
|
insertAfterPanelId: seeded.panel.id,
|
||||||
|
sourcePanelId: seeded.foreignPanel.id,
|
||||||
|
variant: {
|
||||||
|
video_prompt: 'variant prompt',
|
||||||
|
description: 'variant description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
const json = await response.json() as { error?: { code?: string } }
|
||||||
|
expect(json.error?.code).toBe('INVALID_PARAMS')
|
||||||
|
|
||||||
|
const afterCount = await prisma.novelPromotionPanel.count({
|
||||||
|
where: { storyboardId: seeded.storyboard.id },
|
||||||
|
})
|
||||||
|
expect(afterCount).toBe(beforeCount)
|
||||||
|
|
||||||
|
resetAuthMockState()
|
||||||
|
})
|
||||||
|
})
|
||||||
108
tests/regression/task-dedupe-recovery.test.ts
Normal file
108
tests/regression/task-dedupe-recovery.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
|
||||||
|
import { createTask } from '@/lib/task/service'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { resetBillingState } from '../helpers/db-reset'
|
||||||
|
import { createTestProject, createTestUser } from '../helpers/billing-fixtures'
|
||||||
|
|
||||||
|
const reconcileMock = vi.hoisted(() => ({
|
||||||
|
isJobAlive: vi.fn(async () => true),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/task/reconcile', () => reconcileMock)
|
||||||
|
|
||||||
|
describe('regression - task dedupe recovery', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetBillingState()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
reconcileMock.isJobAlive.mockResolvedValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces locale-less queued task instead of deduping forever', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
const project = await createTestProject(user.id)
|
||||||
|
const stale = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||||
|
targetType: 'NovelPromotionEpisode',
|
||||||
|
targetId: 'episode-regression-1',
|
||||||
|
status: TASK_STATUS.QUEUED,
|
||||||
|
payload: { episodeId: 'episode-regression-1' },
|
||||||
|
dedupeKey: 'script_to_storyboard_run:episode-regression-1',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const replacement = await createTask({
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
|
||||||
|
targetType: 'NovelPromotionEpisode',
|
||||||
|
targetId: 'episode-regression-1',
|
||||||
|
payload: {
|
||||||
|
episodeId: 'episode-regression-1',
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'script_to_storyboard_run:episode-regression-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(replacement.deduped).toBe(false)
|
||||||
|
expect(replacement.task.id).not.toBe(stale.id)
|
||||||
|
|
||||||
|
const failedStale = await prisma.task.findUnique({ where: { id: stale.id } })
|
||||||
|
expect(failedStale).toMatchObject({
|
||||||
|
status: TASK_STATUS.FAILED,
|
||||||
|
errorCode: 'TASK_LOCALE_REQUIRED',
|
||||||
|
dedupeKey: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces orphaned queued task when queue job is gone', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
const project = await createTestProject(user.id)
|
||||||
|
const orphan = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VIDEO_PANEL,
|
||||||
|
targetType: 'NovelPromotionPanel',
|
||||||
|
targetId: 'panel-regression-1',
|
||||||
|
status: TASK_STATUS.QUEUED,
|
||||||
|
payload: {
|
||||||
|
storyboardId: 'storyboard-regression-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'video_panel:panel-regression-1',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reconcileMock.isJobAlive.mockResolvedValue(false)
|
||||||
|
|
||||||
|
const replacement = await createTask({
|
||||||
|
userId: user.id,
|
||||||
|
projectId: project.id,
|
||||||
|
type: TASK_TYPE.VIDEO_PANEL,
|
||||||
|
targetType: 'NovelPromotionPanel',
|
||||||
|
targetId: 'panel-regression-1',
|
||||||
|
payload: {
|
||||||
|
storyboardId: 'storyboard-regression-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
meta: { locale: 'zh' },
|
||||||
|
},
|
||||||
|
dedupeKey: 'video_panel:panel-regression-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(replacement.deduped).toBe(false)
|
||||||
|
expect(replacement.task.id).not.toBe(orphan.id)
|
||||||
|
|
||||||
|
const failedOrphan = await prisma.task.findUnique({ where: { id: orphan.id } })
|
||||||
|
expect(failedOrphan).toMatchObject({
|
||||||
|
status: TASK_STATUS.FAILED,
|
||||||
|
errorCode: 'RECONCILE_ORPHAN',
|
||||||
|
dedupeKey: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
68
tests/regression/task-enqueue-billing-rollback.test.ts
Normal file
68
tests/regression/task-enqueue-billing-rollback.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { submitTask } from '@/lib/task/submitter'
|
||||||
|
import { TASK_TYPE } from '@/lib/task/types'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { resetBillingState } from '../helpers/db-reset'
|
||||||
|
import { createTestUser, seedBalance } from '../helpers/billing-fixtures'
|
||||||
|
|
||||||
|
const queueState = vi.hoisted(() => ({
|
||||||
|
message: 'queue add failed',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/task/queues', () => ({
|
||||||
|
addTaskJob: vi.fn(async () => {
|
||||||
|
throw new Error(queueState.message)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/task/publisher', () => ({
|
||||||
|
publishTaskEvent: vi.fn(async () => ({})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('regression - enqueue compensation', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetBillingState()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.BILLING_MODE = 'ENFORCE'
|
||||||
|
queueState.message = 'queue unavailable'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls back frozen balance when queue submission fails', async () => {
|
||||||
|
const user = await createTestUser()
|
||||||
|
await seedBalance(user.id, 10)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
submitTask({
|
||||||
|
userId: user.id,
|
||||||
|
locale: 'en',
|
||||||
|
projectId: 'project-regression-enqueue',
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
targetType: 'VoiceLine',
|
||||||
|
targetId: 'line-regression-enqueue',
|
||||||
|
payload: { maxSeconds: 6 },
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'EXTERNAL_ERROR' })
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
type: TASK_TYPE.VOICE_LINE,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
const balance = await prisma.userBalance.findUnique({ where: { userId: user.id } })
|
||||||
|
const freeze = await prisma.balanceFreeze.findFirst({ orderBy: { createdAt: 'desc' } })
|
||||||
|
|
||||||
|
expect(task).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'ENQUEUE_FAILED',
|
||||||
|
})
|
||||||
|
expect(task?.billingInfo).toMatchObject({
|
||||||
|
billable: true,
|
||||||
|
status: 'rolled_back',
|
||||||
|
})
|
||||||
|
expect(balance?.balance).toBeCloseTo(10, 8)
|
||||||
|
expect(balance?.frozenAmount).toBeCloseTo(0, 8)
|
||||||
|
expect(freeze?.status).toBe('rolled_back')
|
||||||
|
})
|
||||||
|
})
|
||||||
142
tests/system/generate-image.system.test.ts
Normal file
142
tests/system/generate-image.system.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { callRoute } from '../integration/api/helpers/call-route'
|
||||||
|
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||||
|
import { resetSystemState } from '../helpers/db-reset'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { seedMinimalDomainState } from './helpers/seed'
|
||||||
|
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||||
|
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||||
|
|
||||||
|
const imageState = vi.hoisted(() => ({
|
||||||
|
mode: 'success' as 'success' | 'fatal',
|
||||||
|
cosKey: 'cos/system-image-generated.png',
|
||||||
|
errorMessage: 'IMAGE_GENERATION_FATAL',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
|
||||||
|
'@/lib/workers/handlers/image-task-handler-shared',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
generateLabeledImageToCos: vi.fn(async () => {
|
||||||
|
if (imageState.mode === 'fatal') {
|
||||||
|
throw new Error(imageState.errorMessage)
|
||||||
|
}
|
||||||
|
return imageState.cosKey
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/media/outbound-image', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('system - generate image', () => {
|
||||||
|
let workers: SystemWorkers = {}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
imageState.mode = 'success'
|
||||||
|
imageState.cosKey = 'cos/system-image-generated.png'
|
||||||
|
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
|
||||||
|
await resetSystemState()
|
||||||
|
installAuthMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopSystemWorkers(workers)
|
||||||
|
workers = {}
|
||||||
|
resetAuthMockState()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('route -> queue -> worker -> db writes imageUrl and lifecycle events', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
workers = await startSystemWorkers(['image'])
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
type: 'character',
|
||||||
|
id: seeded.character.id,
|
||||||
|
appearanceId: seeded.appearance.id,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { async: boolean; taskId: string }
|
||||||
|
expect(json.async).toBe(true)
|
||||||
|
expect(typeof json.taskId).toBe('string')
|
||||||
|
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId)
|
||||||
|
expect(task.status).toBe('completed')
|
||||||
|
expect(task.type).toBe('image_character')
|
||||||
|
expect(task.targetId).toBe(seeded.appearance.id)
|
||||||
|
|
||||||
|
const appearance = await prisma.characterAppearance.findUnique({
|
||||||
|
where: { id: seeded.appearance.id },
|
||||||
|
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||||
|
})
|
||||||
|
expect(appearance).toEqual({
|
||||||
|
imageUrl: imageState.cosKey,
|
||||||
|
imageUrls: JSON.stringify([imageState.cosKey]),
|
||||||
|
selectedIndex: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fatal provider path -> task fails and existing appearance images stay unchanged', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
imageState.mode = 'fatal'
|
||||||
|
imageState.errorMessage = 'IMAGE_GENERATION_FATAL'
|
||||||
|
workers = await startSystemWorkers(['image'])
|
||||||
|
|
||||||
|
const originalAppearance = await prisma.characterAppearance.findUnique({
|
||||||
|
where: { id: seeded.appearance.id },
|
||||||
|
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-image/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
type: 'character',
|
||||||
|
id: seeded.character.id,
|
||||||
|
appearanceId: seeded.appearance.id,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { taskId: string }
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId)
|
||||||
|
expect(task.status).toBe('failed')
|
||||||
|
expect(task.errorMessage).toContain('IMAGE_GENERATION_FATAL')
|
||||||
|
|
||||||
|
const appearance = await prisma.characterAppearance.findUnique({
|
||||||
|
where: { id: seeded.appearance.id },
|
||||||
|
select: { imageUrl: true, imageUrls: true, selectedIndex: true },
|
||||||
|
})
|
||||||
|
expect(appearance).toEqual(originalAppearance)
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
121
tests/system/generate-video.system.test.ts
Normal file
121
tests/system/generate-video.system.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { callRoute } from '../integration/api/helpers/call-route'
|
||||||
|
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||||
|
import { resetSystemState } from '../helpers/db-reset'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { seedMinimalDomainState } from './helpers/seed'
|
||||||
|
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||||
|
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||||
|
|
||||||
|
type PollState = {
|
||||||
|
status: 'processing' | 'completed'
|
||||||
|
resultUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoState = vi.hoisted(() => ({
|
||||||
|
pollResponses: new Map<string, PollState[]>(),
|
||||||
|
uploadedCosKey: 'video/system-video.mp4',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/generator-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/generator-api')>('@/lib/generator-api')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
generateVideo: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
async: true,
|
||||||
|
externalId: 'video-ext-1',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/async-poll', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/async-poll')>('@/lib/async-poll')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
pollAsyncTask: vi.fn(async (externalId: string) => {
|
||||||
|
const queue = videoState.pollResponses.get(externalId) || []
|
||||||
|
const next = queue.shift()
|
||||||
|
if (!next) {
|
||||||
|
return { status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' }
|
||||||
|
}
|
||||||
|
videoState.pollResponses.set(externalId, queue)
|
||||||
|
return next
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/media/outbound-image', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/media/outbound-image')>('@/lib/media/outbound-image')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/workers/utils', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/workers/utils')>('@/lib/workers/utils')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
uploadVideoSourceToCos: vi.fn(async () => videoState.uploadedCosKey),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('system - generate video', () => {
|
||||||
|
let workers: SystemWorkers = {}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
videoState.uploadedCosKey = 'video/system-video.mp4'
|
||||||
|
videoState.pollResponses.clear()
|
||||||
|
videoState.pollResponses.set('video-ext-1', [
|
||||||
|
{ status: 'processing' },
|
||||||
|
{ status: 'completed', resultUrl: 'https://provider.example/video-final.mp4' },
|
||||||
|
])
|
||||||
|
await resetSystemState()
|
||||||
|
installAuthMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopSystemWorkers(workers)
|
||||||
|
workers = {}
|
||||||
|
resetAuthMockState()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queued external generation -> polling -> videoUrl persisted', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
workers = await startSystemWorkers(['video'])
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/generate-video/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
storyboardId: seeded.storyboard.id,
|
||||||
|
panelIndex: 0,
|
||||||
|
videoModel: 'fal::seedance/video',
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { async: boolean; taskId: string }
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId)
|
||||||
|
|
||||||
|
expect(task.status).toBe('completed')
|
||||||
|
expect(task.type).toBe('video_panel')
|
||||||
|
expect(task.externalId).toBe('video-ext-1')
|
||||||
|
|
||||||
|
const panel = await prisma.novelPromotionPanel.findUnique({
|
||||||
|
where: { id: seeded.panel.id },
|
||||||
|
select: { videoUrl: true },
|
||||||
|
})
|
||||||
|
expect(panel?.videoUrl).toBe(videoState.uploadedCosKey)
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
184
tests/system/helpers/seed.ts
Normal file
184
tests/system/helpers/seed.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { prisma } from '../../helpers/prisma'
|
||||||
|
import {
|
||||||
|
createFixtureEpisode,
|
||||||
|
createFixtureNovelProject,
|
||||||
|
createFixtureProject,
|
||||||
|
createFixtureUser,
|
||||||
|
} from '../../helpers/fixtures'
|
||||||
|
|
||||||
|
function nextSuffix() {
|
||||||
|
return randomUUID().slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedMinimalDomainState() {
|
||||||
|
const user = await createFixtureUser()
|
||||||
|
const project = await createFixtureProject(user.id)
|
||||||
|
const novelProject = await createFixtureNovelProject(project.id)
|
||||||
|
const episode = await createFixtureEpisode(novelProject.id)
|
||||||
|
|
||||||
|
const clip = await prisma.novelPromotionClip.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
summary: 'seed clip',
|
||||||
|
content: 'seed clip content',
|
||||||
|
screenplay: 'seed screenplay',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const storyboard = await prisma.novelPromotionStoryboard.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
clipId: clip.id,
|
||||||
|
panelCount: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const panel = await prisma.novelPromotionPanel.create({
|
||||||
|
data: {
|
||||||
|
storyboardId: storyboard.id,
|
||||||
|
panelIndex: 0,
|
||||||
|
panelNumber: 1,
|
||||||
|
shotType: '中景',
|
||||||
|
cameraMove: '固定',
|
||||||
|
description: 'seed panel',
|
||||||
|
videoPrompt: 'seed video prompt',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
imageUrl: 'https://provider.example/panel.jpg',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const character = await prisma.novelPromotionCharacter.create({
|
||||||
|
data: {
|
||||||
|
novelPromotionProjectId: novelProject.id,
|
||||||
|
name: 'Narrator',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const appearance = await prisma.characterAppearance.create({
|
||||||
|
data: {
|
||||||
|
characterId: character.id,
|
||||||
|
appearanceIndex: 0,
|
||||||
|
changeReason: 'default',
|
||||||
|
description: 'Narrator appearance',
|
||||||
|
imageUrls: JSON.stringify(['images/character-seed.jpg']),
|
||||||
|
imageUrl: 'images/character-seed.jpg',
|
||||||
|
selectedIndex: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const location = await prisma.novelPromotionLocation.create({
|
||||||
|
data: {
|
||||||
|
novelPromotionProjectId: novelProject.id,
|
||||||
|
name: 'Office',
|
||||||
|
summary: 'Office summary',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const locationImage = await prisma.locationImage.create({
|
||||||
|
data: {
|
||||||
|
locationId: location.id,
|
||||||
|
imageIndex: 0,
|
||||||
|
description: 'Office image',
|
||||||
|
imageUrl: 'images/location-seed.jpg',
|
||||||
|
isSelected: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const voiceLine = await prisma.novelPromotionVoiceLine.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
lineIndex: 1,
|
||||||
|
speaker: 'Narrator',
|
||||||
|
content: 'Hello world',
|
||||||
|
matchedPanelId: panel.id,
|
||||||
|
matchedStoryboardId: storyboard.id,
|
||||||
|
matchedPanelIndex: panel.panelIndex,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.novelPromotionEpisode.update({
|
||||||
|
where: { id: episode.id },
|
||||||
|
data: {
|
||||||
|
speakerVoices: JSON.stringify({
|
||||||
|
Narrator: {
|
||||||
|
provider: 'fal',
|
||||||
|
voiceType: 'uploaded',
|
||||||
|
audioUrl: 'https://provider.example/reference.wav',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondaryPanel = await prisma.novelPromotionPanel.create({
|
||||||
|
data: {
|
||||||
|
storyboardId: storyboard.id,
|
||||||
|
panelIndex: 1,
|
||||||
|
panelNumber: 2,
|
||||||
|
shotType: '近景',
|
||||||
|
cameraMove: '推镜',
|
||||||
|
description: 'secondary panel',
|
||||||
|
videoPrompt: 'secondary prompt',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.novelPromotionStoryboard.update({
|
||||||
|
where: { id: storyboard.id },
|
||||||
|
data: { panelCount: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const foreignStoryboard = await prisma.novelPromotionStoryboard.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
clipId: (await prisma.novelPromotionClip.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
summary: 'foreign clip',
|
||||||
|
content: 'foreign clip content',
|
||||||
|
screenplay: 'foreign screenplay',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})).id,
|
||||||
|
panelCount: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const foreignPanel = await prisma.novelPromotionPanel.create({
|
||||||
|
data: {
|
||||||
|
id: `panel-foreign-${nextSuffix()}`,
|
||||||
|
storyboardId: foreignStoryboard.id,
|
||||||
|
panelIndex: 0,
|
||||||
|
panelNumber: 1,
|
||||||
|
shotType: '远景',
|
||||||
|
cameraMove: '固定',
|
||||||
|
description: 'foreign panel',
|
||||||
|
videoPrompt: 'foreign prompt',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
project,
|
||||||
|
novelProject,
|
||||||
|
episode,
|
||||||
|
clip,
|
||||||
|
storyboard,
|
||||||
|
panel,
|
||||||
|
secondaryPanel,
|
||||||
|
foreignStoryboard,
|
||||||
|
foreignPanel,
|
||||||
|
character,
|
||||||
|
appearance,
|
||||||
|
location,
|
||||||
|
locationImage,
|
||||||
|
voiceLine,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/system/helpers/tasks.ts
Normal file
52
tests/system/helpers/tasks.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { TASK_EVENT_TYPE, TASK_STATUS, type TaskEventType, type TaskStatus } from '@/lib/task/types'
|
||||||
|
import { expect } from 'vitest'
|
||||||
|
import { prisma } from '../../helpers/prisma'
|
||||||
|
|
||||||
|
type WaitTaskOptions = {
|
||||||
|
timeoutMs?: number
|
||||||
|
intervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
TASK_STATUS.DISMISSED,
|
||||||
|
])
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTaskTerminalState(taskId: string, options: WaitTaskOptions = {}) {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 15_000
|
||||||
|
const intervalMs = options.intervalMs ?? 100
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startedAt <= timeoutMs) {
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
})
|
||||||
|
if (task && TERMINAL_STATUSES.has(task.status as TaskStatus)) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
await sleep(intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`TASK_WAIT_TIMEOUT: ${taskId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTaskEventTypes(taskId: string): Promise<TaskEventType[]> {
|
||||||
|
const events = await prisma.taskEvent.findMany({
|
||||||
|
where: { taskId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { eventType: true },
|
||||||
|
})
|
||||||
|
return events.map((event) => event.eventType as TaskEventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectLifecycleEvents(types: ReadonlyArray<TaskEventType>, terminal: 'completed' | 'failed') {
|
||||||
|
const expectedTerminal = terminal === 'completed' ? TASK_EVENT_TYPE.COMPLETED : TASK_EVENT_TYPE.FAILED
|
||||||
|
expect(types).toContain(TASK_EVENT_TYPE.CREATED)
|
||||||
|
expect(types).toContain(TASK_EVENT_TYPE.PROCESSING)
|
||||||
|
expect(types).toContain(expectedTerminal)
|
||||||
|
}
|
||||||
40
tests/system/helpers/workers.ts
Normal file
40
tests/system/helpers/workers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Worker } from 'bullmq'
|
||||||
|
import type { TaskJobData } from '@/lib/task/types'
|
||||||
|
|
||||||
|
export type SystemWorkerScope = 'image' | 'video' | 'voice' | 'text'
|
||||||
|
|
||||||
|
export type SystemWorkers = Partial<Record<SystemWorkerScope, Worker<TaskJobData>>>
|
||||||
|
|
||||||
|
async function createWorker(scope: SystemWorkerScope): Promise<Worker<TaskJobData>> {
|
||||||
|
if (scope === 'image') {
|
||||||
|
const mod = await import('@/lib/workers/image.worker')
|
||||||
|
return mod.createImageWorker()
|
||||||
|
}
|
||||||
|
if (scope === 'video') {
|
||||||
|
const mod = await import('@/lib/workers/video.worker')
|
||||||
|
return mod.createVideoWorker()
|
||||||
|
}
|
||||||
|
if (scope === 'voice') {
|
||||||
|
const mod = await import('@/lib/workers/voice.worker')
|
||||||
|
return mod.createVoiceWorker()
|
||||||
|
}
|
||||||
|
const mod = await import('@/lib/workers/text.worker')
|
||||||
|
return mod.createTextWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSystemWorkers(scopes: ReadonlyArray<SystemWorkerScope>): Promise<SystemWorkers> {
|
||||||
|
const started: SystemWorkers = {}
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const worker = await createWorker(scope)
|
||||||
|
await worker.waitUntilReady()
|
||||||
|
started[scope] = worker
|
||||||
|
}
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopSystemWorkers(workers: SystemWorkers): Promise<void> {
|
||||||
|
for (const worker of Object.values(workers)) {
|
||||||
|
if (!worker) continue
|
||||||
|
await worker.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
351
tests/system/text-workflow.system.test.ts
Normal file
351
tests/system/text-workflow.system.test.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { callRoute } from '../integration/api/helpers/call-route'
|
||||||
|
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||||
|
import { resetSystemState } from '../helpers/db-reset'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { seedMinimalDomainState } from './helpers/seed'
|
||||||
|
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||||
|
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||||
|
import { createFixtureEpisode, createFixtureNovelProject, createFixtureProject, createFixtureUser } from '../helpers/fixtures'
|
||||||
|
|
||||||
|
type FakeAiResult = {
|
||||||
|
text: string
|
||||||
|
reasoning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeVoiceLineRow = {
|
||||||
|
lineIndex: number
|
||||||
|
speaker: string
|
||||||
|
content: string
|
||||||
|
emotionStrength: number
|
||||||
|
matchedPanel: {
|
||||||
|
storyboardId: string
|
||||||
|
panelIndex: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textState = vi.hoisted(() => ({
|
||||||
|
aiResults: [] as FakeAiResult[],
|
||||||
|
voiceLineResults: [] as FakeVoiceLineRow[],
|
||||||
|
parseFailureCount: 0,
|
||||||
|
orchestratorClipId: 'clip-seed',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/ai-runtime', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/ai-runtime')>('@/lib/ai-runtime')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
executeAiTextStep: vi.fn(async () => {
|
||||||
|
const next = textState.aiResults.shift()
|
||||||
|
if (!next) {
|
||||||
|
return {
|
||||||
|
text: '{"ok":true}',
|
||||||
|
reasoning: '',
|
||||||
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||||
|
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: next.text,
|
||||||
|
reasoning: next.reasoning || '',
|
||||||
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||||
|
completion: { usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } },
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/novel-promotion/script-to-storyboard/orchestrator')>(
|
||||||
|
'@/lib/novel-promotion/script-to-storyboard/orchestrator',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
runScriptToStoryboardOrchestrator: vi.fn(async () => ({
|
||||||
|
clipPanels: [
|
||||||
|
{
|
||||||
|
clipId: textState.orchestratorClipId,
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
panelIndex: 1,
|
||||||
|
shotType: 'close-up',
|
||||||
|
cameraMove: 'static',
|
||||||
|
description: 'system generated panel',
|
||||||
|
videoPrompt: 'system video prompt',
|
||||||
|
location: 'Office',
|
||||||
|
characters: ['Narrator'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
totalPanelCount: 1,
|
||||||
|
totalStepCount: 4,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/script-to-storyboard-helpers')>(
|
||||||
|
'@/lib/workers/handlers/script-to-storyboard-helpers',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
parseVoiceLinesJson: vi.fn(() => {
|
||||||
|
if (textState.parseFailureCount > 0) {
|
||||||
|
textState.parseFailureCount -= 1
|
||||||
|
throw new Error('invalid voice json')
|
||||||
|
}
|
||||||
|
return textState.voiceLineResults
|
||||||
|
}),
|
||||||
|
persistStoryboardsAndPanels: vi.fn(async (input: { episodeId: string }) => {
|
||||||
|
const clip = await prisma.novelPromotionClip.findFirst({
|
||||||
|
where: { episodeId: input.episodeId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
if (!clip) {
|
||||||
|
throw new Error(`TEST_CLIP_NOT_FOUND: ${input.episodeId}`)
|
||||||
|
}
|
||||||
|
const storyboard = await prisma.novelPromotionStoryboard.create({
|
||||||
|
data: {
|
||||||
|
id: 'storyboard-1',
|
||||||
|
episodeId: input.episodeId,
|
||||||
|
clipId: clip.id,
|
||||||
|
panelCount: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const panel = await prisma.novelPromotionPanel.create({
|
||||||
|
data: {
|
||||||
|
id: 'panel-1',
|
||||||
|
storyboardId: storyboard.id,
|
||||||
|
panelIndex: 1,
|
||||||
|
panelNumber: 1,
|
||||||
|
shotType: 'close-up',
|
||||||
|
cameraMove: 'static',
|
||||||
|
description: 'system generated panel',
|
||||||
|
videoPrompt: 'system video prompt',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return [{ storyboardId: storyboard.id, panels: [{ id: panel.id, panelIndex: 1 }] }]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
|
||||||
|
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
|
||||||
|
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
|
||||||
|
createWorkerLLMStreamCallbacks: vi.fn(() => ({
|
||||||
|
onStage: vi.fn(),
|
||||||
|
onChunk: vi.fn(),
|
||||||
|
onComplete: vi.fn(),
|
||||||
|
onError: vi.fn(),
|
||||||
|
flush: vi.fn(async () => undefined),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function seedScriptToStoryboardState() {
|
||||||
|
const user = await createFixtureUser()
|
||||||
|
const project = await createFixtureProject(user.id)
|
||||||
|
const novelProject = await createFixtureNovelProject(project.id)
|
||||||
|
const episode = await createFixtureEpisode(novelProject.id)
|
||||||
|
const clip = await prisma.novelPromotionClip.create({
|
||||||
|
data: {
|
||||||
|
episodeId: episode.id,
|
||||||
|
summary: 'script clip',
|
||||||
|
content: 'clip content',
|
||||||
|
screenplay: 'screenplay text',
|
||||||
|
location: 'Office',
|
||||||
|
characters: JSON.stringify(['Narrator']),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.novelPromotionCharacter.create({
|
||||||
|
data: {
|
||||||
|
novelPromotionProjectId: novelProject.id,
|
||||||
|
name: 'Narrator',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await prisma.novelPromotionLocation.create({
|
||||||
|
data: {
|
||||||
|
novelPromotionProjectId: novelProject.id,
|
||||||
|
name: 'Office',
|
||||||
|
summary: 'Office',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
textState.orchestratorClipId = clip.id
|
||||||
|
return { user, project, novelProject, episode, clip }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('system - text workflows', () => {
|
||||||
|
let workers: SystemWorkers = {}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
textState.aiResults = []
|
||||||
|
textState.voiceLineResults = []
|
||||||
|
textState.parseFailureCount = 0
|
||||||
|
textState.orchestratorClipId = 'clip-seed'
|
||||||
|
await resetSystemState()
|
||||||
|
installAuthMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopSystemWorkers(workers)
|
||||||
|
workers = {}
|
||||||
|
resetAuthMockState()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('script-to-storyboard success -> persists storyboard/panel/voiceLine and completes task', async () => {
|
||||||
|
const seeded = await seedScriptToStoryboardState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
textState.aiResults = [{ text: 'voice-lines-json' }]
|
||||||
|
textState.voiceLineResults = [
|
||||||
|
{
|
||||||
|
lineIndex: 1,
|
||||||
|
speaker: 'Narrator',
|
||||||
|
content: 'Hello world',
|
||||||
|
emotionStrength: 0.8,
|
||||||
|
matchedPanel: {
|
||||||
|
storyboardId: 'storyboard-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
workers = await startSystemWorkers(['text'])
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{ locale: 'zh', episodeId: seeded.episode.id },
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { taskId: string }
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||||
|
expect(task.status).toBe('completed')
|
||||||
|
expect(task.type).toBe('script_to_storyboard_run')
|
||||||
|
expect(task.result).toEqual(expect.objectContaining({
|
||||||
|
episodeId: seeded.episode.id,
|
||||||
|
panelCount: 1,
|
||||||
|
voiceLineCount: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const storyboards = await prisma.novelPromotionStoryboard.findMany({
|
||||||
|
where: { episodeId: seeded.episode.id },
|
||||||
|
select: { id: true, panelCount: true },
|
||||||
|
})
|
||||||
|
expect(storyboards.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const persistedVoiceLines = await prisma.novelPromotionVoiceLine.findMany({
|
||||||
|
where: { episodeId: seeded.episode.id },
|
||||||
|
orderBy: { lineIndex: 'asc' },
|
||||||
|
select: {
|
||||||
|
lineIndex: true,
|
||||||
|
speaker: true,
|
||||||
|
content: true,
|
||||||
|
matchedPanelId: true,
|
||||||
|
matchedPanelIndex: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(persistedVoiceLines).toEqual([
|
||||||
|
{
|
||||||
|
lineIndex: 1,
|
||||||
|
speaker: 'Narrator',
|
||||||
|
content: 'Hello world',
|
||||||
|
matchedPanelId: expect.any(String),
|
||||||
|
matchedPanelIndex: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'completed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('script-to-storyboard parse retry -> second attempt succeeds', async () => {
|
||||||
|
const seeded = await seedScriptToStoryboardState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
textState.aiResults = [
|
||||||
|
{ text: 'invalid-voice-json' },
|
||||||
|
{ text: 'valid-voice-json' },
|
||||||
|
]
|
||||||
|
textState.voiceLineResults = [
|
||||||
|
{
|
||||||
|
lineIndex: 1,
|
||||||
|
speaker: 'Narrator',
|
||||||
|
content: 'Retry success',
|
||||||
|
emotionStrength: 0.4,
|
||||||
|
matchedPanel: {
|
||||||
|
storyboardId: 'storyboard-1',
|
||||||
|
panelIndex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
textState.parseFailureCount = 1
|
||||||
|
workers = await startSystemWorkers(['text'])
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/script-to-storyboard-stream/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{ locale: 'zh', episodeId: seeded.episode.id },
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await response.json() as { taskId: string }
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||||
|
expect(task.status).toBe('completed')
|
||||||
|
expect(task.result).toEqual(expect.objectContaining({
|
||||||
|
voiceLineCount: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const voiceLines = await prisma.novelPromotionVoiceLine.findMany({
|
||||||
|
where: { episodeId: seeded.episode.id },
|
||||||
|
select: { content: true },
|
||||||
|
})
|
||||||
|
expect(voiceLines).toEqual([{ content: 'Retry success' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('insert-panel invalid ai payload -> task fails and no dirty panel remains', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
textState.aiResults = [{ text: 'not-json' }]
|
||||||
|
workers = await startSystemWorkers(['text'])
|
||||||
|
|
||||||
|
const beforeCount = await prisma.novelPromotionPanel.count({
|
||||||
|
where: { storyboardId: seeded.storyboard.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/insert-panel/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
storyboardId: seeded.storyboard.id,
|
||||||
|
insertAfterPanelId: seeded.panel.id,
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { taskId: string }
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId, { timeoutMs: 20_000 })
|
||||||
|
expect(task.status).toBe('failed')
|
||||||
|
|
||||||
|
const afterCount = await prisma.novelPromotionPanel.count({
|
||||||
|
where: { storyboardId: seeded.storyboard.id },
|
||||||
|
})
|
||||||
|
expect(afterCount).toBe(beforeCount)
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
108
tests/system/voice-generate.system.test.ts
Normal file
108
tests/system/voice-generate.system.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { callRoute } from '../integration/api/helpers/call-route'
|
||||||
|
import { installAuthMocks, mockAuthenticated, resetAuthMockState } from '../helpers/auth'
|
||||||
|
import { resetSystemState } from '../helpers/db-reset'
|
||||||
|
import { prisma } from '../helpers/prisma'
|
||||||
|
import { seedMinimalDomainState } from './helpers/seed'
|
||||||
|
import { expectLifecycleEvents, listTaskEventTypes, waitForTaskTerminalState } from './helpers/tasks'
|
||||||
|
import { startSystemWorkers, stopSystemWorkers, type SystemWorkers } from './helpers/workers'
|
||||||
|
|
||||||
|
const voiceState = vi.hoisted(() => ({
|
||||||
|
audioUrl: 'voice/system-line.wav',
|
||||||
|
audioDuration: 1200,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-config', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/api-config')>('@/lib/api-config')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveModelSelectionOrSingle: vi.fn(async () => ({
|
||||||
|
provider: 'fal',
|
||||||
|
modelId: 'fal-audio-model',
|
||||||
|
modelKey: 'fal::audio-model',
|
||||||
|
mediaType: 'audio',
|
||||||
|
})),
|
||||||
|
getProviderKey: vi.fn((providerId: string) => providerId),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/voice/generate-voice-line', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/voice/generate-voice-line')>('@/lib/voice/generate-voice-line')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
generateVoiceLine: vi.fn(async (params: {
|
||||||
|
lineId: string
|
||||||
|
}) => {
|
||||||
|
await prisma.novelPromotionVoiceLine.update({
|
||||||
|
where: { id: params.lineId },
|
||||||
|
data: {
|
||||||
|
audioUrl: voiceState.audioUrl,
|
||||||
|
audioDuration: voiceState.audioDuration,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
lineId: params.lineId,
|
||||||
|
audioUrl: voiceState.audioUrl,
|
||||||
|
storageKey: voiceState.audioUrl,
|
||||||
|
audioDuration: voiceState.audioDuration,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('system - voice generate', () => {
|
||||||
|
let workers: SystemWorkers = {}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
voiceState.audioUrl = 'voice/system-line.wav'
|
||||||
|
voiceState.audioDuration = 1200
|
||||||
|
await resetSystemState()
|
||||||
|
installAuthMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopSystemWorkers(workers)
|
||||||
|
workers = {}
|
||||||
|
resetAuthMockState()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('route -> voice worker -> line audio persisted', async () => {
|
||||||
|
const seeded = await seedMinimalDomainState()
|
||||||
|
mockAuthenticated(seeded.user.id)
|
||||||
|
workers = await startSystemWorkers(['voice'])
|
||||||
|
|
||||||
|
const mod = await import('@/app/api/novel-promotion/[projectId]/voice-generate/route')
|
||||||
|
const response = await callRoute(
|
||||||
|
mod.POST,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
episodeId: seeded.episode.id,
|
||||||
|
lineId: seeded.voiceLine.id,
|
||||||
|
audioModel: 'fal::audio-model',
|
||||||
|
},
|
||||||
|
{ params: { projectId: seeded.project.id } },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json() as { success: boolean; async: boolean; taskId: string }
|
||||||
|
expect(json.success).toBe(true)
|
||||||
|
const task = await waitForTaskTerminalState(json.taskId)
|
||||||
|
expect(task.status).toBe('completed')
|
||||||
|
expect(task.type).toBe('voice_line')
|
||||||
|
|
||||||
|
const voiceLine = await prisma.novelPromotionVoiceLine.findUnique({
|
||||||
|
where: { id: seeded.voiceLine.id },
|
||||||
|
select: { audioUrl: true, audioDuration: true },
|
||||||
|
})
|
||||||
|
expect(voiceLine).toEqual({
|
||||||
|
audioUrl: voiceState.audioUrl,
|
||||||
|
audioDuration: voiceState.audioDuration,
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventTypes = await listTaskEventTypes(json.taskId)
|
||||||
|
expectLifecycleEvents(eventTypes, 'completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
tests/unit/guards/changed-file-test-impact-guard.test.ts
Normal file
29
tests/unit/guards/changed-file-test-impact-guard.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { inspectChangedFiles } from '../../../scripts/guards/changed-file-test-impact-guard.mjs'
|
||||||
|
|
||||||
|
describe('changed-file-test-impact-guard', () => {
|
||||||
|
it('requires api changes to be paired with contract, system, or regression tests', () => {
|
||||||
|
const violations = inspectChangedFiles([
|
||||||
|
'src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||||
|
])
|
||||||
|
expect(violations).toEqual([
|
||||||
|
'api: changing src/app/api/** requires a matching contract, system, or regression test change; sources=src/app/api/novel-promotion/[projectId]/generate-image/route.ts',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts worker changes when system tests are updated together', () => {
|
||||||
|
const violations = inspectChangedFiles([
|
||||||
|
'src/lib/workers/image.worker.ts',
|
||||||
|
'tests/system/generate-image.system.test.ts',
|
||||||
|
])
|
||||||
|
expect(violations).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts provider changes when provider contract coverage is updated', () => {
|
||||||
|
const violations = inspectChangedFiles([
|
||||||
|
'src/lib/model-gateway/openai-compat/image.ts',
|
||||||
|
'tests/unit/model-gateway/openai-compat-template-image-output-urls.test.ts',
|
||||||
|
])
|
||||||
|
expect(violations).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
27
tests/unit/task/error-catalog.contract.test.ts
Normal file
27
tests/unit/task/error-catalog.contract.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ERROR_CATALOG, ERROR_CATEGORY, getErrorSpec } from '@/lib/errors/codes'
|
||||||
|
|
||||||
|
describe('error catalog contract', () => {
|
||||||
|
it('keeps every catalog entry self-consistent and reachable through getErrorSpec', () => {
|
||||||
|
const seenMessageKeys = new Set<string>()
|
||||||
|
|
||||||
|
for (const [code, spec] of Object.entries(ERROR_CATALOG)) {
|
||||||
|
expect(getErrorSpec(code as keyof typeof ERROR_CATALOG)).toEqual(spec)
|
||||||
|
expect(spec.defaultMessage.trim().length).toBeGreaterThan(0)
|
||||||
|
expect(spec.userMessageKey.trim().length).toBeGreaterThan(0)
|
||||||
|
expect(spec.httpStatus).toBeGreaterThanOrEqual(200)
|
||||||
|
expect(spec.httpStatus).toBeLessThan(600)
|
||||||
|
expect(Object.values(ERROR_CATEGORY)).toContain(spec.category)
|
||||||
|
expect(seenMessageKeys.has(spec.userMessageKey)).toBe(false)
|
||||||
|
seenMessageKeys.add(spec.userMessageKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps retryable provider/system errors out of 4xx except 429 and 202', () => {
|
||||||
|
for (const spec of Object.values(ERROR_CATALOG)) {
|
||||||
|
if (!spec.retryable) continue
|
||||||
|
if (spec.httpStatus >= 500) continue
|
||||||
|
expect([202, 429]).toContain(spec.httpStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,78 +1,51 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { withUserConcurrencyGate } from '@/lib/workers/user-concurrency-gate'
|
import { withUserConcurrencyGate } from '@/lib/workers/user-concurrency-gate'
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
function deferred<T>() {
|
||||||
return new Promise((resolve) => {
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
setTimeout(resolve, ms)
|
const promise = new Promise<T>((nextResolve) => {
|
||||||
|
resolve = nextResolve
|
||||||
})
|
})
|
||||||
|
return { promise, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('user concurrency gate', () => {
|
describe('user concurrency gate', () => {
|
||||||
it('enforces max concurrent runs for same user and scope', async () => {
|
it('serializes same-scope work for the same user when limit is 1', async () => {
|
||||||
let activeCount = 0
|
const firstDone = deferred<void>()
|
||||||
let maxActiveCount = 0
|
const events: string[] = []
|
||||||
|
|
||||||
const runTask = async (taskId: number) => await withUserConcurrencyGate({
|
const first = withUserConcurrencyGate({
|
||||||
scope: 'video',
|
scope: 'image',
|
||||||
userId: 'user-gate-video',
|
userId: 'user-1',
|
||||||
limit: 2,
|
limit: 1,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
void taskId
|
events.push('first:start')
|
||||||
activeCount += 1
|
await firstDone.promise
|
||||||
maxActiveCount = Math.max(maxActiveCount, activeCount)
|
events.push('first:end')
|
||||||
await wait(20)
|
|
||||||
activeCount -= 1
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.all([
|
const second = withUserConcurrencyGate({
|
||||||
runTask(1),
|
scope: 'image',
|
||||||
runTask(2),
|
userId: 'user-1',
|
||||||
runTask(3),
|
limit: 1,
|
||||||
runTask(4),
|
run: async () => {
|
||||||
|
events.push('second:start')
|
||||||
|
events.push('second:end')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(events).toEqual(['first:start'])
|
||||||
|
|
||||||
|
firstDone.resolve()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
'first:start',
|
||||||
|
'first:end',
|
||||||
|
'second:start',
|
||||||
|
'second:end',
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(maxActiveCount).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not share slots between different users', async () => {
|
|
||||||
let activeCount = 0
|
|
||||||
let maxActiveCount = 0
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
withUserConcurrencyGate({
|
|
||||||
scope: 'image',
|
|
||||||
userId: 'user-gate-image-a',
|
|
||||||
limit: 1,
|
|
||||||
run: async () => {
|
|
||||||
activeCount += 1
|
|
||||||
maxActiveCount = Math.max(maxActiveCount, activeCount)
|
|
||||||
await wait(20)
|
|
||||||
activeCount -= 1
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
withUserConcurrencyGate({
|
|
||||||
scope: 'image',
|
|
||||||
userId: 'user-gate-image-b',
|
|
||||||
limit: 1,
|
|
||||||
run: async () => {
|
|
||||||
activeCount += 1
|
|
||||||
maxActiveCount = Math.max(maxActiveCount, activeCount)
|
|
||||||
await wait(20)
|
|
||||||
activeCount -= 1
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(maxActiveCount).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws when concurrency limit is invalid', async () => {
|
|
||||||
await expect(withUserConcurrencyGate({
|
|
||||||
scope: 'video',
|
|
||||||
userId: 'user-gate-invalid',
|
|
||||||
limit: 0,
|
|
||||||
run: async () => undefined,
|
|
||||||
})).rejects.toThrow('WORKFLOW_CONCURRENCY_INVALID')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
49
vitest.core-coverage.config.ts
Normal file
49
vitest.core-coverage.config.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
css: false,
|
||||||
|
pool: 'forks',
|
||||||
|
poolOptions: {
|
||||||
|
forks: {
|
||||||
|
minForks: 1,
|
||||||
|
maxForks: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setupFiles: ['./tests/setup/env.ts'],
|
||||||
|
globalSetup: ['./tests/setup/global-setup.ts'],
|
||||||
|
include: ['**/*.test.ts'],
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 60_000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json-summary'],
|
||||||
|
reportsDirectory: './coverage/core-baseline',
|
||||||
|
include: [
|
||||||
|
'src/app/api/**',
|
||||||
|
'src/lib/task/**',
|
||||||
|
'src/lib/workers/**',
|
||||||
|
'src/lib/media/**',
|
||||||
|
'src/lib/errors/**',
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
branches: 0,
|
||||||
|
functions: 0,
|
||||||
|
lines: 0,
|
||||||
|
statements: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user