mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
test: harden voice call regression assertions
This commit is contained in:
@@ -11,6 +11,14 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
||||
return createVoiceCallBaseConfig({ provider });
|
||||
}
|
||||
|
||||
function requireElevenLabsTtsConfig(config: Pick<VoiceCallConfig, "tts">) {
|
||||
const tts = config.tts;
|
||||
if (!tts?.elevenlabs) {
|
||||
throw new Error("voice-call config did not preserve nested elevenlabs TTS config");
|
||||
}
|
||||
return { tts, elevenlabs: tts.elevenlabs };
|
||||
}
|
||||
|
||||
describe("validateProviderConfig", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const clearProviderEnv = () => {
|
||||
@@ -207,16 +215,13 @@ describe("normalizeVoiceCallConfig", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const tts = normalized.tts;
|
||||
if (!tts?.elevenlabs) {
|
||||
throw new Error("voice-call config did not preserve nested elevenlabs TTS config");
|
||||
}
|
||||
const { tts, elevenlabs } = requireElevenLabsTtsConfig(normalized);
|
||||
expect(tts.provider).toBe("elevenlabs");
|
||||
expect(tts.elevenlabs.apiKey).toEqual({
|
||||
expect(elevenlabs.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "elevenlabs",
|
||||
id: "ELEVENLABS_API_KEY",
|
||||
});
|
||||
expect(tts.elevenlabs.voiceSettings).toEqual({ speed: 1.1 });
|
||||
expect(elevenlabs.voiceSettings).toEqual({ speed: 1.1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js";
|
||||
|
||||
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 requireTurnToken(provider: Awaited<ReturnType<typeof createManagerHarness>>["provider"]) {
|
||||
const firstStart = provider.startListeningCalls[0];
|
||||
if (!firstStart?.turnToken) {
|
||||
throw new Error("expected closed-loop turn to capture a turn token");
|
||||
}
|
||||
return firstStart.turnToken;
|
||||
}
|
||||
|
||||
describe("CallManager closed-loop turns", () => {
|
||||
it("completes a closed-loop turn without live audio", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
@@ -31,12 +50,12 @@ describe("CallManager closed-loop turns", () => {
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
const call = requireCall(manager, 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>;
|
||||
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);
|
||||
@@ -90,8 +109,7 @@ describe("CallManager closed-loop turns", () => {
|
||||
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");
|
||||
const expectedTurnToken = requireTurnToken(provider);
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-bad",
|
||||
@@ -125,8 +143,8 @@ describe("CallManager closed-loop turns", () => {
|
||||
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"]);
|
||||
const call = requireCall(manager, started.callId);
|
||||
expect(call.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
|
||||
});
|
||||
|
||||
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
||||
@@ -167,14 +185,14 @@ describe("CallManager closed-loop turns", () => {
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
const call = requireCall(manager, 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>;
|
||||
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");
|
||||
@@ -209,8 +227,8 @@ describe("CallManager closed-loop turns", () => {
|
||||
expect(result.transcript).toBe(`Answer ${i}`);
|
||||
}
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
const call = requireCall(manager, 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);
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("CallManager notify and mapping", () => {
|
||||
|
||||
const afterFailure = requireCall(manager, callId);
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(afterFailure.metadata?.initialMessage).toBe("Retry me");
|
||||
expect(afterFailure.metadata).toEqual(expect.objectContaining({ initialMessage: "Retry me" }));
|
||||
expect(afterFailure.state).toBe("listening");
|
||||
|
||||
manager.processEvent({
|
||||
@@ -248,7 +248,7 @@ describe("CallManager notify and mapping", () => {
|
||||
|
||||
const afterSuccess = requireCall(manager, callId);
|
||||
expect(provider.playTtsCalls).toHaveLength(2);
|
||||
expect(afterSuccess.metadata?.initialMessage).toBeUndefined();
|
||||
expect(afterSuccess.metadata).not.toHaveProperty("initialMessage");
|
||||
});
|
||||
|
||||
it("speaks initial message only once on repeated stream-connect triggers", async () => {
|
||||
@@ -313,7 +313,7 @@ describe("CallManager notify and mapping", () => {
|
||||
await Promise.all([first, second]);
|
||||
|
||||
const call = requireCall(manager, callId);
|
||||
expect(call.metadata?.initialMessage).toBeUndefined();
|
||||
expect(call.metadata).not.toHaveProperty("initialMessage");
|
||||
expect(provider.playTtsCalls).toHaveLength(1);
|
||||
expect(requireFirstPlayTtsCall(provider).text).toBe("In-flight hello");
|
||||
});
|
||||
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
writeCallsToStore,
|
||||
} from "./manager.test-harness.js";
|
||||
|
||||
function requireSingleActiveCall(manager: CallManager) {
|
||||
const activeCalls = manager.getActiveCalls();
|
||||
expect(activeCalls).toHaveLength(1);
|
||||
const activeCall = activeCalls[0];
|
||||
if (!activeCall) {
|
||||
throw new Error("expected restored active call");
|
||||
}
|
||||
return activeCall;
|
||||
}
|
||||
|
||||
describe("CallManager verification on restore", () => {
|
||||
async function initializeManager(params?: {
|
||||
callOverrides?: Parameters<typeof makePersistedCall>[0];
|
||||
@@ -50,21 +60,18 @@ describe("CallManager verification on restore", () => {
|
||||
providerResult: { status: "in-progress", isTerminal: false },
|
||||
});
|
||||
|
||||
const activeCalls = manager.getActiveCalls();
|
||||
expect(activeCalls).toHaveLength(1);
|
||||
const activeCall = activeCalls[0];
|
||||
if (!activeCall) {
|
||||
throw new Error("expected restored active call");
|
||||
}
|
||||
const activeCall = requireSingleActiveCall(manager);
|
||||
expect(activeCall.callId).toBe(call.callId);
|
||||
});
|
||||
|
||||
it("keeps calls when provider returns unknown (transient error)", async () => {
|
||||
const { manager } = await initializeManager({
|
||||
const { call, manager } = await initializeManager({
|
||||
providerResult: { status: "error", isTerminal: false, isUnknown: true },
|
||||
});
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
const activeCall = requireSingleActiveCall(manager);
|
||||
expect(activeCall.callId).toBe(call.callId);
|
||||
expect(activeCall.state).toBe(call.state);
|
||||
});
|
||||
|
||||
it("skips calls older than maxDurationSeconds", async () => {
|
||||
@@ -88,7 +95,7 @@ describe("CallManager verification on restore", () => {
|
||||
});
|
||||
|
||||
it("keeps call when getCallStatus throws (verification failure)", async () => {
|
||||
const { manager } = await initializeManager({
|
||||
const { call, manager } = await initializeManager({
|
||||
configureProvider: (provider) => {
|
||||
provider.getCallStatus = async () => {
|
||||
throw new Error("network failure");
|
||||
@@ -96,6 +103,8 @@ describe("CallManager verification on restore", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.getActiveCalls()).toHaveLength(1);
|
||||
const activeCall = requireSingleActiveCall(manager);
|
||||
expect(activeCall.callId).toBe(call.callId);
|
||||
expect(activeCall.state).toBe(call.state);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,13 @@ function requireEvent<T>(event: T | undefined, message: string): T {
|
||||
return event;
|
||||
}
|
||||
|
||||
function requireResponseBody(body: string | undefined): string {
|
||||
if (!body) {
|
||||
throw new Error("Plivo provider did not return a response body");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
describe("PlivoProvider", () => {
|
||||
it("parses answer callback into call.answered and returns keep-alive XML", () => {
|
||||
const provider = new PlivoProvider({
|
||||
@@ -29,8 +36,9 @@ describe("PlivoProvider", () => {
|
||||
expect(event.type).toBe("call.answered");
|
||||
expect(event.callId).toBe("internal-call-id");
|
||||
expect(event.providerCallId).toBe("call-uuid");
|
||||
expect(result.providerResponseBody).toContain("<Wait");
|
||||
expect(result.providerResponseBody).toContain('length="300"');
|
||||
const responseBody = requireResponseBody(result.providerResponseBody);
|
||||
expect(responseBody).toContain("<Wait");
|
||||
expect(responseBody).toContain('length="300"');
|
||||
});
|
||||
|
||||
it("uses verified request key when provided", () => {
|
||||
|
||||
@@ -46,12 +46,25 @@ function expectReplayVerification(
|
||||
) {
|
||||
expect(results.map((result) => result.ok)).toEqual([true, true]);
|
||||
expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
|
||||
const firstKey = results[0]?.verifiedRequestKey;
|
||||
if (!firstKey) {
|
||||
const firstResult = results[0];
|
||||
if (!firstResult?.verifiedRequestKey) {
|
||||
throw new Error("expected Telnyx verification to produce a request key");
|
||||
}
|
||||
expect(firstKey).toEqual(expect.any(String));
|
||||
expect(results[1]?.verifiedRequestKey).toBe(firstKey);
|
||||
const secondResult = results[1];
|
||||
if (!secondResult?.verifiedRequestKey) {
|
||||
throw new Error("expected replayed Telnyx verification to preserve the request key");
|
||||
}
|
||||
const firstKey = firstResult.verifiedRequestKey;
|
||||
const secondKey = secondResult.verifiedRequestKey;
|
||||
expect(firstKey.length).toBeGreaterThan(0);
|
||||
expect(secondKey).toBe(firstKey);
|
||||
}
|
||||
|
||||
function requireJwkX(jwk: JsonWebKey) {
|
||||
if (typeof jwk.x !== "string" || jwk.x.length === 0) {
|
||||
throw new Error("expected Ed25519 JWK export to expose x");
|
||||
}
|
||||
return jwk.x;
|
||||
}
|
||||
|
||||
function expectWebhookVerificationSucceeds(params: {
|
||||
@@ -110,9 +123,8 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
||||
const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||
expect(jwk.kty).toBe("OKP");
|
||||
expect(jwk.crv).toBe("Ed25519");
|
||||
expect(typeof jwk.x).toBe("string");
|
||||
|
||||
const rawPublicKey = decodeBase64Url(jwk.x as string);
|
||||
const rawPublicKey = decodeBase64Url(requireJwkX(jwk));
|
||||
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
|
||||
expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
|
||||
});
|
||||
@@ -167,6 +179,10 @@ describe("TelnyxProvider.parseWebhookEvent", () => {
|
||||
);
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc");
|
||||
const event = result.events[0];
|
||||
if (!event) {
|
||||
throw new Error("expected Telnyx parseWebhookEvent to produce one event");
|
||||
}
|
||||
expect(event.dedupeKey).toBe("telnyx:req:abc");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@ function expectStreamingTwiml(body: string) {
|
||||
expect(body).toContain("<Connect>");
|
||||
}
|
||||
|
||||
function expectQueueTwiml(body: string) {
|
||||
expect(body).toContain("Please hold while we connect you.");
|
||||
expect(body).toContain("<Enqueue");
|
||||
expect(body).toContain("hold-queue");
|
||||
}
|
||||
|
||||
function requireResponseBody(body: string | undefined): string {
|
||||
if (!body) {
|
||||
throw new Error("Twilio provider did not return a response body");
|
||||
@@ -84,10 +90,8 @@ describe("TwilioProvider", () => {
|
||||
const firstResult = provider.parseWebhookEvent(firstInbound);
|
||||
const secondResult = provider.parseWebhookEvent(secondInbound);
|
||||
|
||||
expect(firstResult.providerResponseBody).toContain("<Connect>");
|
||||
expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
|
||||
expect(secondResult.providerResponseBody).toContain("<Enqueue");
|
||||
expect(secondResult.providerResponseBody).toContain("hold-queue");
|
||||
expectStreamingTwiml(requireResponseBody(firstResult.providerResponseBody));
|
||||
expectQueueTwiml(requireResponseBody(secondResult.providerResponseBody));
|
||||
});
|
||||
|
||||
it("connects next inbound call after unregisterCallStream cleanup", () => {
|
||||
@@ -99,8 +103,9 @@ describe("TwilioProvider", () => {
|
||||
provider.unregisterCallStream("CA311");
|
||||
const secondResult = provider.parseWebhookEvent(secondInbound);
|
||||
|
||||
expect(secondResult.providerResponseBody).toContain("<Connect>");
|
||||
expect(secondResult.providerResponseBody).not.toContain("hold-queue");
|
||||
const secondBody = requireResponseBody(secondResult.providerResponseBody);
|
||||
expectStreamingTwiml(secondBody);
|
||||
expect(secondBody).not.toContain("hold-queue");
|
||||
});
|
||||
|
||||
it("cleans up active inbound call on completed status callback", () => {
|
||||
@@ -115,8 +120,9 @@ describe("TwilioProvider", () => {
|
||||
provider.parseWebhookEvent(completed);
|
||||
const nextResult = provider.parseWebhookEvent(nextInbound);
|
||||
|
||||
expect(nextResult.providerResponseBody).toContain("<Connect>");
|
||||
expect(nextResult.providerResponseBody).not.toContain("hold-queue");
|
||||
const nextBody = requireResponseBody(nextResult.providerResponseBody);
|
||||
expectStreamingTwiml(nextBody);
|
||||
expect(nextBody).not.toContain("hold-queue");
|
||||
});
|
||||
|
||||
it("cleans up active inbound call on canceled status callback", () => {
|
||||
@@ -131,8 +137,9 @@ describe("TwilioProvider", () => {
|
||||
provider.parseWebhookEvent(canceled);
|
||||
const nextResult = provider.parseWebhookEvent(nextInbound);
|
||||
|
||||
expect(nextResult.providerResponseBody).toContain("<Connect>");
|
||||
expect(nextResult.providerResponseBody).not.toContain("hold-queue");
|
||||
const nextBody = requireResponseBody(nextResult.providerResponseBody);
|
||||
expectStreamingTwiml(nextBody);
|
||||
expect(nextBody).not.toContain("hold-queue");
|
||||
});
|
||||
|
||||
it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
|
||||
@@ -143,7 +150,9 @@ describe("TwilioProvider", () => {
|
||||
provider.parseWebhookEvent(firstInbound);
|
||||
const result = provider.parseWebhookEvent(secondInbound);
|
||||
|
||||
expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"');
|
||||
expect(requireResponseBody(result.providerResponseBody)).toContain(
|
||||
'waitUrl="/voice/hold-music"',
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a stable fallback dedupeKey for identical request payloads", () => {
|
||||
|
||||
@@ -32,6 +32,19 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
|
||||
return { runtime, runEmbeddedPiAgent };
|
||||
}
|
||||
|
||||
function requireEmbeddedAgentArgs(runEmbeddedPiAgent: ReturnType<typeof vi.fn>) {
|
||||
const calls = runEmbeddedPiAgent.mock.calls as unknown[][];
|
||||
const firstCall = calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("voice response generator did not invoke the embedded agent");
|
||||
}
|
||||
const args = firstCall[0] as { extraSystemPrompt?: string } | undefined;
|
||||
if (!args?.extraSystemPrompt) {
|
||||
throw new Error("voice response generator did not pass the spoken-output contract prompt");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function runGenerateVoiceResponse(
|
||||
payloads: Array<Record<string, unknown>>,
|
||||
overrides?: {
|
||||
@@ -68,15 +81,7 @@ describe("generateVoiceResponse", () => {
|
||||
|
||||
expect(result.text).toBe("Hello from JSON.");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
const calls = runEmbeddedPiAgent.mock.calls as unknown[][];
|
||||
const firstCall = calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("voice response generator did not invoke the embedded agent");
|
||||
}
|
||||
const args = firstCall[0] as { extraSystemPrompt?: string } | undefined;
|
||||
if (!args?.extraSystemPrompt) {
|
||||
throw new Error("voice response generator did not pass the spoken-output contract prompt");
|
||||
}
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
|
||||
expect(args.extraSystemPrompt).toContain('{"spoken":"..."}');
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ function createCoreConfig(): CoreConfig {
|
||||
return { messages: { tts } };
|
||||
}
|
||||
|
||||
function requireMergedTtsConfig(mergedConfig: CoreConfig | undefined) {
|
||||
const tts = mergedConfig?.messages?.tts;
|
||||
if (!tts) {
|
||||
throw new Error("telephony TTS runtime did not receive merged TTS config");
|
||||
}
|
||||
return tts as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
|
||||
let mergedConfig: CoreConfig | undefined;
|
||||
const provider = createTelephonyTtsProvider({
|
||||
@@ -32,11 +40,7 @@ async function mergeOverride(override: unknown): Promise<Record<string, unknown>
|
||||
});
|
||||
|
||||
await provider.synthesizeForTelephony("hello");
|
||||
const tts = mergedConfig?.messages?.tts;
|
||||
if (!tts) {
|
||||
throw new Error("telephony TTS runtime did not receive merged TTS config");
|
||||
}
|
||||
return tts as Record<string, unknown>;
|
||||
return requireMergedTtsConfig(mergedConfig);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -29,9 +29,16 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string,
|
||||
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);
|
||||
if (
|
||||
!address ||
|
||||
typeof address !== "object" ||
|
||||
!("port" in address) ||
|
||||
(typeof address.port !== "number" && typeof address.port !== "string") ||
|
||||
!address.port
|
||||
) {
|
||||
throw new Error("voice webhook server did not expose a bound port");
|
||||
}
|
||||
requestUrl.port = String(address.port);
|
||||
return await fetch(requestUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
|
||||
@@ -56,6 +56,33 @@ const createManager = (calls: CallRecord[]) => {
|
||||
return { manager, endCall, processEvent };
|
||||
};
|
||||
|
||||
function hasPort(value: unknown): value is { port: number | string } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const maybeAddress = value as { port?: unknown };
|
||||
return typeof maybeAddress.port === "number" || typeof maybeAddress.port === "string";
|
||||
}
|
||||
|
||||
function requireBoundRequestUrl(server: VoiceCallWebhookServer, baseUrl: string) {
|
||||
const address = (
|
||||
server as unknown as { server?: { address?: () => unknown } }
|
||||
).server?.address?.();
|
||||
if (!hasPort(address) || !address.port) {
|
||||
throw new Error("voice webhook server did not expose a bound port");
|
||||
}
|
||||
const requestUrl = new URL(baseUrl);
|
||||
requestUrl.port = String(address.port);
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
function expectWebhookUrl(url: string, expectedPath: string) {
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.pathname).toBe(expectedPath);
|
||||
expect(parsed.port).not.toBe("");
|
||||
expect(parsed.port).not.toBe("0");
|
||||
}
|
||||
|
||||
async function runStaleCallReaperCase(params: {
|
||||
callAgeMs: number;
|
||||
staleCallReaperSeconds: number;
|
||||
@@ -79,13 +106,7 @@ async function runStaleCallReaperCase(params: {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const requestUrl = requireBoundRequestUrl(server, baseUrl);
|
||||
return await fetch(requestUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
@@ -154,13 +175,7 @@ describe("VoiceCallWebhookServer path matching", () => {
|
||||
|
||||
try {
|
||||
const baseUrl = await server.start();
|
||||
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);
|
||||
}
|
||||
const requestUrl = requireBoundRequestUrl(server, baseUrl);
|
||||
requestUrl.pathname = "/voice/webhook-evil";
|
||||
|
||||
const response = await fetch(requestUrl.toString(), {
|
||||
@@ -324,11 +339,10 @@ describe("VoiceCallWebhookServer start idempotency", () => {
|
||||
const secondUrl = await server.start();
|
||||
|
||||
// Dynamic port allocations should resolve to a real listening port.
|
||||
expect(firstUrl).toContain("/voice/webhook");
|
||||
expect(firstUrl).not.toContain(":0/");
|
||||
expectWebhookUrl(firstUrl, "/voice/webhook");
|
||||
// Idempotent re-start should return the same already-bound URL.
|
||||
expect(secondUrl).toBe(firstUrl);
|
||||
expect(secondUrl).toContain("/voice/webhook");
|
||||
expectWebhookUrl(secondUrl, "/voice/webhook");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
@@ -340,12 +354,12 @@ describe("VoiceCallWebhookServer start idempotency", () => {
|
||||
const server = new VoiceCallWebhookServer(config, manager, provider);
|
||||
|
||||
const firstUrl = await server.start();
|
||||
expect(firstUrl).toContain("/voice/webhook");
|
||||
expectWebhookUrl(firstUrl, "/voice/webhook");
|
||||
await server.stop();
|
||||
|
||||
// After stopping, a new start should succeed
|
||||
const secondUrl = await server.start();
|
||||
expect(secondUrl).toContain("/voice/webhook");
|
||||
expectWebhookUrl(secondUrl, "/voice/webhook");
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user