mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
test(voice-call): cover outbound call flow helpers
This commit is contained in:
261
extensions/voice-call/src/manager/outbound.test.ts
Normal file
261
extensions/voice-call/src/manager/outbound.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
addTranscriptEntryMock,
|
||||
clearMaxDurationTimerMock,
|
||||
generateNotifyTwimlMock,
|
||||
getCallByProviderCallIdMock,
|
||||
mapVoiceToPollyMock,
|
||||
persistCallRecordMock,
|
||||
rejectTranscriptWaiterMock,
|
||||
transitionStateMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addTranscriptEntryMock: vi.fn(),
|
||||
clearMaxDurationTimerMock: vi.fn(),
|
||||
generateNotifyTwimlMock: vi.fn(),
|
||||
getCallByProviderCallIdMock: vi.fn(),
|
||||
mapVoiceToPollyMock: vi.fn(),
|
||||
persistCallRecordMock: vi.fn(),
|
||||
rejectTranscriptWaiterMock: vi.fn(),
|
||||
transitionStateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./state.js", () => ({
|
||||
addTranscriptEntry: addTranscriptEntryMock,
|
||||
transitionState: transitionStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
persistCallRecord: persistCallRecordMock,
|
||||
}));
|
||||
|
||||
vi.mock("./timers.js", () => ({
|
||||
clearMaxDurationTimer: clearMaxDurationTimerMock,
|
||||
clearTranscriptWaiter: vi.fn(),
|
||||
rejectTranscriptWaiter: rejectTranscriptWaiterMock,
|
||||
waitForFinalTranscript: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lookup.js", () => ({
|
||||
getCallByProviderCallId: getCallByProviderCallIdMock,
|
||||
}));
|
||||
|
||||
vi.mock("../voice-mapping.js", () => ({
|
||||
mapVoiceToPolly: mapVoiceToPollyMock,
|
||||
}));
|
||||
|
||||
vi.mock("./twiml.js", () => ({
|
||||
generateNotifyTwiml: generateNotifyTwimlMock,
|
||||
}));
|
||||
|
||||
import { endCall, initiateCall, speak } from "./outbound.js";
|
||||
|
||||
describe("voice-call outbound helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mapVoiceToPollyMock.mockReturnValue("Polly.Joanna");
|
||||
generateNotifyTwimlMock.mockReturnValue("<Response />");
|
||||
});
|
||||
|
||||
it("guards initiateCall when provider, webhook, capacity, or fromNumber are missing", async () => {
|
||||
const base = {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
config: {
|
||||
maxConcurrentCalls: 1,
|
||||
outbound: { defaultMode: "conversation" },
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
await expect(initiateCall({ ...(base as never), provider: undefined }, "+14155550123")).resolves.toEqual({
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "Provider not initialized",
|
||||
});
|
||||
|
||||
await expect(
|
||||
initiateCall({ ...(base as never), provider: { name: "twilio" }, webhookUrl: undefined }, "+14155550123"),
|
||||
).resolves.toEqual({
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "Webhook URL not configured",
|
||||
});
|
||||
|
||||
const saturated = {
|
||||
...base,
|
||||
activeCalls: new Map([["existing", {}]]),
|
||||
provider: { name: "twilio" },
|
||||
};
|
||||
await expect(initiateCall(saturated as never, "+14155550123")).resolves.toEqual({
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "Maximum concurrent calls (1) reached",
|
||||
});
|
||||
|
||||
await expect(
|
||||
initiateCall({ ...(base as never), provider: { name: "twilio" }, config: { ...base.config, fromNumber: "" } }, "+14155550123"),
|
||||
).resolves.toEqual({
|
||||
callId: "",
|
||||
success: false,
|
||||
error: "fromNumber not configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("initiates notify-mode calls with inline TwiML and records provider ids", async () => {
|
||||
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" }));
|
||||
const ctx = {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { name: "twilio", initiateCall: initiateProviderCall },
|
||||
config: {
|
||||
maxConcurrentCalls: 3,
|
||||
outbound: { defaultMode: "conversation" },
|
||||
fromNumber: "+14155550100",
|
||||
tts: { openai: { voice: "nova" } },
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
const result = await initiateCall(ctx as never, "+14155550123", "session-1", {
|
||||
mode: "notify",
|
||||
message: "hello there",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
callId: expect.any(String),
|
||||
success: true,
|
||||
});
|
||||
const callId = result.callId;
|
||||
|
||||
expect(mapVoiceToPollyMock).toHaveBeenCalledWith("nova");
|
||||
expect(generateNotifyTwimlMock).toHaveBeenCalledWith("hello there", "Polly.Joanna");
|
||||
expect(initiateProviderCall).toHaveBeenCalledWith({
|
||||
callId,
|
||||
from: "+14155550100",
|
||||
to: "+14155550123",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
inlineTwiml: "<Response />",
|
||||
});
|
||||
expect(ctx.providerCallIdMap.get("provider-1")).toBe(callId);
|
||||
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails initiateCall cleanly when provider initiation throws", async () => {
|
||||
const ctx = {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: {
|
||||
name: "mock",
|
||||
initiateCall: vi.fn(async () => {
|
||||
throw new Error("provider down");
|
||||
}),
|
||||
},
|
||||
config: {
|
||||
maxConcurrentCalls: 3,
|
||||
outbound: { defaultMode: "conversation" },
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
await expect(initiateCall(ctx as never, "+14155550123")).resolves.toEqual({
|
||||
callId: expect.any(String),
|
||||
success: false,
|
||||
error: "provider down",
|
||||
});
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("speaks through connected calls and rolls back to listening on provider errors", async () => {
|
||||
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
|
||||
const playTts = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { name: "twilio", playTts },
|
||||
config: { tts: { openai: { voice: "alloy" } } },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
};
|
||||
|
||||
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
|
||||
expect(transitionStateMock).toHaveBeenCalledWith(call, "speaking");
|
||||
expect(playTts).toHaveBeenCalledWith({
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
text: "hello",
|
||||
voice: "alloy",
|
||||
});
|
||||
expect(addTranscriptEntryMock).toHaveBeenCalledWith(call, "bot", "hello");
|
||||
|
||||
playTts.mockImplementationOnce(async () => {
|
||||
throw new Error("tts failed");
|
||||
});
|
||||
await expect(speak(ctx as never, "call-1", "hello again")).resolves.toEqual({
|
||||
success: false,
|
||||
error: "tts failed",
|
||||
});
|
||||
expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening");
|
||||
});
|
||||
|
||||
it("ends connected calls, clears timers, and rejects pending transcripts", async () => {
|
||||
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
|
||||
const hangupCall = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
providerCallIdMap: new Map([["provider-1", "call-1"]]),
|
||||
provider: { hangupCall },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
transcriptWaiters: new Map(),
|
||||
maxDurationTimers: new Map(),
|
||||
};
|
||||
|
||||
await expect(endCall(ctx as never, "call-1")).resolves.toEqual({ success: true });
|
||||
expect(hangupCall).toHaveBeenCalledWith({
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
expect(call).toEqual(
|
||||
expect.objectContaining({
|
||||
state: "hangup-bot",
|
||||
endReason: "hangup-bot",
|
||||
}),
|
||||
);
|
||||
expect(clearMaxDurationTimerMock).toHaveBeenCalledWith(ctx, "call-1");
|
||||
expect(rejectTranscriptWaiterMock).toHaveBeenCalledWith(ctx, "call-1", "Call ended: hangup-bot");
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(ctx.providerCallIdMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles missing, disconnected, and already-ended calls", async () => {
|
||||
await expect(
|
||||
speak(
|
||||
{
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { name: "twilio", playTts: vi.fn() },
|
||||
config: {},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
} as never,
|
||||
"missing",
|
||||
"hello",
|
||||
),
|
||||
).resolves.toEqual({ success: false, error: "Call not found" });
|
||||
|
||||
await expect(
|
||||
endCall(
|
||||
{
|
||||
activeCalls: new Map([["call-1", { callId: "call-1", state: "completed", providerCallId: "provider-1" }]]),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { hangupCall: vi.fn() },
|
||||
storePath: "/tmp/voice-call.json",
|
||||
transcriptWaiters: new Map(),
|
||||
maxDurationTimers: new Map(),
|
||||
} as never,
|
||||
"call-1",
|
||||
),
|
||||
).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user