From be1853534abde67673eb2cc46777d8e20e1247b3 Mon Sep 17 00:00:00 2001 From: saturn Date: Mon, 9 Mar 2026 02:53:06 +0800 Subject: [PATCH] feat: implement robustness guards --- .husky/pre-commit | 4 + .husky/pre-push | 4 + package-lock.json | 17 + package.json | 17 +- scripts/guards/api-route-contract-guard.mjs | 116 +++++++ .../image-reference-normalization-guard.mjs | 101 ++++++ .../guards/task-submit-compensation-guard.mjs | 84 +++++ scripts/migrate-to-minio.ts | 46 +-- src/app/api/cos/image/route.ts | 2 +- .../[projectId]/panel-variant/route.ts | 198 +++++++++--- src/components/Navbar.tsx | 3 +- src/lib/media/outbound-image.test.ts | 31 ++ src/lib/media/outbound-image.ts | 112 ++++++- .../handlers/panel-variant-task-handler.ts | 90 +++++- tests/contracts/requirements-matrix.ts | 43 +++ tests/contracts/route-behavior-matrix.ts | 2 +- tests/contracts/route-catalog.ts | 1 + .../api/contract/direct-submit-routes.test.ts | 39 ++- .../api/contract/infra-routes.test.ts | 207 ++++++++++++ .../api/specific/panel-variant-route.test.ts | 298 ++++++++++++++++++ .../provider-card-tutorial-modal.test.ts | 17 +- .../guards/api-route-contract-guard.test.ts | 53 ++++ ...mage-reference-normalization-guard.test.ts | 53 ++++ .../task-submit-compensation-guard.test.ts | 43 +++ .../worker/panel-variant-task-handler.test.ts | 46 ++- 25 files changed, 1531 insertions(+), 96 deletions(-) create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 scripts/guards/api-route-contract-guard.mjs create mode 100644 scripts/guards/image-reference-normalization-guard.mjs create mode 100644 scripts/guards/task-submit-compensation-guard.mjs create mode 100644 tests/integration/api/contract/infra-routes.test.ts create mode 100644 tests/integration/api/specific/panel-variant-route.test.ts create mode 100644 tests/unit/guards/api-route-contract-guard.test.ts create mode 100644 tests/unit/guards/image-reference-normalization-guard.test.ts create mode 100644 tests/unit/guards/task-submit-compensation-guard.test.ts diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..6c1ba94 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e + +npm run verify:commit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..16b18dc --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +set -e + +npm run verify:push diff --git a/package-lock.json b/package-lock.json index 3dbe0eb..eecebb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "15.5.4", + "husky": "^9.1.7", "rimraf": "^6.1.2", "tailwindcss": "^4", "tsx": "^4.20.5", @@ -10950,6 +10951,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", diff --git a/package.json b/package.json index 5bcfc62..4f96e2b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "postinstall": "prisma generate", + "prepare": "husky", "dev": "npm run storage:init && concurrently --kill-others \"npm run dev:next\" \"npm run dev:worker\" \"npm run dev:watchdog\" \"npm run dev:board\"", "dev:next": "cross-env NODE_OPTIONS=\"--no-deprecation\" next dev --turbopack -H 0.0.0.0", "dev:worker": "tsx watch --env-file=.env src/lib/workers/index.ts", @@ -23,7 +24,7 @@ "start:watchdog": "tsx --env-file=.env scripts/watchdog.ts", "start:board": "tsx --env-file=.env scripts/bull-board.ts", "stats:errors": "tsx scripts/task-error-stats.ts", - "check:api-handler": "tsx scripts/check-api-handler.ts", + "check:api-handler": "node scripts/guards/api-route-contract-guard.mjs", "check:logs": "tsx scripts/check-no-console.ts", "check:log-semantic": "tsx scripts/check-log-semantic.ts", "check:media-normalization": "tsx scripts/check-media-normalization.ts", @@ -54,6 +55,9 @@ "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", + "check:requirements-matrix": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/contracts/requirements-matrix.test.ts", + "check:image-reference-normalization": "node scripts/guards/image-reference-normalization-guard.mjs", + "check:task-submit-compensation": "node scripts/guards/task-submit-compensation-guard.mjs", "check:locale-navigation": "node scripts/guards/locale-navigation-guard.mjs", "check:prompt-i18n": "node scripts/guards/prompt-i18n-guard.mjs", "check:prompt-i18n-regression": "node scripts/guards/prompt-semantic-regression.mjs", @@ -67,17 +71,21 @@ "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:no-api-direct-llm-call && npm run check:test-coverage-guards && 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: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:chain": "cross-env BILLING_TEST_BOOTSTRAP=1 vitest run tests/integration/chain", - "test:behavior:unit": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/unit/helpers tests/unit/worker tests/unit/optimistic", + "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:chain": "cross-env BILLING_TEST_BOOTSTRAP=0 vitest run tests/integration/chain", - "test:behavior:guards": "npm run check:test-coverage-guards", + "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", + "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", "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", @@ -156,6 +164,7 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "15.5.4", + "husky": "^9.1.7", "rimraf": "^6.1.2", "tailwindcss": "^4", "tsx": "^4.20.5", diff --git a/scripts/guards/api-route-contract-guard.mjs b/scripts/guards/api-route-contract-guard.mjs new file mode 100644 index 0000000..3bcdfab --- /dev/null +++ b/scripts/guards/api-route-contract-guard.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +import fs from 'fs' +import path from 'path' +import process from 'process' +import { pathToFileURL } from 'url' + +const root = process.cwd() +const apiDir = path.join(root, 'src', 'app', 'api') + +export const API_HANDLER_ALLOWLIST = new Set([ + 'src/app/api/auth/[...nextauth]/route.ts', + 'src/app/api/files/[...path]/route.ts', + 'src/app/api/system/boot-id/route.ts', +]) + +export const PUBLIC_ROUTE_ALLOWLIST = new Set([ + 'src/app/api/auth/[...nextauth]/route.ts', + 'src/app/api/auth/register/route.ts', + 'src/app/api/cos/image/route.ts', + 'src/app/api/files/[...path]/route.ts', + 'src/app/api/storage/sign/route.ts', + 'src/app/api/system/boot-id/route.ts', +]) + +const AUTH_CALL_PATTERNS = [ + /\brequireUserAuth\s*\(/, + /\brequireProjectAuth\s*\(/, + /\brequireProjectAuthLight\s*\(/, +] + +function fail(title, details = []) { + process.stderr.write(`\n[api-route-contract-guard] ${title}\n`) + for (const detail of details) { + process.stderr.write(` - ${detail}\n`) + } + process.exit(1) +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(fullPath, out) + continue + } + if (entry.name === 'route.ts') out.push(fullPath) + } + return out +} + +function toRel(fullPath) { + return path.relative(root, fullPath).split(path.sep).join('/') +} + +function hasApiHandlerWrapper(content) { + return /\bapiHandler\s*\(/.test(content) +} + +function hasRequiredAuth(content) { + return AUTH_CALL_PATTERNS.some((pattern) => pattern.test(content)) +} + +export function inspectRouteContract(relPath, content) { + const violations = [] + + if (!API_HANDLER_ALLOWLIST.has(relPath) && !hasApiHandlerWrapper(content)) { + violations.push(`${relPath} missing apiHandler wrapper`) + } + + if (!PUBLIC_ROUTE_ALLOWLIST.has(relPath) && !hasRequiredAuth(content)) { + violations.push(`${relPath} missing requireUserAuth/requireProjectAuth/requireProjectAuthLight`) + } + + return violations +} + +export function findApiRouteContractViolations(scanRoot = root) { + const routesRoot = path.join(scanRoot, 'src', 'app', 'api') + return walk(routesRoot) + .map((fullPath) => { + const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/') + const content = fs.readFileSync(fullPath, 'utf8') + return inspectRouteContract(relPath, content) + }) + .flat() +} + +export function main() { + if (!fs.existsSync(apiDir)) { + fail('Missing src/app/api directory') + } + + const violations = walk(apiDir) + .map((fullPath) => { + const relPath = toRel(fullPath) + const content = fs.readFileSync(fullPath, 'utf8') + return inspectRouteContract(relPath, content) + }) + .flat() + + if (violations.length > 0) { + fail('Found API route contract violations', violations) + } + + process.stdout.write( + `[api-route-contract-guard] OK routes=${walk(apiDir).length} public=${PUBLIC_ROUTE_ALLOWLIST.size} apiHandlerExceptions=${API_HANDLER_ALLOWLIST.size}\n`, + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/scripts/guards/image-reference-normalization-guard.mjs b/scripts/guards/image-reference-normalization-guard.mjs new file mode 100644 index 0000000..dabe372 --- /dev/null +++ b/scripts/guards/image-reference-normalization-guard.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import fs from 'fs' +import path from 'path' +import process from 'process' +import { pathToFileURL } from 'url' + +const root = process.cwd() +const handlersDir = path.join(root, 'src', 'lib', 'workers', 'handlers') + +export const NORMALIZATION_HELPER_ALLOWLIST = new Set([ + 'src/lib/workers/handlers/image-task-handler-shared.ts', +]) + +const ACCEPTED_NORMALIZATION_MARKERS = [ + /\bnormalizeReferenceImagesForGeneration\s*\(/, + /\bnormalizeToBase64ForGeneration\s*\(/, + /\bgenerateLabeledImageToCos\s*\(/, +] + +function fail(title, details = []) { + process.stderr.write(`\n[image-reference-normalization-guard] ${title}\n`) + for (const detail of details) { + process.stderr.write(` - ${detail}\n`) + } + process.exit(1) +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(fullPath, out) + continue + } + if (entry.name.endsWith('.ts')) out.push(fullPath) + } + return out +} + +function toRel(fullPath) { + return path.relative(root, fullPath).split(path.sep).join('/') +} + +function usesGenerationReferenceImages(content) { + return /\bresolveImageSourceFromGeneration\s*\(/.test(content) && /\breferenceImages\s*:/.test(content) +} + +function hasNormalizationMarker(content) { + return ACCEPTED_NORMALIZATION_MARKERS.some((pattern) => pattern.test(content)) +} + +export function inspectImageReferenceNormalization(relPath, content) { + if (NORMALIZATION_HELPER_ALLOWLIST.has(relPath)) return [] + if (!usesGenerationReferenceImages(content)) return [] + if (hasNormalizationMarker(content)) return [] + return [ + `${relPath} uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos`, + ] +} + +export function findImageReferenceNormalizationViolations(scanRoot = root) { + const scanDir = path.join(scanRoot, 'src', 'lib', 'workers', 'handlers') + return walk(scanDir) + .map((fullPath) => { + const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/') + const content = fs.readFileSync(fullPath, 'utf8') + return inspectImageReferenceNormalization(relPath, content) + }) + .flat() +} + +export function main() { + if (!fs.existsSync(handlersDir)) { + fail('Missing src/lib/workers/handlers directory') + } + + const handlerFiles = walk(handlersDir) + const violations = handlerFiles + .map((fullPath) => { + const relPath = toRel(fullPath) + const content = fs.readFileSync(fullPath, 'utf8') + return inspectImageReferenceNormalization(relPath, content) + }) + .flat() + + if (violations.length > 0) { + fail('Found image reference normalization violations', violations) + } + + process.stdout.write( + `[image-reference-normalization-guard] OK handlers=${handlerFiles.length} allowlist=${NORMALIZATION_HELPER_ALLOWLIST.size}\n`, + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/scripts/guards/task-submit-compensation-guard.mjs b/scripts/guards/task-submit-compensation-guard.mjs new file mode 100644 index 0000000..95ace4a --- /dev/null +++ b/scripts/guards/task-submit-compensation-guard.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import fs from 'fs' +import path from 'path' +import process from 'process' +import { pathToFileURL } from 'url' + +const root = process.cwd() +const apiDir = path.join(root, 'src', 'app', 'api') +const CREATE_PATTERN = /\.\s*create\s*\(/ +const SUBMIT_TASK_PATTERN = /\bsubmitTask\s*\(/ +const ROLLBACK_PATTERN = /rollback|compensat/i + +function fail(title, details = []) { + process.stderr.write(`\n[task-submit-compensation-guard] ${title}\n`) + for (const detail of details) { + process.stderr.write(` - ${detail}\n`) + } + process.exit(1) +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name === '.git' || entry.name === '.next' || entry.name === 'node_modules') continue + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(fullPath, out) + continue + } + if (entry.name === 'route.ts') out.push(fullPath) + } + return out +} + +function toRel(fullPath) { + return path.relative(root, fullPath).split(path.sep).join('/') +} + +export function inspectTaskSubmitCompensation(relPath, content) { + if (!CREATE_PATTERN.test(content)) return [] + if (!SUBMIT_TASK_PATTERN.test(content)) return [] + if (ROLLBACK_PATTERN.test(content)) return [] + return [ + `${relPath} creates data before submitTask without explicit rollback/compensation marker`, + ] +} + +export function findTaskSubmitCompensationViolations(scanRoot = root) { + const routesRoot = path.join(scanRoot, 'src', 'app', 'api') + return walk(routesRoot) + .map((fullPath) => { + const relPath = path.relative(scanRoot, fullPath).split(path.sep).join('/') + const content = fs.readFileSync(fullPath, 'utf8') + return inspectTaskSubmitCompensation(relPath, content) + }) + .flat() +} + +export function main() { + if (!fs.existsSync(apiDir)) { + fail('Missing src/app/api directory') + } + + const routeFiles = walk(apiDir) + const violations = routeFiles + .map((fullPath) => { + const relPath = toRel(fullPath) + const content = fs.readFileSync(fullPath, 'utf8') + return inspectTaskSubmitCompensation(relPath, content) + }) + .flat() + + if (violations.length > 0) { + fail('Found create+submitTask routes without compensation marker', violations) + } + + process.stdout.write(`[task-submit-compensation-guard] OK routes=${routeFiles.length}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/scripts/migrate-to-minio.ts b/scripts/migrate-to-minio.ts index d9a5c67..41365f9 100644 --- a/scripts/migrate-to-minio.ts +++ b/scripts/migrate-to-minio.ts @@ -48,7 +48,7 @@ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } function log(level: string, message: string, ...args: unknown[]) { if (LOG_LEVELS[level as keyof typeof LOG_LEVELS] >= LOG_LEVELS[CONFIG.options.logLevel as keyof typeof LOG_LEVELS]) { const timestamp = new Date().toISOString() - console[level === 'error' ? 'error' : 'log'](\`[\${timestamp}] [\${level.toUpperCase()}] \${message}\`, ...args) + console[level === 'error' ? 'error' : 'log'](`[${timestamp}] [${level.toUpperCase()}] ${message}`, ...args) } } @@ -87,7 +87,7 @@ async function scanLocalFiles(dir: string, basePath = ''): Promise) { // ==================== 存储桶检查/创建 ==================== async function ensureBucket() { - log('info', \`检查存储桶: \${CONFIG.minio.bucket}\`) + log('info', `检查存储桶: ${CONFIG.minio.bucket}`) const exists = await minioClient.bucketExists(CONFIG.minio.bucket) if (!exists) { - log('info', \`创建存储桶: \${CONFIG.minio.bucket}\`) + log('info', `创建存储桶: ${CONFIG.minio.bucket}`) await minioClient.makeBucket(CONFIG.minio.bucket, CONFIG.minio.region) // 设置存储桶为 public read (可选,根据需求) @@ -144,7 +144,7 @@ async function ensureBucket() { Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], - Resource: [\`arn:aws:s3:::\${CONFIG.minio.bucket}/*\`] + Resource: [`arn:aws:s3:::${CONFIG.minio.bucket}/*`] } ] } @@ -159,12 +159,12 @@ async function uploadFile(fileInfo: {localPath: string, key: string, size: numbe // 检查是否已迁移 if (migratedKeys.has(key)) { - log('debug', \`跳过已迁移: \${key}\`) + log('debug', `跳过已迁移: ${key}`) return { status: 'skipped', key } } if (CONFIG.options.dryRun) { - log('info', \`[DRY RUN] 将上传: \${key} (\${formatBytes(size)})\`) + log('info', `[DRY RUN] 将上传: ${key} (${formatBytes(size)})`) return { status: 'dry_run', key } } @@ -185,11 +185,11 @@ async function uploadFile(fileInfo: {localPath: string, key: string, size: numbe // 记录迁移成功 migratedKeys.add(key) - log('info', \`✓ 上传成功: \${key} (\${formatBytes(size)})\`) + log('info', `✓ 上传成功: ${key} (${formatBytes(size)})`) return { status: 'success', key, size } } catch (err: unknown) { - log('error', \`✗ 上传失败: \${key}\`, (err as Error).message) + log('error', `✗ 上传失败: ${key}`, (err as Error).message) return { status: 'error', key, error: (err as Error).message } } } @@ -251,17 +251,17 @@ async function main() { console.log() log('info', '配置信息:') - log('info', \` 本地目录: \${path.resolve(CONFIG.local.baseDir)}\`) - log('info', \` MinIO: \${CONFIG.minio.endPoint}:\${CONFIG.minio.port}/\${CONFIG.minio.bucket}\`) - log('info', \` 并发数: \${CONFIG.options.concurrency}\`) - log('info', \` 干运行: \${CONFIG.options.dryRun}\`) - log('info', \` 断点续传: \${CONFIG.options.resume}\`) + log('info', ` 本地目录: ${path.resolve(CONFIG.local.baseDir)}`) + log('info', ` MinIO: ${CONFIG.minio.endPoint}:${CONFIG.minio.port}/${CONFIG.minio.bucket}`) + log('info', ` 并发数: ${CONFIG.options.concurrency}`) + log('info', ` 干运行: ${CONFIG.options.dryRun}`) + log('info', ` 断点续传: ${CONFIG.options.resume}`) console.log() // 1. 扫描本地文件 log('info', '扫描本地文件...') const files = await scanLocalFiles(CONFIG.local.baseDir) - log('info', \`找到 \${files.length} 个文件\`) + log('info', `找到 ${files.length} 个文件`) if (files.length === 0) { log('info', '没有需要迁移的文件') @@ -269,12 +269,12 @@ async function main() { } const totalSize = files.reduce((sum, f) => sum + f.size, 0) - log('info', \`总大小: \${formatBytes(totalSize)}\`) + log('info', `总大小: ${formatBytes(totalSize)}`) console.log() // 2. 加载进度 const migratedKeys = await loadProgress() - log('info', \`已迁移: \${migratedKeys.size} 个文件\`) + log('info', `已迁移: ${migratedKeys.size} 个文件`) // 3. 确保存储桶存在 await ensureBucket() @@ -298,7 +298,7 @@ async function main() { if (processed % 10 === 0) { await saveProgress(migratedKeys) const progress = ((processed / files.length) * 100).toFixed(1) - log('info', \`进度: \${progress}% (\${processed}/\${files.length})\`) + log('info', `进度: ${progress}% (${processed}/${files.length})`) } return result @@ -315,11 +315,11 @@ async function main() { console.log('╔══════════════════════════════════════════════════════════╗') console.log('║ 迁移完成 ║') console.log('╠══════════════════════════════════════════════════════════╣') - console.log(\`║ 总文件数: \${String(files.length).padEnd(39)} ║\`) - console.log(\`║ 成功: \${String(success).padEnd(39)} ║\`) - console.log(\`║ 失败: \${String(failed).padEnd(39)} ║\`) - console.log(\`║ 跳过: \${String(skipped).padEnd(39)} ║\`) - console.log(\`║ 耗时: \${String(duration + 's').padEnd(39)} ║\`) + console.log(`║ 总文件数: ${String(files.length).padEnd(39)} ║`) + console.log(`║ 成功: ${String(success).padEnd(39)} ║`) + console.log(`║ 失败: ${String(failed).padEnd(39)} ║`) + console.log(`║ 跳过: ${String(skipped).padEnd(39)} ║`) + console.log(`║ 耗时: ${String(duration + 's').padEnd(39)} ║`) console.log('╚══════════════════════════════════════════════════════════╝') // 7. 后续步骤提示 diff --git a/src/app/api/cos/image/route.ts b/src/app/api/cos/image/route.ts index 79cd069..6a6b8c0 100644 --- a/src/app/api/cos/image/route.ts +++ b/src/app/api/cos/image/route.ts @@ -11,5 +11,5 @@ export const GET = apiHandler(async (request: NextRequest) => { } const location = `/api/storage/sign?key=${encodeURIComponent(key)}&expires=${encodeURIComponent(expires)}` - return NextResponse.redirect(location) + return NextResponse.redirect(new URL(location, request.url)) }) diff --git a/src/app/api/novel-promotion/[projectId]/panel-variant/route.ts b/src/app/api/novel-promotion/[projectId]/panel-variant/route.ts index 9b1a2ff..d33c80c 100644 --- a/src/app/api/novel-promotion/[projectId]/panel-variant/route.ts +++ b/src/app/api/novel-promotion/[projectId]/panel-variant/route.ts @@ -1,12 +1,70 @@ import { NextRequest, NextResponse } from 'next/server' import { requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError, getRequestId } from '@/lib/api-errors' -import { submitTask } from '@/lib/task/submitter' -import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' -import { TASK_TYPE } from '@/lib/task/types' import { buildDefaultTaskBillingInfo } from '@/lib/billing' import { getProjectModelConfig, buildImageBillingPayload } from '@/lib/config-service' import { prisma } from '@/lib/prisma' +import { submitTask } from '@/lib/task/submitter' +import { resolveRequiredTaskLocale } from '@/lib/task/resolve-locale' +import { TASK_TYPE } from '@/lib/task/types' + +function createPanelVariantId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `panel-variant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +async function rollbackCreatedVariantPanel(params: { + panelId: string + storyboardId: string + panelIndex: number +}) { + await prisma.$transaction(async (tx) => { + await tx.novelPromotionPanel.delete({ + where: { id: params.panelId }, + }) + + const maxPanel = await tx.novelPromotionPanel.findFirst({ + where: { storyboardId: params.storyboardId }, + orderBy: { panelIndex: 'desc' }, + select: { panelIndex: true }, + }) + const maxPanelIndex = maxPanel?.panelIndex ?? -1 + const offset = maxPanelIndex + 1000 + + await tx.novelPromotionPanel.updateMany({ + where: { + storyboardId: params.storyboardId, + panelIndex: { gt: params.panelIndex }, + }, + data: { + panelIndex: { increment: offset }, + panelNumber: { increment: offset }, + }, + }) + + await tx.novelPromotionPanel.updateMany({ + where: { + storyboardId: params.storyboardId, + panelIndex: { gt: params.panelIndex + offset }, + }, + data: { + panelIndex: { decrement: offset + 1 }, + panelNumber: { decrement: offset + 1 }, + }, + }) + + const panelCount = await tx.novelPromotionPanel.count({ + where: { storyboardId: params.storyboardId }, + }) + + await tx.novelPromotionStoryboard.update({ + where: { id: params.storyboardId }, + data: { panelCount }, + }) + }) +} export const POST = apiHandler(async ( request: NextRequest, @@ -33,42 +91,79 @@ export const POST = apiHandler(async ( throw new ApiError('INVALID_PARAMS') } - // 在 API route 中同步创建 panel(无图片),确保新 panel 立即存在于数据库, - // 避免乐观更新与 worker 之间的状态真空期 - const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } }) - if (!sourcePanel) { + const storyboard = await prisma.novelPromotionStoryboard.findUnique({ + where: { id: storyboardId }, + select: { + id: true, + episode: { + select: { + novelPromotionProject: { + select: { + projectId: true, + }, + }, + }, + }, + }, + }) + if (!storyboard || storyboard.episode.novelPromotionProject.projectId !== projectId) { throw new ApiError('NOT_FOUND') } + const sourcePanel = await prisma.novelPromotionPanel.findUnique({ where: { id: sourcePanelId } }) + if (!sourcePanel || sourcePanel.storyboardId !== storyboardId) { + throw new ApiError('INVALID_PARAMS') + } + const insertAfter = await prisma.novelPromotionPanel.findUnique({ where: { id: insertAfterPanelId } }) - if (!insertAfter) { - throw new ApiError('NOT_FOUND') + if (!insertAfter || insertAfter.storyboardId !== storyboardId) { + throw new ApiError('INVALID_PARAMS') + } + + const projectModelConfig = await getProjectModelConfig(projectId, session.user.id) + const imageModel = projectModelConfig.storyboardModel + const createdPanelId = createPanelVariantId() + + let billingPayload: Record + try { + billingPayload = await buildImageBillingPayload({ + projectId, + userId: session.user.id, + imageModel, + basePayload: { ...body, newPanelId: createdPanelId }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Image model capability not configured' + throw new ApiError('INVALID_PARAMS', { + code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', + message, + }) } const createdPanel = await prisma.$transaction(async (tx) => { - // Two-phase reindexing to avoid unique constraint collision on (storyboardId, panelIndex) const affectedPanels = await tx.novelPromotionPanel.findMany({ where: { storyboardId, panelIndex: { gt: insertAfter.panelIndex } }, select: { id: true, panelIndex: true }, - orderBy: { panelIndex: 'asc' } + orderBy: { panelIndex: 'asc' }, }) - // Phase A: shift to negative indices - for (const p of affectedPanels) { + + for (const panel of affectedPanels) { await tx.novelPromotionPanel.update({ - where: { id: p.id }, - data: { panelIndex: -(p.panelIndex + 1) } - }) - } - // Phase B: set final positive indices - for (const p of affectedPanels) { - await tx.novelPromotionPanel.update({ - where: { id: p.id }, - data: { panelIndex: p.panelIndex + 1 } + where: { id: panel.id }, + data: { panelIndex: -(panel.panelIndex + 1) }, }) } - return tx.novelPromotionPanel.create({ + for (const panel of affectedPanels) { + await tx.novelPromotionPanel.update({ + where: { id: panel.id }, + data: { panelIndex: panel.panelIndex + 1 }, + }) + } + + const created = await tx.novelPromotionPanel.create({ data: { + id: createdPanelId, storyboardId, panelIndex: insertAfter.panelIndex + 1, panelNumber: insertAfter.panelIndex + 2, @@ -79,39 +174,44 @@ export const POST = apiHandler(async ( location: variant.location || sourcePanel.location, characters: variant.characters ? JSON.stringify(variant.characters) : sourcePanel.characters, srtSegment: sourcePanel.srtSegment, - duration: sourcePanel.duration - } + duration: sourcePanel.duration, + }, }) + + const panelCount = await tx.novelPromotionPanel.count({ + where: { storyboardId }, + }) + + await tx.novelPromotionStoryboard.update({ + where: { id: storyboardId }, + data: { panelCount }, + }) + + return created }) - const projectModelConfig = await getProjectModelConfig(projectId, session.user.id) - const imageModel = projectModelConfig.storyboardModel - - let billingPayload: Record + let result: Awaited> try { - billingPayload = await buildImageBillingPayload({ - projectId, + result = await submitTask({ userId: session.user.id, - imageModel, - basePayload: { ...body, newPanelId: createdPanel.id }, + locale, + requestId: getRequestId(request), + projectId, + type: TASK_TYPE.PANEL_VARIANT, + targetType: 'NovelPromotionPanel', + targetId: createdPanel.id, + payload: billingPayload, + dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`, + billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload), }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Image model capability not configured' - throw new ApiError('INVALID_PARAMS', { code: 'IMAGE_MODEL_CAPABILITY_NOT_CONFIGURED', message }) + } catch (error) { + await rollbackCreatedVariantPanel({ + panelId: createdPanel.id, + storyboardId, + panelIndex: createdPanel.panelIndex, + }) + throw error } - // Task target 指向新创建的 panel,使 task state 监控系统正确追踪 - const result = await submitTask({ - userId: session.user.id, - locale, - requestId: getRequestId(request), - projectId, - type: TASK_TYPE.PANEL_VARIANT, - targetType: 'NovelPromotionPanel', - targetId: createdPanel.id, - payload: billingPayload, - dedupeKey: `panel_variant:${storyboardId}:${insertAfterPanelId}:${sourcePanelId}`, - billingInfo: buildDefaultTaskBillingInfo(TASK_TYPE.PANEL_VARIANT, billingPayload) - }) return NextResponse.json({ ...result, panelId: createdPanel.id }) }) diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ac4f89c..66d7239 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -19,6 +19,7 @@ export default function Navbar() { const [checkMsg, setCheckMsg] = useState(null) const [checkMsgFading, setCheckMsgFading] = useState(false) const [manualChecking, setManualChecking] = useState(false) + const downloadLogsHref = '/api/admin/download-logs' const handleCheckUpdate = async () => { setCheckMsg(null) @@ -125,7 +126,7 @@ export default function Navbar() { { expect(dataUrl).toBe('data:image/png;base64,AQID') }) + it('sniffs png mime when upstream returns application/octet-stream', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + arrayBuffer: async () => Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, + ]).buffer, + } as Response) + + const dataUrl = await normalizeToBase64ForGeneration('images/direct.png') + expect(dataUrl).toBe('data:image/png;base64,iVBORw0KGgoAAAAN') + }) + + it('sniffs jpeg mime when upstream returns application/octet-stream', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + arrayBuffer: async () => Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xe0, + 0x00, 0x10, 0x4a, 0x46, + 0x49, 0x46, 0x00, 0x01, + ]).buffer, + } as Response) + + const dataUrl = await normalizeToBase64ForGeneration('images/direct.jpg') + expect(dataUrl).toBe('data:image/jpeg;base64,/9j/4AAQSkZJRgAB') + }) + it('normalizes references with dedupe and failure isolation', async () => { fetchMock.mockImplementation(async (url: string) => { if (String(url).includes('/api/bad.png')) { diff --git a/src/lib/media/outbound-image.ts b/src/lib/media/outbound-image.ts index 939f651..d8a8374 100644 --- a/src/lib/media/outbound-image.ts +++ b/src/lib/media/outbound-image.ts @@ -163,9 +163,115 @@ function toUrlMaybe(value: string): URL | null { return null } -function guessContentType(input: string, contentTypeHeader: string | null): string { +function detectMimeFromBuffer(buffer: Uint8Array): string | null { + if (buffer.length >= 8) { + const isPng = + buffer[0] === 0x89 + && buffer[1] === 0x50 + && buffer[2] === 0x4e + && buffer[3] === 0x47 + && buffer[4] === 0x0d + && buffer[5] === 0x0a + && buffer[6] === 0x1a + && buffer[7] === 0x0a + if (isPng) return 'image/png' + } + + if (buffer.length >= 3) { + const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff + if (isJpeg) return 'image/jpeg' + } + + if (buffer.length >= 6) { + const isGif87a = + buffer[0] === 0x47 + && buffer[1] === 0x49 + && buffer[2] === 0x46 + && buffer[3] === 0x38 + && buffer[4] === 0x37 + && buffer[5] === 0x61 + const isGif89a = + buffer[0] === 0x47 + && buffer[1] === 0x49 + && buffer[2] === 0x46 + && buffer[3] === 0x38 + && buffer[4] === 0x39 + && buffer[5] === 0x61 + if (isGif87a || isGif89a) return 'image/gif' + } + + if (buffer.length >= 12) { + const isWebp = + buffer[0] === 0x52 + && buffer[1] === 0x49 + && buffer[2] === 0x46 + && buffer[3] === 0x46 + && buffer[8] === 0x57 + && buffer[9] === 0x45 + && buffer[10] === 0x42 + && buffer[11] === 0x50 + if (isWebp) return 'image/webp' + } + + if (buffer.length >= 12) { + const isWav = + buffer[0] === 0x52 + && buffer[1] === 0x49 + && buffer[2] === 0x46 + && buffer[3] === 0x46 + && buffer[8] === 0x57 + && buffer[9] === 0x41 + && buffer[10] === 0x56 + && buffer[11] === 0x45 + if (isWav) return 'audio/wav' + } + + if (buffer.length >= 4) { + const isOgg = + buffer[0] === 0x4f + && buffer[1] === 0x67 + && buffer[2] === 0x67 + && buffer[3] === 0x53 + if (isOgg) return 'audio/ogg' + } + + if (buffer.length >= 3) { + const isMp3WithId3 = + buffer[0] === 0x49 + && buffer[1] === 0x44 + && buffer[2] === 0x33 + const isMp3FrameSync = + buffer[0] === 0xff + && (buffer[1] & 0xe0) === 0xe0 + if (isMp3WithId3 || isMp3FrameSync) return 'audio/mpeg' + } + + if (buffer.length >= 12) { + const isWebm = + buffer[0] === 0x1a + && buffer[1] === 0x45 + && buffer[2] === 0xdf + && buffer[3] === 0xa3 + if (isWebm) return 'video/webm' + } + + if (buffer.length >= 8) { + const isMp4 = + buffer[4] === 0x66 + && buffer[5] === 0x74 + && buffer[6] === 0x79 + && buffer[7] === 0x70 + if (isMp4) return 'video/mp4' + } + + return null +} + +function guessContentType(input: string, contentTypeHeader: string | null, buffer: Uint8Array): string { const headerType = contentTypeHeader?.split(';')[0]?.trim() - if (headerType) return headerType + if (headerType && headerType !== DEFAULT_CONTENT_TYPE) return headerType + const sniffedType = detectMimeFromBuffer(buffer) + if (sniffedType) return sniffedType const parsed = toUrlMaybe(input) const pathname = parsed?.pathname ?? input const ext = path.extname(pathname).toLowerCase() @@ -308,8 +414,8 @@ export async function normalizeToBase64ForGeneration(input: string): Promise> +}): string[] { + const refs: string[] = [] + if (params.sourcePanelImageUrl) refs.push(params.sourcePanelImageUrl) + + if (params.includeCharacterAssets) { + const panelCharacters = parsePanelCharacterReferences(params.newPanel.characters) + for (const item of panelCharacters) { + const character = findCharacterByName(params.projectData.characters || [], item.name) + if (!character) continue + + const appearances = character.appearances || [] + let appearance = appearances[0] + if (item.appearance) { + const matched = appearances.find((candidate) => (candidate.changeReason || '').toLowerCase() === item.appearance!.toLowerCase()) + if (matched) appearance = matched + } + + if (!appearance) continue + const imageUrls = parseImageUrls((appearance as { imageUrls?: string | null }).imageUrls || null, 'characterAppearance.imageUrls') + const selectedIndex = typeof (appearance as { selectedIndex?: number | null }).selectedIndex === 'number' + ? (appearance as { selectedIndex?: number | null }).selectedIndex + : null + const selectedUrl = (selectedIndex !== null && selectedIndex !== undefined ? imageUrls[selectedIndex] : null) + || imageUrls[0] + || appearance.imageUrl + || null + const signed = toSignedUrlIfCos(selectedUrl, 3600) + if (signed) refs.push(signed) + } + } + + if (params.includeLocationAsset && params.newPanel.location) { + const location = (params.projectData.locations || []).find( + (item) => item.name.toLowerCase() === params.newPanel.location!.toLowerCase(), + ) + if (location) { + const selected = (location.images || []).find((image) => image.isSelected) || location.images?.[0] + const signed = toSignedUrlIfCos(selected?.imageUrl, 3600) + if (signed) refs.push(signed) + } + } + + return refs +} + interface PanelVariantPayload { shot_type?: string camera_move?: string @@ -108,6 +174,8 @@ export async function handlePanelVariantTask(job: Job) { const payload = (job.data.payload || {}) as AnyObj const newPanelId = pickFirstString(payload.newPanelId) const sourcePanelId = pickFirstString(payload.sourcePanelId) + const includeCharacterAssets = payload.includeCharacterAssets !== false + const includeLocationAsset = payload.includeLocationAsset !== false const variant: PanelVariantPayload = payload.variant && typeof payload.variant === 'object' ? (payload.variant as PanelVariantPayload) : {} @@ -132,16 +200,22 @@ export async function handlePanelVariantTask(job: Job) { if (!storyboardModel) throw new Error('Storyboard model not configured') // 收集参考图(与 panel-image-task-handler 共用同一链路) - const refs = await collectPanelReferenceImages(projectData, newPanel) - // 额外加入源镜头图片作为参考 const sourcePanelImageUrl = toSignedUrlIfCos(sourcePanel.imageUrl, 3600) - if (sourcePanelImageUrl) refs.unshift(sourcePanelImageUrl) + const refs = buildVariantReferenceImages({ + includeCharacterAssets, + includeLocationAsset, + newPanel, + sourcePanelImageUrl, + projectData, + }) const normalizedRefs = await normalizeReferenceImagesForGeneration(refs) // 使用 agent_shot_variant_generate.txt 提示词模板 const artStyle = getArtStylePrompt(modelConfig.artStyle, job.data.locale) const charactersInfo = buildCharactersInfo(newPanel, projectData) - const characterAssetsDesc = buildCharacterAssetsDescription(newPanel, projectData) + const characterAssetsDesc = includeCharacterAssets + ? buildCharacterAssetsDescription(newPanel, projectData) + : (job.data.locale === 'en' ? 'Character reference images disabled' : '未使用角色参考图') const locationName = newPanel.location || sourcePanel.location || '' const prompt = buildVariantPrompt({ @@ -157,7 +231,11 @@ export async function handlePanelVariantTask(job: Job) { targetCameraMove: variant.camera_move || sourcePanel.cameraMove || '', videoPrompt: pickFirstString(variant.video_prompt, variant.description) || '', characterAssets: characterAssetsDesc, - locationAsset: locationName ? `场景:${locationName}` : '无场景参考', + locationAsset: buildLocationAssetDescription({ + includeLocationAsset, + locationName, + locale: job.data.locale, + }), aspectRatio, style: artStyle || '与参考图风格一致', }) diff --git a/tests/contracts/requirements-matrix.ts b/tests/contracts/requirements-matrix.ts index 8a2b632..3ec1687 100644 --- a/tests/contracts/requirements-matrix.ts +++ b/tests/contracts/requirements-matrix.ts @@ -57,6 +57,29 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/integration/chain/video.chain.test.ts', ], }, + { + id: 'REQ-NP-INSERT-PANEL-AUTO-ANALYZE', + feature: 'Novel promotion insert panel', + userValue: 'AI 自动分析插入分镜时不会因空输入失败', + risk: 'route 与 worker 契约分叉导致异步任务直接报错', + priority: 'P0', + tests: [ + 'tests/unit/novel-promotion/insert-panel-user-input.test.ts', + 'tests/integration/api/contract/direct-submit-routes.test.ts', + ], + }, + { + id: 'REQ-NP-PANEL-VARIANT-SAFETY', + feature: 'Novel promotion panel variant', + userValue: '镜头变体只能插入当前 storyboard,任务失败可回滚,资产开关真实生效', + risk: '跨分镜误插入、创建脏 panel、参考图开关失效', + priority: 'P0', + tests: [ + 'tests/integration/api/specific/panel-variant-route.test.ts', + 'tests/integration/api/contract/direct-submit-routes.test.ts', + 'tests/unit/worker/panel-variant-task-handler.test.ts', + ], + }, { id: 'REQ-NP-TEXT-ANALYSIS', feature: 'Text analysis and storyboard orchestration', @@ -81,4 +104,24 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray = [ 'tests/unit/optimistic/sse-invalidation.test.ts', ], }, + { + id: 'REQ-API-CONFIG-TUTORIAL-PORTAL', + feature: 'API config tutorial modal layering', + userValue: '开通教程浮层只高亮当前教程,不污染其他 provider card', + risk: '弹层挂载在局部层叠上下文内,导致高亮重叠和误覆盖', + priority: 'P1', + tests: [ + 'tests/unit/api-config/provider-card-tutorial-modal.test.ts', + ], + }, + { + id: 'REQ-INFRA-PUBLIC-ROUTES', + feature: 'Infra and public routes', + userValue: '基础公共路由可稳定访问,公开范围明确且有测试兜底', + risk: '特殊公开路由缺少约束或回归覆盖,导致泄漏、误拦截或行为漂移', + priority: 'P1', + tests: [ + 'tests/integration/api/contract/infra-routes.test.ts', + ], + }, ] diff --git a/tests/contracts/route-behavior-matrix.ts b/tests/contracts/route-behavior-matrix.ts index c0a16bf..ea58c17 100644 --- a/tests/contracts/route-behavior-matrix.ts +++ b/tests/contracts/route-behavior-matrix.ts @@ -15,7 +15,7 @@ const CONTRACT_TEST_BY_GROUP: Record 'task-infra-routes': 'tests/integration/api/contract/task-infra-routes.test.ts', 'user-project-routes': 'tests/integration/api/contract/crud-routes.test.ts', 'auth-routes': 'tests/integration/api/contract/crud-routes.test.ts', - 'infra-routes': 'tests/integration/api/contract/crud-routes.test.ts', + 'infra-routes': 'tests/integration/api/contract/infra-routes.test.ts', } function resolveChainTest(routeFile: string): string { diff --git a/tests/contracts/route-catalog.ts b/tests/contracts/route-catalog.ts index e5e35fc..1e6ecd4 100644 --- a/tests/contracts/route-catalog.ts +++ b/tests/contracts/route-catalog.ts @@ -25,6 +25,7 @@ export type RouteCatalogEntry = { } const ROUTE_FILES = [ + 'src/app/api/admin/download-logs/route.ts', 'src/app/api/asset-hub/ai-design-character/route.ts', 'src/app/api/asset-hub/ai-design-location/route.ts', 'src/app/api/asset-hub/ai-modify-character/route.ts', diff --git a/tests/integration/api/contract/direct-submit-routes.test.ts b/tests/integration/api/contract/direct-submit-routes.test.ts index 8f3cbb9..7272f54 100644 --- a/tests/integration/api/contract/direct-submit-routes.test.ts +++ b/tests/integration/api/contract/direct-submit-routes.test.ts @@ -76,6 +76,17 @@ const prismaMock = vi.hoisted(() => ({ userPreference: { findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })), }, + novelPromotionStoryboard: { + findUnique: vi.fn(async () => ({ + id: 'storyboard-1', + episode: { + novelPromotionProject: { + projectId: 'project-1', + }, + }, + })), + update: vi.fn(async () => ({})), + }, novelPromotionPanel: { findFirst: vi.fn(async () => ({ id: 'panel-1' })), findMany: vi.fn(async () => []), @@ -84,6 +95,7 @@ const prismaMock = vi.hoisted(() => ({ if (id === 'panel-src') { return { id, + storyboardId: 'storyboard-1', panelIndex: 1, shotType: 'wide', cameraMove: 'static', @@ -98,6 +110,7 @@ const prismaMock = vi.hoisted(() => ({ if (id === 'panel-ins') { return { id, + storyboardId: 'storyboard-1', panelIndex: 2, shotType: 'medium', cameraMove: 'push', @@ -111,6 +124,7 @@ const prismaMock = vi.hoisted(() => ({ } return { id, + storyboardId: 'storyboard-1', panelIndex: 0, shotType: 'medium', cameraMove: 'static', @@ -124,6 +138,10 @@ const prismaMock = vi.hoisted(() => ({ }), update: vi.fn(async () => ({})), create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })), + findUniqueOrThrow: vi.fn(), + delete: vi.fn(async () => ({})), + count: vi.fn(async () => 3), + updateMany: vi.fn(async () => ({ count: 0 })), }, novelPromotionProject: { findUnique: vi.fn(async () => ({ @@ -153,14 +171,31 @@ const prismaMock = vi.hoisted(() => ({ novelPromotionPanel: { findMany: (args: unknown) => Promise> update: (args: unknown) => Promise - create: (args: unknown) => Promise<{ id: string; panelIndex: number }> + create: (args: { data?: { id?: string; panelIndex?: number } }) => Promise<{ id: string; panelIndex: number }> + findFirst: (args: unknown) => Promise<{ panelIndex: number } | null> + delete: (args: unknown) => Promise + count: (args: unknown) => Promise + updateMany: (args: unknown) => Promise<{ count: number }> + } + novelPromotionStoryboard: { + update: (args: unknown) => Promise } }) => Promise) => { const tx = { novelPromotionPanel: { findMany: async () => [], update: async () => ({}), - create: async () => ({ id: 'panel-created', panelIndex: 3 }), + create: async (args: { data?: { id?: string; panelIndex?: number } }) => ({ + id: args.data?.id || 'panel-created', + panelIndex: args.data?.panelIndex ?? 3, + }), + findFirst: async () => ({ panelIndex: 3 }), + delete: async () => ({}), + count: async () => 3, + updateMany: async () => ({ count: 0 }), + }, + novelPromotionStoryboard: { + update: async () => ({}), }, } return await fn(tx) diff --git a/tests/integration/api/contract/infra-routes.test.ts b/tests/integration/api/contract/infra-routes.test.ts new file mode 100644 index 0000000..3b7310e --- /dev/null +++ b/tests/integration/api/contract/infra-routes.test.ts @@ -0,0 +1,207 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ROUTE_CATALOG } from '../../../contracts/route-catalog' +import { buildMockRequest } from '../../../helpers/request' + +const authState = vi.hoisted(() => ({ + authenticated: false, +})) + +const loggingMock = vi.hoisted(() => ({ + readAllLogs: vi.fn(async () => 'worker log line 1\nworker log line 2'), +})) + +const storageMock = vi.hoisted(() => ({ + getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`), +})) + +vi.mock('@/lib/api-auth', () => { + const unauthorized = () => new Response( + JSON.stringify({ error: { code: 'UNAUTHORIZED' } }), + { status: 401, headers: { 'content-type': 'application/json' } }, + ) + + return { + isErrorResponse: (value: unknown) => value instanceof Response, + requireUserAuth: async () => { + if (!authState.authenticated) return unauthorized() + return { session: { user: { id: 'user-1' } } } + }, + } +}) + +vi.mock('@/lib/logging/file-writer', () => loggingMock) +vi.mock('@/lib/storage', () => storageMock) + +describe('api contract - infra routes (behavior)', () => { + const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes') + const originalUploadDir = process.env.UPLOAD_DIR + const tempState = { + uploadDirAbs: '', + uploadDirRel: '', + } + + beforeEach(() => { + vi.clearAllMocks() + authState.authenticated = false + vi.resetModules() + }) + + afterEach(async () => { + vi.resetModules() + if (tempState.uploadDirAbs) { + await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true }) + tempState.uploadDirAbs = '' + tempState.uploadDirRel = '' + } + if (originalUploadDir === undefined) { + delete process.env.UPLOAD_DIR + } else { + process.env.UPLOAD_DIR = originalUploadDir + } + }) + + async function prepareUploadDir(): Promise { + const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + tempState.uploadDirRel = path.join('.tmp', unique) + tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel) + process.env.UPLOAD_DIR = tempState.uploadDirRel + await fs.mkdir(tempState.uploadDirAbs, { recursive: true }) + } + + it('infra route group exists', () => { + expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([ + 'src/app/api/admin/download-logs/route.ts', + 'src/app/api/cos/image/route.ts', + 'src/app/api/files/[...path]/route.ts', + 'src/app/api/storage/sign/route.ts', + 'src/app/api/system/boot-id/route.ts', + ])) + }) + + it('GET /api/admin/download-logs rejects unauthenticated requests', async () => { + const mod = await import('@/app/api/admin/download-logs/route') + const req = buildMockRequest({ + path: '/api/admin/download-logs', + method: 'GET', + }) + + const res = await mod.GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(401) + expect(loggingMock.readAllLogs).not.toHaveBeenCalled() + }) + + it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => { + authState.authenticated = true + const mod = await import('@/app/api/admin/download-logs/route') + const req = buildMockRequest({ + path: '/api/admin/download-logs', + method: 'GET', + }) + + const res = await mod.GET(req, { params: Promise.resolve({}) }) + const text = await res.text() + + expect(res.status).toBe(200) + expect(text).toContain('worker log line 1') + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="waoowaoo-logs-/) + }) + + it('GET /api/cos/image redirects to signed storage route with normalized query', async () => { + const mod = await import('@/app/api/cos/image/route') + const req = buildMockRequest({ + path: '/api/cos/image?key=folder/a.png&expires=7200', + method: 'GET', + }) + + const res = await mod.GET(req, { params: Promise.resolve({}) }) + + expect(res.status).toBe(307) + expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200') + }) + + it('GET /api/storage/sign redirects to signed object url with default ttl', async () => { + const mod = await import('@/app/api/storage/sign/route') + const req = buildMockRequest({ + path: '/api/storage/sign?key=folder/a.png', + method: 'GET', + }) + + const res = await mod.GET(req, { params: Promise.resolve({}) }) + + expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600) + expect(res.status).toBe(307) + expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600') + }) + + it('GET /api/system/boot-id returns the current server boot id', async () => { + const mod = await import('@/app/api/system/boot-id/route') + const serverBoot = await import('@/lib/server-boot') + const res = await mod.GET() + const json = await res.json() as { bootId: string } + + expect(res.status).toBe(200) + expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID) + expect(typeof json.bootId).toBe('string') + expect(json.bootId.length).toBeGreaterThan(0) + }) + + it('GET /api/files/[...path] rejects path traversal attempts', async () => { + await prepareUploadDir() + const mod = await import('@/app/api/files/[...path]/route') + const req = buildMockRequest({ + path: '/api/files/%2E%2E/secret.txt', + method: 'GET', + }) + + const res = await mod.GET(req, { + params: Promise.resolve({ path: ['..', 'secret.txt'] }), + }) + const json = await res.json() as { error: string } + + expect(res.status).toBe(403) + expect(json.error).toBe('Access denied') + }) + + it('GET /api/files/[...path] returns 404 when the file is missing', async () => { + await prepareUploadDir() + const mod = await import('@/app/api/files/[...path]/route') + const req = buildMockRequest({ + path: '/api/files/missing.txt', + method: 'GET', + }) + + const res = await mod.GET(req, { + params: Promise.resolve({ path: ['missing.txt'] }), + }) + const json = await res.json() as { error: string } + + expect(res.status).toBe(404) + expect(json.error).toBe('File not found') + }) + + it('GET /api/files/[...path] serves local files from the configured upload dir', async () => { + await prepareUploadDir() + const nestedDir = path.join(tempState.uploadDirAbs, 'folder') + await fs.mkdir(nestedDir, { recursive: true }) + await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8') + + const mod = await import('@/app/api/files/[...path]/route') + const req = buildMockRequest({ + path: '/api/files/folder/hello.txt', + method: 'GET', + }) + + const res = await mod.GET(req, { + params: Promise.resolve({ path: ['folder', 'hello.txt'] }), + }) + const text = await res.text() + + expect(res.status).toBe(200) + expect(text).toBe('hello local file') + expect(res.headers.get('content-type')).toBe('text/plain') + expect(res.headers.get('cache-control')).toBe('public, max-age=31536000') + }) +}) diff --git a/tests/integration/api/specific/panel-variant-route.test.ts b/tests/integration/api/specific/panel-variant-route.test.ts new file mode 100644 index 0000000..3238417 --- /dev/null +++ b/tests/integration/api/specific/panel-variant-route.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMockRequest } from '../../../helpers/request' + +type PanelRecord = { + id: string + storyboardId: string + panelIndex: number + shotType: string + cameraMove: string + description: string + videoPrompt: string + location: string + characters: string + srtSegment: string + duration: number +} + +type StoryboardRecord = { + id: string + episode: { + novelPromotionProject: { + projectId: string + } + } +} + +const authMock = vi.hoisted(() => ({ + requireProjectAuthLight: vi.fn(async () => ({ + session: { user: { id: 'user-1' } }, + project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' }, + })), + isErrorResponse: vi.fn((value: unknown) => value instanceof Response), +})) + +const submitTaskMock = vi.hoisted(() => vi.fn(async () => ({ + success: true, + async: true, + taskId: 'task-panel-variant', + runId: null, + status: 'queued', + deduped: false, +}))) + +const configServiceMock = vi.hoisted(() => ({ + getProjectModelConfig: vi.fn(async () => ({ + storyboardModel: 'img::storyboard', + })), + buildImageBillingPayload: vi.fn(async (input: { basePayload: Record }) => ({ + ...input.basePayload, + generationOptions: { resolution: '1024x1024' }, + })), +})) + +const rollbackSpy = vi.hoisted(() => ({ + delete: vi.fn(async () => ({})), + findFirst: vi.fn(async () => ({ panelIndex: 4 })), + updateMany: vi.fn(async () => ({ count: 2 })), + count: vi.fn(async () => 3), + storyboardUpdate: vi.fn(async () => ({})), +})) + +const createTxSpy = vi.hoisted(() => ({ + findMany: vi.fn(async () => [ + { id: 'panel-after-1', panelIndex: 2 }, + { id: 'panel-after-2', panelIndex: 3 }, + ]), + update: vi.fn(async () => ({})), + create: vi.fn(async (args: { data: PanelRecord }) => ({ + id: args.data.id, + panelIndex: args.data.panelIndex, + })), + count: vi.fn(async () => 4), + storyboardUpdate: vi.fn(async () => ({})), +})) + +const routeState = vi.hoisted(() => ({ + storyboard: { + id: 'storyboard-1', + episode: { + novelPromotionProject: { + projectId: 'project-1', + }, + }, + } satisfies StoryboardRecord, + panels: new Map(), +})) + +const prismaMock = vi.hoisted(() => ({ + novelPromotionStoryboard: { + findUnique: vi.fn(async () => routeState.storyboard), + }, + novelPromotionPanel: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null), + }, + $transaction: vi.fn(async ( + fn: (tx: { + novelPromotionPanel: { + findMany: typeof createTxSpy.findMany + update: typeof createTxSpy.update + create: typeof createTxSpy.create + delete: typeof rollbackSpy.delete + findFirst: typeof rollbackSpy.findFirst + updateMany: typeof rollbackSpy.updateMany + count: typeof rollbackSpy.count + } + novelPromotionStoryboard: { + update: typeof createTxSpy.storyboardUpdate + } + }) => Promise, + ) => { + const invocation = prismaMock.$transaction.mock.calls.length + if (invocation > 1) { + return await fn({ + novelPromotionPanel: { + findMany: createTxSpy.findMany, + update: createTxSpy.update, + create: createTxSpy.create, + delete: rollbackSpy.delete, + findFirst: rollbackSpy.findFirst, + updateMany: rollbackSpy.updateMany, + count: rollbackSpy.count, + }, + novelPromotionStoryboard: { + update: rollbackSpy.storyboardUpdate, + }, + }) + } + + return await fn({ + novelPromotionPanel: { + findMany: createTxSpy.findMany, + update: createTxSpy.update, + create: createTxSpy.create, + delete: rollbackSpy.delete, + findFirst: rollbackSpy.findFirst, + updateMany: rollbackSpy.updateMany, + count: rollbackSpy.count, + }, + novelPromotionStoryboard: { + update: createTxSpy.storyboardUpdate, + }, + }) + }), +})) + +vi.mock('@/lib/api-auth', () => authMock) +vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock })) +vi.mock('@/lib/config-service', () => configServiceMock) +vi.mock('@/lib/prisma', () => ({ prisma: prismaMock })) +vi.mock('@/lib/billing', () => ({ + buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })), +})) +vi.mock('@/lib/task/resolve-locale', () => ({ + resolveRequiredTaskLocale: vi.fn(() => 'zh'), +})) + +function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord { + return { + id, + storyboardId, + panelIndex, + shotType: 'medium', + cameraMove: 'static', + description: `description-${id}`, + videoPrompt: `prompt-${id}`, + location: 'Old Town', + characters: '[]', + srtSegment: '', + duration: 3, + } +} + +async function invokeRoute(body: Record): Promise { + const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route') + const req = buildMockRequest({ + path: '/api/novel-promotion/project-1/panel-variant', + method: 'POST', + body, + }) + return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) }) +} + +describe('api specific - panel variant route', () => { + beforeEach(() => { + vi.clearAllMocks() + + routeState.storyboard = { + id: 'storyboard-1', + episode: { + novelPromotionProject: { + projectId: 'project-1', + }, + }, + } + routeState.panels = new Map([ + ['panel-src', buildPanel('panel-src', 'storyboard-1', 1)], + ['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)], + ]) + }) + + it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => { + routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1)) + + const res = await invokeRoute({ + storyboardId: 'storyboard-1', + insertAfterPanelId: 'panel-ins', + sourcePanelId: 'panel-src', + variant: { video_prompt: 'variant prompt', description: 'variant desc' }, + }) + + const json = await res.json() as { error: { code: string } } + expect(res.status).toBe(400) + expect(json.error.code).toBe('INVALID_PARAMS') + expect(createTxSpy.create).not.toHaveBeenCalled() + expect(submitTaskMock).not.toHaveBeenCalled() + }) + + it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => { + routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2)) + + const res = await invokeRoute({ + storyboardId: 'storyboard-1', + insertAfterPanelId: 'panel-ins', + sourcePanelId: 'panel-src', + variant: { video_prompt: 'variant prompt', description: 'variant desc' }, + }) + + const json = await res.json() as { error: { code: string } } + expect(res.status).toBe(400) + expect(json.error.code).toBe('INVALID_PARAMS') + expect(createTxSpy.create).not.toHaveBeenCalled() + expect(submitTaskMock).not.toHaveBeenCalled() + }) + + it('does not create panel when image billing payload validation fails', async () => { + configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability')) + + const res = await invokeRoute({ + storyboardId: 'storyboard-1', + insertAfterPanelId: 'panel-ins', + sourcePanelId: 'panel-src', + variant: { video_prompt: 'variant prompt', description: 'variant desc' }, + }) + + const json = await res.json() as { error: { code: string; message: string } } + expect(res.status).toBe(400) + expect(json.error.code).toBe('INVALID_PARAMS') + expect(json.error.message).toBe('missing capability') + expect(createTxSpy.create).not.toHaveBeenCalled() + expect(submitTaskMock).not.toHaveBeenCalled() + }) + + it('rolls back the created panel when submitTask fails after insertion', async () => { + submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable')) + + const res = await invokeRoute({ + storyboardId: 'storyboard-1', + insertAfterPanelId: 'panel-ins', + sourcePanelId: 'panel-src', + variant: { video_prompt: 'variant prompt', description: 'variant desc' }, + }) + + const json = await res.json() as { error: { code: string } } + expect(res.status).toBe(502) + expect(json.error.code).toBe('EXTERNAL_ERROR') + + expect(createTxSpy.create).toHaveBeenCalledTimes(1) + const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id + expect(createdPanelId).toEqual(expect.any(String)) + expect(rollbackSpy.delete).toHaveBeenCalledWith({ + where: { id: createdPanelId }, + }) + expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, { + where: { + storyboardId: 'storyboard-1', + panelIndex: { gt: 3 }, + }, + data: { + panelIndex: { increment: 1004 }, + panelNumber: { increment: 1004 }, + }, + }) + expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, { + where: { + storyboardId: 'storyboard-1', + panelIndex: { gt: 1007 }, + }, + data: { + panelIndex: { decrement: 1005 }, + panelNumber: { decrement: 1005 }, + }, + }) + expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({ + where: { id: 'storyboard-1' }, + data: { panelCount: 3 }, + }) + }) +}) diff --git a/tests/unit/api-config/provider-card-tutorial-modal.test.ts b/tests/unit/api-config/provider-card-tutorial-modal.test.ts index 2fd1162..a4c86e5 100644 --- a/tests/unit/api-config/provider-card-tutorial-modal.test.ts +++ b/tests/unit/api-config/provider-card-tutorial-modal.test.ts @@ -107,6 +107,20 @@ function createState(tutorial: ProviderTutorial): UseProviderCardStateResult { } } +function ProviderCardShellWithBody( + props: Omit, 'children'>, +): React.ReactElement { + const ProviderCardShellComponent = + ProviderCardShell as unknown as React.ComponentType< + React.PropsWithChildren, 'children'>> + > + return createElement( + ProviderCardShellComponent, + props, + createElement('div', null, 'provider-body'), + ) +} + describe('ProviderCardShell tutorial modal', () => { afterEach(() => { vi.clearAllMocks() @@ -145,7 +159,7 @@ describe('ProviderCardShell tutorial modal', () => { const html = renderToStaticMarkup( createElement( - ProviderCardShell, + ProviderCardShellWithBody, { provider: { id: 'ark', @@ -156,7 +170,6 @@ describe('ProviderCardShell tutorial modal', () => { t, state, }, - createElement('div', null, 'provider-body'), ), ) diff --git a/tests/unit/guards/api-route-contract-guard.test.ts b/tests/unit/guards/api-route-contract-guard.test.ts new file mode 100644 index 0000000..c76c500 --- /dev/null +++ b/tests/unit/guards/api-route-contract-guard.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + API_HANDLER_ALLOWLIST, + PUBLIC_ROUTE_ALLOWLIST, + inspectRouteContract, +} from '../../../scripts/guards/api-route-contract-guard.mjs' + +describe('api route contract guard', () => { + it('allows explicit public and framework-managed exceptions', () => { + expect(API_HANDLER_ALLOWLIST.has('src/app/api/auth/[...nextauth]/route.ts')).toBe(true) + expect(PUBLIC_ROUTE_ALLOWLIST.has('src/app/api/system/boot-id/route.ts')).toBe(true) + expect( + inspectRouteContract( + 'src/app/api/system/boot-id/route.ts', + 'export async function GET() { return Response.json({ bootId: "x" }) }', + ), + ).toEqual([]) + }) + + it('passes protected routes that use apiHandler and explicit auth', () => { + const content = ` + import { requireUserAuth } from '@/lib/api-auth' + import { apiHandler } from '@/lib/api-errors' + export const GET = apiHandler(async () => { + await requireUserAuth() + return Response.json({ ok: true }) + }) + ` + + expect(inspectRouteContract('src/app/api/user/secure/route.ts', content)).toEqual([]) + }) + + it('flags protected routes that skip apiHandler or auth', () => { + const missingApiHandler = ` + import { requireUserAuth } from '@/lib/api-auth' + export async function GET() { + await requireUserAuth() + return Response.json({ ok: true }) + } + ` + const missingAuth = ` + import { apiHandler } from '@/lib/api-errors' + export const GET = apiHandler(async () => Response.json({ ok: true })) + ` + + expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingApiHandler)).toEqual([ + 'src/app/api/user/secure/route.ts missing apiHandler wrapper', + ]) + expect(inspectRouteContract('src/app/api/user/secure/route.ts', missingAuth)).toEqual([ + 'src/app/api/user/secure/route.ts missing requireUserAuth/requireProjectAuth/requireProjectAuthLight', + ]) + }) +}) diff --git a/tests/unit/guards/image-reference-normalization-guard.test.ts b/tests/unit/guards/image-reference-normalization-guard.test.ts new file mode 100644 index 0000000..68131a5 --- /dev/null +++ b/tests/unit/guards/image-reference-normalization-guard.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + NORMALIZATION_HELPER_ALLOWLIST, + inspectImageReferenceNormalization, +} from '../../../scripts/guards/image-reference-normalization-guard.mjs' + +describe('image reference normalization guard', () => { + it('allows shared helper exceptions explicitly', () => { + expect(NORMALIZATION_HELPER_ALLOWLIST.has('src/lib/workers/handlers/image-task-handler-shared.ts')).toBe(true) + expect( + inspectImageReferenceNormalization( + 'src/lib/workers/handlers/image-task-handler-shared.ts', + 'resolveImageSourceFromGeneration(job, { options: params.options })\nreferenceImages?: string[]', + ), + ).toEqual([]) + }) + + it('passes handlers that normalize reference images before generation', () => { + const content = ` + import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image' + async function run() { + const normalizedRefs = await normalizeReferenceImagesForGeneration(refs) + return await resolveImageSourceFromGeneration(job, { + options: { + referenceImages: normalizedRefs, + }, + }) + } + ` + + expect( + inspectImageReferenceNormalization('src/lib/workers/handlers/panel-image-task-handler.ts', content), + ).toEqual([]) + }) + + it('flags handlers that send referenceImages without normalization markers', () => { + const content = ` + async function run() { + return await resolveImageSourceFromGeneration(job, { + options: { + referenceImages: refs, + }, + }) + } + ` + + expect( + inspectImageReferenceNormalization('src/lib/workers/handlers/bad-handler.ts', content), + ).toEqual([ + 'src/lib/workers/handlers/bad-handler.ts uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos', + ]) + }) +}) diff --git a/tests/unit/guards/task-submit-compensation-guard.test.ts b/tests/unit/guards/task-submit-compensation-guard.test.ts new file mode 100644 index 0000000..63ce213 --- /dev/null +++ b/tests/unit/guards/task-submit-compensation-guard.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { inspectTaskSubmitCompensation } from '../../../scripts/guards/task-submit-compensation-guard.mjs' + +describe('task submit compensation guard', () => { + it('passes routes that create data before submitTask and define rollback handling', () => { + const content = ` + async function rollbackCreatedRecord() {} + export const POST = apiHandler(async () => { + await prisma.panel.create({ data: {} }) + try { + return await submitTask({}) + } catch (error) { + await rollbackCreatedRecord() + throw error + } + }) + ` + + expect( + inspectTaskSubmitCompensation('src/app/api/novel-promotion/[projectId]/panel-variant/route.ts', content), + ).toEqual([]) + }) + + it('ignores routes that do not combine create and submitTask', () => { + expect(inspectTaskSubmitCompensation('src/app/api/user/api-config/route.ts', 'await submitTask({})')).toEqual([]) + expect(inspectTaskSubmitCompensation('src/app/api/projects/route.ts', 'await prisma.project.create({ data: {} })')).toEqual([]) + }) + + it('flags routes that create data before submitTask without compensation marker', () => { + const content = ` + export const POST = apiHandler(async () => { + await prisma.panel.create({ data: {} }) + return await submitTask({}) + }) + ` + + expect( + inspectTaskSubmitCompensation('src/app/api/example/route.ts', content), + ).toEqual([ + 'src/app/api/example/route.ts creates data before submitTask without explicit rollback/compensation marker', + ]) + }) +}) diff --git a/tests/unit/worker/panel-variant-task-handler.test.ts b/tests/unit/worker/panel-variant-task-handler.test.ts index de8db77..54be657 100644 --- a/tests/unit/worker/panel-variant-task-handler.test.ts +++ b/tests/unit/worker/panel-variant-task-handler.test.ts @@ -21,8 +21,16 @@ const sharedMock = vi.hoisted(() => ({ collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']), resolveNovelData: vi.fn(async () => ({ videoRatio: '16:9', - characters: [{ name: 'Hero', introduction: '主角' }], - locations: [{ name: 'Old Town' }], + characters: [{ + name: 'Hero', + introduction: '主角', + appearances: [{ + changeReason: 'default', + imageUrls: JSON.stringify(['cos/hero-default.png']), + imageUrl: 'cos/hero-default.png', + }], + }], + locations: [{ name: 'Old Town', images: [] }], })), })) @@ -30,6 +38,10 @@ const outboundMock = vi.hoisted(() => ({ normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)), })) +const promptMock = vi.hoisted(() => ({ + buildPrompt: vi.fn(() => 'panel-variant-prompt'), +})) + vi.mock('@/lib/prisma', () => ({ prisma: prismaMock })) vi.mock('@/lib/workers/utils', () => utilsMock) vi.mock('@/lib/media/outbound-image', () => outboundMock) @@ -46,7 +58,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => { }) vi.mock('@/lib/prompt-i18n', () => ({ PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' }, - buildPrompt: vi.fn(() => 'panel-variant-prompt'), + buildPrompt: promptMock.buildPrompt, })) import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler' @@ -123,7 +135,7 @@ describe('worker panel-variant-task-handler behavior', () => { aspectRatio: '16:9', referenceImages: [ 'normalized:https://signed.example/cos/panel-source.png', - 'normalized:https://signed.example/ref-character.png', + 'normalized:https://signed.example/cos/hero-default.png', ], }), }), @@ -140,4 +152,30 @@ describe('worker panel-variant-task-handler behavior', () => { imageUrl: 'cos/panel-variant-new.png', }) }) + + it('respects reference asset toggles when character/location assets are disabled', async () => { + const payload = { + newPanelId: 'panel-new', + sourcePanelId: 'panel-source', + includeCharacterAssets: false, + includeLocationAsset: false, + variant: { + title: '禁用资产版本', + description: '只参考原镜头', + video_prompt: '只参考原镜头', + }, + } + + await handlePanelVariantTask(buildJob(payload)) + + expect(outboundMock.normalizeReferenceImagesForGeneration).toHaveBeenCalledWith([ + 'https://signed.example/cos/panel-source.png', + ]) + expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({ + variables: expect.objectContaining({ + character_assets: '未使用角色参考图', + location_asset: '未使用场景参考图', + }), + })) + }) })