128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|