tests: add unit tests for OpenAIVideoProvider (#3059)

Adds offline OpenAIVideoProvider.test.ts covering parameter mapping
(size and seconds snapping to allowed values, input_reference
forwarding), polling for queued/in_progress jobs (under fake timers),
sora-2-pro size tiers (xl/xxl per-second pricing), per-second metering
on default tier, failure handling, and error paths. Mocks the OpenAI
SDK at the module boundary against a real PuterServer + live
MeteringService.

Closes #2993

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Salazar
2026-05-10 14:27:30 -07:00
committed by GitHub
parent 1d36811dd6
commit 3d593f7cd7
@@ -0,0 +1,513 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for OpenAIVideoProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs OpenAIVideoProvider directly against the live
* wired `MeteringService`. The OpenAI SDK is mocked at the module
* boundary — that's the real network egress point. Covers parameter
* mapping (size/seconds normalization, input_reference forwarding),
* polling/long-running job state, sora-2-pro size tiering, error
* paths, and per-second cost reporting.
*/
import { Readable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { OpenAIVideoProvider } from './OpenAIVideoProvider.js';
import { OPENAI_VIDEO_MODELS } from './models.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const {
videosCreateMock,
videosRetrieveMock,
videosDownloadContentMock,
openAICtor,
} = vi.hoisted(() => ({
videosCreateMock: vi.fn(),
videosRetrieveMock: vi.fn(),
videosDownloadContentMock: vi.fn(),
openAICtor: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.videos = {
create: videosCreateMock,
retrieve: videosRetrieveMock,
downloadContent: videosDownloadContentMock,
};
// Sibling chat / image providers in the same boot.
this.chat = { completions: { create: vi.fn() } };
this.images = { generate: vi.fn() };
this.audio = { speech: { create: vi.fn() } };
});
(OpenAICtor as unknown as { OpenAI: unknown }).OpenAI = OpenAICtor;
return { OpenAI: OpenAICtor, default: OpenAICtor };
});
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () =>
new OpenAIVideoProvider({ apiKey: 'test-key' }, server.services.metering);
const sampleVideoBytes = () =>
new Uint8Array(Buffer.from('video-bytes')).buffer as ArrayBuffer;
const completedJob = (
overrides: Partial<{
id: string;
size: string;
seconds: string;
}> = {},
) => ({
id: 'job-1',
status: 'completed' as const,
size: '720x1280',
seconds: '4',
...overrides,
});
const downloadResponse = () => ({
headers: new Headers({ 'content-type': 'video/mp4' }),
body: null,
arrayBuffer: async () => sampleVideoBytes(),
});
beforeEach(() => {
videosCreateMock.mockReset();
videosRetrieveMock.mockReset();
videosDownloadContentMock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
hasCreditsSpy.mockResolvedValue(true);
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('OpenAIVideoProvider construction', () => {
it('constructs the OpenAI SDK with the configured api key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({ apiKey: 'test-key' });
});
it('throws when no apiKey is supplied', () => {
expect(
() =>
new OpenAIVideoProvider(
{ apiKey: '' },
server.services.metering,
),
).toThrow(/API key/i);
});
});
// ── Model catalog ───────────────────────────────────────────────────
describe('OpenAIVideoProvider model catalog', () => {
it('getDefaultModel() returns the first catalog entry id', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe(OPENAI_VIDEO_MODELS[0].id);
});
it('models() lists every catalog entry verbatim', async () => {
const provider = makeProvider();
expect(await provider.models()).toBe(OPENAI_VIDEO_MODELS);
});
});
// ── test_mode bypass ────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate test_mode', () => {
it('returns the canned sample URL without hitting credits or the SDK', async () => {
const provider = makeProvider();
const result = await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
test_mode: true,
}),
);
expect(result).toBe('https://assets.puter.site/txt2vid.mp4');
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Argument validation ─────────────────────────────────────────────
describe('OpenAIVideoProvider.generate argument validation', () => {
it('throws 400 when prompt is missing or blank', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({ prompt: '', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
withTestActor(() =>
provider.generate({ prompt: ' ', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
it('throws 400 when model is unknown', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-fake' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate credit gate', () => {
it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => {
const provider = makeProvider();
hasCreditsSpy.mockResolvedValueOnce(false);
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toMatchObject({ statusCode: 402 });
expect(videosCreateMock).not.toHaveBeenCalled();
});
});
// ── Request shape & parameter mapping ──────────────────────────────
describe('OpenAIVideoProvider.generate parameter mapping', () => {
it('forwards model + prompt + normalised size and seconds defaults', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.model).toBe('sora-2');
expect(sent.prompt).toBe('hi');
// Default seconds = 4, default size = first dimension.
expect(sent.seconds).toBe('4');
expect(sent.size).toBe('720x1280');
});
it('snaps invalid seconds to the default (4) and invalid sizes to the first allowed', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: 7, // not in [4, 8, 12]
size: '99x99', // not in sora-2 dimensions
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.seconds).toBe('4');
expect(sent.size).toBe('720x1280');
});
it('honours valid seconds and size verbatim, normalising whitespace', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
seconds: '12',
size: '1280 x 720',
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.seconds).toBe('12');
expect(sent.size).toBe('1280x720');
});
it('forwards input_reference when supplied', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
input_reference: 'https://example/keyframe.png',
}),
);
const sent = videosCreateMock.mock.calls[0]![0];
expect(sent.input_reference).toBe('https://example/keyframe.png');
});
});
// ── Polling / long-running job state ───────────────────────────────
describe('OpenAIVideoProvider.generate polling', () => {
it('returns the downloaded stream when the job completes on first poll', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(completedJob());
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
const result = (await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
)) as { stream: Readable; content_type: string };
expect(result.content_type).toBe('video/mp4');
expect(result.stream).toBeInstanceOf(Readable);
// Retrieve doesn't need to be called when status is already
// terminal on creation.
expect(videosRetrieveMock).not.toHaveBeenCalled();
});
it('polls past queued/in_progress states until completion', async () => {
vi.useFakeTimers();
try {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-poll',
status: 'queued',
size: '720x1280',
seconds: '4',
});
videosRetrieveMock
.mockResolvedValueOnce({
id: 'job-poll',
status: 'in_progress',
})
.mockResolvedValueOnce(completedJob({ id: 'job-poll' }));
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
const promise = withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
);
// Two poll intervals (5s each) → terminal state.
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(5_000);
await promise;
expect(videosRetrieveMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('throws when the polled job ends in failed state', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
status: 'failed',
error: { message: 'content policy violation' },
size: '720x1280',
seconds: '4',
});
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toThrow('content policy violation');
expect(videosDownloadContentMock).not.toHaveBeenCalled();
});
});
// ── Sora-2-Pro size tiers ──────────────────────────────────────────
describe('OpenAIVideoProvider.generate sora-2-pro size tiers', () => {
it('meters the xxl tier when the resolved size is 1080x1920', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1080x1920', seconds: '4' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2-pro',
size: '1080x1920',
seconds: 4,
}),
);
const proModel = OPENAI_VIDEO_MODELS.find((m) => m.id === 'sora-2-pro')!;
const xxlPerSecond = proModel.costs!['per-second-xxl'];
const expectedCost = xxlPerSecond * 1_000_000 * 4;
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2-pro:xxl');
expect(count).toBe(4);
expect(cost).toBe(expectedCost);
});
it('meters the xl tier when the resolved size is 1024x1792', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1024x1792', seconds: '8' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2-pro',
size: '1024x1792',
seconds: 8,
}),
);
const [, usageType, count] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2-pro:xl');
expect(count).toBe(8);
});
it('meters the default tier on sora-2 across all dimensions', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ size: '1280x720', seconds: '8' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({
prompt: 'hi',
model: 'sora-2',
size: '1280x720',
seconds: 8,
}),
);
const [, usageType, count] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2:default');
expect(count).toBe(8);
});
});
// ── Cost reporting & metering ───────────────────────────────────────
describe('OpenAIVideoProvider.generate metering', () => {
it('meters seconds × per-second cents × 1e6 under openai:<model>:default', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce(
completedJob({ seconds: '4' }),
);
videosDownloadContentMock.mockResolvedValueOnce(downloadResponse());
await withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2', seconds: 4 }),
);
const sora2 = OPENAI_VIDEO_MODELS.find((m) => m.id === 'sora-2')!;
const expectedCost = sora2.costs!['per-second'] * 1_000_000 * 4;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('openai:sora-2:default');
expect(count).toBe(4);
expect(cost).toBe(expectedCost);
});
it('does NOT meter when the job ends in failed state', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
status: 'failed',
error: { message: 'boom' },
size: '720x1280',
seconds: '4',
});
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toThrow();
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Error paths ─────────────────────────────────────────────────────
describe('OpenAIVideoProvider.generate error paths', () => {
it('propagates SDK errors thrown from videos.create and does not meter', async () => {
const provider = makeProvider();
const apiError = new Error('upstream blew up');
videosCreateMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toBe(apiError);
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});