mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:58:38 +00:00
test(voice-call): split call manager tests by scenario
This commit is contained in:
218
extensions/voice-call/src/manager.closed-loop.test.ts
Normal file
218
extensions/voice-call/src/manager.closed-loop.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js";
|
||||
|
||||
describe("CallManager closed-loop turns", () => {
|
||||
it("completes a closed-loop turn without live audio", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000003");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-closed-loop-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Please check status",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const turn = await turnPromise;
|
||||
expect(turn.success).toBe(true);
|
||||
expect(turn.transcript).toBe("Please check status");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"How can I help?",
|
||||
"Please check status",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(metadata.turnCount).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects overlapping continueCall requests for the same call", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-overlap-answered");
|
||||
|
||||
const first = manager.continueCall(started.callId, "First prompt");
|
||||
const second = await manager.continueCall(started.callId, "Second prompt");
|
||||
expect(second.success).toBe(false);
|
||||
expect(second.error).toBe("Already waiting for transcript");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-overlap-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Done",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const firstResult = await first;
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(firstResult.transcript).toBe("Done");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
|
||||
const { manager, provider } = await createManagerHarness(
|
||||
{
|
||||
transcriptTimeoutMs: 5000,
|
||||
},
|
||||
new FakeProvider("twilio"),
|
||||
);
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-turn-token-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "Prompt");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedTurnToken = provider.startListeningCalls[0]?.turnToken;
|
||||
expect(typeof expectedTurnToken).toBe("string");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-bad",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "stale replay",
|
||||
isFinal: true,
|
||||
turnToken: "wrong-token",
|
||||
});
|
||||
|
||||
const pendingState = await Promise.race([
|
||||
turnPromise.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
|
||||
]);
|
||||
expect(pendingState).toBe("pending");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-good",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "final answer",
|
||||
isFinal: true,
|
||||
turnToken: expectedTurnToken,
|
||||
});
|
||||
|
||||
const turnResult = await turnPromise;
|
||||
expect(turnResult.success).toBe(true);
|
||||
expect(turnResult.transcript).toBe("final answer");
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
|
||||
});
|
||||
|
||||
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000005");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-multi-answered");
|
||||
|
||||
const firstTurn = manager.continueCall(started.callId, "First question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-1",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "First answer",
|
||||
isFinal: true,
|
||||
});
|
||||
await firstTurn;
|
||||
|
||||
const secondTurn = manager.continueCall(started.callId, "Second question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-2",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Second answer",
|
||||
isFinal: true,
|
||||
});
|
||||
const secondResult = await secondTurn;
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"First question",
|
||||
"First answer",
|
||||
"Second question",
|
||||
"Second answer",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(2);
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(provider.startListeningCalls).toHaveLength(2);
|
||||
expect(provider.stopListeningCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles repeated closed-loop turns without waiter churn", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000006");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-loop-answered");
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: `evt-loop-speech-${i}`,
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: `Answer ${i}`,
|
||||
isFinal: true,
|
||||
});
|
||||
const result = await turnPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.transcript).toBe(`Answer ${i}`);
|
||||
}
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(5);
|
||||
expect(provider.startListeningCalls).toHaveLength(5);
|
||||
expect(provider.stopListeningCalls).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
121
extensions/voice-call/src/manager.inbound-allowlist.test.ts
Normal file
121
extensions/voice-call/src/manager.inbound-allowlist.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createManagerHarness } from "./manager.test-harness.js";
|
||||
|
||||
describe("CallManager inbound allowlist", () => {
|
||||
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-missing",
|
||||
type: "call.initiated",
|
||||
callId: "call-missing",
|
||||
providerCallId: "provider-missing",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
|
||||
});
|
||||
|
||||
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-anon",
|
||||
type: "call.initiated",
|
||||
callId: "call-anon",
|
||||
providerCallId: "provider-anon",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "anonymous",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
|
||||
});
|
||||
|
||||
it("rejects inbound calls that only match allowlist suffixes", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-suffix",
|
||||
type: "call.initiated",
|
||||
callId: "call-suffix",
|
||||
providerCallId: "provider-suffix",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+99915550001234",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
|
||||
});
|
||||
|
||||
it("rejects duplicate inbound events with a single hangup call", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "disabled",
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-reject-init",
|
||||
type: "call.initiated",
|
||||
callId: "provider-dup",
|
||||
providerCallId: "provider-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-reject-ring",
|
||||
type: "call.ringing",
|
||||
callId: "provider-dup",
|
||||
providerCallId: "provider-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
|
||||
});
|
||||
|
||||
it("accepts inbound calls that exactly match the allowlist", async () => {
|
||||
const { manager } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-exact",
|
||||
type: "call.initiated",
|
||||
callId: "call-exact",
|
||||
providerCallId: "provider-exact",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15550001234",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
|
||||
});
|
||||
});
|
||||
53
extensions/voice-call/src/manager.notify.test.ts
Normal file
53
extensions/voice-call/src/manager.notify.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createManagerHarness, FakeProvider } from "./manager.test-harness.js";
|
||||
|
||||
describe("CallManager notify and mapping", () => {
|
||||
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
||||
const { manager } = await createManagerHarness();
|
||||
|
||||
const { callId, success, error } = await manager.initiateCall("+15550000001");
|
||||
expect(success).toBe(true);
|
||||
expect(error).toBeUndefined();
|
||||
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("request-uuid");
|
||||
expect(manager.getCallByProviderCallId("request-uuid")?.callId).toBe(callId);
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-1",
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("call-uuid");
|
||||
expect(manager.getCallByProviderCallId("call-uuid")?.callId).toBe(callId);
|
||||
expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["plivo", "twilio"] as const)(
|
||||
"speaks initial message on answered for notify mode (%s)",
|
||||
async (providerName) => {
|
||||
const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
|
||||
|
||||
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
|
||||
message: "Hello there",
|
||||
mode: "notify",
|
||||
});
|
||||
expect(success).toBe(true);
|
||||
|
||||
manager.processEvent({
|
||||
id: `evt-2-${providerName}`,
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Hello there");
|
||||
},
|
||||
);
|
||||
});
|
||||
130
extensions/voice-call/src/manager.restore.test.ts
Normal file
130
extensions/voice-call/src/manager.restore.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VoiceCallConfigSchema } from "./config.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import {
|
||||
createTestStorePath,
|
||||
FakeProvider,
|
||||
makePersistedCall,
|
||||
writeCallsToStore,
|
||||
} from "./manager.test-harness.js";
|
||||
|
||||
describe("CallManager verification on restore", () => {
|
||||
it("skips stale calls reported terminal by provider", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "completed", isTerminal: true };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps calls reported active by provider", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "in-progress", isTerminal: false };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
|
||||
});
|
||||
|
||||
it("keeps calls when provider returns unknown (transient error)", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips calls older than maxDurationSeconds", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall({
|
||||
startedAt: Date.now() - 600_000,
|
||||
answeredAt: Date.now() - 590_000,
|
||||
});
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
maxDurationSeconds: 300,
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips calls without providerCallId", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall({ providerCallId: undefined, state: "initiated" });
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps call when getCallStatus throws (verification failure)", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatus = async () => {
|
||||
throw new Error("network failure");
|
||||
};
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
125
extensions/voice-call/src/manager.test-harness.ts
Normal file
125
extensions/voice-call/src/manager.test-harness.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { VoiceCallConfigSchema } from "./config.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import type {
|
||||
GetCallStatusInput,
|
||||
GetCallStatusResult,
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
PlayTtsInput,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "./types.js";
|
||||
|
||||
export class FakeProvider implements VoiceCallProvider {
|
||||
readonly name: "plivo" | "twilio";
|
||||
readonly playTtsCalls: PlayTtsInput[] = [];
|
||||
readonly hangupCalls: HangupCallInput[] = [];
|
||||
readonly startListeningCalls: StartListeningInput[] = [];
|
||||
readonly stopListeningCalls: StopListeningInput[] = [];
|
||||
getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
|
||||
|
||||
constructor(name: "plivo" | "twilio" = "plivo") {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
parseWebhookEvent(_ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
return { events: [], statusCode: 200 };
|
||||
}
|
||||
|
||||
async initiateCall(_input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||
return { providerCallId: "request-uuid", status: "initiated" };
|
||||
}
|
||||
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
this.hangupCalls.push(input);
|
||||
}
|
||||
|
||||
async playTts(input: PlayTtsInput): Promise<void> {
|
||||
this.playTtsCalls.push(input);
|
||||
}
|
||||
|
||||
async startListening(input: StartListeningInput): Promise<void> {
|
||||
this.startListeningCalls.push(input);
|
||||
}
|
||||
|
||||
async stopListening(input: StopListeningInput): Promise<void> {
|
||||
this.stopListeningCalls.push(input);
|
||||
}
|
||||
|
||||
async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
||||
return this.getCallStatusResult;
|
||||
}
|
||||
}
|
||||
|
||||
let storeSeq = 0;
|
||||
|
||||
export function createTestStorePath(): string {
|
||||
storeSeq += 1;
|
||||
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
|
||||
}
|
||||
|
||||
export async function createManagerHarness(
|
||||
configOverrides: Record<string, unknown> = {},
|
||||
provider = new FakeProvider(),
|
||||
): Promise<{
|
||||
manager: CallManager;
|
||||
provider: FakeProvider;
|
||||
}> {
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
...configOverrides,
|
||||
});
|
||||
const manager = new CallManager(config, createTestStorePath());
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
return { manager, provider };
|
||||
}
|
||||
|
||||
export function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
|
||||
manager.processEvent({
|
||||
id: eventId,
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function writeCallsToStore(storePath: string, calls: Record<string, unknown>[]): void {
|
||||
fs.mkdirSync(storePath, { recursive: true });
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n";
|
||||
fs.writeFileSync(logPath, lines);
|
||||
}
|
||||
|
||||
export function makePersistedCall(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
provider: "plivo",
|
||||
direction: "outbound",
|
||||
state: "answered",
|
||||
from: "+15550000000",
|
||||
to: "+15550000001",
|
||||
startedAt: Date.now() - 30_000,
|
||||
answeredAt: Date.now() - 25_000,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VoiceCallConfigSchema } from "./config.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
import type {
|
||||
GetCallStatusInput,
|
||||
GetCallStatusResult,
|
||||
HangupCallInput,
|
||||
InitiateCallInput,
|
||||
InitiateCallResult,
|
||||
PlayTtsInput,
|
||||
ProviderWebhookParseResult,
|
||||
StartListeningInput,
|
||||
StopListeningInput,
|
||||
WebhookContext,
|
||||
WebhookVerificationResult,
|
||||
} from "./types.js";
|
||||
|
||||
class FakeProvider implements VoiceCallProvider {
|
||||
readonly name: "plivo" | "twilio";
|
||||
readonly playTtsCalls: PlayTtsInput[] = [];
|
||||
readonly hangupCalls: HangupCallInput[] = [];
|
||||
readonly startListeningCalls: StartListeningInput[] = [];
|
||||
readonly stopListeningCalls: StopListeningInput[] = [];
|
||||
getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
|
||||
|
||||
constructor(name: "plivo" | "twilio" = "plivo") {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
||||
return { ok: true };
|
||||
}
|
||||
parseWebhookEvent(_ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
return { events: [], statusCode: 200 };
|
||||
}
|
||||
async initiateCall(_input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||
return { providerCallId: "request-uuid", status: "initiated" };
|
||||
}
|
||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||
this.hangupCalls.push(input);
|
||||
}
|
||||
async playTts(input: PlayTtsInput): Promise<void> {
|
||||
this.playTtsCalls.push(input);
|
||||
}
|
||||
async startListening(input: StartListeningInput): Promise<void> {
|
||||
this.startListeningCalls.push(input);
|
||||
}
|
||||
async stopListening(input: StopListeningInput): Promise<void> {
|
||||
this.stopListeningCalls.push(input);
|
||||
}
|
||||
async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
||||
return this.getCallStatusResult;
|
||||
}
|
||||
}
|
||||
|
||||
let storeSeq = 0;
|
||||
|
||||
function createTestStorePath(): string {
|
||||
storeSeq += 1;
|
||||
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
|
||||
}
|
||||
|
||||
async function createManagerHarness(
|
||||
configOverrides: Record<string, unknown> = {},
|
||||
provider = new FakeProvider(),
|
||||
): Promise<{
|
||||
manager: CallManager;
|
||||
provider: FakeProvider;
|
||||
}> {
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
...configOverrides,
|
||||
});
|
||||
const manager = new CallManager(config, createTestStorePath());
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
return { manager, provider };
|
||||
}
|
||||
|
||||
function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
|
||||
manager.processEvent({
|
||||
id: eventId,
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("CallManager", () => {
|
||||
it("upgrades providerCallId mapping when provider ID changes", async () => {
|
||||
const { manager } = await createManagerHarness();
|
||||
|
||||
const { callId, success, error } = await manager.initiateCall("+15550000001");
|
||||
expect(success).toBe(true);
|
||||
expect(error).toBeUndefined();
|
||||
|
||||
// The provider returned a request UUID as the initial providerCallId.
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("request-uuid");
|
||||
expect(manager.getCallByProviderCallId("request-uuid")?.callId).toBe(callId);
|
||||
|
||||
// Provider later reports the actual call UUID.
|
||||
manager.processEvent({
|
||||
id: "evt-1",
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(manager.getCall(callId)?.providerCallId).toBe("call-uuid");
|
||||
expect(manager.getCallByProviderCallId("call-uuid")?.callId).toBe(callId);
|
||||
expect(manager.getCallByProviderCallId("request-uuid")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["plivo", "twilio"] as const)(
|
||||
"speaks initial message on answered for notify mode (%s)",
|
||||
async (providerName) => {
|
||||
const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
|
||||
|
||||
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
|
||||
message: "Hello there",
|
||||
mode: "notify",
|
||||
});
|
||||
expect(success).toBe(true);
|
||||
|
||||
manager.processEvent({
|
||||
id: `evt-2-${providerName}`,
|
||||
type: "call.answered",
|
||||
callId,
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(provider.playTtsCalls[0]?.text).toBe("Hello there");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-missing",
|
||||
type: "call.initiated",
|
||||
callId: "call-missing",
|
||||
providerCallId: "provider-missing",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
|
||||
});
|
||||
|
||||
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-anon",
|
||||
type: "call.initiated",
|
||||
callId: "call-anon",
|
||||
providerCallId: "provider-anon",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "anonymous",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
|
||||
});
|
||||
|
||||
it("rejects inbound calls that only match allowlist suffixes", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-suffix",
|
||||
type: "call.initiated",
|
||||
callId: "call-suffix",
|
||||
providerCallId: "provider-suffix",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+99915550001234",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
|
||||
});
|
||||
|
||||
it("rejects duplicate inbound events with a single hangup call", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
inboundPolicy: "disabled",
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-reject-init",
|
||||
type: "call.initiated",
|
||||
callId: "provider-dup",
|
||||
providerCallId: "provider-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-reject-ring",
|
||||
type: "call.ringing",
|
||||
callId: "provider-dup",
|
||||
providerCallId: "provider-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-dup")).toBeUndefined();
|
||||
expect(provider.hangupCalls).toHaveLength(1);
|
||||
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
|
||||
});
|
||||
|
||||
it("accepts inbound calls that exactly match the allowlist", async () => {
|
||||
const { manager } = await createManagerHarness({
|
||||
inboundPolicy: "allowlist",
|
||||
allowFrom: ["+15550001234"],
|
||||
});
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-allowlist-exact",
|
||||
type: "call.initiated",
|
||||
callId: "call-exact",
|
||||
providerCallId: "provider-exact",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15550001234",
|
||||
to: "+15550000000",
|
||||
});
|
||||
|
||||
expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
|
||||
});
|
||||
|
||||
it("completes a closed-loop turn without live audio", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000003");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-closed-loop-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Please check status",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const turn = await turnPromise;
|
||||
expect(turn.success).toBe(true);
|
||||
expect(turn.transcript).toBe("Please check status");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"How can I help?",
|
||||
"Please check status",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(metadata.turnCount).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects overlapping continueCall requests for the same call", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-overlap-answered");
|
||||
|
||||
const first = manager.continueCall(started.callId, "First prompt");
|
||||
const second = await manager.continueCall(started.callId, "Second prompt");
|
||||
expect(second.success).toBe(false);
|
||||
expect(second.error).toBe("Already waiting for transcript");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-overlap-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Done",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const firstResult = await first;
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(firstResult.transcript).toBe("Done");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
|
||||
const { manager, provider } = await createManagerHarness(
|
||||
{
|
||||
transcriptTimeoutMs: 5000,
|
||||
},
|
||||
new FakeProvider("twilio"),
|
||||
);
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-turn-token-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "Prompt");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedTurnToken = provider.startListeningCalls[0]?.turnToken;
|
||||
expect(typeof expectedTurnToken).toBe("string");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-bad",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "stale replay",
|
||||
isFinal: true,
|
||||
turnToken: "wrong-token",
|
||||
});
|
||||
|
||||
const pendingState = await Promise.race([
|
||||
turnPromise.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
|
||||
]);
|
||||
expect(pendingState).toBe("pending");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-good",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "final answer",
|
||||
isFinal: true,
|
||||
turnToken: expectedTurnToken,
|
||||
});
|
||||
|
||||
const turnResult = await turnPromise;
|
||||
expect(turnResult.success).toBe(true);
|
||||
expect(turnResult.transcript).toBe("final answer");
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
|
||||
});
|
||||
|
||||
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000005");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-multi-answered");
|
||||
|
||||
const firstTurn = manager.continueCall(started.callId, "First question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-1",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "First answer",
|
||||
isFinal: true,
|
||||
});
|
||||
await firstTurn;
|
||||
|
||||
const secondTurn = manager.continueCall(started.callId, "Second question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-2",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Second answer",
|
||||
isFinal: true,
|
||||
});
|
||||
const secondResult = await secondTurn;
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"First question",
|
||||
"First answer",
|
||||
"Second question",
|
||||
"Second answer",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(2);
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(provider.startListeningCalls).toHaveLength(2);
|
||||
expect(provider.stopListeningCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles repeated closed-loop turns without waiter churn", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000006");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-loop-answered");
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: `evt-loop-speech-${i}`,
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: `Answer ${i}`,
|
||||
isFinal: true,
|
||||
});
|
||||
const result = await turnPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.transcript).toBe(`Answer ${i}`);
|
||||
}
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(5);
|
||||
expect(provider.startListeningCalls).toHaveLength(5);
|
||||
expect(provider.stopListeningCalls).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call verification on restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeCallsToStore(storePath: string, calls: Record<string, unknown>[]): void {
|
||||
fs.mkdirSync(storePath, { recursive: true });
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n";
|
||||
fs.writeFileSync(logPath, lines);
|
||||
}
|
||||
|
||||
function makePersistedCall(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
provider: "plivo",
|
||||
direction: "outbound",
|
||||
state: "answered",
|
||||
from: "+15550000000",
|
||||
to: "+15550000001",
|
||||
startedAt: Date.now() - 30_000,
|
||||
answeredAt: Date.now() - 25_000,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CallManager verification on restore", () => {
|
||||
it("skips stale calls reported terminal by provider", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "completed", isTerminal: true };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps calls reported active by provider", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "in-progress", isTerminal: false };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
|
||||
});
|
||||
|
||||
it("keeps calls when provider returns unknown (transient error)", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true };
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips calls older than maxDurationSeconds", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall({
|
||||
startedAt: Date.now() - 600_000, // 10 minutes ago
|
||||
answeredAt: Date.now() - 590_000,
|
||||
});
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
maxDurationSeconds: 300, // 5 minutes
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips calls without providerCallId", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall({ providerCallId: undefined, state: "initiated" });
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps call when getCallStatus throws (verification failure)", async () => {
|
||||
const storePath = createTestStorePath();
|
||||
const call = makePersistedCall();
|
||||
writeCallsToStore(storePath, [call]);
|
||||
|
||||
const provider = new FakeProvider();
|
||||
provider.getCallStatus = async () => {
|
||||
throw new Error("network failure");
|
||||
};
|
||||
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
});
|
||||
const manager = new CallManager(config, storePath);
|
||||
await manager.initialize(provider, "https://example.com/voice/webhook");
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user