From 2a66eaf473e590898e9eefddbf3487a1abd9a644 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 17:22:57 +0000 Subject: [PATCH] test: harden voice call regression assertions --- extensions/voice-call/src/config.test.ts | 17 +++--- .../src/manager.closed-loop.test.ts | 42 ++++++++++----- .../voice-call/src/manager.notify.test.ts | 6 +-- .../voice-call/src/manager.restore.test.ts | 29 +++++++---- .../voice-call/src/providers/plivo.test.ts | 12 ++++- .../voice-call/src/providers/telnyx.test.ts | 30 ++++++++--- .../voice-call/src/providers/twilio.test.ts | 31 +++++++---- .../voice-call/src/response-generator.test.ts | 23 ++++---- .../voice-call/src/telephony-tts.test.ts | 14 +++-- .../src/webhook.hangup-once.lifecycle.test.ts | 11 +++- extensions/voice-call/src/webhook.test.ts | 52 ++++++++++++------- 11 files changed, 181 insertions(+), 86 deletions(-) diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index 5e4b3373f0d..e22133cb939 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -11,6 +11,14 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi return createVoiceCallBaseConfig({ provider }); } +function requireElevenLabsTtsConfig(config: Pick) { + const tts = config.tts; + if (!tts?.elevenlabs) { + throw new Error("voice-call config did not preserve nested elevenlabs TTS config"); + } + return { tts, elevenlabs: tts.elevenlabs }; +} + describe("validateProviderConfig", () => { const originalEnv = { ...process.env }; const clearProviderEnv = () => { @@ -207,16 +215,13 @@ describe("normalizeVoiceCallConfig", () => { }, }); - const tts = normalized.tts; - if (!tts?.elevenlabs) { - throw new Error("voice-call config did not preserve nested elevenlabs TTS config"); - } + const { tts, elevenlabs } = requireElevenLabsTtsConfig(normalized); expect(tts.provider).toBe("elevenlabs"); - expect(tts.elevenlabs.apiKey).toEqual({ + expect(elevenlabs.apiKey).toEqual({ source: "env", provider: "elevenlabs", id: "ELEVENLABS_API_KEY", }); - expect(tts.elevenlabs.voiceSettings).toEqual({ speed: 1.1 }); + expect(elevenlabs.voiceSettings).toEqual({ speed: 1.1 }); }); }); diff --git a/extensions/voice-call/src/manager.closed-loop.test.ts b/extensions/voice-call/src/manager.closed-loop.test.ts index 85e2ab6f021..b84a8ca4c9c 100644 --- a/extensions/voice-call/src/manager.closed-loop.test.ts +++ b/extensions/voice-call/src/manager.closed-loop.test.ts @@ -1,6 +1,25 @@ import { describe, expect, it } from "vitest"; import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js"; +function requireCall( + manager: Awaited>["manager"], + callId: string, +) { + const call = manager.getCall(callId); + if (!call) { + throw new Error(`expected active call ${callId}`); + } + return call; +} + +function requireTurnToken(provider: Awaited>["provider"]) { + const firstStart = provider.startListeningCalls[0]; + if (!firstStart?.turnToken) { + throw new Error("expected closed-loop turn to capture a turn token"); + } + return firstStart.turnToken; +} + describe("CallManager closed-loop turns", () => { it("completes a closed-loop turn without live audio", async () => { const { manager, provider } = await createManagerHarness({ @@ -31,12 +50,12 @@ describe("CallManager closed-loop turns", () => { expect(provider.startListeningCalls).toHaveLength(1); expect(provider.stopListeningCalls).toHaveLength(1); - const call = manager.getCall(started.callId); - expect(call?.transcript.map((entry) => entry.text)).toEqual([ + const call = requireCall(manager, started.callId); + expect(call.transcript.map((entry) => entry.text)).toEqual([ "How can I help?", "Please check status", ]); - const metadata = (call?.metadata ?? {}) as Record; + const metadata = (call.metadata ?? {}) as Record; expect(typeof metadata.lastTurnLatencyMs).toBe("number"); expect(typeof metadata.lastTurnListenWaitMs).toBe("number"); expect(metadata.turnCount).toBe(1); @@ -90,8 +109,7 @@ describe("CallManager closed-loop turns", () => { const turnPromise = manager.continueCall(started.callId, "Prompt"); await new Promise((resolve) => setTimeout(resolve, 0)); - const expectedTurnToken = provider.startListeningCalls[0]?.turnToken; - expect(typeof expectedTurnToken).toBe("string"); + const expectedTurnToken = requireTurnToken(provider); manager.processEvent({ id: "evt-turn-token-bad", @@ -125,8 +143,8 @@ describe("CallManager closed-loop turns", () => { expect(turnResult.success).toBe(true); expect(turnResult.transcript).toBe("final answer"); - const call = manager.getCall(started.callId); - expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]); + const call = requireCall(manager, started.callId); + expect(call.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]); }); it("tracks latency metadata across multiple closed-loop turns", async () => { @@ -167,14 +185,14 @@ describe("CallManager closed-loop turns", () => { expect(secondResult.success).toBe(true); - const call = manager.getCall(started.callId); - expect(call?.transcript.map((entry) => entry.text)).toEqual([ + const call = requireCall(manager, started.callId); + expect(call.transcript.map((entry) => entry.text)).toEqual([ "First question", "First answer", "Second question", "Second answer", ]); - const metadata = (call?.metadata ?? {}) as Record; + const metadata = (call.metadata ?? {}) as Record; expect(metadata.turnCount).toBe(2); expect(typeof metadata.lastTurnLatencyMs).toBe("number"); expect(typeof metadata.lastTurnListenWaitMs).toBe("number"); @@ -209,8 +227,8 @@ describe("CallManager closed-loop turns", () => { expect(result.transcript).toBe(`Answer ${i}`); } - const call = manager.getCall(started.callId); - const metadata = (call?.metadata ?? {}) as Record; + const call = requireCall(manager, started.callId); + const metadata = (call.metadata ?? {}) as Record; expect(metadata.turnCount).toBe(5); expect(provider.startListeningCalls).toHaveLength(5); expect(provider.stopListeningCalls).toHaveLength(5); diff --git a/extensions/voice-call/src/manager.notify.test.ts b/extensions/voice-call/src/manager.notify.test.ts index 3dbc2893ba1..f7839e3a0a9 100644 --- a/extensions/voice-call/src/manager.notify.test.ts +++ b/extensions/voice-call/src/manager.notify.test.ts @@ -234,7 +234,7 @@ describe("CallManager notify and mapping", () => { const afterFailure = requireCall(manager, callId); expect(provider.playTtsCalls).toHaveLength(1); - expect(afterFailure.metadata?.initialMessage).toBe("Retry me"); + expect(afterFailure.metadata).toEqual(expect.objectContaining({ initialMessage: "Retry me" })); expect(afterFailure.state).toBe("listening"); manager.processEvent({ @@ -248,7 +248,7 @@ describe("CallManager notify and mapping", () => { const afterSuccess = requireCall(manager, callId); expect(provider.playTtsCalls).toHaveLength(2); - expect(afterSuccess.metadata?.initialMessage).toBeUndefined(); + expect(afterSuccess.metadata).not.toHaveProperty("initialMessage"); }); it("speaks initial message only once on repeated stream-connect triggers", async () => { @@ -313,7 +313,7 @@ describe("CallManager notify and mapping", () => { await Promise.all([first, second]); const call = requireCall(manager, callId); - expect(call.metadata?.initialMessage).toBeUndefined(); + expect(call.metadata).not.toHaveProperty("initialMessage"); expect(provider.playTtsCalls).toHaveLength(1); expect(requireFirstPlayTtsCall(provider).text).toBe("In-flight hello"); }); diff --git a/extensions/voice-call/src/manager.restore.test.ts b/extensions/voice-call/src/manager.restore.test.ts index 09175336e5a..61dccf0e095 100644 --- a/extensions/voice-call/src/manager.restore.test.ts +++ b/extensions/voice-call/src/manager.restore.test.ts @@ -8,6 +8,16 @@ import { writeCallsToStore, } from "./manager.test-harness.js"; +function requireSingleActiveCall(manager: CallManager) { + const activeCalls = manager.getActiveCalls(); + expect(activeCalls).toHaveLength(1); + const activeCall = activeCalls[0]; + if (!activeCall) { + throw new Error("expected restored active call"); + } + return activeCall; +} + describe("CallManager verification on restore", () => { async function initializeManager(params?: { callOverrides?: Parameters[0]; @@ -50,21 +60,18 @@ describe("CallManager verification on restore", () => { providerResult: { status: "in-progress", isTerminal: false }, }); - const activeCalls = manager.getActiveCalls(); - expect(activeCalls).toHaveLength(1); - const activeCall = activeCalls[0]; - if (!activeCall) { - throw new Error("expected restored active call"); - } + const activeCall = requireSingleActiveCall(manager); expect(activeCall.callId).toBe(call.callId); }); it("keeps calls when provider returns unknown (transient error)", async () => { - const { manager } = await initializeManager({ + const { call, manager } = await initializeManager({ providerResult: { status: "error", isTerminal: false, isUnknown: true }, }); - expect(manager.getActiveCalls()).toHaveLength(1); + const activeCall = requireSingleActiveCall(manager); + expect(activeCall.callId).toBe(call.callId); + expect(activeCall.state).toBe(call.state); }); it("skips calls older than maxDurationSeconds", async () => { @@ -88,7 +95,7 @@ describe("CallManager verification on restore", () => { }); it("keeps call when getCallStatus throws (verification failure)", async () => { - const { manager } = await initializeManager({ + const { call, manager } = await initializeManager({ configureProvider: (provider) => { provider.getCallStatus = async () => { throw new Error("network failure"); @@ -96,6 +103,8 @@ describe("CallManager verification on restore", () => { }, }); - expect(manager.getActiveCalls()).toHaveLength(1); + const activeCall = requireSingleActiveCall(manager); + expect(activeCall.callId).toBe(call.callId); + expect(activeCall.state).toBe(call.state); }); }); diff --git a/extensions/voice-call/src/providers/plivo.test.ts b/extensions/voice-call/src/providers/plivo.test.ts index 82c2c49c7e1..e1aa6b492fa 100644 --- a/extensions/voice-call/src/providers/plivo.test.ts +++ b/extensions/voice-call/src/providers/plivo.test.ts @@ -8,6 +8,13 @@ function requireEvent(event: T | undefined, message: string): T { return event; } +function requireResponseBody(body: string | undefined): string { + if (!body) { + throw new Error("Plivo provider did not return a response body"); + } + return body; +} + describe("PlivoProvider", () => { it("parses answer callback into call.answered and returns keep-alive XML", () => { const provider = new PlivoProvider({ @@ -29,8 +36,9 @@ describe("PlivoProvider", () => { expect(event.type).toBe("call.answered"); expect(event.callId).toBe("internal-call-id"); expect(event.providerCallId).toBe("call-uuid"); - expect(result.providerResponseBody).toContain(" { diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index d116cd19950..336d385711c 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -46,12 +46,25 @@ function expectReplayVerification( ) { expect(results.map((result) => result.ok)).toEqual([true, true]); expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]); - const firstKey = results[0]?.verifiedRequestKey; - if (!firstKey) { + const firstResult = results[0]; + if (!firstResult?.verifiedRequestKey) { throw new Error("expected Telnyx verification to produce a request key"); } - expect(firstKey).toEqual(expect.any(String)); - expect(results[1]?.verifiedRequestKey).toBe(firstKey); + const secondResult = results[1]; + if (!secondResult?.verifiedRequestKey) { + throw new Error("expected replayed Telnyx verification to preserve the request key"); + } + const firstKey = firstResult.verifiedRequestKey; + const secondKey = secondResult.verifiedRequestKey; + expect(firstKey.length).toBeGreaterThan(0); + expect(secondKey).toBe(firstKey); +} + +function requireJwkX(jwk: JsonWebKey) { + if (typeof jwk.x !== "string" || jwk.x.length === 0) { + throw new Error("expected Ed25519 JWK export to expose x"); + } + return jwk.x; } function expectWebhookVerificationSucceeds(params: { @@ -110,9 +123,8 @@ describe("TelnyxProvider.verifyWebhook", () => { const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey; expect(jwk.kty).toBe("OKP"); expect(jwk.crv).toBe("Ed25519"); - expect(typeof jwk.x).toBe("string"); - const rawPublicKey = decodeBase64Url(jwk.x as string); + const rawPublicKey = decodeBase64Url(requireJwkX(jwk)); const rawPublicKeyBase64 = rawPublicKey.toString("base64"); expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey }); }); @@ -167,6 +179,10 @@ describe("TelnyxProvider.parseWebhookEvent", () => { ); expect(result.events).toHaveLength(1); - expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc"); + const event = result.events[0]; + if (!event) { + throw new Error("expected Telnyx parseWebhookEvent to produce one event"); + } + expect(event.dedupeKey).toBe("telnyx:req:abc"); }); }); diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 177ff9d7d75..00fce8753e7 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -27,6 +27,12 @@ function expectStreamingTwiml(body: string) { expect(body).toContain(""); } +function expectQueueTwiml(body: string) { + expect(body).toContain("Please hold while we connect you."); + expect(body).toContain(" { 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(" { @@ -99,8 +103,9 @@ describe("TwilioProvider", () => { provider.unregisterCallStream("CA311"); const secondResult = provider.parseWebhookEvent(secondInbound); - expect(secondResult.providerResponseBody).toContain(""); - expect(secondResult.providerResponseBody).not.toContain("hold-queue"); + const secondBody = requireResponseBody(secondResult.providerResponseBody); + expectStreamingTwiml(secondBody); + expect(secondBody).not.toContain("hold-queue"); }); it("cleans up active inbound call on completed status callback", () => { @@ -115,8 +120,9 @@ describe("TwilioProvider", () => { provider.parseWebhookEvent(completed); const nextResult = provider.parseWebhookEvent(nextInbound); - expect(nextResult.providerResponseBody).toContain(""); - expect(nextResult.providerResponseBody).not.toContain("hold-queue"); + const nextBody = requireResponseBody(nextResult.providerResponseBody); + expectStreamingTwiml(nextBody); + expect(nextBody).not.toContain("hold-queue"); }); it("cleans up active inbound call on canceled status callback", () => { @@ -131,8 +137,9 @@ describe("TwilioProvider", () => { provider.parseWebhookEvent(canceled); const nextResult = provider.parseWebhookEvent(nextInbound); - expect(nextResult.providerResponseBody).toContain(""); - expect(nextResult.providerResponseBody).not.toContain("hold-queue"); + const nextBody = requireResponseBody(nextResult.providerResponseBody); + expectStreamingTwiml(nextBody); + expect(nextBody).not.toContain("hold-queue"); }); it("QUEUE_TWIML references /voice/hold-music waitUrl", () => { @@ -143,7 +150,9 @@ describe("TwilioProvider", () => { provider.parseWebhookEvent(firstInbound); const result = provider.parseWebhookEvent(secondInbound); - expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"'); + expect(requireResponseBody(result.providerResponseBody)).toContain( + 'waitUrl="/voice/hold-music"', + ); }); it("uses a stable fallback dedupeKey for identical request payloads", () => { diff --git a/extensions/voice-call/src/response-generator.test.ts b/extensions/voice-call/src/response-generator.test.ts index de21d456917..88ab5bc4060 100644 --- a/extensions/voice-call/src/response-generator.test.ts +++ b/extensions/voice-call/src/response-generator.test.ts @@ -32,6 +32,19 @@ function createAgentRuntime(payloads: Array>) { return { runtime, runEmbeddedPiAgent }; } +function requireEmbeddedAgentArgs(runEmbeddedPiAgent: ReturnType) { + const calls = runEmbeddedPiAgent.mock.calls as unknown[][]; + const firstCall = calls[0]; + if (!firstCall) { + throw new Error("voice response generator did not invoke the embedded agent"); + } + const args = firstCall[0] as { extraSystemPrompt?: string } | undefined; + if (!args?.extraSystemPrompt) { + throw new Error("voice response generator did not pass the spoken-output contract prompt"); + } + return args; +} + async function runGenerateVoiceResponse( payloads: Array>, overrides?: { @@ -68,15 +81,7 @@ describe("generateVoiceResponse", () => { expect(result.text).toBe("Hello from JSON."); expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); - const calls = runEmbeddedPiAgent.mock.calls as unknown[][]; - const firstCall = calls[0]; - if (!firstCall) { - throw new Error("voice response generator did not invoke the embedded agent"); - } - const args = firstCall[0] as { extraSystemPrompt?: string } | undefined; - if (!args?.extraSystemPrompt) { - throw new Error("voice response generator did not pass the spoken-output contract prompt"); - } + const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent); expect(args.extraSystemPrompt).toContain('{"spoken":"..."}'); }); diff --git a/extensions/voice-call/src/telephony-tts.test.ts b/extensions/voice-call/src/telephony-tts.test.ts index ee62bd050c0..8e05b6f8e97 100644 --- a/extensions/voice-call/src/telephony-tts.test.ts +++ b/extensions/voice-call/src/telephony-tts.test.ts @@ -14,6 +14,14 @@ function createCoreConfig(): CoreConfig { return { messages: { tts } }; } +function requireMergedTtsConfig(mergedConfig: CoreConfig | undefined) { + const tts = mergedConfig?.messages?.tts; + if (!tts) { + throw new Error("telephony TTS runtime did not receive merged TTS config"); + } + return tts as Record; +} + async function mergeOverride(override: unknown): Promise> { let mergedConfig: CoreConfig | undefined; const provider = createTelephonyTtsProvider({ @@ -32,11 +40,7 @@ async function mergeOverride(override: unknown): Promise }); await provider.synthesizeForTelephony("hello"); - const tts = mergedConfig?.messages?.tts; - if (!tts) { - throw new Error("telephony TTS runtime did not receive merged TTS config"); - } - return tts as Record; + return requireMergedTtsConfig(mergedConfig); } afterEach(() => { diff --git a/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts index 1289eb01b13..67751e8e027 100644 --- a/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts +++ b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts @@ -29,9 +29,16 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, server as unknown as { server?: { address?: () => unknown } } ).server?.address?.(); const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); + if ( + !address || + typeof address !== "object" || + !("port" in address) || + (typeof address.port !== "number" && typeof address.port !== "string") || + !address.port + ) { + throw new Error("voice webhook server did not expose a bound port"); } + requestUrl.port = String(address.port); return await fetch(requestUrl.toString(), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index e12aac29d9b..f88383751c2 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -56,6 +56,33 @@ const createManager = (calls: CallRecord[]) => { return { manager, endCall, processEvent }; }; +function hasPort(value: unknown): value is { port: number | string } { + if (!value || typeof value !== "object") { + return false; + } + const maybeAddress = value as { port?: unknown }; + return typeof maybeAddress.port === "number" || typeof maybeAddress.port === "string"; +} + +function requireBoundRequestUrl(server: VoiceCallWebhookServer, baseUrl: string) { + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + if (!hasPort(address) || !address.port) { + throw new Error("voice webhook server did not expose a bound port"); + } + const requestUrl = new URL(baseUrl); + requestUrl.port = String(address.port); + return requestUrl; +} + +function expectWebhookUrl(url: string, expectedPath: string) { + const parsed = new URL(url); + expect(parsed.pathname).toBe(expectedPath); + expect(parsed.port).not.toBe(""); + expect(parsed.port).not.toBe("0"); +} + async function runStaleCallReaperCase(params: { callAgeMs: number; staleCallReaperSeconds: number; @@ -79,13 +106,7 @@ async function runStaleCallReaperCase(params: { } async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) { - const address = ( - server as unknown as { server?: { address?: () => unknown } } - ).server?.address?.(); - const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); - } + const requestUrl = requireBoundRequestUrl(server, baseUrl); return await fetch(requestUrl.toString(), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -154,13 +175,7 @@ describe("VoiceCallWebhookServer path matching", () => { try { const baseUrl = await server.start(); - const address = ( - server as unknown as { server?: { address?: () => unknown } } - ).server?.address?.(); - const requestUrl = new URL(baseUrl); - if (address && typeof address === "object" && "port" in address && address.port) { - requestUrl.port = String(address.port); - } + const requestUrl = requireBoundRequestUrl(server, baseUrl); requestUrl.pathname = "/voice/webhook-evil"; const response = await fetch(requestUrl.toString(), { @@ -324,11 +339,10 @@ describe("VoiceCallWebhookServer start idempotency", () => { const secondUrl = await server.start(); // Dynamic port allocations should resolve to a real listening port. - expect(firstUrl).toContain("/voice/webhook"); - expect(firstUrl).not.toContain(":0/"); + expectWebhookUrl(firstUrl, "/voice/webhook"); // Idempotent re-start should return the same already-bound URL. expect(secondUrl).toBe(firstUrl); - expect(secondUrl).toContain("/voice/webhook"); + expectWebhookUrl(secondUrl, "/voice/webhook"); } finally { await server.stop(); } @@ -340,12 +354,12 @@ describe("VoiceCallWebhookServer start idempotency", () => { const server = new VoiceCallWebhookServer(config, manager, provider); const firstUrl = await server.start(); - expect(firstUrl).toContain("/voice/webhook"); + expectWebhookUrl(firstUrl, "/voice/webhook"); await server.stop(); // After stopping, a new start should succeed const secondUrl = await server.start(); - expect(secondUrl).toContain("/voice/webhook"); + expectWebhookUrl(secondUrl, "/voice/webhook"); await server.stop(); });