import { beforeEach, describe, expect, it, vi } from "vitest"; import type { VoiceCallConfig } from "./config.js"; import type { CoreConfig } from "./core-bridge.js"; const mocks = vi.hoisted(() => ({ resolveVoiceCallConfig: vi.fn(), validateProviderConfig: vi.fn(), managerInitialize: vi.fn(), webhookStart: vi.fn(), webhookStop: vi.fn(), webhookGetMediaStreamHandler: vi.fn(), startTunnel: vi.fn(), setupTailscaleExposure: vi.fn(), cleanupTailscaleExposure: vi.fn(), })); vi.mock("./config.js", () => ({ resolveVoiceCallConfig: mocks.resolveVoiceCallConfig, validateProviderConfig: mocks.validateProviderConfig, })); vi.mock("./manager.js", () => ({ CallManager: class { initialize = mocks.managerInitialize; }, })); vi.mock("./webhook.js", () => ({ VoiceCallWebhookServer: class { start = mocks.webhookStart; stop = mocks.webhookStop; getMediaStreamHandler = mocks.webhookGetMediaStreamHandler; }, })); vi.mock("./tunnel.js", () => ({ startTunnel: mocks.startTunnel, })); vi.mock("./webhook/tailscale.js", () => ({ setupTailscaleExposure: mocks.setupTailscaleExposure, cleanupTailscaleExposure: mocks.cleanupTailscaleExposure, })); import { createVoiceCallRuntime } from "./runtime.js"; function createBaseConfig(): VoiceCallConfig { return { enabled: true, provider: "mock", fromNumber: "+15550001234", inboundPolicy: "disabled", allowFrom: [], outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, maxDurationSeconds: 300, staleCallReaperSeconds: 600, silenceTimeoutMs: 800, transcriptTimeoutMs: 180000, ringTimeoutMs: 30000, maxConcurrentCalls: 1, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" }, tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false }, webhookSecurity: { allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [], }, streaming: { enabled: false, sttProvider: "openai-realtime", sttModel: "gpt-4o-transcribe", silenceDurationMs: 800, vadThreshold: 0.5, streamPath: "/voice/stream", preStartTimeoutMs: 5000, maxPendingConnections: 32, maxPendingConnectionsPerIp: 4, maxConnections: 128, }, skipSignatureVerification: false, stt: { provider: "openai", model: "whisper-1" }, tts: { provider: "openai", openai: { model: "gpt-4o-mini-tts", voice: "coral" }, }, responseModel: "openai/gpt-4o-mini", responseTimeoutMs: 30000, }; } describe("createVoiceCallRuntime lifecycle", () => { beforeEach(() => { vi.clearAllMocks(); mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg); mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] }); mocks.managerInitialize.mockResolvedValue(undefined); mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook"); mocks.webhookStop.mockResolvedValue(undefined); mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined); mocks.startTunnel.mockResolvedValue(null); mocks.setupTailscaleExposure.mockResolvedValue(null); mocks.cleanupTailscaleExposure.mockResolvedValue(undefined); }); it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => { const tunnelStop = vi.fn().mockResolvedValue(undefined); mocks.startTunnel.mockResolvedValue({ publicUrl: "https://public.example/voice/webhook", provider: "ngrok", stop: tunnelStop, }); mocks.managerInitialize.mockRejectedValue(new Error("init failed")); await expect( createVoiceCallRuntime({ config: createBaseConfig(), coreConfig: {}, }), ).rejects.toThrow("init failed"); expect(tunnelStop).toHaveBeenCalledTimes(1); expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1); expect(mocks.webhookStop).toHaveBeenCalledTimes(1); }); it("returns an idempotent stop handler", async () => { const tunnelStop = vi.fn().mockResolvedValue(undefined); mocks.startTunnel.mockResolvedValue({ publicUrl: "https://public.example/voice/webhook", provider: "ngrok", stop: tunnelStop, }); const runtime = await createVoiceCallRuntime({ config: createBaseConfig(), coreConfig: {} as CoreConfig, }); await runtime.stop(); await runtime.stop(); expect(tunnelStop).toHaveBeenCalledTimes(1); expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1); expect(mocks.webhookStop).toHaveBeenCalledTimes(1); }); });