155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { queryFalStatus, submitFalTask } from '@/lib/async-submit'
|
|
import { startScenarioServer } from '../../helpers/fakes/scenario-server'
|
|
|
|
describe('provider contract - fal queue', () => {
|
|
let server: Awaited<ReturnType<typeof startScenarioServer>> | null = null
|
|
|
|
beforeEach(async () => {
|
|
server = await startScenarioServer()
|
|
process.env.FAL_QUEUE_BASE_URL = `${server.baseUrl}/fal`
|
|
})
|
|
|
|
afterEach(async () => {
|
|
delete process.env.FAL_QUEUE_BASE_URL
|
|
await server?.close()
|
|
server = null
|
|
})
|
|
|
|
it('submits the expected auth header and json payload', async () => {
|
|
server!.defineScenario({
|
|
method: 'POST',
|
|
path: '/fal/fal-ai/nano-banana-pro',
|
|
mode: 'success',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: { request_id: 'req_image_1' },
|
|
},
|
|
})
|
|
|
|
const requestId = await submitFalTask(
|
|
'fal-ai/nano-banana-pro',
|
|
{
|
|
prompt: 'generate image',
|
|
image_urls: ['data:image/png;base64,AAAA'],
|
|
},
|
|
'fal-key-1',
|
|
)
|
|
|
|
expect(requestId).toBe('req_image_1')
|
|
const requests = server!.getRequests('POST', '/fal/fal-ai/nano-banana-pro')
|
|
expect(requests).toHaveLength(1)
|
|
expect(requests[0]?.headers.authorization).toBe('Key fal-key-1')
|
|
expect(JSON.parse(requests[0]?.bodyText || '{}')).toEqual({
|
|
prompt: 'generate image',
|
|
image_urls: ['data:image/png;base64,AAAA'],
|
|
})
|
|
})
|
|
|
|
it('treats transient status failure as pending and completes after retry', async () => {
|
|
server!.defineScenario({
|
|
method: 'GET',
|
|
path: '/fal/fal-ai/veo3.1/requests/req_video_1/status',
|
|
mode: 'retryable_error_then_success',
|
|
pollSequence: [
|
|
{ status: 503, body: { error: 'upstream unavailable' } },
|
|
{ status: 200, body: { status: 'COMPLETED' } },
|
|
],
|
|
})
|
|
server!.defineScenario({
|
|
method: 'GET',
|
|
path: '/fal/fal-ai/veo3.1/fast/image-to-video/requests/req_video_1',
|
|
mode: 'success',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: {
|
|
video: { url: 'https://cdn.local/video.mp4' },
|
|
},
|
|
},
|
|
})
|
|
|
|
const first = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
|
const second = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_video_1', 'fal-key-2')
|
|
|
|
expect(first).toEqual({
|
|
status: 'IN_PROGRESS',
|
|
completed: false,
|
|
failed: false,
|
|
})
|
|
expect(second).toEqual({
|
|
status: 'COMPLETED',
|
|
completed: true,
|
|
failed: false,
|
|
resultUrl: 'https://cdn.local/video.mp4',
|
|
})
|
|
})
|
|
|
|
it('marks a failed status response as failed with explicit provider error', async () => {
|
|
server!.defineScenario({
|
|
method: 'GET',
|
|
path: '/fal/fal-ai/veo3.1/requests/req_failed/status',
|
|
mode: 'fatal_error',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: {
|
|
status: 'FAILED',
|
|
error: 'content moderation failed',
|
|
},
|
|
},
|
|
})
|
|
|
|
const result = await queryFalStatus('fal-ai/veo3.1/fast/image-to-video', 'req_failed', 'fal-key-3')
|
|
expect(result).toEqual({
|
|
status: 'FAILED',
|
|
completed: false,
|
|
failed: true,
|
|
error: 'content moderation failed',
|
|
})
|
|
})
|
|
|
|
it('fails explicitly when submit response is malformed', async () => {
|
|
server!.defineScenario({
|
|
method: 'POST',
|
|
path: '/fal/fal-ai/nano-banana-pro',
|
|
mode: 'malformed_response',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: { ok: true },
|
|
},
|
|
})
|
|
|
|
await expect(
|
|
submitFalTask('fal-ai/nano-banana-pro', { prompt: 'bad response' }, 'fal-key-4'),
|
|
).rejects.toThrow('FAL未返回request_id')
|
|
})
|
|
|
|
it('treats completed result without media url as failed', async () => {
|
|
server!.defineScenario({
|
|
method: 'GET',
|
|
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media/status',
|
|
mode: 'queued_then_success',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: { status: 'COMPLETED' },
|
|
},
|
|
})
|
|
server!.defineScenario({
|
|
method: 'GET',
|
|
path: '/fal/fal-ai/nano-banana-pro/requests/req_no_media',
|
|
mode: 'malformed_response',
|
|
submitResponse: {
|
|
status: 200,
|
|
body: { images: [] },
|
|
},
|
|
})
|
|
|
|
const result = await queryFalStatus('fal-ai/nano-banana-pro', 'req_no_media', 'fal-key-5')
|
|
expect(result).toEqual({
|
|
status: 'COMPLETED',
|
|
completed: true,
|
|
failed: false,
|
|
resultUrl: undefined,
|
|
})
|
|
})
|
|
})
|