mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 14:30:57 +00:00
test: add voice-call hangup-once lifecycle regression
This commit is contained in:
127
extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts
Normal file
127
extensions/voice-call/src/webhook.hangup-once.lifecycle.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user