Files

103 lines
4.1 KiB
TypeScript

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";
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((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<void>((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("returns synthesized MP3 audio with the server-owned voice", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(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<EdgeTtsSynthesizer>(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<EdgeTtsSynthesizer>(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<Uint8Array>((resolve) => {
releaseFirst = resolve;
});
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => 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<EdgeTtsSynthesizer>(() => new Promise(() => undefined));
const baseUrl = await startServer({ synthesize, timeoutMs: 5 });
expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504);
});
});