import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiError, optionalResource, request } from './http' import { projectsApi } from '../features/projects/api' import { breakdownApi } from '../features/breakdown/api' import { storyboardApi } from '../features/storyboard/api' import { subjectImagesApi } from '../features/subject-images/api' afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() }) describe('后端 API 契约', () => { it('图库读取与单图、批量和切换主图使用各自接口,模型由后端统一配置', async () => { const fetcher = vi.fn().mockImplementation(async () => new Response('{"data":[]}')) vi.stubGlobal('fetch', fetcher) await subjectImagesApi.listForms('a/b') await subjectImagesApi.listImages('form/id') await subjectImagesApi.generate('form/id', { setPrimary: false, width: 2048, height: 2048, prompt: '本次提示词' }) await subjectImagesApi.generateProject('a/b', { concurrency: 2, force: false }) await subjectImagesApi.setPrimary('form/id', 'image/id') expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([ ['/api/projects/a%2Fb/subject-forms', 'GET'], ['/api/subject-forms/form%2Fid/images', 'GET'], ['/api/subject-forms/form%2Fid/images', 'POST'], ['/api/projects/a%2Fb/subject-images/generate', 'POST'], ['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT'] ]) expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({ setPrimary: false, width: 2048, height: 2048, prompt: '本次提示词' }) expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({ concurrency: 2, force: false }) expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined() }) it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => { const fetcher = vi.fn().mockImplementation(async () => new Response('{"data":{}}')) vi.stubGlobal('fetch', fetcher) await storyboardApi.generateDirection('a/b', 2) await storyboardApi.generateDirections('p', { concurrency: 2, force: false }) await storyboardApi.generateVisualState('p', 2, 0) await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 }) expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([ ['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }], ['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }], [ '/api/projects/p/storyboard-visual-states/generate-test', 'POST', { episodeNo: 2, persist: true, maxRepairAttempts: 0 } ], [ '/api/projects/p/storyboard-visual-states/generate', 'POST', { concurrency: 3, force: true, maxRepairAttempts: 0 } ] ]) }) it('查询按剧集,镜头工具使用数据库 ID,读取提示词仍需显式 POST', async () => { const fetcher = vi.fn().mockImplementation(async () => new Response('{"data":{}}')) vi.stubGlobal('fetch', fetcher) await storyboardApi.directions('p', 4) await storyboardApi.visualStates('p', 4) await storyboardApi.references('shot/id') await storyboardApi.generationSpec('shot/id') await storyboardApi.generatePrompt('shot/id', false) await storyboardApi.generatePrompts('p', { concurrency: 2, force: false }) expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ '/api/projects/p/storyboard-directions?episodeNo=4', '/api/projects/p/storyboard-visual-states?episodeNo=4', '/api/storyboard-shots/shot%2Fid/references', '/api/storyboard-shots/shot%2Fid/generation-spec', '/api/storyboard-shots/shot%2Fid/video-prompt', '/api/projects/p/video-prompts/generate' ]) expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false }) }) it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => { vi.useFakeTimers() let finish!: (response: Response) => void const fetcher = vi.fn().mockImplementation( () => new Promise(resolve => { finish = resolve }) ) vi.stubGlobal('fetch', fetcher) const pending = storyboardApi.generateVisualState('p', 1, 2) await vi.advanceTimersByTimeAsync(120_000) expect(fetcher).toHaveBeenCalledOnce() expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false) finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}')) await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false }) }) it('解包 data,同时保留创建项目的顶层 202 结构', async () => { const fetcher = vi .fn<(url: string, options: RequestInit) => Promise>() .mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'p1' }] }))) .mockResolvedValueOnce( new Response(JSON.stringify({ projectId: 'p2', status: 'generating' }), { status: 202 }) ) vi.stubGlobal('fetch', fetcher) expect(await projectsApi.list()).toEqual([{ id: 'p1' }]) expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({ projectId: 'p2', status: 'generating' }) expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({ topic: '故事', style: '悬疑', episodeCount: 3 }) }) it('只将缺少 checkpoint 的 404 视为可选结果,保留服务器错误', async () => { expect(await optionalResource(Promise.reject(new ApiError('没有 checkpoint', 404)))).toBeNull() await expect(optionalResource(Promise.reject(new ApiError('数据库异常', 500)))).rejects.toMatchObject({ status: 500 }) }) it('保留错误细节且拒绝 HTML 代理错误', async () => { vi.stubGlobal( 'fetch', vi .fn<(url: string, options: RequestInit) => Promise>() .mockResolvedValueOnce( new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 }) ) .mockResolvedValueOnce(new Response('Bad Gateway', { status: 502 })) ) await expect(request('/projects')).rejects.toMatchObject({ status: 400, details: ['缺少形态'] }) await expect(request('/projects')).rejects.toThrow('接口未返回 JSON') }) it('预览模块使用逗号参数,三种恢复不误发到 start', async () => { const fetcher = vi .fn<(url: string, options: RequestInit) => Promise>() .mockImplementation(() => Promise.resolve(new Response('{"data":{}}'))) vi.stubGlobal('fetch', fetcher) await breakdownApi.preview('a/b', { groupSize: 3, modules: ['character', 'scene'] }) for (const action of ['retry', 'resume-shots', 'resume-storyboard'] as const) await breakdownApi.run('p', action) expect(fetcher.mock.calls.map(call => call[0])).toEqual([ '/api/projects/a%2Fb/breakdown-preview?groupSize=3&modules=character%2Cscene', '/api/projects/p/breakdown/retry', '/api/projects/p/breakdown/resume-shots', '/api/projects/p/breakdown/resume-storyboard' ]) }) it('长工作流不使用普通查询的超时,不自动重试 POST', async () => { vi.useFakeTimers() let finish!: (value: Response) => void const fetcher = vi.fn<(url: string, options: RequestInit) => Promise>().mockImplementation( () => new Promise(resolve => { finish = resolve }) ) vi.stubGlobal('fetch', fetcher) const running = breakdownApi.run('p', 'start', { groupSize: 3, modules: ['prop'] }) await vi.advanceTimersByTimeAsync(120_000) expect(fetcher).toHaveBeenCalledTimes(1) expect(fetcher.mock.calls[0]![1].signal?.aborted).toBe(false) finish(new Response('{"data":{"workflowExecution":{"status":"failed"}}}')) expect(await running).toMatchObject({ workflowExecution: { status: 'failed' } }) }) })