181 lines
8.9 KiB
TypeScript
181 lines
8.9 KiB
TypeScript
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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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<Response>>()
|
|
.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<Response>>()
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 })
|
|
)
|
|
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { 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<Response>>()
|
|
.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<Response>>().mockImplementation(
|
|
() =>
|
|
new Promise<Response>(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' } })
|
|
})
|
|
})
|