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