From 41e0049045dea3a6b103a6442b19dcedd1f91c80 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 10 May 2026 14:26:56 -0700 Subject: [PATCH] tests: add unit tests for VideoGenerationDriver (#3062) Adds offline VideoGenerationDriver.test.ts covering provider selection/dispatch (args.provider, model-id resolution across the unified catalog, Context.driverName fallback), parameter validation (seconds + dimension snapping to model.durationSeconds / model.dimensions, string coercion), error mapping (provider HttpErrors pass through, SDK errors don't bill), and metering propagation (driver-level dispatch reaches the provider's incrementUsage). Mocks each provider's SDK boundary against a real PuterServer wired with credentials for every video provider. Closes #2991 Co-authored-by: Claude Opus 4.7 (1M context) --- .../ai-video/VideoGenerationDriver.test.ts | 450 ++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 src/backend/drivers/ai-video/VideoGenerationDriver.test.ts diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts new file mode 100644 index 000000000..d8903ff79 --- /dev/null +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -0,0 +1,450 @@ +/* + * 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 . + */ + +/** + * Offline unit tests for VideoGenerationDriver. + * + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock + * redis) with API keys for every video provider so the driver + * registers and indexes them all. Then drives `server.drivers.aiVideo` + * directly. Provider SDKs are mocked at the module boundary so the + * driver's routing and dispatch logic runs without real network egress. + * Aligns with AGENTS.md: "Prefer test server over mocking deps." + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; + +import { runWithContext } from '../../core/context.js'; +import { SYSTEM_ACTOR } from '../../core/actor.js'; +import { PuterServer } from '../../server.js'; +import type { MeteringService } from '../../services/metering/MeteringService.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { VideoGenerationDriver } from './VideoGenerationDriver.js'; + +// ── SDK mocks ────────────────────────────────────────────────────── +// +// These boot during PuterServer.start() since each provider's +// constructor instantiates its SDK. The driver-level tests only care +// about which provider the driver dispatched to. + +const { + openaiVideosCreateMock, + openaiVideosRetrieveMock, + openaiVideosDownloadMock, +} = vi.hoisted(() => ({ + openaiVideosCreateMock: vi.fn(), + openaiVideosRetrieveMock: vi.fn(), + openaiVideosDownloadMock: vi.fn(), +})); + +vi.mock('openai', () => { + const OpenAICtor = vi.fn().mockImplementation(function ( + this: Record, + ) { + this.videos = { + create: openaiVideosCreateMock, + retrieve: openaiVideosRetrieveMock, + downloadContent: openaiVideosDownloadMock, + }; + 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 }; +}); + +const { geminiGenerateVideosMock } = vi.hoisted(() => ({ + geminiGenerateVideosMock: vi.fn(), +})); + +vi.mock('@google/genai', () => { + const GoogleGenAI = vi.fn().mockImplementation(function ( + this: Record, + ) { + this.models = { + generateContent: vi.fn(), + generateImages: vi.fn(), + generateVideos: geminiGenerateVideosMock, + }; + this.operations = { getVideosOperation: vi.fn() }; + }); + return { GoogleGenAI }; +}); + +const { togetherVideosCreateMock, togetherVideosRetrieveMock } = vi.hoisted( + () => ({ + togetherVideosCreateMock: vi.fn(), + togetherVideosRetrieveMock: vi.fn(), + }), +); + +vi.mock('together-ai', () => { + const Together = vi.fn().mockImplementation(function ( + this: Record, + ) { + this.videos = { + create: togetherVideosCreateMock, + retrieve: togetherVideosRetrieveMock, + }; + this.images = { generate: vi.fn() }; + this.chat = { completions: { create: vi.fn() } }; + this.models = { list: vi.fn() }; + }); + return { Together, default: Together }; +}); + +// ── Test harness ──────────────────────────────────────────────────── + +let server: PuterServer; +let driver: VideoGenerationDriver; +let hasCreditsSpy: MockInstance; + +beforeAll(async () => { + server = await setupTestServer({ + providers: { + 'openai-video-generation': { apiKey: 'oai-key' }, + 'together-video-generation': { apiKey: 'tg-key' }, + 'gemini-video-generation': { apiKey: 'gem-key' }, + }, + } as never); + driver = server.drivers.aiVideo as unknown as VideoGenerationDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +beforeEach(() => { + openaiVideosCreateMock.mockReset(); + openaiVideosRetrieveMock.mockReset(); + openaiVideosDownloadMock.mockReset(); + geminiGenerateVideosMock.mockReset(); + togetherVideosCreateMock.mockReset(); + togetherVideosRetrieveMock.mockReset(); + hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits'); + hasCreditsSpy.mockResolvedValue(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const withActor = (fn: () => T | Promise): Promise => + Promise.resolve(runWithContext({ actor: SYSTEM_ACTOR }, fn)); + +const withDriverName = (driverName: string, fn: () => T | Promise) => + Promise.resolve(runWithContext({ actor: SYSTEM_ACTOR, driverName }, fn)); + +const openaiCompletedJob = () => ({ + id: 'oai-job', + status: 'completed' as const, + size: '720x1280', + seconds: '4', +}); + +const openaiDownload = () => ({ + headers: new Headers({ 'content-type': 'video/mp4' }), + body: null, + arrayBuffer: async () => + new Uint8Array(Buffer.from('video-bytes')).buffer as ArrayBuffer, +}); + +// ── Authentication ────────────────────────────────────────────────── + +describe('VideoGenerationDriver.generate authentication', () => { + it('throws 401 when no actor is on the request context', async () => { + await expect( + driver.generate({ prompt: 'hi', model: 'sora-2' } as never), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); + +// ── Argument validation ───────────────────────────────────────────── + +describe('VideoGenerationDriver.generate argument validation', () => { + it('throws 400 when no provider knows the requested model', async () => { + await expect( + withActor(() => + driver.generate({ + prompt: 'hi', + model: 'totally-not-a-real-model', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// ── Catalog & list ────────────────────────────────────────────────── + +describe('VideoGenerationDriver catalog', () => { + it('models() returns deduped entries sorted by provider then id', async () => { + const all = await driver.models(); + const ids = all.map((m) => m.id); + // Sentinel ids from each provider. + expect(ids).toContain('sora-2'); // OpenAI + expect(ids).toContain('veo-2.0-generate-001'); // Gemini + // Together IDs are lowercased togetherai:org/model strings. + expect(ids).toContain('togetherai:minimax/video-01-director'); + // Sort assertion: same-provider entries should be alphabetical. + const openaiEntries = all.filter((m) => m.provider === 'openai-video-generation'); + const openaiIds = openaiEntries.map((m) => m.id); + expect(openaiIds).toEqual([...openaiIds].sort()); + }); + + it('list() returns ids sorted', async () => { + const ids = await driver.list(); + expect(ids).toEqual([...ids].sort()); + }); + + it('getReportedCosts emits per-cost-key line items namespaced by provider:model:costKey', () => { + const reported = driver.getReportedCosts() as Array<{ + usageType: string; + costValue: number; + source: string; + }>; + // sora-2 has a per-second line — must surface in reportedCosts. + const sora2PerSec = reported.find( + (r) => r.usageType === 'openai-video-generation:sora-2:per-second', + ); + expect(sora2PerSec).toBeDefined(); + expect(sora2PerSec?.source).toBe( + 'driver:aiVideo/openai-video-generation', + ); + }); +}); + +// ── Provider routing ──────────────────────────────────────────────── + +describe('VideoGenerationDriver.generate provider routing', () => { + it('routes a known sora-2 id to the OpenAI video provider', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ prompt: 'hi', model: 'sora-2' } as never), + ); + + expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1); + expect(togetherVideosCreateMock).not.toHaveBeenCalled(); + expect(geminiGenerateVideosMock).not.toHaveBeenCalled(); + }); + + it('routes a known veo-2.0-generate-001 id to the Gemini provider', async () => { + geminiGenerateVideosMock.mockResolvedValueOnce({ + done: true, + response: { + generatedVideos: [ + { video: { uri: 'https://gemini/out.mp4' } }, + ], + }, + }); + + await withActor(() => + driver.generate({ + prompt: 'hi', + model: 'veo-2.0-generate-001', + } as never), + ); + + expect(geminiGenerateVideosMock).toHaveBeenCalledTimes(1); + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('routes a known togetherai:minimax/video-01-director id to the Together provider', async () => { + togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' }); + togetherVideosRetrieveMock.mockResolvedValueOnce({ + id: 'tg-job', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withActor(() => + driver.generate({ + prompt: 'hi', + model: 'togetherai:minimax/video-01-director', + } as never), + ); + + expect(togetherVideosCreateMock).toHaveBeenCalledTimes(1); + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('lowercases model lookups so case variants resolve (SORA-2 → sora-2)', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ prompt: 'hi', model: 'SORA-2' } as never), + ); + + expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1); + }); + + it('defaults to openai-video-generation when no model or provider hint is supplied', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ prompt: 'hi' } as never), + ); + + expect(openaiVideosCreateMock).toHaveBeenCalledTimes(1); + }); + + it('falls through to the requested provider via Context.driverName when args.provider is not supplied', async () => { + togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' }); + togetherVideosRetrieveMock.mockResolvedValueOnce({ + id: 'tg-job', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withDriverName('together-video-generation', () => + driver.generate({ + prompt: 'hi', + model: 'togetherai:minimax/video-01-director', + } as never), + ); + + expect(togetherVideosCreateMock).toHaveBeenCalledTimes(1); + }); +}); + +// ── Parameter validation / normalisation ─────────────────────────── + +describe('VideoGenerationDriver.generate parameter normalisation', () => { + it('snaps invalid seconds to the first allowed value for the resolved model', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + seconds: 999, // not in [4, 8, 12] + } as never), + ); + + const sent = openaiVideosCreateMock.mock.calls[0]![0]; + // Sora-2 first allowed second is 4 (snapped from 999). + expect(sent.seconds).toBe('4'); + }); + + it('snaps invalid resolution to the first allowed dimension for the resolved model', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + size: '99x99', + } as never), + ); + + const sent = openaiVideosCreateMock.mock.calls[0]![0]; + // Sora-2 first dimension is 720x1280. + expect(sent.size).toBe('720x1280'); + }); + + it('coerces a string seconds value to a number before snapping', async () => { + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + seconds: '8', + } as never), + ); + + const sent = openaiVideosCreateMock.mock.calls[0]![0]; + expect(sent.seconds).toBe('8'); + }); +}); + +// ── Error mapping ────────────────────────────────────────────────── + +describe('VideoGenerationDriver.generate error mapping', () => { + it('passes through provider HttpError (e.g. 400 on missing prompt) with same status code', async () => { + await expect( + withActor(() => + driver.generate({ + prompt: '', + model: 'sora-2', + } as never), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + // Provider should not be called when validation lives at provider level. + // The error is thrown by the provider; ensure no upstream call leaked. + expect(openaiVideosCreateMock).not.toHaveBeenCalled(); + }); + + it('does not meter when the dispatched provider throws an SDK error', async () => { + const incrementUsageSpy = vi.spyOn( + server.services.metering, + 'incrementUsage', + ); + openaiVideosCreateMock.mockRejectedValueOnce(new Error('upstream blew up')); + + await expect( + withActor(() => + driver.generate({ + prompt: 'hi', + model: 'sora-2', + } as never), + ), + ).rejects.toThrow('upstream blew up'); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }); +}); + +// ── Metering propagation ─────────────────────────────────────────── + +describe('VideoGenerationDriver metering propagation', () => { + it('hands off to the provider whose metering call records the dispatched provider key', async () => { + const incrementUsageSpy = vi.spyOn( + server.services.metering, + 'incrementUsage', + ); + openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob()); + openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload()); + + await withActor(() => + driver.generate({ prompt: 'hi', model: 'sora-2' } as never), + ); + + expect(incrementUsageSpy).toHaveBeenCalledTimes(1); + const [, usageType] = incrementUsageSpy.mock.calls[0]!; + // OpenAIVideoProvider meters under the openai:: shape. + expect(usageType).toMatch(/^openai:sora-2:/); + }); +});