diff --git a/extensions/voice-call/src/providers/twilio/api.test.ts b/extensions/voice-call/src/providers/twilio/api.test.ts new file mode 100644 index 00000000000..5fb761a3257 --- /dev/null +++ b/extensions/voice-call/src/providers/twilio/api.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { twilioApiRequest } from "./api.js"; + +const originalFetch = globalThis.fetch; + +describe("twilioApiRequest", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("posts form bodies with basic auth and parses json", async () => { + globalThis.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }); + }) as typeof fetch; + + await expect( + twilioApiRequest({ + baseUrl: "https://api.twilio.com", + accountSid: "AC123", + authToken: "secret", + endpoint: "/Calls.json", + body: { + To: "+14155550123", + StatusCallbackEvent: ["initiated", "completed"], + }, + }), + ).resolves.toEqual({ sid: "CA123" }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0] ?? []; + expect(url).toBe("https://api.twilio.com/Calls.json"); + expect(init).toEqual( + expect.objectContaining({ + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }), + ); + expect(String(init?.body)).toBe("To=%2B14155550123&StatusCallbackEvent=initiated&StatusCallbackEvent=completed"); + }); + + it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => { + const responses = [ + new Response(null, { status: 204 }), + new Response("missing", { status: 404 }), + ]; + globalThis.fetch = vi.fn(async () => responses.shift()!) as typeof fetch; + + await expect( + twilioApiRequest({ + baseUrl: "https://api.twilio.com", + accountSid: "AC123", + authToken: "secret", + endpoint: "/Calls.json", + body: new URLSearchParams({ To: "+14155550123" }), + }), + ).resolves.toBeUndefined(); + + await expect( + twilioApiRequest({ + baseUrl: "https://api.twilio.com", + accountSid: "AC123", + authToken: "secret", + endpoint: "/Calls/missing.json", + body: {}, + allowNotFound: true, + }), + ).resolves.toBeUndefined(); + }); + + it("throws twilio api errors for non-ok responses", async () => { + globalThis.fetch = vi.fn(async () => new Response("bad request", { status: 400 })) as typeof fetch; + + await expect( + twilioApiRequest({ + baseUrl: "https://api.twilio.com", + accountSid: "AC123", + authToken: "secret", + endpoint: "/Calls.json", + body: {}, + }), + ).rejects.toThrow("Twilio API error: 400 bad request"); + }); +}); diff --git a/extensions/voice-call/src/webhook/stale-call-reaper.test.ts b/extensions/voice-call/src/webhook/stale-call-reaper.test.ts new file mode 100644 index 00000000000..a7f2528513b --- /dev/null +++ b/extensions/voice-call/src/webhook/stale-call-reaper.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startStaleCallReaper } from "./stale-call-reaper.js"; + +describe("startStaleCallReaper", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-22T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("returns null when disabled or non-positive", () => { + const manager = { + getActiveCalls: vi.fn(() => []), + endCall: vi.fn(), + }; + + expect(startStaleCallReaper({ manager: manager as never })).toBeNull(); + expect(startStaleCallReaper({ manager: manager as never, staleCallReaperSeconds: 0 })).toBeNull(); + }); + + it("reaps stale calls and ignores fresh ones", async () => { + const endCall = vi.fn(async () => {}); + const manager = { + getActiveCalls: vi.fn(() => [ + { + callId: "call-stale", + startedAt: Date.now() - 61_000, + state: "active", + }, + { + callId: "call-fresh", + startedAt: Date.now() - 10_000, + state: "active", + }, + ]), + endCall, + }; + + const stop = startStaleCallReaper({ + manager: manager as never, + staleCallReaperSeconds: 60, + }); + + await vi.advanceTimersByTimeAsync(30_000); + + expect(endCall).toHaveBeenCalledTimes(1); + expect(endCall).toHaveBeenCalledWith("call-stale"); + + stop?.(); + }); + + it("logs and swallows endCall failures", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const manager = { + getActiveCalls: vi.fn(() => [ + { + callId: "call-stale", + startedAt: Date.now() - 61_000, + state: "active", + }, + ]), + endCall: vi.fn(async () => { + throw new Error("network"); + }), + }; + + const stop = startStaleCallReaper({ + manager: manager as never, + staleCallReaperSeconds: 60, + }); + + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledWith("[voice-call] Reaper failed to end call call-stale:", expect.any(Error)); + + stop?.(); + }); +});