From 6686e3a0d752189c77c06703661e3948415df2aa Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 10 May 2026 22:27:35 -0700 Subject: [PATCH] tests: add tests for SpeechToTextDriver (#3071) closes #3001 --- .../ai-speech2txt/SpeechToTextDriver.test.ts | 786 ++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts diff --git a/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts b/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts new file mode 100644 index 000000000..b10e4c85c --- /dev/null +++ b/src/backend/drivers/ai-speech2txt/SpeechToTextDriver.test.ts @@ -0,0 +1,786 @@ +/* + * 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 SpeechToTextDriver. + * + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock + * redis) configured with an OpenAI API key, then drives + * `server.drivers.aiSpeech2Txt` directly. The OpenAI SDK is mocked at + * the module boundary — that's the real network egress point — so the + * driver never reaches OpenAI. Audio inputs use `data:` URLs through + * the live `loadFileInput`, and the FS-resolution branch is exercised + * by writing a real file via `server.services.fs.write`. Aligns with + * AGENTS.md: "Prefer test server over mocking deps." + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; + +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import type { MeteringService } from '../../services/metering/MeteringService.js'; +import { setupTestServer } from '../../testUtil.js'; +import { generateDefaultFsentries } from '../../util/userProvisioning.js'; +import type { SpeechToTextDriver } from './SpeechToTextDriver.js'; +import { SPEECH_TO_TEXT_COSTS } from './costs.js'; + +// ── OpenAI SDK mock ───────────────────────────────────────────────── +// +// The driver does `import OpenAI, { toFile } from 'openai'` and then +// calls `audio.transcriptions.create` / `audio.translations.create`. +// Mock the constructor + `toFile` so the driver never actually issues +// a network request and we can inspect the payload it would have sent. + +const { + transcriptionsCreateMock, + translationsCreateMock, + openAICtor, + toFileMock, +} = vi.hoisted(() => ({ + transcriptionsCreateMock: vi.fn(), + translationsCreateMock: vi.fn(), + openAICtor: vi.fn(), + // Capture the (buffer, filename, options) handed to `toFile` and + // return a sentinel object so we can assert it was forwarded as + // `payload.file` to the OpenAI call. Real toFile builds a multipart + // FileLike which would otherwise leak into the assertions. + toFileMock: vi.fn( + async ( + buffer: unknown, + filename: string, + options?: { type?: string }, + ) => ({ + __mockFile: true, + buffer, + filename, + type: options?.type, + }), + ), +})); + +vi.mock('openai', () => { + const OpenAICtor = vi.fn().mockImplementation(function ( + this: Record, + opts: unknown, + ) { + openAICtor(opts); + this.audio = { + transcriptions: { create: transcriptionsCreateMock }, + translations: { create: translationsCreateMock }, + // Sibling TTS provider in the same PuterServer constructs + // its own OpenAI client during boot — keep that namespace + // populated so the boot doesn't crash on missing fields. + speech: { create: vi.fn() }, + }; + this.chat = { completions: { create: vi.fn() } }; + this.images = { generate: vi.fn() }; + this.responses = { create: vi.fn() }; + }); + // Two consumer shapes coexist in the codebase: + // - `import OpenAI from 'openai'; new OpenAI(...)` (this driver) + // - `import openai from 'openai'; new openai.OpenAI(...)` (Ollama chat) + // The default export has to satisfy both, so attach `.OpenAI` onto + // the constructor itself before returning. + (OpenAICtor as unknown as { OpenAI: unknown }).OpenAI = OpenAICtor; + return { + OpenAI: OpenAICtor, + default: OpenAICtor, + toFile: toFileMock, + }; +}); + +// ── Test harness ──────────────────────────────────────────────────── + +let server: PuterServer; +let driver: SpeechToTextDriver; +let hasCreditsSpy: MockInstance; +let incrementUsageSpy: MockInstance; + +beforeAll(async () => { + server = await setupTestServer({ + providers: { + 'openai-speech-to-text': { apiKey: 'openai-test-key' }, + }, + } as never); + driver = server.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +beforeEach(() => { + transcriptionsCreateMock.mockReset(); + translationsCreateMock.mockReset(); + openAICtor.mockReset(); + toFileMock.mockClear(); + // Spy on metering — keep the real impl so its recording side runs, + // but capture calls so per-test assertions can inspect them. + hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits'); + incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { + const username = `stt-${Math.random().toString(36).slice(2, 10)}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + created, + ); + const refreshed = (await server.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + }, + }; +}; + +const withActor = (actor: Actor, fn: () => T | Promise): Promise => + Promise.resolve(runWithContext({ actor }, fn)); + +const dataUrl = (buffer: Buffer, mime: string) => + `data:${mime};base64,${buffer.toString('base64')}`; + +// ── getReportedCosts ──────────────────────────────────────────────── + +describe('SpeechToTextDriver.getReportedCosts', () => { + it('mirrors every entry in costs.ts as a per-second line item', () => { + const reported = driver.getReportedCosts(); + + expect(reported).toHaveLength(Object.keys(SPEECH_TO_TEXT_COSTS).length); + for (const [usageType, ucentsPerUnit] of Object.entries( + SPEECH_TO_TEXT_COSTS, + )) { + expect(reported).toContainEqual({ + usageType, + ucentsPerUnit, + unit: 'second', + source: 'driver:aiSpeech2Txt', + }); + } + }); +}); + +// ── list_models ───────────────────────────────────────────────────── + +describe('SpeechToTextDriver.list_models', () => { + it('returns the full catalog with response_formats and capability flags', async () => { + const models = await driver.list_models(); + const ids = models.map((m) => m.id); + + expect(ids).toEqual( + expect.arrayContaining([ + 'gpt-4o-mini-transcribe', + 'gpt-4o-transcribe', + 'gpt-4o-transcribe-diarize', + 'whisper-1', + ]), + ); + + const miniTranscribe = models.find( + (m) => m.id === 'gpt-4o-mini-transcribe', + )!; + expect(miniTranscribe.type).toBe('transcription'); + expect(miniTranscribe.supports_prompt).toBe(true); + expect(miniTranscribe.supports_logprobs).toBe(true); + expect(miniTranscribe.response_formats).toEqual(['json', 'text']); + + // whisper-1 is the only one we classify as "translation" since + // it's the default model the driver picks for translate(). + const whisper = models.find((m) => m.id === 'whisper-1')!; + expect(whisper.type).toBe('translation'); + expect(whisper.response_formats).toEqual( + expect.arrayContaining(['json', 'text', 'srt', 'verbose_json', 'vtt']), + ); + expect( + (whisper as { supports_timestamp_granularities?: boolean }) + .supports_timestamp_granularities, + ).toBe(true); + + const diarize = models.find( + (m) => m.id === 'gpt-4o-transcribe-diarize', + )!; + expect(diarize.supports_prompt).toBe(false); + expect(diarize.supports_logprobs).toBe(false); + expect( + (diarize as { supports_diarization?: boolean }).supports_diarization, + ).toBe(true); + expect(diarize.response_formats).toContain('diarized_json'); + }); +}); + +// ── test_mode bypass ──────────────────────────────────────────────── + +describe('SpeechToTextDriver.transcribe test_mode', () => { + it('returns the canned sample for transcribe, bypassing all I/O', async () => { + const result = (await driver.transcribe({ + file: undefined, + test_mode: true, + })) as { text: string; model: string; language: string }; + + expect(result.text).toMatch(/sample transcription/i); + expect(result.language).toBe('en'); + // No file required, no actor required, no SDK / metering hit. + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + expect(translationsCreateMock).not.toHaveBeenCalled(); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + expect(result.model).toBe('gpt-4o-mini-transcribe'); + }); + + it('returns the canned sample for translate with whisper-1 default', async () => { + const result = (await driver.translate({ + file: undefined, + test_mode: true, + })) as { text: string; model: string }; + expect(result.model).toBe('whisper-1'); + expect(translationsCreateMock).not.toHaveBeenCalled(); + }); + + it('echoes an explicit model in test_mode rather than the default', async () => { + const result = (await driver.transcribe({ + file: undefined, + test_mode: true, + model: 'whisper-1', + })) as { model: string }; + expect(result.model).toBe('whisper-1'); + }); +}); + +// ── Argument validation ───────────────────────────────────────────── + +describe('SpeechToTextDriver argument validation', () => { + it('rejects streaming with 400 — not yet supported', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + stream: true, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + }); + + it('throws 400 when file is missing', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => driver.transcribe({ file: undefined })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 401 when no actor is on the request context', async () => { + await expect( + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + }), + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('throws 400 on an unknown model', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'totally-not-real', + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + }); + + it('throws 400 when response_format is not supported by the chosen model', async () => { + const { actor } = await makeUser(); + // `srt` is whisper-only — not in the gpt-4o-mini-transcribe catalog. + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + response_format: 'srt', + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 400 when prompt is supplied to a model that does not support it', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-transcribe-diarize', + prompt: 'context', + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('throws 400 when logprobs is requested on a model that does not support it', async () => { + const { actor } = await makeUser(); + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'whisper-1', + logprobs: true, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); +}); + +// ── Credit gate ───────────────────────────────────────────────────── + +describe('SpeechToTextDriver credit gate', () => { + it('throws 402 BEFORE hitting OpenAI when actor lacks credits', async () => { + hasCreditsSpy.mockResolvedValueOnce(false); + const { actor } = await makeUser(); + + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('audio-bytes'), 'audio/mp3'), + }), + ), + ).rejects.toMatchObject({ statusCode: 402 }); + + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }); +}); + +// ── Audio input handling ──────────────────────────────────────────── + +describe('SpeechToTextDriver audio input handling', () => { + it('decodes a base64 data URL and forwards the raw buffer to OpenAI', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ + text: 'hello world', + }); + + const audioBytes = Buffer.from('fake-mp3-bytes'); + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(audioBytes, 'audio/mp3'), + }), + ); + + // toFile got the decoded buffer + mime type from the data URL. + expect(toFileMock).toHaveBeenCalledTimes(1); + const [toFileBuf, , toFileOpts] = toFileMock.mock.calls[0]!; + expect(Buffer.isBuffer(toFileBuf)).toBe(true); + expect((toFileBuf as Buffer).equals(audioBytes)).toBe(true); + expect(toFileOpts).toEqual({ type: 'audio/mp3' }); + + // That sentinel is what got forwarded as `payload.file`. + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.file).toEqual({ + __mockFile: true, + buffer: audioBytes, + filename: expect.any(String), + type: 'audio/mp3', + }); + }); + + it('resolves an FS path through the live FSService and preserves the filename', async () => { + const { actor, userId } = await makeUser(); + const audioBytes = Buffer.from('fs-backed-audio-data'); + await server.services.fs.write(userId, { + fileMetadata: { + path: `/${actor.user.username}/clip.mp3`, + size: audioBytes.byteLength, + contentType: 'audio/mpeg', + }, + fileContent: audioBytes, + }); + + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'ok' }); + + await withActor(actor, () => + driver.transcribe({ + file: { path: `/${actor.user.username}/clip.mp3` }, + }), + ); + + const [, toFileName, toFileOpts] = toFileMock.mock.calls[0]!; + expect(toFileName).toBe('clip.mp3'); + expect(toFileOpts).toEqual({ type: 'audio/mpeg' }); + }); + + it('rejects audio above the 25 MB cap via loadFileInput (413 storage_limit_reached)', async () => { + const { actor } = await makeUser(); + // 25 MiB + 1 byte → exceeds MAX_AUDIO_FILE_SIZE; loadFileInput throws + // 413 from assertMax (not 400 — this is a payload-size error, not a + // bad request). + const huge = Buffer.alloc(25 * 1024 * 1024 + 1, 0); + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(huge, 'audio/mp3'), + }), + ), + ).rejects.toMatchObject({ statusCode: 413 }); + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + }); +}); + +// ── Model selection / payload shape ───────────────────────────────── + +describe('SpeechToTextDriver model selection and payload shape', () => { + it('defaults transcribe() to gpt-4o-mini-transcribe', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.model).toBe('gpt-4o-mini-transcribe'); + // translate endpoint must not be touched. + expect(translationsCreateMock).not.toHaveBeenCalled(); + }); + + it('defaults translate() to whisper-1 and hits the translations endpoint', async () => { + const { actor } = await makeUser(); + translationsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + await withActor(actor, () => + driver.translate({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + }), + ); + + const sent = translationsCreateMock.mock.calls[0]![0]; + expect(sent.model).toBe('whisper-1'); + expect(transcriptionsCreateMock).not.toHaveBeenCalled(); + }); + + it('forwards optional fields (language, prompt, logprobs, temperature)', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + language: 'en', + prompt: 'transcribe this carefully', + logprobs: true, + temperature: 0.2, + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.language).toBe('en'); + expect(sent.prompt).toBe('transcribe this carefully'); + expect(sent.logprobs).toBe(true); + expect(sent.temperature).toBe(0.2); + }); + + it('forwards timestamp_granularities only on whisper-1 (the model that supports it)', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'whisper-1', + response_format: 'verbose_json', + timestamp_granularities: ['word'], + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.timestamp_granularities).toEqual(['word']); + }); + + it('passes extra_body through verbatim for non-diarize models', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + extra_body: { custom: 'value' }, + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.extra_body).toEqual({ custom: 'value' }); + }); +}); + +// ── Diarization branch ────────────────────────────────────────────── + +describe('SpeechToTextDriver diarization handling', () => { + it('defaults response_format to diarized_json on the diarize model', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-transcribe-diarize', + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.response_format).toBe('diarized_json'); + }); + + it('auto-enables chunking_strategy when estimated duration exceeds 30s', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] }); + + // estimatedSeconds = ceil(bytes / 16000); 16000 * 31 = 496000 bytes + // → 31s > 30s threshold → driver sets chunking_strategy = 'auto'. + const longAudio = Buffer.alloc(16000 * 31, 0); + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(longAudio, 'audio/mp3'), + model: 'gpt-4o-transcribe-diarize', + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.chunking_strategy).toBe('auto'); + }); + + it('does NOT auto-enable chunking_strategy for short audio', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] }); + + // 16000 bytes = 1s estimated → below the 30s threshold. + const shortAudio = Buffer.from('short'); + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(shortAudio, 'audio/mp3'), + model: 'gpt-4o-transcribe-diarize', + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.chunking_strategy).toBeUndefined(); + }); + + it('packs known_speaker_names / known_speaker_references into extra_body', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ segments: [] }); + + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-transcribe-diarize', + known_speaker_names: ['Alice', 'Bob'], + known_speaker_references: ['ref1', 'ref2'], + extra_body: { keep_me: true }, + }), + ); + + const sent = transcriptionsCreateMock.mock.calls[0]![0]; + expect(sent.extra_body).toEqual({ + keep_me: true, + known_speaker_names: ['Alice', 'Bob'], + known_speaker_references: ['ref1', 'ref2'], + }); + }); +}); + +// ── Response shape ────────────────────────────────────────────────── + +describe('SpeechToTextDriver response shape', () => { + it('returns a raw string when response_format=text and OpenAI yields a string', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce( + 'just the plain text transcript', + ); + + const result = await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + response_format: 'text', + }), + ); + + expect(result).toBe('just the plain text transcript'); + }); + + it('extracts .text when response_format=text but OpenAI returns an object', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ + text: 'extracted from object', + }); + + const result = await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + response_format: 'text', + }), + ); + + expect(result).toBe('extracted from object'); + }); + + it('forwards the OpenAI object verbatim for non-text response formats', async () => { + const { actor } = await makeUser(); + const upstream = { + text: 'hello world', + language: 'en', + duration: 1.5, + segments: [{ id: 0, text: 'hello' }], + }; + transcriptionsCreateMock.mockResolvedValueOnce(upstream); + + const result = await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + }), + ); + + expect(result).toBe(upstream); + }); +}); + +// ── Metering ──────────────────────────────────────────────────────── + +describe('SpeechToTextDriver metering', () => { + it('meters estimated seconds × per-model ucents from costs.ts', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + // estimatedSeconds = max(1, ceil(bytes / 16000)). 32000 bytes → 2s. + const audio = Buffer.alloc(32000, 0); + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(audio, 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + }), + ); + + const usageType = 'openai:gpt-4o-mini-transcribe:second'; + const perSecond = SPEECH_TO_TEXT_COSTS[usageType]; + const sttCalls = incrementUsageSpy.mock.calls.filter( + ([, type]) => type === usageType, + ); + expect(sttCalls).toHaveLength(1); + const [actorArg, , count, cost] = sttCalls[0]!; + expect((actorArg as Actor).user.id).toBe(actor.user.id); + expect(count).toBe(2); + expect(cost).toBe(perSecond * 2); + }); + + it('clamps the metered duration to a minimum of one second', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + // 1-byte buffer → ceil(1/16000) = 1 → metered as 1 second. + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + model: 'whisper-1', + }), + ); + + const usageType = 'openai:whisper-1:second'; + const sttCalls = incrementUsageSpy.mock.calls.filter( + ([, type]) => type === usageType, + ); + expect(sttCalls).toHaveLength(1); + const [, , count, cost] = sttCalls[0]!; + expect(count).toBe(1); + expect(cost).toBe(SPEECH_TO_TEXT_COSTS[usageType]); + }); + + it('asks hasEnoughCredits for the same total it later meters', async () => { + const { actor } = await makeUser(); + transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' }); + + const audio = Buffer.alloc(32000, 0); + await withActor(actor, () => + driver.transcribe({ + file: dataUrl(audio, 'audio/mp3'), + model: 'gpt-4o-mini-transcribe', + }), + ); + + const usageType = 'openai:gpt-4o-mini-transcribe:second'; + const expected = SPEECH_TO_TEXT_COSTS[usageType] * 2; + const creditCall = hasCreditsSpy.mock.calls[0]!; + expect(creditCall[1]).toBe(expected); + }); +}); + +// ── Error paths ───────────────────────────────────────────────────── + +describe('SpeechToTextDriver error paths', () => { + it('propagates upstream OpenAI errors and does NOT meter when the call rejects', async () => { + const { actor } = await makeUser(); + const sdkError = new Error('upstream blew up'); + transcriptionsCreateMock.mockRejectedValueOnce(sdkError); + + await expect( + withActor(actor, () => + driver.transcribe({ + file: dataUrl(Buffer.from('a'), 'audio/mp3'), + }), + ), + ).rejects.toBe(sdkError); + expect(incrementUsageSpy).not.toHaveBeenCalled(); + }); +});