test: add voice-call hangup-once lifecycle regression

This commit is contained in:
Tak Hoffman
2026-03-19 16:47:25 -05:00
parent 566e4cf77b
commit 73e08775d7

View File

@@ -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> = {}): 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<FakeProvider["hangupCall"]>[0]): Promise<void> {
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();
}
});
});