Files
short-drama-agent-front/src/lib/http.test.ts
T

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' } })
})
})