diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts new file mode 100644 index 000000000..092ab5a72 --- /dev/null +++ b/src/cli/program.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendCommand = vi.fn(); +const statusCommand = vi.fn(); +const upCommand = vi.fn().mockResolvedValue({ server: undefined }); +const webhookCommand = vi.fn().mockResolvedValue(undefined); +const ensureTwilioEnv = vi.fn(); +const loginWeb = vi.fn(); +const monitorWebProvider = vi.fn(); +const pickProvider = vi.fn(); +const monitorTwilio = vi.fn(); +const logTwilioFrom = vi.fn(); +const logWebSelfId = vi.fn(); +const waitForever = vi.fn(); +const spawnRelayTmux = vi.fn().mockResolvedValue("warelay-relay"); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +vi.mock("../commands/send.js", () => ({ sendCommand })); +vi.mock("../commands/status.js", () => ({ statusCommand })); +vi.mock("../commands/up.js", () => ({ upCommand })); +vi.mock("../commands/webhook.js", () => ({ webhookCommand })); +vi.mock("../env.js", () => ({ ensureTwilioEnv })); +vi.mock("../runtime.js", () => ({ defaultRuntime: runtime })); +vi.mock("../provider-web.js", () => ({ + loginWeb, + monitorWebProvider, + pickProvider, +})); +vi.mock("./deps.js", () => ({ + createDefaultDeps: () => ({ waitForever }), + logTwilioFrom, + logWebSelfId, + monitorTwilio, +})); +vi.mock("./relay_tmux.js", () => ({ spawnRelayTmux })); + +const { buildProgram } = await import("./program.js"); + +describe("cli program", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("runs send with required options", async () => { + const program = buildProgram(); + await program.parseAsync(["send", "--to", "+1", "--message", "hi"], { + from: "user", + }); + expect(sendCommand).toHaveBeenCalled(); + }); + + it("rejects invalid relay provider", async () => { + const program = buildProgram(); + await expect( + program.parseAsync(["relay", "--provider", "bogus"], { from: "user" }), + ).rejects.toThrow("exit"); + expect(runtime.error).toHaveBeenCalledWith( + "--provider must be auto, web, or twilio", + ); + }); + + it("falls back to twilio when web relay fails", async () => { + pickProvider.mockResolvedValue("web"); + monitorWebProvider.mockRejectedValue(new Error("no web")); + const program = buildProgram(); + await program.parseAsync( + ["relay", "--provider", "auto", "--interval", "2", "--lookback", "1"], + { from: "user" }, + ); + expect(logWebSelfId).toHaveBeenCalled(); + expect(ensureTwilioEnv).toHaveBeenCalled(); + expect(monitorTwilio).toHaveBeenCalledWith(2, 1); + }); + + it("runs relay tmux attach command", async () => { + const program = buildProgram(); + await program.parseAsync(["relay:tmux:attach"], { from: "user" }); + expect(spawnRelayTmux).toHaveBeenCalledWith( + "pnpm warelay relay --verbose", + true, + false, + ); + }); +}); diff --git a/src/cli/prompt.test.ts b/src/cli/prompt.test.ts new file mode 100644 index 000000000..0c1a2d8bc --- /dev/null +++ b/src/cli/prompt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isYes, setVerbose, setYes } from "../globals.js"; + +vi.mock("node:readline/promises", () => { + const question = vi.fn<[], Promise>(); + const close = vi.fn(); + const createInterface = vi.fn(() => ({ question, close })); + return { default: { createInterface } }; +}); + +type ReadlineMock = { + default: { + createInterface: () => { + question: ReturnType>>; + close: ReturnType; + }; + }; +}; + +const { promptYesNo } = await import("./prompt.js"); +const readline = (await import("node:readline/promises")) as ReadlineMock; + +describe("promptYesNo", () => { + it("returns true when global --yes is set", async () => { + setYes(true); + setVerbose(false); + const result = await promptYesNo("Continue?"); + expect(result).toBe(true); + expect(isYes()).toBe(true); + }); + + it("asks the question and respects default", async () => { + setYes(false); + setVerbose(false); + const { question: questionMock } = readline.default.createInterface(); + questionMock.mockResolvedValueOnce(""); + const resultDefaultYes = await promptYesNo("Continue?", true); + expect(resultDefaultYes).toBe(true); + + questionMock.mockResolvedValueOnce("n"); + const resultNo = await promptYesNo("Continue?", true); + expect(resultNo).toBe(false); + + questionMock.mockResolvedValueOnce("y"); + const resultYes = await promptYesNo("Continue?", false); + expect(resultYes).toBe(true); + }); +}); diff --git a/src/cli/relay_tmux.test.ts b/src/cli/relay_tmux.test.ts new file mode 100644 index 000000000..6daac19d4 --- /dev/null +++ b/src/cli/relay_tmux.test.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from "node:events"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => { + const spawn = vi.fn((_cmd: string, _args: string[]) => { + const proc = new EventEmitter() as EventEmitter & { + kill: ReturnType; + }; + queueMicrotask(() => { + proc.emit("exit", 0); + }); + proc.kill = vi.fn(); + return proc; + }); + return { spawn }; +}); + +const { spawnRelayTmux } = await import("./relay_tmux.js"); +const { spawn } = await import("node:child_process"); + +describe("spawnRelayTmux", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("kills old session, starts new one, and attaches", async () => { + const session = await spawnRelayTmux("echo hi", true, true); + expect(session).toBe("warelay-relay"); + const spawnMock = spawn as unknown as vi.Mock; + expect(spawnMock.mock.calls.length).toBe(3); + const calls = spawnMock.mock.calls as Array<[string, string[], unknown]>; + expect(calls[0][0]).toBe("tmux"); // kill-session + expect(calls[1][2]?.cmd ?? "").not.toBeUndefined(); // new session + expect(calls[2][1][0]).toBe("attach-session"); + }); + + it("can skip attach", async () => { + await spawnRelayTmux("echo hi", false, true); + const spawnMock = spawn as unknown as vi.Mock; + const hasAttach = spawnMock.mock.calls.some( + (c) => + Array.isArray(c[1]) && (c[1] as string[]).includes("attach-session"), + ); + expect(hasAttach).toBe(false); + }); +}); diff --git a/src/cli/wait.test.ts b/src/cli/wait.test.ts new file mode 100644 index 000000000..480e600bf --- /dev/null +++ b/src/cli/wait.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; + +import { waitForever } from "./wait.js"; + +describe("waitForever", () => { + it("creates an unref'ed interval and returns a pending promise", () => { + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const promise = waitForever(); + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + 1_000_000, + ); + expect(promise).toBeInstanceOf(Promise); + setIntervalSpy.mockRestore(); + }); +}); diff --git a/src/commands/up.test.ts b/src/commands/up.test.ts new file mode 100644 index 000000000..c75486839 --- /dev/null +++ b/src/commands/up.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CliDeps } from "../cli/deps.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { upCommand } from "./up.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +const makeDeps = (): CliDeps => ({ + ensurePortAvailable: vi.fn().mockResolvedValue(undefined), + readEnv: vi.fn().mockReturnValue({ + whatsappFrom: "whatsapp:+1555", + whatsappSenderSid: "WW", + }), + ensureBinary: vi.fn().mockResolvedValue(undefined), + ensureFunnel: vi.fn().mockResolvedValue(undefined), + getTailnetHostname: vi.fn().mockResolvedValue("tailnet-host"), + startWebhook: vi.fn().mockResolvedValue({ server: true }), + createClient: vi.fn().mockReturnValue({ client: true }), + findWhatsappSenderSid: vi.fn().mockResolvedValue("SID123"), + updateWebhook: vi.fn().mockResolvedValue(undefined), +}); + +describe("upCommand", () => { + it("throws on invalid port", async () => { + await expect(() => + upCommand({ port: "0", path: "/cb" }, makeDeps(), runtime), + ).rejects.toThrow("Port must be between 1 and 65535"); + }); + + it("performs dry run and returns mock data", async () => { + runtime.log.mockClear(); + const result = await upCommand( + { port: "42873", path: "/cb", dryRun: true }, + makeDeps(), + runtime, + ); + expect(runtime.log).toHaveBeenCalledWith( + "[dry-run] would enable funnel on port 42873", + ); + expect(result?.publicUrl).toBe("https://dry-run/cb"); + expect(result?.senderSid).toBeUndefined(); + }); + + it("enables funnel, starts webhook, and updates Twilio", async () => { + const deps = makeDeps(); + const res = await upCommand( + { port: "42873", path: "/hook", verbose: true }, + deps, + runtime, + ); + expect(deps.ensureBinary).toHaveBeenCalledWith( + "tailscale", + undefined, + runtime, + ); + expect(deps.ensureFunnel).toHaveBeenCalled(); + expect(deps.startWebhook).toHaveBeenCalled(); + expect(deps.updateWebhook).toHaveBeenCalledWith( + expect.anything(), + "SID123", + "https://tailnet-host/hook", + "POST", + runtime, + ); + expect(res?.publicUrl).toBe("https://tailnet-host/hook"); + // waiter is returned to keep the process alive in real use. + expect(typeof res?.waiter).toBe("function"); + }); +}); diff --git a/src/commands/webhook.test.ts b/src/commands/webhook.test.ts new file mode 100644 index 000000000..8105c4f69 --- /dev/null +++ b/src/commands/webhook.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CliDeps } from "../cli/deps.js"; +import type { RuntimeEnv } from "../runtime.js"; + +import { webhookCommand } from "./webhook.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), +}; + +const deps: CliDeps = { + ensurePortAvailable: vi.fn().mockResolvedValue(undefined), + startWebhook: vi.fn().mockResolvedValue({ server: true }), +}; + +describe("webhookCommand", () => { + it("throws on invalid port", async () => { + await expect(() => + webhookCommand({ port: "70000", path: "/hook" }, deps, runtime), + ).rejects.toThrow("Port must be between 1 and 65535"); + }); + + it("logs dry run instead of starting server", async () => { + runtime.log.mockClear(); + const res = await webhookCommand( + { port: "42873", path: "/hook", reply: "dry-run" }, + deps, + runtime, + ); + expect(res).toBeUndefined(); + expect(runtime.log).toHaveBeenCalledWith( + "[dry-run] would start webhook on port 42873 path /hook", + ); + }); + + it("starts webhook when valid", async () => { + const res = await webhookCommand( + { port: "42873", path: "/hook", reply: "ok", verbose: true }, + deps, + runtime, + ); + expect(deps.startWebhook).toHaveBeenCalledWith( + 42873, + "/hook", + "ok", + true, + runtime, + ); + expect(res).toEqual({ server: true }); + }); +}); diff --git a/src/infra/binaries.test.ts b/src/infra/binaries.test.ts new file mode 100644 index 000000000..df707313f --- /dev/null +++ b/src/infra/binaries.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { runExec } from "../process/exec.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { ensureBinary } from "./binaries.js"; + +describe("ensureBinary", () => { + it("passes through when binary exists", async () => { + const exec: typeof runExec = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + }); + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + await ensureBinary("node", exec, runtime); + expect(exec).toHaveBeenCalledWith("which", ["node"]); + }); + + it("logs and exits when missing", async () => { + const exec: typeof runExec = vi + .fn() + .mockRejectedValue(new Error("missing")); + const error = vi.fn(); + const exit = vi.fn(() => { + throw new Error("exit"); + }); + await expect( + ensureBinary("ghost", exec, { log: vi.fn(), error, exit }), + ).rejects.toThrow("exit"); + expect(error).toHaveBeenCalledWith( + "Missing required binary: ghost. Please install it.", + ); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 000000000..98a0561c1 --- /dev/null +++ b/src/logger.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +import { setVerbose } from "./globals.js"; +import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js"; +import type { RuntimeEnv } from "./runtime.js"; + +describe("logger helpers", () => { + it("formats messages through runtime log/error", () => { + const log = vi.fn(); + const error = vi.fn(); + const runtime: RuntimeEnv = { log, error, exit: vi.fn() }; + + logInfo("info", runtime); + logWarn("warn", runtime); + logSuccess("ok", runtime); + logError("bad", runtime); + + expect(log).toHaveBeenCalledTimes(3); + expect(error).toHaveBeenCalledTimes(1); + }); + + it("only logs debug when verbose is enabled", () => { + const logVerbose = vi.spyOn(console, "log"); + setVerbose(false); + logDebug("quiet"); + expect(logVerbose).not.toHaveBeenCalled(); + + setVerbose(true); + logVerbose.mockClear(); + logDebug("loud"); + expect(logVerbose).toHaveBeenCalled(); + logVerbose.mockRestore(); + }); +}); diff --git a/src/providers/web/index.test.ts b/src/providers/web/index.test.ts new file mode 100644 index 000000000..d6e08e5d3 --- /dev/null +++ b/src/providers/web/index.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import * as impl from "../../provider-web.js"; +import * as entry from "./index.js"; + +describe("providers/web entrypoint", () => { + it("re-exports web provider helpers", () => { + expect(entry.createWaSocket).toBe(impl.createWaSocket); + expect(entry.loginWeb).toBe(impl.loginWeb); + expect(entry.logWebSelfId).toBe(impl.logWebSelfId); + expect(entry.monitorWebInbox).toBe(impl.monitorWebInbox); + expect(entry.monitorWebProvider).toBe(impl.monitorWebProvider); + expect(entry.pickProvider).toBe(impl.pickProvider); + expect(entry.sendMessageWeb).toBe(impl.sendMessageWeb); + expect(entry.WA_WEB_AUTH_DIR).toBe(impl.WA_WEB_AUTH_DIR); + expect(entry.waitForWaConnection).toBe(impl.waitForWaConnection); + expect(entry.webAuthExists).toBe(impl.webAuthExists); + }); +}); diff --git a/src/webhook/server.test.ts b/src/webhook/server.test.ts new file mode 100644 index 000000000..cf27da0f4 --- /dev/null +++ b/src/webhook/server.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import * as impl from "../twilio/webhook.js"; +import * as entry from "./server.js"; + +describe("webhook server wrapper", () => { + it("re-exports startWebhook", () => { + expect(entry.startWebhook).toBe(impl.startWebhook); + }); +}); diff --git a/src/webhook/update.test.ts b/src/webhook/update.test.ts new file mode 100644 index 000000000..621bac32f --- /dev/null +++ b/src/webhook/update.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import * as impl from "../twilio/update-webhook.js"; +import * as entry from "./update.js"; + +describe("webhook update wrappers", () => { + it("mirror the Twilio implementations", () => { + expect(entry.updateWebhook).toBe(impl.updateWebhook); + expect(entry.findIncomingNumberSid).toBe(impl.findIncomingNumberSid); + expect(entry.findMessagingServiceSid).toBe(impl.findMessagingServiceSid); + expect(entry.setMessagingServiceWebhook).toBe( + impl.setMessagingServiceWebhook, + ); + }); +});