feat: implement robustness guards
This commit is contained in:
@@ -57,6 +57,29 @@ export const REQUIREMENTS_MATRIX: ReadonlyArray<RequirementCoverageEntry> = [
|
||||
'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<RequirementCoverageEntry> = [
|
||||
'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',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -15,7 +15,7 @@ const CONTRACT_TEST_BY_GROUP: Record<RouteCatalogEntry['contractGroup'], string>
|
||||
'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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Array<{ id: string; panelIndex: number }>>
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
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<unknown>
|
||||
count: (args: unknown) => Promise<number>
|
||||
updateMany: (args: unknown) => Promise<{ count: number }>
|
||||
}
|
||||
novelPromotionStoryboard: {
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionPanel: {
|
||||
findMany: async () => [],
|
||||
update: async () => ({}),
|
||||
create: async () => ({ 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)
|
||||
|
||||
207
tests/integration/api/contract/infra-routes.test.ts
Normal file
207
tests/integration/api/contract/infra-routes.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
authenticated: false,
|
||||
}))
|
||||
|
||||
const loggingMock = vi.hoisted(() => ({
|
||||
readAllLogs: vi.fn(async () => 'worker log line 1\nworker log line 2'),
|
||||
}))
|
||||
|
||||
const storageMock = vi.hoisted(() => ({
|
||||
getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/logging/file-writer', () => loggingMock)
|
||||
vi.mock('@/lib/storage', () => storageMock)
|
||||
|
||||
describe('api contract - infra routes (behavior)', () => {
|
||||
const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes')
|
||||
const originalUploadDir = process.env.UPLOAD_DIR
|
||||
const tempState = {
|
||||
uploadDirAbs: '',
|
||||
uploadDirRel: '',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = false
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (tempState.uploadDirAbs) {
|
||||
await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true })
|
||||
tempState.uploadDirAbs = ''
|
||||
tempState.uploadDirRel = ''
|
||||
}
|
||||
if (originalUploadDir === undefined) {
|
||||
delete process.env.UPLOAD_DIR
|
||||
} else {
|
||||
process.env.UPLOAD_DIR = originalUploadDir
|
||||
}
|
||||
})
|
||||
|
||||
async function prepareUploadDir(): Promise<void> {
|
||||
const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
tempState.uploadDirRel = path.join('.tmp', unique)
|
||||
tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel)
|
||||
process.env.UPLOAD_DIR = tempState.uploadDirRel
|
||||
await fs.mkdir(tempState.uploadDirAbs, { recursive: true })
|
||||
}
|
||||
|
||||
it('infra route group exists', () => {
|
||||
expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([
|
||||
'src/app/api/admin/download-logs/route.ts',
|
||||
'src/app/api/cos/image/route.ts',
|
||||
'src/app/api/files/[...path]/route.ts',
|
||||
'src/app/api/storage/sign/route.ts',
|
||||
'src/app/api/system/boot-id/route.ts',
|
||||
]))
|
||||
})
|
||||
|
||||
it('GET /api/admin/download-logs rejects unauthenticated requests', async () => {
|
||||
const mod = await import('@/app/api/admin/download-logs/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/admin/download-logs',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
expect(loggingMock.readAllLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/admin/download-logs/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/admin/download-logs',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain('worker log line 1')
|
||||
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
||||
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="waoowaoo-logs-/)
|
||||
})
|
||||
|
||||
it('GET /api/cos/image redirects to signed storage route with normalized query', async () => {
|
||||
const mod = await import('@/app/api/cos/image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/cos/image?key=folder/a.png&expires=7200',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200')
|
||||
})
|
||||
|
||||
it('GET /api/storage/sign redirects to signed object url with default ttl', async () => {
|
||||
const mod = await import('@/app/api/storage/sign/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/storage/sign?key=folder/a.png',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
|
||||
expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600)
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600')
|
||||
})
|
||||
|
||||
it('GET /api/system/boot-id returns the current server boot id', async () => {
|
||||
const mod = await import('@/app/api/system/boot-id/route')
|
||||
const serverBoot = await import('@/lib/server-boot')
|
||||
const res = await mod.GET()
|
||||
const json = await res.json() as { bootId: string }
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID)
|
||||
expect(typeof json.bootId).toBe('string')
|
||||
expect(json.bootId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] rejects path traversal attempts', async () => {
|
||||
await prepareUploadDir()
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/%2E%2E/secret.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['..', 'secret.txt'] }),
|
||||
})
|
||||
const json = await res.json() as { error: string }
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(json.error).toBe('Access denied')
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] returns 404 when the file is missing', async () => {
|
||||
await prepareUploadDir()
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/missing.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['missing.txt'] }),
|
||||
})
|
||||
const json = await res.json() as { error: string }
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(json.error).toBe('File not found')
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] serves local files from the configured upload dir', async () => {
|
||||
await prepareUploadDir()
|
||||
const nestedDir = path.join(tempState.uploadDirAbs, 'folder')
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8')
|
||||
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/folder/hello.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['folder', 'hello.txt'] }),
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toBe('hello local file')
|
||||
expect(res.headers.get('content-type')).toBe('text/plain')
|
||||
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000')
|
||||
})
|
||||
})
|
||||
298
tests/integration/api/specific/panel-variant-route.test.ts
Normal file
298
tests/integration/api/specific/panel-variant-route.test.ts
Normal file
@@ -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<typeof import('@/lib/task/submitter').submitTask>(async () => ({
|
||||
success: true,
|
||||
async: true,
|
||||
taskId: 'task-panel-variant',
|
||||
runId: null,
|
||||
status: 'queued',
|
||||
deduped: false,
|
||||
})))
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getProjectModelConfig: vi.fn(async () => ({
|
||||
storyboardModel: 'img::storyboard',
|
||||
})),
|
||||
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
|
||||
...input.basePayload,
|
||||
generationOptions: { resolution: '1024x1024' },
|
||||
})),
|
||||
}))
|
||||
|
||||
const rollbackSpy = vi.hoisted(() => ({
|
||||
delete: vi.fn(async () => ({})),
|
||||
findFirst: vi.fn(async () => ({ panelIndex: 4 })),
|
||||
updateMany: vi.fn(async () => ({ count: 2 })),
|
||||
count: vi.fn(async () => 3),
|
||||
storyboardUpdate: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
const createTxSpy = vi.hoisted(() => ({
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 'panel-after-1', panelIndex: 2 },
|
||||
{ id: 'panel-after-2', panelIndex: 3 },
|
||||
]),
|
||||
update: vi.fn(async () => ({})),
|
||||
create: vi.fn(async (args: { data: PanelRecord }) => ({
|
||||
id: args.data.id,
|
||||
panelIndex: args.data.panelIndex,
|
||||
})),
|
||||
count: vi.fn(async () => 4),
|
||||
storyboardUpdate: vi.fn(async () => ({})),
|
||||
}))
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
storyboard: {
|
||||
id: 'storyboard-1',
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: 'project-1',
|
||||
},
|
||||
},
|
||||
} satisfies StoryboardRecord,
|
||||
panels: new Map<string, PanelRecord>(),
|
||||
}))
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionStoryboard: {
|
||||
findUnique: vi.fn(async () => routeState.storyboard),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),
|
||||
},
|
||||
$transaction: vi.fn(async (
|
||||
fn: (tx: {
|
||||
novelPromotionPanel: {
|
||||
findMany: typeof createTxSpy.findMany
|
||||
update: typeof createTxSpy.update
|
||||
create: typeof createTxSpy.create
|
||||
delete: typeof rollbackSpy.delete
|
||||
findFirst: typeof rollbackSpy.findFirst
|
||||
updateMany: typeof rollbackSpy.updateMany
|
||||
count: typeof rollbackSpy.count
|
||||
}
|
||||
novelPromotionStoryboard: {
|
||||
update: typeof createTxSpy.storyboardUpdate
|
||||
}
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
const invocation = prismaMock.$transaction.mock.calls.length
|
||||
if (invocation > 1) {
|
||||
return await fn({
|
||||
novelPromotionPanel: {
|
||||
findMany: createTxSpy.findMany,
|
||||
update: createTxSpy.update,
|
||||
create: createTxSpy.create,
|
||||
delete: rollbackSpy.delete,
|
||||
findFirst: rollbackSpy.findFirst,
|
||||
updateMany: rollbackSpy.updateMany,
|
||||
count: rollbackSpy.count,
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
update: rollbackSpy.storyboardUpdate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return await fn({
|
||||
novelPromotionPanel: {
|
||||
findMany: createTxSpy.findMany,
|
||||
update: createTxSpy.update,
|
||||
create: createTxSpy.create,
|
||||
delete: rollbackSpy.delete,
|
||||
findFirst: rollbackSpy.findFirst,
|
||||
updateMany: rollbackSpy.updateMany,
|
||||
count: rollbackSpy.count,
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
update: createTxSpy.storyboardUpdate,
|
||||
},
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => authMock)
|
||||
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
|
||||
vi.mock('@/lib/config-service', () => configServiceMock)
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
|
||||
}))
|
||||
vi.mock('@/lib/task/resolve-locale', () => ({
|
||||
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
|
||||
}))
|
||||
|
||||
function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {
|
||||
return {
|
||||
id,
|
||||
storyboardId,
|
||||
panelIndex,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
description: `description-${id}`,
|
||||
videoPrompt: `prompt-${id}`,
|
||||
location: 'Old Town',
|
||||
characters: '[]',
|
||||
srtSegment: '',
|
||||
duration: 3,
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeRoute(body: Record<string, unknown>): Promise<Response> {
|
||||
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/novel-promotion/project-1/panel-variant',
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
|
||||
}
|
||||
|
||||
describe('api specific - panel variant route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
routeState.storyboard = {
|
||||
id: 'storyboard-1',
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: 'project-1',
|
||||
},
|
||||
},
|
||||
}
|
||||
routeState.panels = new Map<string, PanelRecord>([
|
||||
['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],
|
||||
['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],
|
||||
])
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {
|
||||
routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {
|
||||
routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create panel when image billing payload validation fails', async () => {
|
||||
configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string; message: string } }
|
||||
expect(res.status).toBe(400)
|
||||
expect(json.error.code).toBe('INVALID_PARAMS')
|
||||
expect(json.error.message).toBe('missing capability')
|
||||
expect(createTxSpy.create).not.toHaveBeenCalled()
|
||||
expect(submitTaskMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the created panel when submitTask fails after insertion', async () => {
|
||||
submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))
|
||||
|
||||
const res = await invokeRoute({
|
||||
storyboardId: 'storyboard-1',
|
||||
insertAfterPanelId: 'panel-ins',
|
||||
sourcePanelId: 'panel-src',
|
||||
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
|
||||
})
|
||||
|
||||
const json = await res.json() as { error: { code: string } }
|
||||
expect(res.status).toBe(502)
|
||||
expect(json.error.code).toBe('EXTERNAL_ERROR')
|
||||
|
||||
expect(createTxSpy.create).toHaveBeenCalledTimes(1)
|
||||
const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id
|
||||
expect(createdPanelId).toEqual(expect.any(String))
|
||||
expect(rollbackSpy.delete).toHaveBeenCalledWith({
|
||||
where: { id: createdPanelId },
|
||||
})
|
||||
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {
|
||||
where: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: { gt: 3 },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { increment: 1004 },
|
||||
panelNumber: { increment: 1004 },
|
||||
},
|
||||
})
|
||||
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {
|
||||
where: {
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: { gt: 1007 },
|
||||
},
|
||||
data: {
|
||||
panelIndex: { decrement: 1005 },
|
||||
panelNumber: { decrement: 1005 },
|
||||
},
|
||||
})
|
||||
expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'storyboard-1' },
|
||||
data: { panelCount: 3 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,20 @@ function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
|
||||
}
|
||||
}
|
||||
|
||||
function ProviderCardShellWithBody(
|
||||
props: Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>,
|
||||
): React.ReactElement {
|
||||
const ProviderCardShellComponent =
|
||||
ProviderCardShell as unknown as React.ComponentType<
|
||||
React.PropsWithChildren<Omit<React.ComponentProps<typeof ProviderCardShell>, 'children'>>
|
||||
>
|
||||
return createElement(
|
||||
ProviderCardShellComponent,
|
||||
props,
|
||||
createElement('div', null, 'provider-body'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProviderCardShell tutorial modal', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
53
tests/unit/guards/api-route-contract-guard.test.ts
Normal file
53
tests/unit/guards/api-route-contract-guard.test.ts
Normal file
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
43
tests/unit/guards/task-submit-compensation-guard.test.ts
Normal file
43
tests/unit/guards/task-submit-compensation-guard.test.ts
Normal file
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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: '未使用场景参考图',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user