From 73e08775d7de9de81eaed4ae25d6068d4ecf4187 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:47:25 -0500 Subject: [PATCH] test: add voice-call hangup-once lifecycle regression --- .../src/webhook.hangup-once.lifecycle.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts diff --git a/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts new file mode 100644 index 00000000000..b6e0604909f --- /dev/null +++ b/extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js"; +import { CallManager } from "./manager.js"; +import { createTestStorePath, FakeProvider } from "./manager.test-harness.js"; +import type { WebhookContext, WebhookParseOptions } from "./types.js"; +import { VoiceCallWebhookServer } from "./webhook.js"; + +const createConfig = (overrides: Partial = {}): VoiceCallConfig => { + const base = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + inboundPolicy: "disabled", + }); + base.serve.port = 0; + + return { + ...base, + ...overrides, + serve: { + ...base.serve, + ...(overrides.serve ?? {}), + }, + }; +}; + +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); + } + return await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); +} + +class RejectInboundReplayProvider extends FakeProvider { + override verifyWebhook() { + return { ok: true, verifiedRequestKey: "verified:req:reject-once" }; + } + + override parseWebhookEvent(_ctx: WebhookContext, options?: WebhookParseOptions) { + return { + statusCode: 200, + events: [ + { + id: "evt-reject-once", + dedupeKey: options?.verifiedRequestKey, + type: "call.initiated" as const, + callId: "provider-inbound-1", + providerCallId: "provider-inbound-1", + timestamp: Date.now(), + direction: "inbound" as const, + from: "+15552222222", + to: "+15550000000", + }, + ], + }; + } +} + +class RejectInboundReplayWithHangupFailureProvider extends RejectInboundReplayProvider { + override async hangupCall(input: Parameters[0]): Promise { + this.hangupCalls.push(input); + throw new Error("hangup failed"); + } +} + +describe("Voice-call webhook hangup-once lifecycle", () => { + afterEach(() => { + // Each test uses an isolated store path, so only server cleanup is needed. + }); + + it("hangs up a rejected inbound replay only once across duplicate webhook delivery", async () => { + const provider = new RejectInboundReplayProvider("plivo"); + const config = createConfig(); + const manager = new CallManager(config, createTestStorePath()); + await manager.initialize(provider, "https://example.com/voice/webhook"); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + const baseUrl = await server.start(); + const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222"); + const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(provider.hangupCalls).toHaveLength(1); + expect(provider.hangupCalls[0]).toEqual( + expect.objectContaining({ + providerCallId: "provider-inbound-1", + reason: "hangup-bot", + }), + ); + expect(manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined(); + } finally { + await server.stop(); + } + }); + + it("does not attempt a second hangup when replay arrives after the first hangup fails", async () => { + const provider = new RejectInboundReplayWithHangupFailureProvider("plivo"); + const config = createConfig(); + const manager = new CallManager(config, createTestStorePath()); + await manager.initialize(provider, "https://example.com/voice/webhook"); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + const baseUrl = await server.start(); + const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222"); + const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&From=%2B15552222222"); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(provider.hangupCalls).toHaveLength(1); + expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-inbound-1"); + expect(manager.getCallByProviderCallId("provider-inbound-1")).toBeUndefined(); + } finally { + await server.stop(); + } + }); +});