// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { generateSubtitlePipeline } from './subtitleService'; describe('generateSubtitlePipeline', () => { beforeEach(() => { vi.stubEnv('VITE_ARK_API_KEY', 'ark-key'); vi.stubEnv('VITE_API_BASE_PATH', '/api'); }); afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); it('posts the selected provider to the server for gemini', async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'queued', stage: 'queued', progress: 5, message: 'Queued', }), { status: 202, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'succeeded', stage: 'succeeded', progress: 100, result: { subtitles: [], speakers: [], quality: 'fallback', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const promise = generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'gemini', null, fetchMock as unknown as typeof fetch, ); await vi.runAllTimersAsync(); await promise; expect(fetchMock).toHaveBeenCalledWith( '/api/generate-subtitles', expect.objectContaining({ method: 'POST', body: expect.any(FormData), }), ); const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; const formData = requestInit.body as FormData; expect(formData.get('targetLanguage')).toBe('English'); expect(formData.get('provider')).toBe('gemini'); expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/generate-subtitles/job-1', { method: 'GET' }); }); it('forwards the tts language in subtitle requests', async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'queued', stage: 'queued', progress: 5, }), { status: 202, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'succeeded', stage: 'succeeded', progress: 100, result: { subtitles: [], speakers: [], quality: 'fallback', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const promise = generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'gemini', null, fetchMock as unknown as typeof fetch, undefined, 'fr', ); await vi.runAllTimersAsync(); await promise; const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; const formData = requestInit.body as FormData; expect(formData.get('ttsLanguage')).toBe('fr'); }); it('uploads doubao videos to ark files before requesting subtitles', async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ id: 'file-123', }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ id: 'file-123', status: 'processing', }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ id: 'file-123', status: 'active', }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'queued', stage: 'queued', progress: 5, }), { status: 202, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'succeeded', stage: 'succeeded', progress: 100, result: { subtitles: [], speakers: [], quality: 'fallback', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const promise = generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'doubao', null, fetchMock as unknown as typeof fetch, ); await vi.runAllTimersAsync(); await promise; expect(fetchMock).toHaveBeenNthCalledWith( 1, 'https://ark.cn-beijing.volces.com/api/v3/files', expect.objectContaining({ method: 'POST', body: expect.any(FormData), }), ); expect(fetchMock).toHaveBeenNthCalledWith( 2, 'https://ark.cn-beijing.volces.com/api/v3/files/file-123', expect.objectContaining({ method: 'GET', headers: { Authorization: 'Bearer ark-key', }, }), ); expect(fetchMock).toHaveBeenNthCalledWith( 3, 'https://ark.cn-beijing.volces.com/api/v3/files/file-123', expect.objectContaining({ method: 'GET', headers: { Authorization: 'Bearer ark-key', }, }), ); const [, subtitleRequest] = fetchMock.mock.calls[3] as unknown as [string, RequestInit]; const subtitleBody = JSON.parse(String(subtitleRequest.body)); expect(fetchMock).toHaveBeenNthCalledWith( 4, '/api/generate-subtitles', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json', }, }), ); expect(subtitleBody).toEqual({ fileId: 'file-123', provider: 'doubao', targetLanguage: 'English', ttsLanguage: 'English', }); expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/generate-subtitles/job-1', { method: 'GET' }); }); it('stops when ark reports file preprocessing failure', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ id: 'file-123', }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ id: 'file-123', status: 'failed', error: { message: 'video preprocess failed', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'doubao', null, fetchMock as unknown as typeof fetch, ), ).rejects.toThrow(/video preprocess failed/i); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('keeps multipart uploads for gemini requests', async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'queued', stage: 'queued', progress: 5, }), { status: 202, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'succeeded', stage: 'succeeded', progress: 100, result: { subtitles: [], speakers: [], quality: 'fallback', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const promise = generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'gemini', null, fetchMock as unknown as typeof fetch, ); await vi.runAllTimersAsync(); await promise; expect(fetchMock).toHaveBeenNthCalledWith( 1, '/api/generate-subtitles', expect.objectContaining({ method: 'POST', body: expect.any(FormData), }), ); }); it('polls every 5 seconds and reports progress updates', async () => { vi.useFakeTimers(); const onProgress = vi.fn(); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'queued', stage: 'queued', progress: 5, message: 'Queued', }), { status: 202, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'running', stage: 'calling_provider', progress: 70, message: 'Calling provider', }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ jobId: 'job-1', requestId: 'req-1', status: 'succeeded', stage: 'succeeded', progress: 100, message: 'Done', result: { subtitles: [], speakers: [], quality: 'fallback', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const promise = generateSubtitlePipeline( new File(['video'], 'clip.mp4', { type: 'video/mp4' }), 'English', 'gemini', null, fetchMock as unknown as typeof fetch, onProgress, ); await vi.runAllTimersAsync(); await promise; expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ jobId: 'job-1', status: 'queued', stage: 'queued', progress: 5, }), ); expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ jobId: 'job-1', status: 'running', stage: 'calling_provider', progress: 70, }), ); }); });