Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { describe, expect, it, vi } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
function createProvider(): TwilioProvider {
return new TwilioProvider(
{ accountSid: "AC123", authToken: "secret" },
{ publicUrl: " https://example.ngrok.app", streamPath: "/voice/stream" },
);
}
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookCont ext {
return {
headers: {},
rawBody,
url: "https://example.ngrok.app/voice/twilio",
method: "POST",
query,
};
}
function expectStreamingTwiml(body: string) {
expect(body).toContain(STREAM_URL);
expect(body).toContain('<Parameter name="token" value="');
expect(body).toContain("<Connect>");
}
function expectQueueTwiml(body: string) {
expect(body).toContain("Please hold while we connect you.");
expect(body).toContain("<Enqueue");
expect(body).toContain("hold-queue");
}
function requireResponseBody(body: string | undefined): string {
if (!body) {
throw new Error("Twilio provider did not return a response body");
}
return body;
}
function requireEvent<T>(event: T | undefined, message: string): T {
if (!event) {
throw new Error(message);
}
return event;
}
type TwilioApiRequest = (
endpoint: string,
params: Record<string, string | string[]>,
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
function createApiRequestMock() {
return vi.fn<TwilioApiRequest>(async () => ({}));
}
function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) {
const provider = createProvider();
const apiRequest = createApiRequestMock();
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
(
provider as unknown as {
callWebhookUrls: Map<string, string>;
}
).callWebhookUrls.set(params.providerCallId, "https://example.ngrok.app/voice/twilio");
if (params.streamSid) {
provider.registerCallStream(params.providerCallId, params.streamSid);
}
return { provider, apiRequest };
}
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
callId: "call-1",
});
const result = provider.parseWebhookEvent(ctx);
expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
});
it("returns empty TwiML for status callbacks", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=outbound-api", {
callId: "call-1",
type: "status",
});
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toBe(
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
);
});
it("returns streaming TwiML for inbound calls", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456");
const result = provider.parseWebhookEvent(ctx);
expectStreamingTwiml(requireResponseBody(result.providerResponseBody));
});
it("returns queue TwiML for second inbound call when first call is active", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222");
const firstResult = provider.parseWebhookEvent(firstInbound);
const secondResult = provider.parseWebhookEvent(secondInbound);
expectStreamingTwiml(requireResponseBody(firstResult.providerResponseBody));
expectQueueTwiml(requireResponseBody(secondResult.providerResponseBody));
});
it("connects next inbound call after unregisterCallStream cleanup", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322");
provider.parseWebhookEvent(firstInbound);
provider.unregisterCallStream("CA311");
const secondResult = provider.parseWebhookEvent(secondInbound);
const secondBody = requireResponseBody(secondResult.providerResponseBody);
expectStreamingTwiml(secondBody);
expect(secondBody).not.toContain("hold-queue");
});
it("cleans up active inbound call on completed status callback", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411");
const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", {
type: "status",
});
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422");
provider.parseWebhookEvent(firstInbound);
provider.parseWebhookEvent(completed);
const nextResult = provider.parseWebhookEvent(nextInbound);
const nextBody = requireResponseBody(nextResult.providerResponseBody);
expectStreamingTwiml(nextBody);
expect(nextBody).not.toContain("hold-queue");
});
it("cleans up active inbound call on canceled status callback", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511");
const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", {
type: "status",
});
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522");
provider.parseWebhookEvent(firstInbound);
provider.parseWebhookEvent(canceled);
const nextResult = provider.parseWebhookEvent(nextInbound);
const nextBody = requireResponseBody(nextResult.providerResponseBody);
expectStreamingTwiml(nextBody);
expect(nextBody).not.toContain("hold-queue");
});
it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
const provider = createProvider();
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622");
provider.parseWebhookEvent(firstInbound);
const result = provider.parseWebhookEvent(secondInbound);
expect(requireResponseBody(result.providerResponseBody)).toContain(
'waitUrl="/voice/hold-music"',
);
});
it("uses a stable fallback dedupeKey for identical request payloads", () => {
const provider = createProvider();
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
const ctxA = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-123" },
};
const ctxB = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-123" },
};
const eventA = provider.parseWebhookEvent(ctxA).events[0];
const eventB = provider.parseWebhookEvent(ctxB).events[0];
const first = requireEvent(eventA, "expected first fallback Twilio event");
const second = requireEvent(eventB, "expected second fallback Twilio event");
expect(first.id).not.toBe(second.id);
expect(first.dedupeKey).toContain("twilio:fallback:");
expect(first.dedupeKey).toBe(second.dedupeKey);
});
it("uses verified request key for dedupe and ignores idempotency header changes", () => {
const provider = createProvider();
const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
const ctxA = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-a" },
};
const ctxB = {
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
headers: { "i-twilio-idempotency-token": "idem-b" },
};
const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
.events[0];
expect(requireEvent(eventA, "expected verified first Twilio event").dedupeKey).toBe(
"twilio:req:abc",
);
expect(requireEvent(eventB, "expected verified second Twilio event").dedupeKey).toBe(
"twilio:req:abc",
);
});
it("keeps turnToken from query on speech events", () => {
const provider = createProvider();
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
callId: "call-2",
turnToken: "turn-xyz",
});
const event = provider.parseWebhookEvent(ctx).events[0];
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
expect(parsed.type).toBe("call.speech");
expect(parsed.turnToken).toBe("turn-xyz");
});
it("fails when an active stream exists but telephony TTS is unavailable", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-stream",
streamSid: "MZ-stream",
});
await expect(
provider.playTts({
callId: "call-stream",
providerCallId: "CA-stream",
text: "Hello stream",
}),
).rejects.toThrow("refusing TwiML fallback");
expect(apiRequest).not.toHaveBeenCalled();
});
it("falls back to TwiML when no active stream exists and telephony TTS is unavailable", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-nostream",
});
await expect(
provider.playTts({
callId: "call-nostream",
providerCallId: "CA-nostream",
text: "Hello TwiML",
}),
).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(1);
const call = apiRequest.mock.calls[0];
const endpoint = call[0];
const params = call[1] as { Twiml?: string };
expect(endpoint).toBe("/Calls/CA-nostream.json");
expect(params.Twiml).toContain("<Say");
});
it("sends DTMF by updating the call and redirecting back to the webhook", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-dtmf",
});
await expect(
provider.sendDtmf({
callId: "call-dtmf",
providerCallId: "CA-dtmf",
digits: "ww123#",
}),
).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(1);
const call = apiRequest.mock.calls[0];
const endpoint = call[0];
const params = call[1] as { Twiml?: string };
expect(endpoint).toBe("/Calls/CA-dtmf.json");
expect(params.Twiml).toContain('<Play digits="ww123#"');
expect(params.Twiml).toContain("<Redirect");
expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio");
});
it("ignores stale stream unregister requests that do not match current stream SID", () => {
const provider = createProvider();
provider.registerCallStream("CA-reconnect", "MZ-new");
provider.unregisterCallStream("CA-reconnect", "MZ-old");
expect(provider.hasRegisteredStream("CA-reconnect")).toBe(true);
provider.unregisterCallStream("CA-reconnect", "MZ-new");
expect(provider.hasRegisteredStream("CA-reconnect")).toBe(false);
});
it("times out telephony synthesis in stream mode and does not send completion mark", async () => {
vi.useFakeTimers();
try {
const provider = createProvider();
provider.registerCallStream("CA-timeout", "MZ-timeout");
const sendAudio = vi.fn();
const sendMark = vi.fn();
const mediaStreamHandler = {
queueTts: async (
_streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
await playFn(new AbortController().signal);
},
sendAudio,
sendMark,
};
provider.setMediaStreamHandler(mediaStreamHandler as never);
provider.setTTSProvider({
synthesisTimeoutMs: 5000,
synthesizeForTelephony: async () => await new Promise<Buffer>(() => {}),
});
const playExpectation = expect(
provider.playTts({
callId: "call-timeout",
providerCallId: "CA-timeout",
text: "Timeout me",
}),
).rejects.toThrow("Telephony TTS synthesis timed out after 5000ms");
await vi.advanceTimersByTimeAsync(5_100);
await playExpectation;
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("fails stream playback when all audio sends and completion mark are dropped", async () => {
const provider = createProvider();
provider.registerCallStream("CA-dropped", "MZ-dropped");
const sendAudio = vi.fn(() => ({ sent: false }));
const sendMark = vi.fn(() => ({ sent: false }));
const mediaStreamHandler = {
queueTts: async (
_streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
await playFn(new AbortController().signal);
},
sendAudio,
sendMark,
};
provider.setMediaStreamHandler(mediaStreamHandler as never);
provider.setTTSProvider({
synthesisTimeoutMs: 5000,
synthesizeForTelephony: async () => Buffer.alloc(320),
});
await expect(
provider.playTts({
callId: "call-dropped",
providerCallId: "CA-dropped",
text: "Dropped audio",
}),
).rejects.toThrow("Telephony stream playback failed");
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).toHaveBeenCalledTimes(1);
});
it("fails stream playback when telephony synthesis returns empty audio", async () => {
const provider = createProvider();
provider.registerCallStream("CA-empty", "MZ-empty");
const sendAudio = vi.fn();
const sendMark = vi.fn();
const mediaStreamHandler = {
queueTts: async (
_streamSid: string,
playFn: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
await playFn(new AbortController().signal);
},
sendAudio,
sendMark,
};
provider.setMediaStreamHandler(mediaStreamHandler as never);
provider.setTTSProvider({
synthesisTimeoutMs: 5000,
synthesizeForTelephony: async () => Buffer.alloc(0),
});
await expect(
provider.playTts({
callId: "call-empty",
providerCallId: "CA-empty",
text: "Empty audio",
}),
).rejects.toThrow("Telephony TTS produced no audio");
expect(sendAudio).toHaveBeenCalled();
expect(sendMark).not.toHaveBeenCalled();
});
});
¤ Dauer der Verarbeitung: 0.31 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|