mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:41:40 +00:00
test: harden voice call manager regressions
This commit is contained in:
@@ -19,8 +19,9 @@ describe("CallManager inbound allowlist", () => {
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({ providerCallId: "provider-missing" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
|
||||
@@ -41,8 +42,9 @@ describe("CallManager inbound allowlist", () => {
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({ providerCallId: "provider-anon" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects inbound calls that only match allowlist suffixes", async () => {
|
||||
@@ -63,8 +65,9 @@ describe("CallManager inbound allowlist", () => {
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({ providerCallId: "provider-suffix" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects duplicate inbound events with a single hangup call", async () => {
|
||||
@@ -95,8 +98,9 @@ describe("CallManager inbound allowlist", () => {
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
|
||||
expect(provider.hangupCalls).toEqual([
|
||||
expect.objectContaining({ providerCallId: "provider-dup" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts inbound calls that exactly match the allowlist", async () => {
|
||||
@@ -116,6 +120,18 @@ describe("CallManager inbound allowlist", () => {
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
|
||||
const call = manager.getCallByProviderCallId("provider-exact");
|
||||
if (!call) {
|
||||
throw new Error("expected exact allowlist match to keep the inbound call");
|
||||
}
|
||||
expect(call).toMatchObject({
|
||||
providerCallId: "provider-exact",
|
||||
direction: "inbound",
|
||||
from: "+15550001234",
|
||||
to: "+15550000000",
|
||||
});
|
||||
expect(call.callId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,36 @@ class DelayedPlayTtsProvider extends FakeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function requireCall(
|
||||
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
|
||||
callId: string,
|
||||
) {
|
||||
const call = manager.getCall(callId);
|
||||
if (!call) {
|
||||
throw new Error(`expected active call ${callId}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function requireMappedCall(
|
||||
manager: Awaited<ReturnType<typeof createManagerHarness>>["manager"],
|
||||
providerCallId: string,
|
||||
) {
|
||||
const call = manager.getCallByProviderCallId(providerCallId);
|
||||
if (!call) {
|
||||
throw new Error(`expected mapped provider call ${providerCallId}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function requireFirstPlayTtsCall(provider: FakeProvider) {
|
||||
const call = provider.playTtsCalls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected provider.playTts to be called once");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("CallManager notify and mapping", () => {
|
||||
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
||||
const { manager } = await createManagerHarness();
|
||||
@@ -39,8 +69,8 @@ describe("CallManager notify and mapping", () => {
|
||||
expect(success).toBe(true);
|
||||
expect(error).toBeUndefined();
|
||||
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("request-uuid");
|
||||
expect(manager.getCallByProviderCallId("request-uuid")?.callId).toBe(callId);
|
||||
expect(requireCall(manager, callId).providerCallId).toBe("request-uuid");
|
||||
expect(requireMappedCall(manager, "request-uuid").callId).toBe(callId);
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-1",
|
||||
@@ -50,8 +80,8 @@ describe("CallManager notify and mapping", () => {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("call-uuid");
|
||||
expect(manager.getCallByProviderCallId("call-uuid")?.callId).toBe(callId);
|
||||
expect(requireCall(manager, callId).providerCallId).toBe("call-uuid");
|
||||
expect(requireMappedCall(manager, "call-uuid").callId).toBe(callId);
|
||||
expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -77,7 +107,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Hello there");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("Hello there");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -101,7 +131,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Hello from conversation");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("Hello from conversation");
|
||||
});
|
||||
|
||||
it("speaks initial message on answered for conversation mode when Twilio streaming is disabled", async () => {
|
||||
@@ -127,7 +157,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Twilio non-stream");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio non-stream");
|
||||
});
|
||||
|
||||
it("waits for stream connect in conversation mode when Twilio streaming is enabled", async () => {
|
||||
@@ -180,7 +210,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Twilio stream unavailable");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("Twilio stream unavailable");
|
||||
});
|
||||
|
||||
it("preserves initialMessage after a failed first playback and retries on next trigger", async () => {
|
||||
@@ -202,10 +232,10 @@ describe("CallManager notify and mapping", () => {
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const afterFailure = manager.getCall(callId);
|
||||
const afterFailure = requireCall(manager, callId);
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(afterFailure?.metadata?.initialMessage).toBe("Retry me");
|
||||
expect(afterFailure?.state).toBe("listening");
|
||||
expect(afterFailure.metadata?.initialMessage).toBe("Retry me");
|
||||
expect(afterFailure.state).toBe("listening");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-retry-2",
|
||||
@@ -216,9 +246,9 @@ describe("CallManager notify and mapping", () => {
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const afterSuccess = manager.getCall(callId);
|
||||
const afterSuccess = requireCall(manager, callId);
|
||||
expect(provider.playTtsCalls).toHaveLength(2);
|
||||
expect(afterSuccess?.metadata?.initialMessage).toBeUndefined();
|
||||
expect(afterSuccess.metadata?.initialMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("speaks initial message only once on repeated stream-connect triggers", async () => {
|
||||
@@ -247,7 +277,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await manager.speakInitialMessage("call-uuid");
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Stream hello");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("Stream hello");
|
||||
});
|
||||
|
||||
it("prevents concurrent initial-message replays while first playback is in flight", async () => {
|
||||
@@ -282,9 +312,9 @@ describe("CallManager notify and mapping", () => {
|
||||
provider.releaseCurrentPlayback();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
const call = manager.getCall(callId);
|
||||
expect(call?.metadata?.initialMessage).toBeUndefined();
|
||||
const call = requireCall(manager, callId);
|
||||
expect(call.metadata?.initialMessage).toBeUndefined();
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("In-flight hello");
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("In-flight hello");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,8 +50,13 @@ describe("CallManager verification on restore", () => {
|
||||
providerResult: { status: "in-progress", isTerminal: false },
|
||||
});
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
|
||||
const activeCalls = manager.getActiveCalls();
|
||||
expect(activeCalls).toHaveLength(1);
|
||||
const activeCall = activeCalls[0];
|
||||
if (!activeCall) {
|
||||
throw new Error("expected restored active call");
|
||||
}
|
||||
expect(activeCall.callId).toBe(call.callId);
|
||||
});
|
||||
|
||||
it("keeps calls when provider returns unknown (transient error)", async () => {
|
||||
|
||||
@@ -90,6 +90,14 @@ function createRejectingInboundContext(): {
|
||||
return { ctx, hangupCalls };
|
||||
}
|
||||
|
||||
function requireFirstActiveCall(ctx: CallManagerContext) {
|
||||
const call = [...ctx.activeCalls.values()][0];
|
||||
if (!call) {
|
||||
throw new Error("expected one active call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("processEvent (functional)", () => {
|
||||
it("calls provider hangup when rejecting inbound call", () => {
|
||||
const { ctx, hangupCalls } = createRejectingInboundContext();
|
||||
@@ -148,8 +156,12 @@ describe("processEvent (functional)", () => {
|
||||
processEvent(ctx, event2);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(hangupCalls).toHaveLength(1);
|
||||
expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
|
||||
expect(hangupCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
providerCallId: "prov-dup",
|
||||
reason: "hangup-bot",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates providerCallId map when provider ID changes", () => {
|
||||
@@ -178,7 +190,11 @@ describe("processEvent (functional)", () => {
|
||||
timestamp: now + 1,
|
||||
});
|
||||
|
||||
expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
|
||||
const activeCall = ctx.activeCalls.get("call-1");
|
||||
if (!activeCall) {
|
||||
throw new Error("expected active call after provider id change");
|
||||
}
|
||||
expect(activeCall.providerCallId).toBe("call-uuid");
|
||||
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
|
||||
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
|
||||
});
|
||||
@@ -254,12 +270,12 @@ describe("processEvent (functional)", () => {
|
||||
|
||||
// Call should be registered in activeCalls and providerCallIdMap
|
||||
expect(ctx.activeCalls.size).toBe(1);
|
||||
expect(ctx.providerCallIdMap.get("CA-external-123")).toBeDefined();
|
||||
const call = [...ctx.activeCalls.values()][0];
|
||||
expect(call?.providerCallId).toBe("CA-external-123");
|
||||
expect(call?.direction).toBe("outbound");
|
||||
expect(call?.from).toBe("+15550000000");
|
||||
expect(call?.to).toBe("+15559876543");
|
||||
const call = requireFirstActiveCall(ctx);
|
||||
expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId);
|
||||
expect(call.providerCallId).toBe("CA-external-123");
|
||||
expect(call.direction).toBe("outbound");
|
||||
expect(call.from).toBe("+15550000000");
|
||||
expect(call.to).toBe("+15559876543");
|
||||
});
|
||||
|
||||
it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
|
||||
@@ -280,8 +296,8 @@ describe("processEvent (functional)", () => {
|
||||
// External outbound calls bypass inbound policy — they should be accepted
|
||||
expect(ctx.activeCalls.size).toBe(1);
|
||||
expect(hangupCalls).toHaveLength(0);
|
||||
const call = [...ctx.activeCalls.values()][0];
|
||||
expect(call?.direction).toBe("outbound");
|
||||
const call = requireFirstActiveCall(ctx);
|
||||
expect(call.direction).toBe("outbound");
|
||||
});
|
||||
|
||||
it("preserves inbound direction for auto-registered inbound calls", () => {
|
||||
@@ -307,8 +323,8 @@ describe("processEvent (functional)", () => {
|
||||
processEvent(ctx, event);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(1);
|
||||
const call = [...ctx.activeCalls.values()][0];
|
||||
expect(call?.direction).toBe("inbound");
|
||||
const call = requireFirstActiveCall(ctx);
|
||||
expect(call.direction).toBe("inbound");
|
||||
});
|
||||
|
||||
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
||||
@@ -352,7 +368,10 @@ describe("processEvent (functional)", () => {
|
||||
});
|
||||
|
||||
const call = ctx.activeCalls.get("call-dedupe");
|
||||
expect(call?.transcript).toHaveLength(1);
|
||||
if (!call) {
|
||||
throw new Error("expected deduped call to remain active");
|
||||
}
|
||||
expect(call.transcript).toHaveLength(1);
|
||||
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user