test(voice-call): cover utility and tailscale helpers

This commit is contained in:
Vincent Koc
2026-03-22 19:52:16 -07:00
parent af333f3a2c
commit dbeac0763c
2 changed files with 206 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveUserPath } from "./utils.js";
describe("resolveUserPath", () => {
it("returns trimmed empty input unchanged", () => {
expect(resolveUserPath(" ")).toBe("");
});
it("expands tildes and resolves relative paths", () => {
expect(resolveUserPath("~/voice-call/config.json")).toBe(
path.resolve(os.homedir(), "voice-call/config.json"),
);
expect(resolveUserPath("./voice-call")).toBe(path.resolve("./voice-call"));
});
});

View File

@@ -0,0 +1,189 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
import {
cleanupTailscaleExposure,
cleanupTailscaleExposureRoute,
getTailscaleDnsName,
getTailscaleSelfInfo,
setupTailscaleExposure,
setupTailscaleExposureRoute,
} from "./tailscale.js";
function createProc(params?: { code?: number; stdout?: string }) {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.kill = vi.fn();
setTimeout(() => {
if (params?.stdout) {
proc.stdout.emit("data", Buffer.from(params.stdout));
}
proc.emit("close", params?.code ?? 0);
}, 0);
return proc;
}
describe("voice-call tailscale helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reads dns and node id from tailscale status json", async () => {
spawnMock
.mockReturnValueOnce(
createProc({
stdout: JSON.stringify({
Self: {
DNSName: "bot.example.ts.net.",
ID: "node-123",
},
}),
}),
)
.mockReturnValueOnce(
createProc({
stdout: JSON.stringify({
Self: {
DNSName: "bot.example.ts.net.",
ID: "node-123",
},
}),
}),
);
await expect(getTailscaleSelfInfo()).resolves.toEqual({
dnsName: "bot.example.ts.net",
nodeId: "node-123",
});
await expect(getTailscaleDnsName()).resolves.toBe("bot.example.ts.net");
});
it("returns null for failing or invalid status responses", async () => {
spawnMock.mockReturnValueOnce(createProc({ code: 1, stdout: "bad" }));
await expect(getTailscaleSelfInfo()).resolves.toBeNull();
spawnMock.mockReturnValueOnce(createProc({ stdout: "{not-json" }));
await expect(getTailscaleSelfInfo()).resolves.toBeNull();
});
it("sets up and cleans up exposure routes with the selected mode", async () => {
spawnMock
.mockReturnValueOnce(
createProc({
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
}),
)
.mockReturnValueOnce(createProc({ code: 0 }))
.mockReturnValueOnce(createProc({ code: 0 }));
await expect(
setupTailscaleExposureRoute({
mode: "serve",
path: "/voice",
localUrl: "http://127.0.0.1:8787/webhook",
}),
).resolves.toBe("https://bot.example.ts.net/voice");
await cleanupTailscaleExposureRoute({ mode: "serve", path: "/voice" });
expect(spawnMock).toHaveBeenNthCalledWith(
1,
"tailscale",
["status", "--json"],
expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }),
);
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"tailscale",
["serve", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"],
expect.any(Object),
);
expect(spawnMock).toHaveBeenNthCalledWith(
3,
"tailscale",
["serve", "off", "/voice"],
expect.any(Object),
);
});
it("returns null when setup cannot resolve dns or route activation fails", async () => {
spawnMock
.mockReturnValueOnce(createProc({ code: 1 }))
.mockReturnValueOnce(
createProc({
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
}),
)
.mockReturnValueOnce(createProc({ code: 1 }));
await expect(
setupTailscaleExposureRoute({
mode: "funnel",
path: "/voice",
localUrl: "http://127.0.0.1:8787/webhook",
}),
).resolves.toBeNull();
await expect(
setupTailscaleExposureRoute({
mode: "funnel",
path: "/voice",
localUrl: "http://127.0.0.1:8787/webhook",
}),
).resolves.toBeNull();
});
it("maps config modes to serve or funnel and skips off", async () => {
spawnMock
.mockReturnValueOnce(
createProc({
stdout: JSON.stringify({ Self: { DNSName: "bot.example.ts.net." } }),
}),
)
.mockReturnValueOnce(createProc({ code: 0 }))
.mockReturnValueOnce(createProc({ code: 0 }));
await expect(
setupTailscaleExposure({
tailscale: { mode: "off", path: "/voice" },
serve: { port: 8787, path: "/webhook" },
} as never),
).resolves.toBeNull();
await expect(
setupTailscaleExposure({
tailscale: { mode: "funnel", path: "/voice" },
serve: { port: 8787, path: "/webhook" },
} as never),
).resolves.toBe("https://bot.example.ts.net/voice");
await cleanupTailscaleExposure({
tailscale: { mode: "serve", path: "/voice" },
serve: { port: 8787, path: "/webhook" },
} as never);
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"tailscale",
["funnel", "--bg", "--yes", "--set-path", "/voice", "http://127.0.0.1:8787/webhook"],
expect.any(Object),
);
expect(spawnMock).toHaveBeenNthCalledWith(
3,
"tailscale",
["serve", "off", "/voice"],
expect.any(Object),
);
});
});