






















@@ -1,6 +1,7 @@
11import { describe, expect, it, vi } from "vitest";
22import type { WebhookContext } from "../types.js";
33import { TwilioProvider } from "./twilio.js";
4+import { TwilioApiError } from "./twilio/api.js";
4556const STREAM_URL = "wss://example.ngrok.app/voice/stream";
67@@ -57,6 +58,16 @@ function createApiRequestMock() {
5758return vi.fn<TwilioApiRequest>(async () => ({}));
5859}
596061+function createTwilioCallStateRaceError(): TwilioApiError {
62+return new TwilioApiError(
63+400,
64+JSON.stringify({
65+code: 21220,
66+message: "Call is not in-progress. Cannot redirect.",
67+}),
68+);
69+}
70+6071function configureTelephonyTwiMlFallback(params: { providerCallId: string; streamSid?: string }) {
6172const provider = createProvider();
6273const apiRequest = createApiRequestMock();
@@ -280,6 +291,38 @@ describe("TwilioProvider", () => {
280291expect(params.Twiml).toContain("<Say");
281292});
282293294+it("retries TwiML fallback when Twilio briefly rejects a live-call update as not in progress", async () => {
295+vi.useFakeTimers();
296+const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
297+try {
298+const { provider, apiRequest } = configureTelephonyTwiMlFallback({
299+providerCallId: "CA-race-play",
300+});
301+apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
302+303+const playback = provider.playTts({
304+callId: "call-race-play",
305+providerCallId: "CA-race-play",
306+text: "Hello after race",
307+});
308+await Promise.resolve();
309+expect(apiRequest).toHaveBeenCalledTimes(1);
310+311+await vi.advanceTimersByTimeAsync(250);
312+await expect(playback).resolves.toBeUndefined();
313+314+expect(apiRequest).toHaveBeenCalledTimes(2);
315+expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-play.json");
316+expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-play.json");
317+expect(warn).toHaveBeenCalledWith(
318+"[voice-call] Twilio playTts update hit call state race (21220); retrying in 250ms",
319+);
320+} finally {
321+warn.mockRestore();
322+vi.useRealTimers();
323+}
324+});
325+283326it("sends DTMF by updating the call and redirecting back to the webhook", async () => {
284327const { provider, apiRequest } = configureTelephonyTwiMlFallback({
285328providerCallId: "CA-dtmf",
@@ -303,6 +346,37 @@ describe("TwilioProvider", () => {
303346expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio");
304347});
305348349+it("retries startListening when Twilio briefly rejects a live-call update as not in progress", async () => {
350+vi.useFakeTimers();
351+const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
352+try {
353+const { provider, apiRequest } = configureTelephonyTwiMlFallback({
354+providerCallId: "CA-race-listen",
355+});
356+apiRequest.mockRejectedValueOnce(createTwilioCallStateRaceError()).mockResolvedValueOnce({});
357+358+const listening = provider.startListening({
359+callId: "call-race-listen",
360+providerCallId: "CA-race-listen",
361+});
362+await Promise.resolve();
363+expect(apiRequest).toHaveBeenCalledTimes(1);
364+365+await vi.advanceTimersByTimeAsync(250);
366+await expect(listening).resolves.toBeUndefined();
367+368+expect(apiRequest).toHaveBeenCalledTimes(2);
369+expect(apiRequest.mock.calls[0]?.[0]).toBe("/Calls/CA-race-listen.json");
370+expect(apiRequest.mock.calls[1]?.[0]).toBe("/Calls/CA-race-listen.json");
371+expect(warn).toHaveBeenCalledWith(
372+"[voice-call] Twilio startListening update hit call state race (21220); retrying in 250ms",
373+);
374+} finally {
375+warn.mockRestore();
376+vi.useRealTimers();
377+}
378+});
379+306380it("ignores stale stream unregister requests that do not match current stream SID", () => {
307381const provider = createProvider();
308382provider.registerCallStream("CA-reconnect", "MZ-new");
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。