diff --git a/mods/apiserver/src/voice/VoiceDispatcher.ts b/mods/apiserver/src/voice/VoiceDispatcher.ts index 90dea92..b19bcde 100644 --- a/mods/apiserver/src/voice/VoiceDispatcher.ts +++ b/mods/apiserver/src/voice/VoiceDispatcher.ts @@ -143,7 +143,20 @@ class VoiceDispatcher { } async handleDial(event: Dial, channel: Channel) { - createHandleDialEventsWithNats(this.nc)(channel.id, event); + // TrackCall registers the stream under the ref CreateCall handed back to + // the client, so the event has to carry that ref and not the channel id. + const callRef = ( + await createGetChannelVarWithoutThrow(channel)(ChannelVar.CALL_REF) + )?.value; + + if (!callRef) { + logger.silly("no callRef found, ignoring handleDial event", { + channelId: channel?.id + }); + return; + } + + createHandleDialEventsWithNats(this.nc)(callRef, event); } async isHandledElsewhere(channel: Channel) { diff --git a/mods/apiserver/test/calls/trackApiOriginatedCall.test.ts b/mods/apiserver/test/calls/trackApiOriginatedCall.test.ts new file mode 100644 index 0000000..9f6af8f --- /dev/null +++ b/mods/apiserver/test/calls/trackApiOriginatedCall.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com) + * http://github.com/fonoster/fonoster + * + * This file is part of Fonoster + * + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DialStatus } from "@fonoster/common"; +import * as chai from "chai"; +import { expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; +import type { Channel, Dial } from "ari-client"; +import { NatsConnection } from "nats"; +import { createSandbox } from "sinon"; +import sinonChai from "sinon-chai"; +import { getAriStub } from "../voice/helper"; + +chai.use(chaiAsPromised); +chai.use(sinonChai); +const sandbox = createSandbox(); + +const CALL_REF = "c21ff1ab-5b46-4d99-8879-fad1e1d02d0a"; +const CHANNEL_ID = "df561aee-dcc1-44f4-a3c0-6caa09f2be0a"; + +// A NATS double that delivers whatever is published straight back to the +// subscriber, so the publisher and the subscriber run as one path. +const createLoopbackNats = () => { + const subscription = { callback: (_err: unknown, _msg: unknown) => {} }; + const publish = sandbox + .stub() + .callsFake((_subject: string, payload: string) => { + subscription.callback(null, { json: () => JSON.parse(payload) }); + }); + + return { + publish, + subscribe: sandbox.stub().returns(subscription) + } as unknown as NatsConnection & { publish: sinon.SinonStub }; +}; + +const createChannel = (callRef?: string) => + ({ + id: CHANNEL_ID, + getChannelVar: sandbox + .stub() + .callsFake(async ({ variable }: { variable: string }) => { + if (variable === "CALL_REF" && callRef) { + return { value: callRef }; + } + throw new Error(`channel variable ${variable} not set`); + }) + }) as unknown as Channel; + +describe("@calls/trackCall for API-originated calls", function () { + afterEach(function () { + return sandbox.restore(); + }); + + it("delivers dial events to the stream registered by TrackCall", async function () { + // Arrange + const { createTrackCall } = await import("../../src/calls/createTrackCall"); + const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher"); + + const nc = createLoopbackNats(); + const call = { + write: sandbox.stub(), + end: sandbox.stub(), + request: { ref: CALL_REF } + }; + + const trackCall = createTrackCall(nc); + trackCall(call, () => {}); + + const voiceDispatcher = new VoiceDispatcher( + getAriStub(sandbox), + nc, + sandbox.stub() + ); + + // Act + await voiceDispatcher.handleDial( + { dialstatus: "PROGRESS" } as unknown as Dial, + createChannel(CALL_REF) + ); + + // Assert + expect(call.write).to.have.been.calledOnceWith({ + ref: CALL_REF, + status: DialStatus.PROGRESS + }); + }); + + it("stops tracking the call after a final dial status", async function () { + // Arrange + const { createTrackCall } = await import("../../src/calls/createTrackCall"); + const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher"); + + const nc = createLoopbackNats(); + const call = { + write: sandbox.stub(), + end: sandbox.stub(), + request: { ref: CALL_REF } + }; + + createTrackCall(nc)(call, () => {}); + + const voiceDispatcher = new VoiceDispatcher( + getAriStub(sandbox), + nc, + sandbox.stub() + ); + + // Act + await voiceDispatcher.handleDial( + { dialstatus: "BUSY" } as unknown as Dial, + createChannel(CALL_REF) + ); + + // Assert + expect(call.write).to.have.been.calledOnceWith({ + ref: CALL_REF, + status: DialStatus.BUSY + }); + + await voiceDispatcher.handleDial( + { dialstatus: "PROGRESS" } as unknown as Dial, + createChannel(CALL_REF) + ); + + expect(call.write).to.have.been.calledOnce; + }); + + it("publishes nothing for a channel without a call ref", async function () { + // Arrange + const { VoiceDispatcher } = await import("../../src/voice/VoiceDispatcher"); + + const nc = createLoopbackNats(); + const voiceDispatcher = new VoiceDispatcher( + getAriStub(sandbox), + nc, + sandbox.stub() + ); + + // Act + await voiceDispatcher.handleDial( + { dialstatus: "PROGRESS" } as unknown as Dial, + createChannel() + ); + + // Assert + expect(nc.publish).to.not.have.been.called; + }); +});