import { describe, expect, it } 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"]): WebhookContext { return { headers: {}, rawBody, url: "https://example.ngrok.app/voice/twilio", method: "POST", query, }; } 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); expect(result.providerResponseBody).toContain(STREAM_URL); expect(result.providerResponseBody).toContain('"); }); 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( '', ); }); it("returns streaming TwiML for inbound calls", () => { const provider = createProvider(); const ctx = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456"); const result = provider.parseWebhookEvent(ctx); expect(result.providerResponseBody).toContain(STREAM_URL); expect(result.providerResponseBody).toContain('"); }); 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); expect(firstResult.providerResponseBody).toContain(""); expect(secondResult.providerResponseBody).toContain("Please hold while we connect you."); expect(secondResult.providerResponseBody).toContain(" { 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); expect(secondResult.providerResponseBody).toContain(""); expect(secondResult.providerResponseBody).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); expect(nextResult.providerResponseBody).toContain(""); expect(nextResult.providerResponseBody).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); expect(nextResult.providerResponseBody).toContain(""); expect(nextResult.providerResponseBody).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(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]; expect(eventA).toBeDefined(); expect(eventB).toBeDefined(); expect(eventA?.id).not.toBe(eventB?.id); expect(eventA?.dedupeKey).toContain("twilio:fallback:"); expect(eventA?.dedupeKey).toBe(eventB?.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(eventA?.dedupeKey).toBe("twilio:req:abc"); expect(eventB?.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]; expect(event?.type).toBe("call.speech"); expect(event?.turnToken).toBe("turn-xyz"); }); });