From dea40ca8be750c02cf2105196c50f09c591dce89 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 10 May 2026 14:27:37 -0700 Subject: [PATCH] tests: add unit tests for TogetherVideoProvider (#3058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds offline TogetherVideoProvider.test.ts covering parameter mapping (togetherai: prefix stripping, seconds default vs no_extra_params, width/height/fps/steps, reference_images / frame_images filtering), polling for queued → in_progress → completed jobs (under fake timers), failure / cancellation / missing-url error paths, and per-video metering. Mocks together-ai at the module boundary against a real PuterServer + live MeteringService. Closes #2994 Co-authored-by: Claude Opus 4.7 (1M context) --- .../together/TogetherVideoProvider.test.ts | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 src/backend/drivers/ai-video/providers/together/TogetherVideoProvider.test.ts diff --git a/src/backend/drivers/ai-video/providers/together/TogetherVideoProvider.test.ts b/src/backend/drivers/ai-video/providers/together/TogetherVideoProvider.test.ts new file mode 100644 index 000000000..a159859f3 --- /dev/null +++ b/src/backend/drivers/ai-video/providers/together/TogetherVideoProvider.test.ts @@ -0,0 +1,518 @@ +/* + * 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 TogetherVideoProvider. + * + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock + * redis) and constructs TogetherVideoProvider directly against the + * live wired `MeteringService`. The Together SDK is mocked at the + * module boundary — that's the real network egress point. Covers + * parameter mapping (durations, dimensions, frame_images, etc.), + * polling/long-running job state, error paths, and cost reporting. + */ + +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 { TogetherVideoProvider } from './TogetherVideoProvider.js'; +import { TOGETHER_VIDEO_GENERATION_MODELS } from './models.js'; + +// ── Together SDK mock ─────────────────────────────────────────────── + +const { videosCreateMock, videosRetrieveMock, togetherCtor } = vi.hoisted( + () => ({ + videosCreateMock: vi.fn(), + videosRetrieveMock: vi.fn(), + togetherCtor: vi.fn(), + }), +); + +vi.mock('together-ai', () => { + const TogetherCtor = vi.fn().mockImplementation(function ( + this: Record, + opts: unknown, + ) { + togetherCtor(opts); + this.videos = { + create: videosCreateMock, + retrieve: videosRetrieveMock, + }; + // Sibling Together-backed providers also boot during PuterServer + // start — keep their namespaces happy. + this.images = { generate: vi.fn() }; + this.chat = { completions: { create: vi.fn() } }; + this.models = { list: vi.fn() }; + }); + return { Together: TogetherCtor, default: TogetherCtor }; +}); + +// ── Test harness ──────────────────────────────────────────────────── + +let server: PuterServer; +let hasCreditsSpy: MockInstance; +let incrementUsageSpy: MockInstance; + +beforeAll(async () => { + server = await setupTestServer(); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeProvider = () => + new TogetherVideoProvider( + { apiKey: 'test-key' }, + server.services.metering, + ); + +beforeEach(() => { + videosCreateMock.mockReset(); + videosRetrieveMock.mockReset(); + togetherCtor.mockReset(); + hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits'); + // Default to "has credits" so per-test setup only needs to override + // for the explicit credit-gate scenarios. SYSTEM_ACTOR has no uuid, + // so the live `getRemainingUsage` path can short-circuit to 0. + hasCreditsSpy.mockResolvedValue(true); + incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Construction ──────────────────────────────────────────────────── + +describe('TogetherVideoProvider construction', () => { + it('constructs the Together SDK with the configured api key', () => { + makeProvider(); + expect(togetherCtor).toHaveBeenCalledTimes(1); + expect(togetherCtor).toHaveBeenCalledWith({ apiKey: 'test-key' }); + }); + + it('throws when no apiKey is supplied', () => { + expect( + () => + new TogetherVideoProvider( + { apiKey: '' }, + server.services.metering, + ), + ).toThrow(/API key/i); + }); +}); + +// ── Model catalog ─────────────────────────────────────────────────── + +describe('TogetherVideoProvider model catalog', () => { + it('getDefaultModel() returns the togetherai-prefixed director model', () => { + const provider = makeProvider(); + expect(provider.getDefaultModel()).toBe( + 'togetherai:minimax/video-01-director', + ); + }); + + it('models() lists every catalog entry with togetherai- aliases populated', async () => { + const provider = makeProvider(); + const models = await provider.models(); + expect(models.length).toBe(TOGETHER_VIDEO_GENERATION_MODELS.length); + for (const m of models) { + // Provider attaches `aliases: [model]` — the bare model id + // (no togetherai: prefix) should be there. + expect(m.aliases?.length ?? 0).toBeGreaterThan(0); + } + }); +}); + +// ── test_mode bypass ──────────────────────────────────────────────── + +describe('TogetherVideoProvider.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', test_mode: true }), + ); + expect(result).toBe('https://assets.puter.site/txt2vid.mp4'); + expect(hasCreditsSpy).not.toHaveBeenCalled(); + expect(videosCreateMock).not.toHaveBeenCalled(); + }); +}); + +// ── Argument validation ───────────────────────────────────────────── + +describe('TogetherVideoProvider.generate argument validation', () => { + it('throws 400 when prompt is missing or blank', async () => { + const provider = makeProvider(); + await expect( + withTestActor(() => provider.generate({ prompt: '' })), + ).rejects.toMatchObject({ statusCode: 400 }); + await expect( + withTestActor(() => provider.generate({ prompt: ' ' })), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(videosCreateMock).not.toHaveBeenCalled(); + }); + + it('throws when no pricing exists for the resolved model', async () => { + const provider = makeProvider(); + // Passing an unknown id falls back to the bare model string, + // which has no entry in the cost table — provider should throw + // before calling the SDK. + await expect( + withTestActor(() => + provider.generate({ prompt: 'hi', model: 'nonexistent/model' }), + ), + ).rejects.toThrow(/No pricing configured/); + expect(videosCreateMock).not.toHaveBeenCalled(); + }); +}); + +// ── Credit gate ───────────────────────────────────────────────────── + +describe('TogetherVideoProvider.generate credit gate', () => { + it('throws 402 BEFORE hitting Together when actor lacks credits', async () => { + const provider = makeProvider(); + hasCreditsSpy.mockResolvedValueOnce(false); + + await expect( + withTestActor(() => provider.generate({ prompt: 'hi' })), + ).rejects.toMatchObject({ statusCode: 402 }); + expect(videosCreateMock).not.toHaveBeenCalled(); + }); +}); + +// ── Request shape & parameter mapping ────────────────────────────── + +describe('TogetherVideoProvider.generate parameter mapping', () => { + it('strips the togetherai: prefix from the model id on the wire', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ + prompt: 'hi', + model: 'togetherai:minimax/video-01-director', + }), + ); + + const sent = videosCreateMock.mock.calls[0]![0]; + expect(sent.model).toBe('minimax/video-01-director'); + expect(sent.prompt).toBe('hi'); + }); + + it('defaults seconds to 6 when no_extra_params is unset and no value is supplied', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => provider.generate({ prompt: 'hi' })); + + const sent = videosCreateMock.mock.calls[0]![0]; + // String-encoded per the SDK contract. + expect(sent.seconds).toBe('6'); + }); + + it('omits seconds when no_extra_params is set and no value is supplied', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ prompt: 'hi', no_extra_params: true }), + ); + + const sent = videosCreateMock.mock.calls[0]![0]; + expect('seconds' in sent).toBe(false); + }); + + it('forwards width/height/fps/steps/guidance_scale/seed/negative_prompt verbatim', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ + prompt: 'hi', + width: 1280, + height: 720, + fps: 24, + steps: 30, + guidance_scale: 7.5, + seed: 42, + negative_prompt: 'no rain', + output_format: 'mp4', + output_quality: 90, + }), + ); + + const sent = videosCreateMock.mock.calls[0]![0]; + expect(sent.width).toBe(1280); + expect(sent.height).toBe(720); + expect(sent.fps).toBe(24); + expect(sent.steps).toBe(30); + expect(sent.guidance_scale).toBe(7.5); + expect(sent.seed).toBe(42); + expect(sent.negative_prompt).toBe('no rain'); + expect(sent.output_format).toBe('mp4'); + expect(sent.output_quality).toBe(90); + }); + + it('filters reference_images and frame_images to valid shapes', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ + prompt: 'hi', + reference_images: [ + 'https://example/a.png', + ' ', + '', + 'https://example/b.png', + ] as never, + frame_images: [ + { input_image: 'https://example/keyframe.png' }, + { input_image: 123 }, // non-string → filtered + {} as never, // missing → filtered + ] as never, + }), + ); + + const sent = videosCreateMock.mock.calls[0]![0]; + expect(sent.reference_images).toEqual([ + 'https://example/a.png', + 'https://example/b.png', + ]); + expect(sent.frame_images).toEqual([ + { input_image: 'https://example/keyframe.png' }, + ]); + }); + + it('forwards metadata when it is an object', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ + prompt: 'hi', + metadata: { traceId: 'abc-123' }, + }), + ); + + const sent = videosCreateMock.mock.calls[0]![0]; + expect(sent.metadata).toEqual({ traceId: 'abc-123' }); + }); +}); + +// ── Polling / long-running job state ─────────────────────────────── + +describe('TogetherVideoProvider.generate polling', () => { + it('returns the video_url from a job that completes on first poll', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + const result = await withTestActor(() => + provider.generate({ prompt: 'hi' }), + ); + expect(result).toBe('https://together/out.mp4'); + // No retries needed. + expect(videosRetrieveMock).toHaveBeenCalledTimes(1); + }); + + it('polls past queued → in_progress states until completion', async () => { + vi.useFakeTimers(); + try { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-2' }); + videosRetrieveMock + .mockResolvedValueOnce({ id: 'job-2', status: 'queued' }) + .mockResolvedValueOnce({ id: 'job-2', status: 'in_progress' }) + .mockResolvedValueOnce({ + id: 'job-2', + status: 'completed', + outputs: { video_url: 'https://together/done.mp4' }, + }); + + const promise = withTestActor(() => + provider.generate({ prompt: 'hi' }), + ); + + // Advance through two 5s poll intervals to reach completion. + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(5_000); + + expect(await promise).toBe('https://together/done.mp4'); + expect(videosRetrieveMock).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it('throws HttpError 500 on failed jobs with the surfaced error message', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-3' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-3', + status: 'failed', + error: { message: 'content policy violation' }, + }); + + await expect( + withTestActor(() => provider.generate({ prompt: 'hi' })), + ).rejects.toMatchObject({ + statusCode: 500, + message: 'content policy violation', + }); + }); + + it('throws when a finished job has no video_url', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-4' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-4', + status: 'completed', + outputs: {}, + }); + + await expect( + withTestActor(() => provider.generate({ prompt: 'hi' })), + ).rejects.toThrow(/did not include a video URL/); + }); + + it('throws when a job is cancelled mid-flight', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-5' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-5', + status: 'cancelled', + }); + + await expect( + withTestActor(() => provider.generate({ prompt: 'hi' })), + ).rejects.toThrow(/cancelled/); + }); +}); + +// ── Cost reporting & metering ─────────────────────────────────────── + +describe('TogetherVideoProvider.generate metering', () => { + it('meters one usage line per video at the per-video cents × 1e6 rate', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-1' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-1', + status: 'completed', + outputs: { video_url: 'https://together/out.mp4' }, + }); + + await withTestActor(() => + provider.generate({ + prompt: 'hi', + model: 'togetherai:minimax/video-01-director', + }), + ); + + // The director model is 28 cents per video → 28_000_000 microcents. + const directorModel = TOGETHER_VIDEO_GENERATION_MODELS.find( + (m) => m.model === 'minimax/video-01-director', + )!; + const expectedCost = directorModel.costs['per-video'] * 1_000_000; + + expect(incrementUsageSpy).toHaveBeenCalledTimes(1); + const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!; + expect(usageType).toBe('together-video:minimax/video-01-director'); + expect(count).toBe(1); + expect(cost).toBe(expectedCost); + }); + + it('does NOT meter when the job fails', async () => { + const provider = makeProvider(); + videosCreateMock.mockResolvedValueOnce({ id: 'job-fail' }); + videosRetrieveMock.mockResolvedValueOnce({ + id: 'job-fail', + status: 'failed', + error: { message: 'boom' }, + }); + + await expect( + withTestActor(() => provider.generate({ prompt: 'hi' })), + ).rejects.toThrow(); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }); +}); + +// ── Error paths ───────────────────────────────────────────────────── + +describe('TogetherVideoProvider.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' })), + ).rejects.toBe(apiError); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }); +});