diff --git a/extensions/voice-call/src/manager.inbound-allowlist.test.ts b/extensions/voice-call/src/manager.inbound-allowlist.test.ts index c5adf7777ad..3325f48ea50 100644 --- a/extensions/voice-call/src/manager.inbound-allowlist.test.ts +++ b/extensions/voice-call/src/manager.inbound-allowlist.test.ts @@ -19,8 +19,9 @@ describe("CallManager inbound allowlist", () => { }); expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined(); - expect(provider.hangupCalls).toHaveLength(1); - expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing"); + expect(provider.hangupCalls).toEqual([ + expect.objectContaining({ providerCallId: "provider-missing" }), + ]); }); it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => { @@ -41,8 +42,9 @@ describe("CallManager inbound allowlist", () => { }); expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined(); - expect(provider.hangupCalls).toHaveLength(1); - expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon"); + expect(provider.hangupCalls).toEqual([ + expect.objectContaining({ providerCallId: "provider-anon" }), + ]); }); it("rejects inbound calls that only match allowlist suffixes", async () => { @@ -63,8 +65,9 @@ describe("CallManager inbound allowlist", () => { }); expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined(); - expect(provider.hangupCalls).toHaveLength(1); - expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix"); + expect(provider.hangupCalls).toEqual([ + expect.objectContaining({ providerCallId: "provider-suffix" }), + ]); }); it("rejects duplicate inbound events with a single hangup call", async () => { @@ -95,8 +98,9 @@ describe("CallManager inbound allowlist", () => { }); expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined(); - expect(provider.hangupCalls).toHaveLength(1); - expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup"); + expect(provider.hangupCalls).toEqual([ + expect.objectContaining({ providerCallId: "provider-dup" }), + ]); }); it("accepts inbound calls that exactly match the allowlist", async () => { @@ -116,6 +120,18 @@ describe("CallManager inbound allowlist", () => { to: "+15550000000", }); - expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined(); + const call = manager.getCallByProviderCallId("provider-exact"); + if (!call) { + throw new Error("expected exact allowlist match to keep the inbound call"); + } + expect(call).toMatchObject({ + providerCallId: "provider-exact", + direction: "inbound", + from: "+15550001234", + to: "+15550000000", + }); + expect(call.callId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); }); }); diff --git a/extensions/voice-call/src/manager.notify.test.ts b/extensions/voice-call/src/manager.notify.test.ts index c49a1cafb76..3dbc2893ba1 100644 --- a/extensions/voice-call/src/manager.notify.test.ts +++ b/extensions/voice-call/src/manager.notify.test.ts @@ -31,6 +31,36 @@ class DelayedPlayTtsProvider extends FakeProvider { } } +function requireCall( + manager: Awaited>["manager"], + callId: string, +) { + const call = manager.getCall(callId); + if (!call) { + throw new Error(`expected active call ${callId}`); + } + return call; +} + +function requireMappedCall( + manager: Awaited>["manager"], + providerCallId: string, +) { + const call = manager.getCallByProviderCallId(providerCallId); + if (!call) { + throw new Error(`expected mapped provider call ${providerCallId}`); + } + return call; +} + +function requireFirstPlayTtsCall(provider: FakeProvider) { + const call = provider.playTtsCalls[0]; + if (!call) { + throw new Error("expected provider.playTts to be called once"); + } + return call; +} + describe("CallManager notify and mapping", () => { it("upgrades providerCallId mapping when provider ID changes", async () => { const { manager } = await createManagerHarness(); @@ -39,8 +69,8 @@ describe("CallManager notify and mapping", () => { expect(success).toBe(true); expect(error).toBeUndefined(); - expect(manager.getCall(callId)?.providerCallId).toBe("request-uuid"); - expect(manager.getCallByProviderCallId("request-uuid")?.callId).toBe(callId); + expect(requireCall(manager, callId).providerCallId).toBe("request-uuid"); + expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId); manager.processEvent({ id: "evt-1", @@ -50,8 +80,8 @@ describe("CallManager notify and mapping", () => { timestamp: Date.now(), }); - expect(manager.getCall(callId)?.providerCallId).toBe("call-uuid"); - expect(manager.getCallByProviderCallId("call-uuid")?.callId).toBe(callId); + expect(requireCall(manager, callId).providerCallId).toBe("call-uuid"); + expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId); expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined(); }); @@ -77,7 +107,7 @@ describe("CallManager notify and mapping", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("Hello there"); + expect(requireFirstPlayTtsCall(provider).text).toBe("Hello there"); }, ); @@ -101,7 +131,7 @@ describe("CallManager notify and mapping", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("Hello from conversation"); + expect(requireFirstPlayTtsCall(provider).text).toBe("Hello from conversation"); }); it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => { @@ -127,7 +157,7 @@ describe("CallManager notify and mapping", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("Twilio non-stream"); + expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio non-stream"); }); it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => { @@ -180,7 +210,7 @@ describe("CallManager notify and mapping", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("Twilio stream unavailable"); + expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio stream unavailable"); }); it("preserves initialMessage after a failed first playback and retries on next trigger", async () => { @@ -202,10 +232,10 @@ describe("CallManager notify and mapping", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - const afterFailure = manager.getCall(callId); + const afterFailure = requireCall(manager, callId); expect(provider.playTtsCalls).toHaveLength(1); - expect(afterFailure?.metadata?.initialMessage).toBe("Retry me"); - expect(afterFailure?.state).toBe("listening"); + expect(afterFailure.metadata?.initialMessage).toBe("Retry me"); + expect(afterFailure.state).toBe("listening"); manager.processEvent({ id: "evt-retry-2", @@ -216,9 +246,9 @@ describe("CallManager notify and mapping", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - const afterSuccess = manager.getCall(callId); + const afterSuccess = requireCall(manager, callId); expect(provider.playTtsCalls).toHaveLength(2); - expect(afterSuccess?.metadata?.initialMessage).toBeUndefined(); + expect(afterSuccess.metadata?.initialMessage).toBeUndefined(); }); it("speaks initial message only once on repeated stream-connect triggers", async () => { @@ -247,7 +277,7 @@ describe("CallManager notify and mapping", () => { await manager.speakInitialMessage("call-uuid"); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("Stream hello"); + expect(requireFirstPlayTtsCall(provider).text).toBe("Stream hello"); }); it("prevents concurrent initial-message replays while first playback is in flight", async () => { @@ -282,9 +312,9 @@ describe("CallManager notify and mapping", () => { provider.releaseCurrentPlayback(); await Promise.all([first, second]); - const call = manager.getCall(callId); - expect(call?.metadata?.initialMessage).toBeUndefined(); + const call = requireCall(manager, callId); + expect(call.metadata?.initialMessage).toBeUndefined(); expect(provider.playTtsCalls).toHaveLength(1); - expect(provider.playTtsCalls[0]?.text).toBe("In-flight hello"); + 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 8f76169546f..09175336e5a 100644 --- a/extensions/voice-call/src/manager.restore.test.ts +++ b/extensions/voice-call/src/manager.restore.test.ts @@ -50,8 +50,13 @@ describe("CallManager verification on restore", () => { providerResult: { status: "in-progress", isTerminal: false }, }); - expect(manager.getActiveCalls()).toHaveLength(1); - expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId); + const activeCalls = manager.getActiveCalls(); + expect(activeCalls).toHaveLength(1); + const activeCall = activeCalls[0]; + if (!activeCall) { + throw new Error("expected restored active call"); + } + expect(activeCall.callId).toBe(call.callId); }); it("keeps calls when provider returns unknown (transient error)", async () => { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index 63a811213e3..f378e43ef14 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -90,6 +90,14 @@ function createRejectingInboundContext(): { return { ctx, hangupCalls }; } +function requireFirstActiveCall(ctx: CallManagerContext) { + const call = [...ctx.activeCalls.values()][0]; + if (!call) { + throw new Error("expected one active call"); + } + return call; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { const { ctx, hangupCalls } = createRejectingInboundContext(); @@ -148,8 +156,12 @@ describe("processEvent (functional)", () => { processEvent(ctx, event2); expect(ctx.activeCalls.size).toBe(0); - expect(hangupCalls).toHaveLength(1); - expect(hangupCalls[0]?.providerCallId).toBe("prov-dup"); + expect(hangupCalls).toEqual([ + expect.objectContaining({ + providerCallId: "prov-dup", + reason: "hangup-bot", + }), + ]); }); it("updates providerCallId map when provider ID changes", () => { @@ -178,7 +190,11 @@ describe("processEvent (functional)", () => { timestamp: now + 1, }); - expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid"); + const activeCall = ctx.activeCalls.get("call-1"); + if (!activeCall) { + throw new Error("expected active call after provider id change"); + } + expect(activeCall.providerCallId).toBe("call-uuid"); expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1"); expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false); }); @@ -254,12 +270,12 @@ describe("processEvent (functional)", () => { // Call should be registered in activeCalls and providerCallIdMap expect(ctx.activeCalls.size).toBe(1); - expect(ctx.providerCallIdMap.get("CA-external-123")).toBeDefined(); - const call = [...ctx.activeCalls.values()][0]; - expect(call?.providerCallId).toBe("CA-external-123"); - expect(call?.direction).toBe("outbound"); - expect(call?.from).toBe("+15550000000"); - expect(call?.to).toBe("+15559876543"); + const call = requireFirstActiveCall(ctx); + expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId); + expect(call.providerCallId).toBe("CA-external-123"); + expect(call.direction).toBe("outbound"); + expect(call.from).toBe("+15550000000"); + expect(call.to).toBe("+15559876543"); }); it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => { @@ -280,8 +296,8 @@ describe("processEvent (functional)", () => { // External outbound calls bypass inbound policy — they should be accepted expect(ctx.activeCalls.size).toBe(1); expect(hangupCalls).toHaveLength(0); - const call = [...ctx.activeCalls.values()][0]; - expect(call?.direction).toBe("outbound"); + const call = requireFirstActiveCall(ctx); + expect(call.direction).toBe("outbound"); }); it("preserves inbound direction for auto-registered inbound calls", () => { @@ -307,8 +323,8 @@ describe("processEvent (functional)", () => { processEvent(ctx, event); expect(ctx.activeCalls.size).toBe(1); - const call = [...ctx.activeCalls.values()][0]; - expect(call?.direction).toBe("inbound"); + const call = requireFirstActiveCall(ctx); + expect(call.direction).toBe("inbound"); }); it("deduplicates by dedupeKey even when event IDs differ", () => { @@ -352,7 +368,10 @@ describe("processEvent (functional)", () => { }); const call = ctx.activeCalls.get("call-dedupe"); - expect(call?.transcript).toHaveLength(1); + if (!call) { + throw new Error("expected deduped call to remain active"); + } + expect(call.transcript).toHaveLength(1); expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]); }); });