import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const callGateway = vi.fn(); const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; vi.mock("../gateway/call.js", () => ({ callGateway, })); vi.mock("./progress.js", () => ({ withProgress, })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli; beforeAll(async () => { ({ registerDevicesCli } = await import("./devices-cli.js")); }); async function runDevicesApprove(argv: string[]) { await runDevicesCommand(["approve", ...argv]); } async function runDevicesCommand(argv: string[]) { const program = new Command(); registerDevicesCli(program); await program.parseAsync(["devices", ...argv], { from: "user" }); } describe("devices cli approve", () => { it("approves an explicit request id without listing", async () => { callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } }); await runDevicesApprove(["req-123"]); expect(callGateway).toHaveBeenCalledTimes(1); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "device.pair.approve", params: { requestId: "req-123" }, }), ); }); it.each([ { name: "id is omitted", args: [] as string[], pending: [ { requestId: "req-1", ts: 1000 }, { requestId: "req-2", ts: 2000 }, ], expectedRequestId: "req-2", }, { name: "--latest is passed", args: ["req-old", "--latest"] as string[], pending: [ { requestId: "req-2", ts: 2000 }, { requestId: "req-3", ts: 3000 }, ], expectedRequestId: "req-3", }, ])("uses latest pending request when $name", async ({ args, pending, expectedRequestId }) => { callGateway .mockResolvedValueOnce({ pending, }) .mockResolvedValueOnce({ device: { deviceId: "device-2" } }); await runDevicesApprove(args); expect(callGateway).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "device.pair.list" }), ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "device.pair.approve", params: { requestId: expectedRequestId }, }), ); }); it("prints an error and exits when no pending requests are available", async () => { callGateway.mockResolvedValueOnce({ pending: [] }); await runDevicesApprove([]); expect(callGateway).toHaveBeenCalledTimes(1); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "device.pair.list" }), ); expect(runtime.error).toHaveBeenCalledWith("No pending device pairing requests to approve"); expect(runtime.exit).toHaveBeenCalledWith(1); expect(callGateway).not.toHaveBeenCalledWith( expect.objectContaining({ method: "device.pair.approve" }), ); }); }); describe("devices cli remove", () => { it("removes a paired device by id", async () => { callGateway.mockResolvedValueOnce({ deviceId: "device-1" }); await runDevicesCommand(["remove", "device-1"]); expect(callGateway).toHaveBeenCalledTimes(1); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-1" }, }), ); }); }); describe("devices cli clear", () => { it("requires --yes before clearing", async () => { await runDevicesCommand(["clear"]); expect(callGateway).not.toHaveBeenCalled(); expect(runtime.error).toHaveBeenCalledWith("Refusing to clear pairing table without --yes"); expect(runtime.exit).toHaveBeenCalledWith(1); }); it("clears paired devices and optionally pending requests", async () => { callGateway .mockResolvedValueOnce({ paired: [{ deviceId: "device-1" }, { deviceId: "device-2" }], pending: [{ requestId: "req-1" }], }) .mockResolvedValueOnce({ deviceId: "device-1" }) .mockResolvedValueOnce({ deviceId: "device-2" }) .mockResolvedValueOnce({ requestId: "req-1", deviceId: "device-1" }); await runDevicesCommand(["clear", "--yes", "--pending"]); expect(callGateway).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "device.pair.list" }), ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-1" } }), ); expect(callGateway).toHaveBeenNthCalledWith( 3, expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-2" } }), ); expect(callGateway).toHaveBeenNthCalledWith( 4, expect.objectContaining({ method: "device.pair.reject", params: { requestId: "req-1" } }), ); }); }); describe("devices cli tokens", () => { it.each([ { label: "rotates a token for a device role", argv: [ "rotate", "--device", "device-1", "--role", "main", "--scope", "messages:send", "--scope", "messages:read", ], expectedCall: { method: "device.token.rotate", params: { deviceId: "device-1", role: "main", scopes: ["messages:send", "messages:read"], }, }, }, { label: "revokes a token for a device role", argv: ["revoke", "--device", "device-1", "--role", "main"], expectedCall: { method: "device.token.revoke", params: { deviceId: "device-1", role: "main", }, }, }, ])("$label", async ({ argv, expectedCall }) => { callGateway.mockResolvedValueOnce({ ok: true }); await runDevicesCommand(argv); expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall)); }); it("rejects blank device or role values", async () => { await runDevicesCommand(["rotate", "--device", " ", "--role", "main"]); expect(callGateway).not.toHaveBeenCalled(); expect(runtime.error).toHaveBeenCalledWith("--device and --role required"); expect(runtime.exit).toHaveBeenCalledWith(1); }); }); afterEach(() => { callGateway.mockReset(); withProgress.mockClear(); runtime.log.mockReset(); runtime.error.mockReset(); runtime.exit.mockReset(); });