diff --git a/package.json b/package.json index 4f96e2b..5677119 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "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-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-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", @@ -71,21 +72,28 @@ "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: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: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: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: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: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: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:pr": "bash scripts/test-regression-runner.sh npm run test:regression", + "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: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: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", "lint:all": "npm run lint -- .", - "verify:commit": "npm run lint:all && npm run typecheck && npm run test:behavior:full", - "verify:push": "npm run lint:all && npm run typecheck && npm run test:regression && npm run build", + "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:all && npm run build", "migrate:image-urls-contract": "tsx scripts/migrate-image-urls-contract.ts", "migrate:model-config-contract": "tsx scripts/migrations/migrate-model-config-contract.ts", "migrate:capability-selections": "tsx scripts/migrations/migrate-capability-selections.ts", diff --git a/scripts/guards/changed-file-test-impact-guard.mjs b/scripts/guards/changed-file-test-impact-guard.mjs new file mode 100644 index 0000000..046f9da --- /dev/null +++ b/scripts/guards/changed-file-test-impact-guard.mjs @@ -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() +} diff --git a/scripts/guards/test-behavior-quality-guard.mjs b/scripts/guards/test-behavior-quality-guard.mjs index 66e09da..ccb023c 100644 --- a/scripts/guards/test-behavior-quality-guard.mjs +++ b/scripts/guards/test-behavior-quality-guard.mjs @@ -6,7 +6,10 @@ import path from 'path' const root = process.cwd() const targetDirs = [ path.join(root, 'tests', 'integration', 'api', 'contract'), + path.join(root, 'tests', 'integration', 'provider'), path.join(root, 'tests', 'integration', 'chain'), + path.join(root, 'tests', 'system'), + path.join(root, 'tests', 'regression'), ] function fail(title, details = []) { diff --git a/src/lib/async-submit.ts b/src/lib/async-submit.ts index ab60e6a..3f5d57f 100644 --- a/src/lib/async-submit.ts +++ b/src/lib/async-submit.ts @@ -1,4 +1,5 @@ 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 ${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 方法 const response = await fetch(statusUrl, { @@ -122,7 +123,7 @@ export async function queryFalStatus(endpoint: string, requestId: string, apiKey // 优先使用返回的 response_url,如果没有则构建 URL // 注意:获取结果必须使用完整的原始端点(包括 /edit 等路径),而不是 baseEndpoint // 否则 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}`) const resultResponse = await fetch(resultUrl, { diff --git a/src/lib/async-task-utils.ts b/src/lib/async-task-utils.ts index 8ac75c0..a4df10e 100644 --- a/src/lib/async-task-utils.ts +++ b/src/lib/async-task-utils.ts @@ -6,6 +6,7 @@ */ import { logInternal } from './logging/semantic' +import { buildFalQueueUrl } from '@/lib/providers/fal/base-url' export interface TaskStatus { status: 'pending' | 'completed' | 'failed' @@ -51,7 +52,7 @@ export async function queryBananaTaskStatus(requestId: string, apiKey: string): try { 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}` }, cache: 'no-store' @@ -68,7 +69,7 @@ export async function queryBananaTaskStatus(requestId: string, apiKey: string): if (data.status === 'COMPLETED') { // 获取结果 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}` }, cache: 'no-store' diff --git a/src/lib/generators/fal.ts b/src/lib/generators/fal.ts index 4829837..95d8f19 100644 --- a/src/lib/generators/fal.ts +++ b/src/lib/generators/fal.ts @@ -25,6 +25,7 @@ import { import { getProviderConfig } from '@/lib/api-config' import { submitFalTask } from '@/lib/async-submit' import { normalizeToBase64ForGeneration } from '@/lib/media/outbound-image' +import { buildFalQueueUrl } from '@/lib/providers/fal/base-url' // ============================================================ // 图像模型端点映射(modelId → FAL 端点前缀) @@ -146,7 +147,7 @@ export class FalImageGenerator extends BaseImageGenerator { logger.info({ message: 'FAL image request body summary', details: { - url: `https://queue.fal.run/${endpoint}`, + url: buildFalQueueUrl(endpoint), promptLength: prompt.length, imageUrlsCount: hasReferenceImages ? (body.image_urls as string[]).length : 0, 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', headers: { 'Content-Type': 'application/json', diff --git a/src/lib/providers/fal/base-url.ts b/src/lib/providers/fal/base-url.ts new file mode 100644 index 0000000..de02f22 --- /dev/null +++ b/src/lib/providers/fal/base-url.ts @@ -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}` +} diff --git a/tests/contracts/behavior-test-standard.md b/tests/contracts/behavior-test-standard.md index 9b19a53..cf2b175 100644 --- a/tests/contracts/behavior-test-standard.md +++ b/tests/contracts/behavior-test-standard.md @@ -2,7 +2,10 @@ ## Scope - `tests/integration/api/contract/**/*.test.ts` +- `tests/integration/provider/**/*.test.ts` - `tests/integration/chain/**/*.test.ts` +- `tests/system/**/*.test.ts` +- `tests/regression/**/*.test.ts` - `tests/unit/worker/**/*.test.ts` ## Must-have @@ -23,3 +26,4 @@ ## Regression rule - One historical bug must map to at least one dedicated regression test case. - Bug fix without matching behavior regression test is incomplete. +- Provider or gateway protocol changes must add a provider contract test or update an existing localhost fake-provider scenario. diff --git a/tests/contracts/requirements-matrix.ts b/tests/contracts/requirements-matrix.ts index 3ec1687..0667597 100644 --- a/tests/contracts/requirements-matrix.ts +++ b/tests/contracts/requirements-matrix.ts @@ -43,6 +43,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/integration/api/contract/direct-submit-routes.test.ts', 'tests/unit/worker/image-task-handlers-core.test.ts', 'tests/integration/chain/image.chain.test.ts', + 'tests/system/generate-image.system.test.ts', ], }, { @@ -55,6 +56,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/integration/api/contract/direct-submit-routes.test.ts', 'tests/unit/worker/video-worker.test.ts', 'tests/integration/chain/video.chain.test.ts', + 'tests/system/generate-video.system.test.ts', ], }, { @@ -66,6 +68,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ tests: [ 'tests/unit/novel-promotion/insert-panel-user-input.test.ts', 'tests/integration/api/contract/direct-submit-routes.test.ts', + 'tests/system/text-workflow.system.test.ts', ], }, { @@ -78,6 +81,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/integration/api/specific/panel-variant-route.test.ts', 'tests/integration/api/contract/direct-submit-routes.test.ts', 'tests/unit/worker/panel-variant-task-handler.test.ts', + 'tests/regression/panel-variant-cross-storyboard.test.ts', ], }, { @@ -90,6 +94,7 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/integration/api/contract/llm-observe-routes.test.ts', 'tests/unit/worker/script-to-storyboard.test.ts', 'tests/integration/chain/text.chain.test.ts', + 'tests/system/text-workflow.system.test.ts', ], }, { @@ -101,9 +106,36 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ tests: [ 'tests/unit/helpers/task-state-service.test.ts', 'tests/integration/api/contract/task-infra-routes.test.ts', + 'tests/integration/task/create-task-dedupe.integration.test.ts', 'tests/unit/optimistic/sse-invalidation.test.ts', ], }, + { + id: 'REQ-PROVIDER-PROTOCOL-CONTRACT', + feature: 'Provider protocol contract', + userValue: '外部 provider 请求格式、轮询状态和错误分类保持稳定', + risk: 'provider 协议漂移导致系统链路仅在真实调用时失败', + priority: 'P0', + tests: [ + 'tests/integration/provider/fal-provider.contract.test.ts', + 'tests/integration/provider/openai-compat-provider.contract.test.ts', + 'tests/unit/task/async-poll-external-id.test.ts', + ], + }, + { + id: 'REQ-TASK-DEDUPE-COMPENSATION', + feature: 'Task dedupe and enqueue compensation', + userValue: '重复提交不会卡死,队列失败不会留下脏冻结或孤儿任务', + risk: '重复任务、孤儿 dedupeKey、enqueue 失败后冻结金额未回滚', + priority: 'P0', + tests: [ + 'tests/integration/task/create-task-dedupe.integration.test.ts', + 'tests/integration/billing/submitter.integration.test.ts', + 'tests/regression/task-dedupe-recovery.test.ts', + 'tests/regression/task-enqueue-billing-rollback.test.ts', + 'tests/unit/worker/user-concurrency-gate.test.ts', + ], + }, { id: 'REQ-API-CONFIG-TUTORIAL-PORTAL', feature: 'API config tutorial modal layering', diff --git a/tests/helpers/auth.ts b/tests/helpers/auth.ts index 1fdcc7b..6f7bb7f 100644 --- a/tests/helpers/auth.ts +++ b/tests/helpers/auth.ts @@ -81,7 +81,7 @@ export function installAuthMocks() { if (state.projectAuthMode === 'not_found') return notFoundResponse() return { 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' }, } }, diff --git a/tests/helpers/fakes/scenario-server.ts b/tests/helpers/fakes/scenario-server.ts new file mode 100644 index 0000000..0dc9727 --- /dev/null +++ b/tests/helpers/fakes/scenario-server.ts @@ -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 + body?: string | Buffer | Record | unknown[] | null + delayMs?: number +} + +export type FakeRequestRecord = { + method: string + path: string + query: string + bodyText: string + headers: Record +} + +type RouteKey = `${Uppercase} ${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 { + 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 | 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() + const routes = new Map() + + 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((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((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + }, + } +} diff --git a/tests/hidden/README.md b/tests/hidden/README.md new file mode 100644 index 0000000..355a48d --- /dev/null +++ b/tests/hidden/README.md @@ -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/**`. diff --git a/tests/integration/api/helpers/call-route.ts b/tests/integration/api/helpers/call-route.ts index ac29c02..8351938 100644 --- a/tests/integration/api/helpers/call-route.ts +++ b/tests/integration/api/helpers/call-route.ts @@ -1,18 +1,19 @@ import { NextRequest } from 'next/server' -type RouteParams = Record +type RouteParamValue = string | string[] | undefined +type RouteParams = Record type HeaderMap = Record -type RouteHandler = ( +type RouteHandler = ( req: NextRequest, - ctx?: { params: Promise }, + ctx: { params: Promise }, ) => Promise -export async function callRoute( - handler: RouteHandler, +export async function callRoute( + handler: RouteHandler, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', body?: unknown, - options?: { headers?: HeaderMap; params?: RouteParams; query?: Record }, + options?: { headers?: HeaderMap; params?: TParams; query?: Record }, ) { const url = new URL('http://localhost:3000/api/test') if (options?.query) { @@ -30,6 +31,6 @@ export async function callRoute( }, ...(payload ? { body: payload } : {}), }) - const context = { params: Promise.resolve(options?.params || {}) } + const context = { params: Promise.resolve((options?.params || {}) as TParams) } return await handler(req, context) } diff --git a/tests/integration/billing/submitter.integration.test.ts b/tests/integration/billing/submitter.integration.test.ts index 0e6d7b8..37dfda4 100644 --- a/tests/integration/billing/submitter.integration.test.ts +++ b/tests/integration/billing/submitter.integration.test.ts @@ -7,8 +7,18 @@ import { prisma } from '../../helpers/prisma' import { resetBillingState } from '../../helpers/db-reset' import { createTestUser, seedBalance } from '../../helpers/billing-fixtures' +const queueState = vi.hoisted(() => ({ + mode: 'success' as 'success' | 'fail', + errorMessage: 'queue add failed', +})) + 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', () => ({ @@ -19,6 +29,8 @@ describe('billing/submitter integration', () => { beforeEach(async () => { await resetBillingState() process.env.BILLING_MODE = 'ENFORCE' + queueState.mode = 'success' + queueState.errorMessage = 'queue add failed' }) 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?.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) + + 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') + }) }) diff --git a/tests/integration/provider/fal-provider.contract.test.ts b/tests/integration/provider/fal-provider.contract.test.ts new file mode 100644 index 0000000..88284ce --- /dev/null +++ b/tests/integration/provider/fal-provider.contract.test.ts @@ -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> | 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, + }) + }) +}) diff --git a/tests/integration/provider/openai-compat-provider.contract.test.ts b/tests/integration/provider/openai-compat-provider.contract.test.ts new file mode 100644 index 0000000..c9952ec --- /dev/null +++ b/tests/integration/provider/openai-compat-provider.contract.test.ts @@ -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> | 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') + }) +}) diff --git a/tests/integration/task/create-task-dedupe.integration.test.ts b/tests/integration/task/create-task-dedupe.integration.test.ts new file mode 100644 index 0000000..528c6d0 --- /dev/null +++ b/tests/integration/task/create-task-dedupe.integration.test.ts @@ -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, + }) + }) +}) diff --git a/tests/regression/panel-variant-cross-storyboard.test.ts b/tests/regression/panel-variant-cross-storyboard.test.ts new file mode 100644 index 0000000..e79e7f6 --- /dev/null +++ b/tests/regression/panel-variant-cross-storyboard.test.ts @@ -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() + }) +}) diff --git a/tests/regression/task-dedupe-recovery.test.ts b/tests/regression/task-dedupe-recovery.test.ts new file mode 100644 index 0000000..91b0054 --- /dev/null +++ b/tests/regression/task-dedupe-recovery.test.ts @@ -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, + }) + }) +}) diff --git a/tests/regression/task-enqueue-billing-rollback.test.ts b/tests/regression/task-enqueue-billing-rollback.test.ts new file mode 100644 index 0000000..0c687f3 --- /dev/null +++ b/tests/regression/task-enqueue-billing-rollback.test.ts @@ -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') + }) +}) diff --git a/tests/system/generate-image.system.test.ts b/tests/system/generate-image.system.test.ts new file mode 100644 index 0000000..35468c1 --- /dev/null +++ b/tests/system/generate-image.system.test.ts @@ -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( + '@/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('@/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') + }) +}) diff --git a/tests/system/generate-video.system.test.ts b/tests/system/generate-video.system.test.ts new file mode 100644 index 0000000..a36f63c --- /dev/null +++ b/tests/system/generate-video.system.test.ts @@ -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(), + uploadedCosKey: 'video/system-video.mp4', +})) + +vi.mock('@/lib/generator-api', async () => { + const actual = await vi.importActual('@/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('@/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('@/lib/media/outbound-image') + return { + ...actual, + normalizeToBase64ForGeneration: vi.fn(async (input: string) => input), + } +}) + +vi.mock('@/lib/workers/utils', async () => { + const actual = await vi.importActual('@/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') + }) +}) diff --git a/tests/system/helpers/seed.ts b/tests/system/helpers/seed.ts new file mode 100644 index 0000000..e4b78eb --- /dev/null +++ b/tests/system/helpers/seed.ts @@ -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, + } +} diff --git a/tests/system/helpers/tasks.ts b/tests/system/helpers/tasks.ts new file mode 100644 index 0000000..85703fb --- /dev/null +++ b/tests/system/helpers/tasks.ts @@ -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([ + TASK_STATUS.COMPLETED, + TASK_STATUS.FAILED, + TASK_STATUS.DISMISSED, +]) + +function sleep(ms: number): Promise { + 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 { + 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, 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) +} diff --git a/tests/system/helpers/workers.ts b/tests/system/helpers/workers.ts new file mode 100644 index 0000000..526a090 --- /dev/null +++ b/tests/system/helpers/workers.ts @@ -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>> + +async function createWorker(scope: SystemWorkerScope): Promise> { + 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): Promise { + 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 { + for (const worker of Object.values(workers)) { + if (!worker) continue + await worker.close() + } +} diff --git a/tests/system/text-workflow.system.test.ts b/tests/system/text-workflow.system.test.ts new file mode 100644 index 0000000..25bd04f --- /dev/null +++ b/tests/system/text-workflow.system.test.ts @@ -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('@/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( + '@/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( + '@/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) => 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') + }) +}) diff --git a/tests/system/voice-generate.system.test.ts b/tests/system/voice-generate.system.test.ts new file mode 100644 index 0000000..1ded32f --- /dev/null +++ b/tests/system/voice-generate.system.test.ts @@ -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('@/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('@/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') + }) +}) diff --git a/tests/unit/guards/changed-file-test-impact-guard.test.ts b/tests/unit/guards/changed-file-test-impact-guard.test.ts new file mode 100644 index 0000000..0789284 --- /dev/null +++ b/tests/unit/guards/changed-file-test-impact-guard.test.ts @@ -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([]) + }) +}) diff --git a/tests/unit/task/error-catalog.contract.test.ts b/tests/unit/task/error-catalog.contract.test.ts new file mode 100644 index 0000000..f7f66d2 --- /dev/null +++ b/tests/unit/task/error-catalog.contract.test.ts @@ -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() + + 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) + } + }) +}) diff --git a/tests/unit/worker/user-concurrency-gate.test.ts b/tests/unit/worker/user-concurrency-gate.test.ts index aa644a4..e677819 100644 --- a/tests/unit/worker/user-concurrency-gate.test.ts +++ b/tests/unit/worker/user-concurrency-gate.test.ts @@ -1,78 +1,51 @@ import { describe, expect, it } from 'vitest' import { withUserConcurrencyGate } from '@/lib/workers/user-concurrency-gate' -function wait(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms) +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((nextResolve) => { + resolve = nextResolve }) + return { promise, resolve } } describe('user concurrency gate', () => { - it('enforces max concurrent runs for same user and scope', async () => { - let activeCount = 0 - let maxActiveCount = 0 + it('serializes same-scope work for the same user when limit is 1', async () => { + const firstDone = deferred() + const events: string[] = [] - const runTask = async (taskId: number) => await withUserConcurrencyGate({ - scope: 'video', - userId: 'user-gate-video', - limit: 2, + const first = withUserConcurrencyGate({ + scope: 'image', + userId: 'user-1', + limit: 1, run: async () => { - void taskId - activeCount += 1 - maxActiveCount = Math.max(maxActiveCount, activeCount) - await wait(20) - activeCount -= 1 + events.push('first:start') + await firstDone.promise + events.push('first:end') }, }) - await Promise.all([ - runTask(1), - runTask(2), - runTask(3), - runTask(4), + const second = withUserConcurrencyGate({ + scope: 'image', + userId: 'user-1', + limit: 1, + 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') }) }) diff --git a/vitest.core-coverage.config.ts b/vitest.core-coverage.config.ts new file mode 100644 index 0000000..38a9fb7 --- /dev/null +++ b/vitest.core-coverage.config.ts @@ -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, + }, + }, + }, +})