diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 39cfb8e82b4f..02a6ec6f13f9 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -28,6 +28,50 @@ function createProvider(): TwilioProvider { ); } +type TwilioPrivateCallState = { + callWebhookUrls: Map; + callStreamMap: Map; + streamAuthTokens: Map; + twimlStorage: Map; + notifyCalls: Set; + activeStreamCalls: Set; +}; + +function getTwilioPrivateCallState(provider: TwilioProvider): TwilioPrivateCallState { + return provider as unknown as TwilioPrivateCallState; +} + +function seedTwilioPrivateCallState(params: { + provider: TwilioProvider; + callId: string; + providerCallId: string; +}): void { + const state = getTwilioPrivateCallState(params.provider); + state.callWebhookUrls.set( + params.providerCallId, + `https://example.ngrok.app/voice/twilio?callId=${params.callId}`, + ); + state.callStreamMap.set(params.providerCallId, "MZ-private-state"); + state.streamAuthTokens.set(params.providerCallId, "stream-token"); + state.twimlStorage.set(params.callId, "Hello"); + state.notifyCalls.add(params.callId); + state.activeStreamCalls.add(params.providerCallId); +} + +function expectTwilioPrivateCallStateReleased(params: { + provider: TwilioProvider; + callId: string; + providerCallId: string; +}): void { + const state = getTwilioPrivateCallState(params.provider); + expect(state.callWebhookUrls.has(params.providerCallId)).toBe(false); + expect(state.callStreamMap.has(params.providerCallId)).toBe(false); + expect(state.streamAuthTokens.has(params.providerCallId)).toBe(false); + expect(state.twimlStorage.has(params.callId)).toBe(false); + expect(state.notifyCalls.has(params.callId)).toBe(false); + expect(state.activeStreamCalls.has(params.providerCallId)).toBe(false); +} + function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext { return { headers: {}, @@ -366,6 +410,55 @@ describe("TwilioProvider", () => { expect(nextBody).not.toContain("hold-queue"); }); + it("releases all provider call state on terminal callbacks and late replays", () => { + const provider = createProvider(); + const callId = "call-terminal"; + const providerCallId = "CA-terminal"; + seedTwilioPrivateCallState({ provider, callId, providerCallId }); + const terminal = createContext( + `CallStatus=completed&Direction=outbound-api&CallSid=${providerCallId}`, + { callId, type: "status" }, + ); + + const first = provider.parseWebhookEvent(terminal).events[0]; + expect(first).toMatchObject({ + type: "call.ended", + callId, + providerCallId, + reason: "completed", + }); + expectTwilioPrivateCallStateReleased({ provider, callId, providerCallId }); + + const lateReplay = provider.parseWebhookEvent(terminal).events[0]; + expect(lateReplay).toMatchObject({ + type: "call.ended", + callId, + providerCallId, + reason: "completed", + }); + expectTwilioPrivateCallStateReleased({ provider, callId, providerCallId }); + }); + + it("releases all provider call state before repeated explicit hangups", async () => { + const provider = createProvider(); + const callId = "call-hangup"; + const providerCallId = "CA-hangup"; + seedTwilioPrivateCallState({ provider, callId, providerCallId }); + const apiRequest = createApiRequestMock(); + ( + provider as unknown as { + apiRequest: TwilioApiRequest; + } + ).apiRequest = apiRequest; + const input = { callId, providerCallId, reason: "hangup-bot" as const }; + + await provider.hangupCall(input); + expectTwilioPrivateCallStateReleased({ provider, callId, providerCallId }); + await provider.hangupCall(input); + expectTwilioPrivateCallStateReleased({ provider, callId, providerCallId }); + expect(apiRequest).toHaveBeenCalledTimes(2); + }); + it("QUEUE_TWIML references /voice/hold-music waitUrl", () => { const provider = createProvider(); const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");