import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEdgeTtsMiddleware, type EdgeTtsMiddlewareOptions, type EdgeTtsSynthesizer } from "./edge-tts-service"; import type { SpeechVoice } from "../src/shared/tts/voices"; const voices: SpeechVoice[] = [ { id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN", gender: "Female" }, { id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN", gender: "Male" }, { id: "zh-CN-XiaoyiNeural", name: "Xiaoyi", locale: "zh-CN", gender: "Female" } ]; const servers: ReturnType[] = []; afterEach(async () => { await Promise.all( servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))) ); }); async function startServer(options: EdgeTtsMiddlewareOptions) { const middleware = createEdgeTtsMiddleware(options); const server = createServer((request, response) => { void middleware(request, response, () => { response.statusCode = 404; response.end(); }); }); servers.push(server); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; return `http://127.0.0.1:${port}`; } function postSpeech(baseUrl: string, body: unknown, headers = true) { return fetch(`${baseUrl}/api/tts/edge`, { method: "POST", ...(headers ? { headers: { "Content-Type": "application/json" } } : {}), body: typeof body === "string" ? body : JSON.stringify(body) }); } describe("Edge TTS adapter", () => { it("falls back to Mandarin when a non-Mandarin default is configured", async () => { const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ defaultVoice: "en-US-AriaNeural", synthesize }); expect((await postSpeech(baseUrl, { text: "测试" })).status).toBe(200); expect(synthesize).toHaveBeenCalledWith("测试", "zh-CN-XiaoxiaoNeural"); }); it("returns all voices and the default, sharing the catalog with selection validation", async () => { const listVoices = vi.fn(async () => voices); const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ listVoices, synthesize, defaultVoice: voices[0].id }); const response = await fetch(`${baseUrl}/api/tts/edge/voices`); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(await response.json()).toEqual({ voices, defaultVoice: voices[0].id }); expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(200); expect(synthesize).toHaveBeenLastCalledWith("测试", voices[1].id); expect((await postSpeech(baseUrl, { text: "test", voice: voices[2].id })).status).toBe(200); expect(synthesize).toHaveBeenLastCalledWith("test", voices[2].id); expect(listVoices).toHaveBeenCalledOnce(); }); it("rejects unknown and malformed voice choices before synthesis", async () => { const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ listVoices: async () => voices, synthesize }); for (const voice of ["zh-CN-UnknownNeural", "en-US-AriaNeural", "zh-TW-HsiaoChenNeural", "zh-CN-liaoning-XiaobeiNeural", "", null, 12, {}]) { expect((await postSpeech(baseUrl, { text: "测试", voice })).status).toBe(400); } expect(synthesize).not.toHaveBeenCalled(); }); it("can use the default when the catalog is unavailable and retry a failed catalog load", async () => { const listVoices = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(voices); const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ listVoices, synthesize }); expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(503); expect((await postSpeech(baseUrl, { text: "默认" })).status).toBe(200); expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(200); expect(listVoices).toHaveBeenCalledTimes(2); }); it("refuses a chosen voice when its availability cannot be verified", async () => { const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ listVoices: async () => { throw new Error("offline"); }, synthesize }); expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(503); expect(synthesize).not.toHaveBeenCalled(); }); it("returns synthesized MP3 audio with the server-owned voice", async () => { const synthesize = vi.fn(async () => Uint8Array.from([73, 68, 51])); const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" }); const response = await postSpeech(baseUrl, { text: " 朗读调度结果 " }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("audio/mpeg"); expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from([73, 68, 51])); expect(synthesize).toHaveBeenCalledWith("朗读调度结果", "zh-CN-XiaoxiaoNeural"); }); it("rejects invalid, unsafe and oversized requests before synthesis", async () => { const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ synthesize }); expect((await postSpeech(baseUrl, "not-json")).status).toBe(400); expect((await postSpeech(baseUrl, { text: "测试" }, false)).status).toBe(415); expect((await postSpeech(baseUrl, { text: "测试", voice: "unsafe" })).status).toBe(400); expect((await postSpeech(baseUrl, { text: "排".repeat(521) })).status).toBe(413); expect(synthesize).not.toHaveBeenCalled(); }); it("limits requests by client and releases the window", async () => { let currentTime = 1_000; const synthesize = vi.fn(async () => Uint8Array.from([1])); const baseUrl = await startServer({ synthesize, maxRequestsPerWindow: 1, rateWindowMs: 1_000, now: () => currentTime }); expect((await postSpeech(baseUrl, { text: "第一次" })).status).toBe(200); expect((await postSpeech(baseUrl, { text: "第二次" })).status).toBe(429); currentTime += 1_000; expect((await postSpeech(baseUrl, { text: "新窗口" })).status).toBe(200); }); it("caps concurrent synthesis work", async () => { let releaseFirst: ((audio: Uint8Array) => void) | undefined; const firstAudio = new Promise((resolve) => { releaseFirst = resolve; }); const synthesize = vi.fn(() => firstAudio); const baseUrl = await startServer({ synthesize, maxConcurrent: 1 }); const firstRequest = postSpeech(baseUrl, { text: "第一段" }); await vi.waitFor(() => expect(synthesize).toHaveBeenCalledTimes(1)); expect((await postSpeech(baseUrl, { text: "第二段" })).status).toBe(429); releaseFirst?.(Uint8Array.from([1])); expect((await firstRequest).status).toBe(200); }); it("returns a gateway timeout without releasing the occupied slot early", async () => { const synthesize = vi.fn(() => new Promise(() => undefined)); const baseUrl = await startServer({ synthesize, timeoutMs: 5 }); expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504); }); });