Files
OmniRoute/tests/unit/gtts-provider.test.ts
Diego Rodrigues de Sa e Souza 38dd62819b feat(providers): Speechmatics STT, gTTS, VibeProxy preset (#6659, #6667, #6874) (#7655)
Validated in merge-train --fast @ 6cafcbb (static gates + 9 changed test files + vitest green, 2m35s; full suite ran today on train 2c tip)
2026-07-18 03:16:58 -03:00

188 lines
7.7 KiB
TypeScript

// gTTS (Google Translate TTS) audio-tts provider (#6667).
//
// The originally-proposed `/translate_tts` GET endpoint is deprecated;
// this covers the current `batchexecute` RPC mechanism: chunking to the
// 100-char limit, RPC body construction, response parsing, registry lookup,
// and the handler's dispatch + error path.
import test from "node:test";
import assert from "node:assert/strict";
import {
GOOGLE_TTS_MAX_CHARS,
GttsUpstreamError,
buildGttsRpcBody,
chunkGttsText,
normalizeGttsLang,
parseBatchExecuteResponse,
synthesizeGtts,
} from "../../open-sse/executors/gtts.ts";
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts";
// ─── chunkGttsText ──────────────────────────────────────────────────────
test("chunkGttsText returns a single chunk for short input", () => {
assert.deepEqual(chunkGttsText("hello world"), ["hello world"]);
});
test("chunkGttsText returns [] for empty/whitespace-only input", () => {
assert.deepEqual(chunkGttsText(""), []);
assert.deepEqual(chunkGttsText(" "), []);
assert.deepEqual(chunkGttsText(undefined), []);
});
test("chunkGttsText splits long input on whitespace within the 100-char limit", () => {
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" "); // > 100 chars
const chunks = chunkGttsText(text);
assert.ok(chunks.length > 1, "must split into multiple chunks");
for (const chunk of chunks) {
assert.ok(chunk.length <= GOOGLE_TTS_MAX_CHARS, `chunk exceeds limit: "${chunk}"`);
}
// Rejoining chunks must reconstruct the original words (no character loss).
assert.equal(chunks.join(" "), text);
});
test("chunkGttsText hard-splits a single word longer than the limit", () => {
const longWord = "a".repeat(250);
const chunks = chunkGttsText(longWord, 100);
assert.equal(chunks.length, 3);
assert.equal(chunks.join(""), longWord);
for (const chunk of chunks) {
assert.ok(chunk.length <= 100);
}
});
// ─── normalizeGttsLang ──────────────────────────────────────────────────
test("normalizeGttsLang accepts simple language codes and defaults to English", () => {
assert.equal(normalizeGttsLang("pt-BR"), "pt-BR");
assert.equal(normalizeGttsLang("es"), "es");
assert.equal(normalizeGttsLang(""), "en");
assert.equal(normalizeGttsLang(undefined), "en");
assert.equal(normalizeGttsLang("<script>alert(1)</script>"), "en");
});
// ─── buildGttsRpcBody ───────────────────────────────────────────────────
test("buildGttsRpcBody wraps text/lang under the jQ1olc RPC id, urlencoded", () => {
const body = buildGttsRpcBody("hello", "en");
assert.match(body, /^f\.req=/);
assert.ok(body.endsWith("&"));
const encoded = body.slice("f.req=".length, -1);
const envelope = JSON.parse(decodeURIComponent(encoded));
assert.deepEqual(envelope, [[["jQ1olc", JSON.stringify(["hello", "en", true, "null"]), null, "generic"]]]);
});
// ─── parseBatchExecuteResponse ──────────────────────────────────────────
function buildBatchExecuteFixture(base64Audio: string): string {
const inner = JSON.stringify([base64Audio, null, null, null, null, null, []]);
const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]);
return `)]}'\n\n${outer.length}\n${outer}\n`;
}
test("parseBatchExecuteResponse extracts the base64 audio payload", () => {
const raw = buildBatchExecuteFixture("aGVsbG8=");
assert.equal(parseBatchExecuteResponse(raw), "aGVsbG8=");
});
test("parseBatchExecuteResponse throws GttsUpstreamError when no payload is found", () => {
assert.throws(() => parseBatchExecuteResponse(")]}'\n\nnot json at all"), GttsUpstreamError);
});
// ─── synthesizeGtts (network via injectable fetch) ───────────────────────
test("synthesizeGtts concatenates decoded audio across multiple chunks", async () => {
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" ");
const expectedChunks = chunkGttsText(text);
assert.ok(expectedChunks.length > 1);
const calls: string[] = [];
const fetchImpl = async (url: string, init: RequestInit) => {
calls.push(String(init.body));
return new Response(buildBatchExecuteFixture(Buffer.from("chunk-audio").toString("base64")), {
status: 200,
headers: { "content-type": "text/plain" },
});
};
const audio = await synthesizeGtts({ text, lang: "en" }, fetchImpl);
assert.equal(calls.length, expectedChunks.length);
assert.equal(audio.toString(), "chunk-audio".repeat(expectedChunks.length));
});
test("synthesizeGtts throws GttsUpstreamError with the upstream status on a non-ok response", async () => {
const fetchImpl = async () =>
new Response("rate limited", { status: 429 });
await assert.rejects(
() => synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl),
(err: unknown) => err instanceof GttsUpstreamError && err.status === 429
);
});
// ─── registry lookup ──────────────────────────────────────────────────
test("gtts is registered as a no-auth speech provider", () => {
const provider = getSpeechProvider("gtts");
assert.ok(provider);
assert.equal(provider?.authType, "none");
assert.equal(provider?.format, "gtts");
const parsed = parseSpeechModel("gtts/default");
assert.equal(parsed.provider, "gtts");
assert.equal(parsed.model, "default");
});
// ─── handleAudioSpeech dispatch ─────────────────────────────────────────
test("handleAudioSpeech routes gtts requests without requiring credentials", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url: string, init: RequestInit) => {
assert.match(String(init.body), /^f\.req=/);
return new Response(buildBatchExecuteFixture(Buffer.from("hi-audio").toString("base64")), {
status: 200,
headers: { "content-type": "text/plain" },
});
}) as typeof fetch;
try {
const providerConfig = getSpeechProvider("gtts");
const response = await handleAudioSpeech({
body: { model: "gtts/default", input: "hi there", voice: "en" },
credentials: null,
resolvedProvider: providerConfig,
resolvedModel: "default",
});
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "audio/mpeg");
const buf = Buffer.from(await response.arrayBuffer());
assert.equal(buf.toString(), "hi-audio");
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioSpeech surfaces gtts upstream errors without leaking a stack trace", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("blocked", { status: 429 })) as typeof fetch;
try {
const providerConfig = getSpeechProvider("gtts");
const response = await handleAudioSpeech({
body: { model: "gtts/default", input: "hi there" },
credentials: null,
resolvedProvider: providerConfig,
resolvedModel: "default",
});
const payload = (await response.json()) as { error: { message: string } };
assert.equal(response.status, 429);
assert.ok(!payload.error.message.includes("at /"), "error body must not leak a stack trace");
} finally {
globalThis.fetch = originalFetch;
}
});