diff --git a/tests/commands/apps.test.ts b/tests/commands/apps.test.ts index 9ea49a9..12c5537 100644 --- a/tests/commands/apps.test.ts +++ b/tests/commands/apps.test.ts @@ -1,849 +1,164 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Command } from "commander"; - -function makeListAllApps( - listApps: (opts: { cursor?: string; limit?: number }) => Promise<{ apps: unknown[]; cursor?: string }>, -) { - return async (opts?: { limit?: number }) => { - const apps: unknown[] = []; - const seenCursors = new Set(); - let cursor: string | undefined; - let pages = 0; - do { - const page = await listApps({ - ...(cursor && { cursor }), - ...(opts?.limit !== undefined && { limit: opts.limit }), - }); - pages += 1; - apps.push(...page.apps); - cursor = page.cursor; - if (cursor && seenCursors.has(cursor)) break; - if (cursor) seenCursors.add(cursor); - } while (cursor); - return { apps, pages }; - }; -} +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +type AppRecord = { + id: string; + name: string; + apiKey: string; + webhookApiKey: string; + chainNetworks: unknown[]; + createdAt: string; +}; + +const APP_ONE: AppRecord = { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", +}; + +const APP_TWO: AppRecord = { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", +}; describe("apps command", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.resetModules(); - }); - - it("apps list is single-page in JSON mode by default", async () => { - const listApps = vi - .fn() - .mockResolvedValue({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }); - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - + it("lists one page by default in JSON mode", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const listApps = vi.fn().mockResolvedValue({ apps: [APP_ONE], cursor: "cursor_2" }); vi.doMock("../../src/lib/resolve.js", () => ({ adminClientFromFlags: () => ({ listApps }), })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync(["node", "test", "apps", "list"], { from: "node" }); + await runRegisteredCommand(registerApps, ["apps", "list"]); - expect(listApps).toHaveBeenCalledTimes(1); expect(listApps).toHaveBeenCalledWith({ cursor: undefined, limit: undefined }); - expect(printJSON).toHaveBeenCalledWith({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps list with --cursor remains single-page in JSON mode", async () => { - const listApps = vi.fn().mockResolvedValue({ - apps: [ - { - id: "app_1", - name: "Cursor App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }); - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps }), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - - const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync( - ["node", "test", "apps", "list", "--cursor", "abc"], - { from: "node" }, - ); - - expect(listApps).toHaveBeenCalledTimes(1); - expect(listApps).toHaveBeenCalledWith({ cursor: "abc", limit: undefined }); - expect(printJSON).toHaveBeenCalledWith({ - apps: [ - { - id: "app_1", - name: "Cursor App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps list with --all paginates in JSON mode", async () => { - const listApps = vi - .fn() - .mockResolvedValueOnce({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }) - .mockResolvedValueOnce({ - apps: [ - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - }); - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps, listAllApps: makeListAllApps(listApps) }), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - - const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync(["node", "test", "apps", "list", "--all"], { - from: "node", - }); - - expect(listApps).toHaveBeenCalledTimes(2); - expect(listApps).toHaveBeenNthCalledWith(1, {}); - expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2" }); - expect(printJSON).toHaveBeenCalledWith({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - pageInfo: { mode: "all", pages: 2, scannedApps: 2 }, - }); + expect(printJSON).toHaveBeenCalledWith({ apps: [APP_ONE], cursor: "cursor_2" }); expect(exitWithError).not.toHaveBeenCalled(); }); - it("apps list --search filters by name or id in JSON mode", async () => { - const listApps = vi - .fn() - .mockResolvedValueOnce({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }) - .mockResolvedValueOnce({ - apps: [ - { - id: "target_2", - name: "Second Target", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - }); - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - + it("returns all pages in --all mode", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const listAllApps = vi.fn().mockResolvedValue({ apps: [APP_ONE, APP_TWO], pages: 2 }); vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps, listAllApps: makeListAllApps(listApps) }), + adminClientFromFlags: () => ({ listAllApps }), })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync(["node", "test", "apps", "list", "--search", "target"], { - from: "node", - }); + await runRegisteredCommand(registerApps, ["apps", "list", "--all"]); - expect(listApps).toHaveBeenCalledTimes(2); + expect(listAllApps).toHaveBeenCalledWith({ limit: undefined }); expect(printJSON).toHaveBeenCalledWith({ - apps: [ - { - id: "target_2", - name: "Second Target", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], + apps: [APP_ONE, APP_TWO], pageInfo: { - mode: "search", + mode: "all", pages: 2, scannedApps: 2, - search: "target", }, }); expect(exitWithError).not.toHaveBeenCalled(); }); - it("apps list --id returns exact app match in JSON mode", async () => { - const listApps = vi.fn().mockResolvedValue({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - }); - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - + it.each([ + { + title: "search filtering", + args: ["apps", "list", "--search", "second"], + expected: { + apps: [APP_TWO], + pageInfo: { mode: "search", pages: 1, scannedApps: 2, search: "second" }, + }, + }, + { + title: "id filtering", + args: ["apps", "list", "--id", "app_2"], + expected: { + apps: [APP_TWO], + pageInfo: { mode: "search", pages: 1, scannedApps: 2, id: "app_2" }, + }, + }, + ])("applies %s in list mode", async ({ args, expected }) => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const listAllApps = vi.fn().mockResolvedValue({ apps: [APP_ONE, APP_TWO], pages: 1 }); vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps, listAllApps: makeListAllApps(listApps) }), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, + adminClientFromFlags: () => ({ listAllApps }), })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); + await runRegisteredCommand(registerApps, args); - await program.parseAsync(["node", "test", "apps", "list", "--id", "app_2"], { - from: "node", - }); - - expect(printJSON).toHaveBeenCalledWith({ - apps: [ - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - pageInfo: { - mode: "search", - pages: 1, - scannedApps: 2, - id: "app_2", - }, - }); + expect(listAllApps).toHaveBeenCalledTimes(1); + expect(printJSON).toHaveBeenCalledWith(expected); expect(exitWithError).not.toHaveBeenCalled(); }); - it("apps list prompts to load next page in TTY mode", async () => { - const listApps = vi - .fn() - .mockResolvedValueOnce({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }) - .mockResolvedValueOnce({ - apps: [ - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - }); - const printTable = vi.fn(); - const emptyState = vi.fn(); - const exitWithError = vi.fn(); - const select = vi.fn().mockResolvedValue("next"); - const isCancel = vi.fn().mockReturnValue(false); - const cancel = vi.fn(); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - + it("surfaces invalid list flag combinations through exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: true }); vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps }), - })); - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: select, - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable, - printKeyValueBox: vi.fn(), - emptyState, - maskIf: (s: string) => s, + adminClientFromFlags: () => ({ listApps: vi.fn() }), })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); + await runRegisteredCommand(registerApps, ["apps", "list", "--all", "--cursor", "abc"]); - await program.parseAsync(["node", "test", "apps", "list"], { - from: "node", - }); - - expect(listApps).toHaveBeenCalledTimes(2); - expect(listApps).toHaveBeenNthCalledWith(1, { cursor: undefined, limit: undefined }); - expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2", limit: undefined }); - expect(select).toHaveBeenCalledTimes(1); - expect(printTable).toHaveBeenCalledTimes(2); - expect(emptyState).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + expect(exitWithError.mock.calls[0][0]).toMatchObject({ code: "INVALID_ARGS" }); }); - it("apps create supports dry-run JSON payload", async () => { - const printJSON = vi.fn(); - const adminClientFromFlags = vi.fn(); - const exitWithError = vi.fn(); - + it("supports create --dry-run preview output", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const createApp = vi.fn(); vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags, + adminClientFromFlags: () => ({ createApp }), })); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: vi.fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/validators.js", async () => { - const actual = await vi.importActual("../../src/lib/validators.js"); - return actual; - }); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync( - [ - "node", - "test", - "apps", - "create", - "--name", - "Demo App", - "--networks", - "eth-mainnet,polygon-mainnet", - "--dry-run", - ], - { from: "node" }, - ); + await runRegisteredCommand(registerApps, [ + "apps", + "create", + "--name", + "My App", + "--networks", + "eth-mainnet,base-mainnet", + "--dry-run", + ]); expect(printJSON).toHaveBeenCalledWith({ dryRun: true, action: "create", payload: { - name: "Demo App", - networks: ["eth-mainnet", "polygon-mainnet"], + name: "My App", + networks: ["eth-mainnet", "base-mainnet"], }, }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); + expect(createApp).not.toHaveBeenCalled(); expect(exitWithError).not.toHaveBeenCalled(); }); - it("apps update requires at least one field", async () => { - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: vi.fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - - const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - await program.parseAsync(["node", "test", "apps", "update", "app_1"], { - from: "node", - }); - - expect(exitWithError).toHaveBeenCalledTimes(1); - }); - - it("apps list skips interactive pagination when --no-interactive is set", async () => { - const listApps = vi - .fn() - .mockResolvedValue({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }); - const promptSelect = vi.fn(); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - + it("deletes an app and returns JSON status", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const deleteApp = vi.fn().mockResolvedValue(undefined); vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags: () => ({ listApps }), - })); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect, + adminClientFromFlags: () => ({ deleteApp }), })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError: vi.fn() })); const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - program.option("--no-interactive"); - registerApps(program); + await runRegisteredCommand(registerApps, ["apps", "delete", "app_123"]); - await program.parseAsync(["node", "test", "--no-interactive", "apps", "list"], { - from: "node", - }); - - expect(listApps).toHaveBeenCalledTimes(1); - expect(promptSelect).not.toHaveBeenCalled(); - }); -}); - -describe("apps dry-run JSON contracts", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.resetModules(); - }); - - async function setupAppsProgram() { - const printJSON = vi.fn(); - const exitWithError = vi.fn(); - const adminClientFromFlags = vi.fn(); - - vi.doMock("../../src/lib/resolve.js", () => ({ - adminClientFromFlags, - })); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => true, - printJSON, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: vi.fn(), - printTable: vi.fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - - const { registerApps } = await import("../../src/commands/apps.js"); - const program = new Command(); - registerApps(program); - - return { program, printJSON, exitWithError, adminClientFromFlags }; - } - - it("apps delete --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync(["node", "test", "apps", "delete", "app_1", "--dry-run"], { - from: "node", - }); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "delete", - payload: { id: "app_1" }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps update --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync( - ["node", "test", "apps", "update", "app_1", "--name", "Updated", "--dry-run"], - { from: "node" }, - ); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "update", - payload: { id: "app_1", name: "Updated" }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps networks --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync( - [ - "node", - "test", - "apps", - "networks", - "app_1", - "--networks", - "eth-mainnet,base-mainnet", - "--dry-run", - ], - { from: "node" }, - ); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "networks", - payload: { id: "app_1", networks: ["eth-mainnet", "base-mainnet"] }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps address-allowlist --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync( - [ - "node", - "test", - "apps", - "address-allowlist", - "app_1", - "--addresses", - "0xabc,0xdef", - "--dry-run", - ], - { from: "node" }, - ); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "address-allowlist", - payload: { - id: "app_1", - addresses: [{ value: "0xabc" }, { value: "0xdef" }], - }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps origin-allowlist --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync( - [ - "node", - "test", - "apps", - "origin-allowlist", - "app_1", - "--origins", - "https://a.com,https://b.com", - "--dry-run", - ], - { from: "node" }, - ); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "origin-allowlist", - payload: { - id: "app_1", - origins: [{ value: "https://a.com" }, { value: "https://b.com" }], - }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); - expect(exitWithError).not.toHaveBeenCalled(); - }); - - it("apps ip-allowlist --dry-run emits expected JSON payload", async () => { - const { program, printJSON, adminClientFromFlags, exitWithError } = - await setupAppsProgram(); - await program.parseAsync( - ["node", "test", "apps", "ip-allowlist", "app_1", "--ips", "1.2.3.4,5.6.7.8", "--dry-run"], - { from: "node" }, - ); - expect(printJSON).toHaveBeenCalledWith({ - dryRun: true, - action: "ip-allowlist", - payload: { - id: "app_1", - ips: [{ value: "1.2.3.4" }, { value: "5.6.7.8" }], - }, - }); - expect(adminClientFromFlags).not.toHaveBeenCalled(); + expect(deleteApp).toHaveBeenCalledWith("app_123"); + expect(printJSON).toHaveBeenCalledWith({ id: "app_123", status: "deleted" }); expect(exitWithError).not.toHaveBeenCalled(); }); }); diff --git a/tests/commands/bundler.test.ts b/tests/commands/bundler.test.ts new file mode 100644 index 0000000..ec5ab0b --- /dev/null +++ b/tests/commands/bundler.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +describe("bundler command", () => { + it("calls eth_sendUserOperation with parsed arguments", async () => { + const { printSyntaxJSON, exitWithError } = installBaseCommandMocks({ jsonMode: false }); + const call = vi.fn().mockResolvedValue({ userOpHash: "0xabc" }); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerBundler } = await import("../../src/commands/bundler.js"); + await runRegisteredCommand(registerBundler, [ + "bundler", + "send-user-operation", + "--user-op", + '{"sender":"0x1"}', + "--entry-point", + "0xentry", + ]); + + expect(call).toHaveBeenCalledWith("eth_sendUserOperation", [{ sender: "0x1" }, "0xentry"]); + expect(printSyntaxJSON).toHaveBeenCalledWith({ userOpHash: "0xabc" }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("routes invalid user-op JSON through exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); + const call = vi.fn(); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerBundler } = await import("../../src/commands/bundler.js"); + await runRegisteredCommand(registerBundler, [ + "bundler", + "send-user-operation", + "--user-op", + "{bad-json", + "--entry-point", + "0xentry", + ]); + + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index e2a8327..a15d592 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -1,594 +1,175 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +const KEY_MAP = { + "api-key": "api_key", + api_key: "api_key", + "access-key": "access_key", + access_key: "access_key", + "webhook-api-key": "webhook_api_key", + webhook_api_key: "webhook_api_key", + app: "app", + network: "network", + verbose: "verbose", + "wallet-key-file": "wallet_key_file", + wallet_key_file: "wallet_key_file", + "wallet-address": "wallet_address", + wallet_address: "wallet_address", + x402: "x402", +} as const; + +function mockConfigModule(deps: { + load?: ReturnType; + save?: ReturnType; + get?: ReturnType; + toMap?: ReturnType; +}) { + const load = deps.load ?? vi.fn().mockReturnValue({}); + const save = deps.save ?? vi.fn(); + const get = deps.get ?? vi.fn(); + const toMap = deps.toMap ?? vi.fn((cfg) => cfg); + + vi.doMock("../../src/lib/config.js", () => ({ + load, + save, + get, + toMap, + KEY_MAP, + })); + + return { load, save, get, toMap }; +} -function makeListAllApps( - listApps: (opts: { cursor?: string; limit?: number }) => Promise<{ apps: unknown[]; cursor?: string }>, -) { - return async (opts?: { limit?: number }) => { - const apps: unknown[] = []; - const seenCursors = new Set(); - let cursor: string | undefined; - let pages = 0; - do { - const page = await listApps({ - ...(cursor && { cursor }), - ...(opts?.limit !== undefined && { limit: opts.limit }), - }); - pages += 1; - apps.push(...page.apps); - cursor = page.cursor; - if (cursor && seenCursors.has(cursor)) break; - if (cursor) seenCursors.add(cursor); - } while (cursor); - return { apps, pages }; - }; +function mockConfigDependencies(opts?: { interactive?: boolean; confirmResult?: boolean | null }) { + const adminCtor = vi.fn(); + const promptConfirm = vi.fn().mockResolvedValue(opts?.confirmResult ?? false); + + vi.doMock("../../src/lib/admin-client.js", () => ({ AdminClient: adminCtor })); + vi.doMock("../../src/lib/interaction.js", () => ({ + isInteractiveAllowed: () => opts?.interactive ?? false, + })); + vi.doMock("../../src/lib/terminal-ui.js", () => ({ + promptAutocomplete: vi.fn(), + promptConfirm, + promptMultiselect: vi.fn(), + promptSelect: vi.fn(), + promptText: vi.fn(), + })); + + return { adminCtor, promptConfirm }; } describe("config command", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.resetModules(); - }); - - it("config set verbose persists normalized boolean", async () => { - const load = vi.fn().mockReturnValue({ api_key: "k" }); + it("normalizes verbose boolean on set", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); const save = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/config.js", () => ({ - load, + mockConfigModule({ + load: vi.fn().mockReturnValue({ api_key: "k" }), save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); + await runRegisteredCommand(registerConfig, ["config", "set", "verbose", "TRUE"]); - await program.parseAsync(["node", "test", "config", "set", "verbose", "TRUE"], { - from: "node", - }); - - expect(load).toHaveBeenCalled(); expect(save).toHaveBeenCalledWith({ api_key: "k", verbose: true }); expect(exitWithError).not.toHaveBeenCalled(); }); - it("config set access-key app selector includes paginated apps", async () => { - const load = vi - .fn() - .mockReturnValueOnce({}) - .mockReturnValueOnce({ access_key: "ak_test" }); - const save = vi.fn(); - const printHuman = vi.fn(); - const select = vi.fn().mockResolvedValue("app_2"); - const isCancel = vi.fn().mockReturnValue(false); - const cancel = vi.fn(); - const listApps = vi - .fn() - .mockResolvedValueOnce({ - apps: [ - { - id: "app_1", - name: "First App", - apiKey: "api_1", - webhookApiKey: "wh_1", - chainNetworks: [], - createdAt: "2025-01-01T00:00:00.000Z", - }, - ], - cursor: "cursor_2", - }) - .mockResolvedValueOnce({ - apps: [ - { - id: "app_2", - name: "Second App", - apiKey: "api_2", - webhookApiKey: "wh_2", - chainNetworks: [], - createdAt: "2025-01-02T00:00:00.000Z", - }, - ], - }); - class MockAdminClient { - constructor(_accessKey: string) {} - listApps = listApps; - listAllApps = makeListAllApps(listApps); - listChains = vi.fn(); - createApp = vi.fn(); - } - const exitWithError = vi.fn(); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: MockAdminClient, - })); - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: select, - promptAutocomplete: vi.fn(), - promptText: vi.fn(), - promptMultiselect: vi.fn(), - promptConfirm: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + it("routes invalid verbose values to exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); + mockConfigModule({ load: vi.fn().mockReturnValue({}), save: vi.fn() }); + mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "set", "access-key", "ak_test"], { - from: "node", - }); + await runRegisteredCommand(registerConfig, ["config", "set", "verbose", "maybe"]); - expect(listApps).toHaveBeenCalledTimes(2); - expect(listApps).toHaveBeenNthCalledWith(1, {}); - expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2" }); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Select default app", - options: expect.arrayContaining([ - { label: "First App (app_1)", value: "app_1" }, - { label: "Second App (app_2)", value: "app_2" }, - ]), - }), - ); - expect(save).toHaveBeenNthCalledWith(1, { access_key: "ak_test" }); - expect(save).toHaveBeenNthCalledWith(2, { - access_key: "ak_test", - api_key: "api_2", - app: { id: "app_2", name: "Second App", apiKey: "api_2", webhookApiKey: "wh_2" }, - }); - expect(exitWithError).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + expect(exitWithError.mock.calls[0][0]).toMatchObject({ code: "INVALID_ARGS" }); }); - it("config reset --yes clears all values without prompting", async () => { - const load = vi.fn().mockReturnValue({ - api_key: "k", - access_key: "ak", - network: "eth-mainnet", - verbose: true, - }); + it("sets access-key without app-selection flow in non-interactive mode", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); const save = vi.fn(); - const printHuman = vi.fn(); - const confirm = vi.fn(); - const exitWithError = vi.fn(); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptConfirm: confirm, - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + const load = vi.fn().mockReturnValue({}); + mockConfigModule({ load, save }); + const { adminCtor } = mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); + await runRegisteredCommand(registerConfig, ["config", "set", "access-key", "ak_test"]); - await program.parseAsync(["node", "test", "config", "reset", "--yes"], { - from: "node", - }); - - expect(load).not.toHaveBeenCalled(); - expect(save).toHaveBeenCalledWith({}); - expect(confirm).not.toHaveBeenCalled(); - expect(printHuman).toHaveBeenCalledWith("\u2713 Reset all config values\n", { - status: "reset", - scope: "all", - }); + expect(save).toHaveBeenCalledWith({ access_key: "ak_test" }); + expect(adminCtor).not.toHaveBeenCalled(); expect(exitWithError).not.toHaveBeenCalled(); }); - it("config reset removes only the selected key", async () => { - const load = vi.fn().mockReturnValue({ - api_key: "k", - access_key: "ak", - network: "polygon-mainnet", - verbose: true, + it("lists config map in JSON mode", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const map = { "api-key": "masked", network: "eth-mainnet" }; + mockConfigModule({ + load: vi.fn().mockReturnValue({ api_key: "key", network: "eth-mainnet" }), + toMap: vi.fn().mockReturnValue(map), }); - const save = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "reset", "network"], { - from: "node", - }); + await runRegisteredCommand(registerConfig, ["config", "list"]); - expect(load).toHaveBeenCalled(); - expect(save).toHaveBeenCalledWith({ - api_key: "k", - access_key: "ak", - verbose: true, - }); + expect(printJSON).toHaveBeenCalledWith(map); expect(exitWithError).not.toHaveBeenCalled(); }); - it("config reset rejects unknown keys", async () => { - const load = vi.fn(); + it("resets one key without wiping other config values", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); const save = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/config.js", () => ({ - load, + mockConfigModule({ + load: vi.fn().mockReturnValue({ api_key: "k", network: "eth-mainnet" }), save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "reset", "unknown"], { - from: "node", - }); + await runRegisteredCommand(registerConfig, ["config", "reset", "network"]); - expect(load).not.toHaveBeenCalled(); - expect(save).not.toHaveBeenCalled(); - expect(exitWithError).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith({ api_key: "k" }); + expect(exitWithError).not.toHaveBeenCalled(); }); - it("config reset in non-tty mode does not prompt", async () => { + it("resets all keys when --yes is provided", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); const save = vi.fn(); - const confirm = vi.fn(); - const exitWithError = vi.fn(); - Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); - - vi.doMock("../../src/lib/config.js", () => ({ - load: vi.fn(), + mockConfigModule({ + load: vi.fn().mockReturnValue({ api_key: "k", access_key: "ak" }), save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptConfirm: confirm, - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + const { promptConfirm } = mockConfigDependencies({ interactive: true }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "reset"], { - from: "node", - }); + await runRegisteredCommand(registerConfig, ["config", "reset", "--yes"]); expect(save).toHaveBeenCalledWith({}); - expect(confirm).not.toHaveBeenCalled(); + expect(promptConfirm).not.toHaveBeenCalled(); expect(exitWithError).not.toHaveBeenCalled(); }); - it("config set verbose rejects invalid values", async () => { - const load = vi.fn().mockReturnValue({ api_key: "k" }); - const save = vi.fn(); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; + it("returns NOT_FOUND contract when config key is missing", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); + mockConfigModule({ + load: vi.fn().mockReturnValue({}), + get: vi.fn().mockReturnValue(undefined), }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - printKeyValueBox: vi.fn(), - emptyState: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + mockConfigDependencies({ interactive: false }); const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "set", "verbose", "yes"], { - from: "node", - }); + await runRegisteredCommand(registerConfig, ["config", "get", "api-key"]); - expect(load).not.toHaveBeenCalled(); - expect(save).not.toHaveBeenCalled(); expect(exitWithError).toHaveBeenCalledTimes(1); - }); - - it("config list warns when api-key mismatches selected app key", async () => { - const load = vi.fn().mockReturnValue({ - api_key: "manual_api_key", - app: { id: "app_1", name: "First App", apiKey: "app_api_key" }, - }); - const printKeyValueBox = vi.fn(); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save: vi.fn(), - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman: vi.fn(), - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - yellow: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printKeyValueBox, - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError: vi.fn() })); - - const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "list"], { - from: "node", - }); - - expect(load).toHaveBeenCalledTimes(1); - expect(printKeyValueBox).toHaveBeenCalledTimes(1); - expect(printKeyValueBox).toHaveBeenCalledWith( - expect.arrayContaining([ - ["api-key", "\u25C6 manual_api_key"], - ]), - ); - expect(logSpy).toHaveBeenCalledWith(""); - expect(logSpy).toHaveBeenCalledWith( - " \u25C6 Warning: api-key differs from the selected app key. RPC commands use api-key; run 'alchemy config set app ' to resync.", - ); - }); - - it("config set api-key warns when selected app key differs", async () => { - const load = vi.fn().mockReturnValue({ - app: { id: "app_1", name: "First App", apiKey: "app_api_key" }, - }); - const save = vi.fn(); - const printHuman = vi.fn(); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const exitWithError = vi.fn(); - - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - get: vi.fn(), - toMap: vi.fn(), - KEY_MAP: { "api-key": "api_key", api_key: "api_key", "access-key": "access_key", access_key: "access_key", network: "network", verbose: "verbose", "wallet-key-file": "wallet_key_file", wallet_key_file: "wallet_key_file", "wallet-address": "wallet_address", wallet_address: "wallet_address", x402: "x402" }, - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/lib/errors.js", async () => { - const actual = await vi.importActual("../../src/lib/errors.js"); - return actual; - }); - vi.doMock("../../src/lib/output.js", () => ({ - isJSONMode: () => false, - printHuman, - printJSON: vi.fn(), - verbose: false, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - green: (s: string) => s, - dim: (s: string) => s, - yellow: (s: string) => s, - withSpinner: async ( - _start: string, - _end: string, - fn: () => Promise, - ) => fn(), - printKeyValueBox: vi.fn(), - maskIf: (s: string) => s, - })); - vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - - const { registerConfig } = await import("../../src/commands/config.js"); - const program = new Command(); - registerConfig(program); - - await program.parseAsync(["node", "test", "config", "set", "api-key", "manual_api_key"], { - from: "node", - }); - - expect(save).toHaveBeenCalledWith({ - app: { id: "app_1", name: "First App", apiKey: "app_api_key" }, - api_key: "manual_api_key", - }); - expect(printHuman).toHaveBeenCalledWith("\u2713 Set api-key\n", { - key: "api-key", - status: "set", - }); - expect(logSpy).toHaveBeenCalledWith( - " \u25C6 Warning: api-key differs from the selected app key. RPC commands use api-key; run 'alchemy config set app ' to resync.", - ); - expect(exitWithError).not.toHaveBeenCalled(); + expect(exitWithError.mock.calls[0][0]).toMatchObject({ code: "NOT_FOUND" }); }); }); diff --git a/tests/commands/gas-manager.test.ts b/tests/commands/gas-manager.test.ts new file mode 100644 index 0000000..e151be8 --- /dev/null +++ b/tests/commands/gas-manager.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +describe("gas-manager command", () => { + it("calls alchemy_requestGasAndPaymasterAndData with parsed body", async () => { + const { printSyntaxJSON, exitWithError } = installBaseCommandMocks({ jsonMode: false }); + const call = vi.fn().mockResolvedValue({ paymasterAndData: "0xabc" }); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerGasManager } = await import("../../src/commands/gas-manager.js"); + await runRegisteredCommand(registerGasManager, [ + "gas-manager", + "request-gas-and-paymaster", + "--body", + '{"sender":"0x1"}', + ]); + + expect(call).toHaveBeenCalledWith("alchemy_requestGasAndPaymasterAndData", [ + { sender: "0x1" }, + ]); + expect(printSyntaxJSON).toHaveBeenCalledWith({ paymasterAndData: "0xabc" }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("routes invalid body JSON through exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: false }); + const call = vi.fn(); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerGasManager } = await import("../../src/commands/gas-manager.js"); + await runRegisteredCommand(registerGasManager, [ + "gas-manager", + "request-paymaster-token-quote", + "--body", + "{bad-json", + ]); + + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/commands/portfolio.test.ts b/tests/commands/portfolio.test.ts new file mode 100644 index 0000000..25d1b62 --- /dev/null +++ b/tests/commands/portfolio.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +describe("portfolio command", () => { + it("calls data API for portfolio tokens", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const callApiData = vi.fn().mockResolvedValue({ items: [{ symbol: "ETH" }] }); + vi.doMock("../../src/lib/rest.js", () => ({ callApiData })); + vi.doMock("../../src/lib/resolve.js", () => ({ resolveAPIKey: () => "api_key" })); + + const { registerPortfolio } = await import("../../src/commands/portfolio.js"); + await runRegisteredCommand(registerPortfolio, [ + "portfolio", + "tokens", + "--body", + '{"addresses":[{"network":"eth-mainnet","address":"0xabc"}]}', + ]); + + expect(callApiData).toHaveBeenCalledWith( + "api_key", + "/assets/tokens/by-address", + expect.objectContaining({ + method: "POST", + body: expect.any(Object), + }), + ); + expect(printJSON).toHaveBeenCalledWith({ items: [{ symbol: "ETH" }] }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("routes invalid JSON body errors to exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const callApiData = vi.fn(); + vi.doMock("../../src/lib/rest.js", () => ({ callApiData })); + vi.doMock("../../src/lib/resolve.js", () => ({ resolveAPIKey: () => "api_key" })); + + const { registerPortfolio } = await import("../../src/commands/portfolio.js"); + await runRegisteredCommand(registerPortfolio, [ + "portfolio", + "tokens", + "--body", + "{bad-json", + ]); + + expect(callApiData).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/commands/transfers.test.ts b/tests/commands/transfers.test.ts new file mode 100644 index 0000000..ca1e640 --- /dev/null +++ b/tests/commands/transfers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +const ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; + +describe("transfers command", () => { + it("builds filter payload and calls alchemy_getAssetTransfers", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const call = vi.fn().mockResolvedValue({ transfers: [{ hash: "0x1" }] }); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerTransfers } = await import("../../src/commands/transfers.js"); + await runRegisteredCommand(registerTransfers, [ + "transfers", + ADDRESS, + "--category", + "erc20,erc721", + "--max-count", + "10", + ]); + + expect(call).toHaveBeenCalledWith("alchemy_getAssetTransfers", [ + expect.objectContaining({ + fromAddress: ADDRESS, + toAddress: ADDRESS, + category: ["erc20", "erc721"], + maxCount: "0xa", + }), + ]); + expect(printJSON).toHaveBeenCalledWith({ transfers: [{ hash: "0x1" }] }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("routes address validation failures through exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const call = vi.fn(); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + + const { registerTransfers } = await import("../../src/commands/transfers.js"); + await runRegisteredCommand(registerTransfers, ["transfers", "not-an-address"]); + + expect(call).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/commands/webhooks.test.ts b/tests/commands/webhooks.test.ts new file mode 100644 index 0000000..14b45e4 --- /dev/null +++ b/tests/commands/webhooks.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBaseCommandMocks, + runRegisteredCommand, + useCommandTestReset, +} from "../helpers/command-harness.js"; + +useCommandTestReset(); + +describe("webhooks command", () => { + it("lists webhooks with webhook API key", async () => { + const { printJSON, exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const callNotify = vi.fn().mockResolvedValue({ data: [{ id: "wh_1" }] }); + vi.doMock("../../src/lib/rest.js", () => ({ callNotify })); + vi.doMock("../../src/lib/config.js", () => ({ load: () => ({}) })); + + const { registerWebhooks } = await import("../../src/commands/webhooks.js"); + await runRegisteredCommand(registerWebhooks, [ + "webhooks", + "--webhook-api-key", + "wh_key", + "list", + ]); + + expect(callNotify).toHaveBeenCalledWith("wh_key", "/team-webhooks"); + expect(printJSON).toHaveBeenCalledWith({ data: [{ id: "wh_1" }] }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("routes invalid create body JSON through exitWithError", async () => { + const { exitWithError } = installBaseCommandMocks({ jsonMode: true }); + const callNotify = vi.fn(); + vi.doMock("../../src/lib/rest.js", () => ({ callNotify })); + vi.doMock("../../src/lib/config.js", () => ({ load: () => ({}) })); + + const { registerWebhooks } = await import("../../src/commands/webhooks.js"); + await runRegisteredCommand(registerWebhooks, [ + "webhooks", + "--webhook-api-key", + "wh_key", + "create", + "--body", + "{bad-json", + ]); + + expect(callNotify).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/helpers/command-harness.ts b/tests/helpers/command-harness.ts new file mode 100644 index 0000000..d17a41d --- /dev/null +++ b/tests/helpers/command-harness.ts @@ -0,0 +1,92 @@ +import { Command } from "commander"; +import { beforeEach, vi } from "vitest"; + +export function useCommandTestReset(): void { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); +} + +export function installBaseCommandMocks(opts?: { jsonMode?: boolean }) { + const jsonMode = opts?.jsonMode ?? true; + + const printJSON = vi.fn(); + const printHuman = vi.fn(); + const printSyntaxJSON = vi.fn(); + const printTable = vi.fn(); + const printKeyValueBox = vi.fn(); + const emptyState = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => jsonMode, + printJSON, + printHuman, + verbose: false, + })); + + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + yellow: (s: string) => s, + maskIf: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printSyntaxJSON, + printTable, + printKeyValueBox, + emptyState, + })); + + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + return { + printJSON, + printHuman, + printSyntaxJSON, + printTable, + printKeyValueBox, + emptyState, + exitWithError, + }; +} + +export async function runRegisteredCommand( + register: (program: Command) => void, + args: string[], +): Promise { + const program = new Command(); + register(program); + await program.parseAsync(["node", "test", ...args], { from: "node" }); +} + +export function setTTY(stdin: boolean, stdout: boolean): () => void { + const prevIn = process.stdin.isTTY; + const prevOut = process.stdout.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: stdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: stdout, + configurable: true, + }); + + return () => { + Object.defineProperty(process.stdin, "isTTY", { + value: prevIn, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: prevOut, + configurable: true, + }); + }; +}