485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { generateWithGrokVideo } from '@/lib/media/adapters/grok-video-adapter';
|
|
import { generateWithHappyHorse } from '@/lib/media/adapters/happyhorse-adapter';
|
|
import { generateWithKling } from '@/lib/media/adapters/kling-adapter';
|
|
import { generateWithMiniMaxVideo } from '@/lib/media/adapters/minimax-video-adapter';
|
|
import { generateWithSeedance } from '@/lib/media/adapters/seedance-adapter';
|
|
import { generateWithVeo } from '@/lib/media/adapters/veo-adapter';
|
|
|
|
const fetchMock = vi.fn();
|
|
|
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
describe('polled video adapter compatibility', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchMock.mockReset();
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('keeps Seedance task routing and success extraction unchanged', async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'seed-task' })).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'seed-task',
|
|
model: 'doubao-seedance-2-0-260128',
|
|
status: 'succeeded',
|
|
content: { video_url: 'https://cdn.example.com/seed.mp4' },
|
|
resolution: '720p',
|
|
ratio: '16:9',
|
|
duration: 5,
|
|
}),
|
|
);
|
|
|
|
const promise = generateWithSeedance(
|
|
{ providerId: 'seedance', apiKey: 'seed-key' },
|
|
{ prompt: 'a paper city', aspectRatio: '16:9', resolution: '720p', duration: 5 },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
url: 'https://cdn.example.com/seed.mp4',
|
|
duration: 5,
|
|
width: 1280,
|
|
height: 720,
|
|
});
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/seed-task',
|
|
);
|
|
});
|
|
|
|
it('preserves the Seedance timeout message and exact poll count', async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'seed-timeout' })).mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse({
|
|
id: 'seed-timeout',
|
|
model: 'doubao-seedance-2-0-260128',
|
|
status: 'running',
|
|
}),
|
|
),
|
|
);
|
|
const promise = generateWithSeedance(
|
|
{ providerId: 'seedance', apiKey: 'seed-key' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Seedance video generation timed out after 300s (task: seed-timeout)',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(300_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(61);
|
|
});
|
|
|
|
it('keeps Kling task routing and success extraction unchanged', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: { task_id: 'kling-task', task_status: 'submitted' },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: {
|
|
task_id: 'kling-task',
|
|
task_status: 'succeed',
|
|
task_result: {
|
|
videos: [
|
|
{
|
|
id: 'video-1',
|
|
url: 'https://cdn.example.com/kling.mp4',
|
|
duration: '5',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const promise = generateWithKling(
|
|
{ providerId: 'kling', apiKey: 'access:secret', baseUrl: 'https://kling.example' },
|
|
{ prompt: 'a paper city', aspectRatio: '16:9', duration: 5 },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
url: 'https://cdn.example.com/kling.mp4',
|
|
duration: 5,
|
|
width: 1280,
|
|
height: 720,
|
|
});
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
'https://kling.example/v1/videos/text2video/kling-task',
|
|
);
|
|
});
|
|
|
|
it('preserves the Kling timeout message and exact poll count', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: { task_id: 'kling-timeout', task_status: 'submitted' },
|
|
}),
|
|
)
|
|
.mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: { task_id: 'kling-timeout', task_status: 'processing' },
|
|
}),
|
|
),
|
|
);
|
|
const promise = generateWithKling(
|
|
{ providerId: 'kling', apiKey: 'access:secret', baseUrl: 'https://kling.example' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Kling video generation timed out after 600s (task: kling-timeout)',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(121);
|
|
});
|
|
|
|
it('preserves the Kling terminal failure message', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: { task_id: 'kling-failed', task_status: 'submitted' },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
code: 0,
|
|
message: 'success',
|
|
data: {
|
|
task_id: 'kling-failed',
|
|
task_status: 'failed',
|
|
task_status_msg: 'content rejected',
|
|
},
|
|
}),
|
|
);
|
|
const promise = generateWithKling(
|
|
{ providerId: 'kling', apiKey: 'access:secret', baseUrl: 'https://kling.example' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Kling video generation failed: content rejected',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps Grok task routing and success extraction unchanged', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({ request_id: 'grok-request' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
status: 'done',
|
|
video: { url: 'https://cdn.example.com/grok.mp4', duration: 6 },
|
|
model: 'grok-imagine-video',
|
|
}),
|
|
);
|
|
|
|
const promise = generateWithGrokVideo(
|
|
{ providerId: 'grok-video', apiKey: 'grok-key', baseUrl: 'https://grok.example/v1' },
|
|
{ prompt: 'a paper city', aspectRatio: '16:9', duration: 6 },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(9_999);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
url: 'https://cdn.example.com/grok.mp4',
|
|
duration: 6,
|
|
width: 1280,
|
|
height: 720,
|
|
});
|
|
expect(fetchMock.mock.calls[1][0]).toBe('https://grok.example/v1/videos/grok-request');
|
|
});
|
|
|
|
it('preserves the Grok timeout message and exact poll count', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({ request_id: 'grok-timeout' }))
|
|
.mockImplementation(() => Promise.resolve(jsonResponse({ status: 'pending', progress: 50 })));
|
|
const promise = generateWithGrokVideo(
|
|
{ providerId: 'grok-video', apiKey: 'grok-key', baseUrl: 'https://grok.example/v1' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Grok video generation timed out after 600s (request: grok-timeout)',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(61);
|
|
});
|
|
|
|
it('preserves the Grok terminal failure message', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({ request_id: 'grok-failed' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ status: 'failed', progress: 42, model: 'grok-imagine-video' }),
|
|
);
|
|
const promise = generateWithGrokVideo(
|
|
{ providerId: 'grok-video', apiKey: 'grok-key', baseUrl: 'https://grok.example/v1' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Grok video generation failed: {"status":"failed","progress":42,"model":"grok-imagine-video"}',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps HappyHorse task routing and success extraction unchanged', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
output: { task_id: 'horse-task', task_status: 'PENDING' },
|
|
request_id: 'request-1',
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
output: {
|
|
task_id: 'horse-task',
|
|
task_status: 'SUCCEEDED',
|
|
video_url: 'https://cdn.example.com/horse.mp4',
|
|
},
|
|
usage: { duration: 5, SR: 720, ratio: '16:9' },
|
|
}),
|
|
);
|
|
|
|
const promise = generateWithHappyHorse(
|
|
{ providerId: 'happyhorse', apiKey: 'horse-key' },
|
|
{ prompt: 'a paper city', aspectRatio: '16:9', resolution: '720p', duration: 5 },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(14_999);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
url: 'https://cdn.example.com/horse.mp4',
|
|
duration: 5,
|
|
width: 1280,
|
|
height: 720,
|
|
});
|
|
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
'https://dashscope.aliyuncs.com/api/v1/tasks/horse-task',
|
|
);
|
|
});
|
|
|
|
it('preserves the HappyHorse timeout message and exact poll count', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ output: { task_id: 'horse-timeout', task_status: 'PENDING' } }),
|
|
)
|
|
.mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse({ output: { task_id: 'horse-timeout', task_status: 'RUNNING' } }),
|
|
),
|
|
);
|
|
const promise = generateWithHappyHorse(
|
|
{ providerId: 'happyhorse', apiKey: 'horse-key' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'HappyHorse video generation timed out after 600s (task: horse-timeout)',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(41);
|
|
});
|
|
|
|
it('returns an immediately completed Veo operation without waiting or polling', async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
name: 'operations/veo-immediate',
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: 'dmlkZW8=', mimeType: 'video/mp4' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await generateWithVeo(
|
|
{ providerId: 'veo', apiKey: 'veo-key', baseUrl: 'https://veo.example' },
|
|
{ prompt: 'a paper city', aspectRatio: '16:9', duration: 8 },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
url: 'data:video/mp4;base64,dmlkZW8=',
|
|
duration: 8,
|
|
width: 1280,
|
|
height: 720,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('rejects an immediately failed Veo operation without waiting or polling', async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
name: 'operations/veo-failed',
|
|
done: true,
|
|
error: { code: 13, message: 'render failed', status: 'INTERNAL' },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
generateWithVeo(
|
|
{ providerId: 'veo', apiKey: 'veo-key', baseUrl: 'https://veo.example' },
|
|
{ prompt: 'a paper city' },
|
|
),
|
|
).rejects.toThrow('Veo generation failed: 13 - render failed');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('keeps Veo operation routing and polled success extraction unchanged', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'operations/veo-task' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
name: 'operations/veo-task',
|
|
done: true,
|
|
response: {
|
|
videos: [{ bytesBase64Encoded: 'cG9sbGVk', mimeType: 'video/webm' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const promise = generateWithVeo(
|
|
{ providerId: 'veo', apiKey: 'veo-key', baseUrl: 'https://veo.example' },
|
|
{ prompt: 'a paper city', aspectRatio: '9:16', duration: 8 },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(9_999);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
url: 'data:video/webm;base64,cG9sbGVk',
|
|
duration: 8,
|
|
width: 720,
|
|
height: 1280,
|
|
});
|
|
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({
|
|
operationName: 'operations/veo-task',
|
|
});
|
|
});
|
|
|
|
it('preserves the Veo timeout message and exact poll count', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'operations/veo-timeout' }))
|
|
.mockImplementation(() =>
|
|
Promise.resolve(jsonResponse({ name: 'operations/veo-timeout', done: false })),
|
|
);
|
|
const promise = generateWithVeo(
|
|
{ providerId: 'veo', apiKey: 'veo-key', baseUrl: 'https://veo.example' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'Veo video generation timed out after 10 minutes',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(61);
|
|
});
|
|
|
|
it('preserves the MiniMax terminal failure message', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ task_id: 'minimax-failed', base_resp: { status_code: 0, status_msg: '' } }),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task_id: 'minimax-failed',
|
|
status: 'Fail',
|
|
base_resp: { status_code: 1008, status_msg: 'quota exceeded' },
|
|
}),
|
|
);
|
|
const promise = generateWithMiniMaxVideo(
|
|
{ providerId: 'minimax-video', apiKey: 'minimax-key' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'MiniMax Video generation failed: quota exceeded',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('preserves the MiniMax timeout message with the last status and exact poll count', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ task_id: 'minimax-timeout', base_resp: { status_code: 0, status_msg: '' } }),
|
|
)
|
|
.mockImplementation(() =>
|
|
Promise.resolve(
|
|
jsonResponse({
|
|
task_id: 'minimax-timeout',
|
|
status: 'Processing',
|
|
base_resp: { status_code: 0, status_msg: '' },
|
|
}),
|
|
),
|
|
);
|
|
const promise = generateWithMiniMaxVideo(
|
|
{ providerId: 'minimax-video', apiKey: 'minimax-key' },
|
|
{ prompt: 'a paper city' },
|
|
);
|
|
const rejection = expect(promise).rejects.toThrow(
|
|
'MiniMax Video: timeout after 120 polls, last status: Processing',
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(600_000);
|
|
|
|
await rejection;
|
|
expect(fetchMock).toHaveBeenCalledTimes(121);
|
|
});
|
|
});
|