diff --git a/tests/hooks/useMcpActions.test.tsx b/tests/hooks/useMcpActions.test.tsx new file mode 100644 index 0000000..2931a8b --- /dev/null +++ b/tests/hooks/useMcpActions.test.tsx @@ -0,0 +1,242 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useMcpActions } from "@/hooks/useMcpActions"; +import type { McpServer } from "@/types"; + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +const getConfigMock = vi.fn(); +const setEnabledMock = vi.fn(); +const upsertServerInConfigMock = vi.fn(); +const deleteServerInConfigMock = vi.fn(); + +vi.mock("@/lib/api", () => ({ + mcpApi: { + getConfig: (...args: unknown[]) => getConfigMock(...args), + setEnabled: (...args: unknown[]) => setEnabledMock(...args), + upsertServerInConfig: (...args: unknown[]) => upsertServerInConfigMock(...args), + deleteServerInConfig: (...args: unknown[]) => deleteServerInConfigMock(...args), + }, +})); + +const createServer = (overrides: Partial = {}): McpServer => ({ + id: "server-1", + name: "Test Server", + description: "desc", + enabled: false, + server: { + type: "stdio", + command: "run.sh", + args: [], + env: {}, + }, + ...overrides, +}); + +const mockConfigResponse = (servers: Record) => ({ + configPath: "/mock/config.json", + servers, +}); + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +describe("useMcpActions", () => { + beforeEach(() => { + getConfigMock.mockReset(); + setEnabledMock.mockReset(); + upsertServerInConfigMock.mockReset(); + deleteServerInConfigMock.mockReset(); + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); + + getConfigMock.mockResolvedValue(mockConfigResponse({})); + setEnabledMock.mockResolvedValue(true); + upsertServerInConfigMock.mockResolvedValue(true); + deleteServerInConfigMock.mockResolvedValue(true); + }); + + const renderUseMcpActions = () => renderHook(() => useMcpActions("claude")); + + it("reloads servers and toggles loading state", async () => { + const server = createServer(); + const deferred = createDeferred>(); + getConfigMock.mockReturnValueOnce(deferred.promise); + const { result } = renderUseMcpActions(); + + let reloadPromise: Promise | undefined; + await act(async () => { + reloadPromise = result.current.reload(); + }); + await waitFor(() => expect(result.current.loading).toBe(true)); + deferred.resolve(mockConfigResponse({ [server.id]: server })); + await act(async () => { + await reloadPromise; + }); + + expect(getConfigMock).toHaveBeenCalledWith("claude"); + expect(result.current.loading).toBe(false); + expect(result.current.servers).toEqual({ [server.id]: server }); + }); + + it("shows toast error when reload fails", async () => { + const error = new Error("load failed"); + getConfigMock.mockRejectedValueOnce(error); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.reload(); + }); + + expect(toastErrorMock).toHaveBeenCalledWith("load failed", { duration: 6000 }); + expect(result.current.servers).toEqual({}); + expect(result.current.loading).toBe(false); + }); + + it("toggles enabled flag optimistically and emits success toasts", async () => { + const server = createServer({ enabled: false }); + getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server })); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.reload(); + }); + + await act(async () => { + await result.current.toggleEnabled(server.id, true); + }); + + expect(setEnabledMock).toHaveBeenCalledWith("claude", server.id, true); + expect(result.current.servers[server.id].enabled).toBe(true); + expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.enabled", { duration: 1500 }); + + await act(async () => { + await result.current.toggleEnabled(server.id, false); + }); + + expect(setEnabledMock).toHaveBeenLastCalledWith("claude", server.id, false); + expect(result.current.servers[server.id].enabled).toBe(false); + expect(toastSuccessMock).toHaveBeenLastCalledWith("mcp.msg.disabled", { duration: 1500 }); + }); + + it("rolls back state and shows error toast when toggle fails", async () => { + const server = createServer({ enabled: false }); + getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server })); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.reload(); + }); + + setEnabledMock.mockRejectedValueOnce(new Error("toggle failed")); + + await act(async () => { + await result.current.toggleEnabled(server.id, true); + }); + + expect(result.current.servers[server.id].enabled).toBe(false); + expect(toastErrorMock).toHaveBeenCalledWith("toggle failed", { duration: 6000 }); + }); + + it("saves server configuration and refreshes list", async () => { + const serverInput = createServer({ id: "old-id", enabled: true }); + const savedServer = { ...serverInput, id: "new-server" }; + getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [savedServer.id]: savedServer })); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.saveServer("new-server", serverInput, { syncOtherSide: true }); + }); + + expect(upsertServerInConfigMock).toHaveBeenCalledWith( + "claude", + "new-server", + { ...serverInput, id: "new-server" }, + { syncOtherSide: true }, + ); + expect(result.current.servers["new-server"]).toEqual(savedServer); + expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.saved", { duration: 1500 }); + }); + + it("propagates error when saveServer fails", async () => { + const serverInput = createServer({ id: "input-id" }); + const failure = new Error("cannot save"); + upsertServerInConfigMock.mockRejectedValueOnce(failure); + const { result } = renderUseMcpActions(); + + let captured: unknown; + await act(async () => { + try { + await result.current.saveServer("server-1", serverInput); + } catch (err) { + captured = err; + } + }); + + expect(upsertServerInConfigMock).toHaveBeenCalled(); + expect(getConfigMock).not.toHaveBeenCalled(); + expect(captured).toBe(failure); + expect(toastErrorMock).toHaveBeenCalledWith("cannot save", { duration: 6000 }); + expect(toastSuccessMock).not.toHaveBeenCalled(); + }); + + it("deletes server and refreshes list", async () => { + const server = createServer(); + getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server })); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.reload(); + }); + + getConfigMock.mockResolvedValueOnce(mockConfigResponse({})); + + await act(async () => { + await result.current.deleteServer(server.id); + }); + + expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id); + expect(result.current.servers[server.id]).toBeUndefined(); + expect(toastSuccessMock).toHaveBeenCalledWith("mcp.msg.deleted", { duration: 1500 }); + }); + + it("propagates delete error and keeps state", async () => { + const server = createServer(); + getConfigMock.mockResolvedValueOnce(mockConfigResponse({ [server.id]: server })); + const { result } = renderUseMcpActions(); + + await act(async () => { + await result.current.reload(); + }); + + const failure = new Error("delete failed"); + deleteServerInConfigMock.mockRejectedValueOnce(failure); + + let captured: unknown; + await act(async () => { + try { + await result.current.deleteServer(server.id); + } catch (err) { + captured = err; + } + }); + + expect(deleteServerInConfigMock).toHaveBeenCalledWith("claude", server.id); + expect(result.current.servers[server.id]).toEqual(server); + expect(captured).toBe(failure); + expect(toastErrorMock).toHaveBeenCalledWith("delete failed", { duration: 6000 }); + }); +}); diff --git a/tests/hooks/useMcpValidation.test.tsx b/tests/hooks/useMcpValidation.test.tsx new file mode 100644 index 0000000..8a624d5 --- /dev/null +++ b/tests/hooks/useMcpValidation.test.tsx @@ -0,0 +1,145 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useMcpValidation } from "@/components/mcp/useMcpValidation"; + +const validateTomlMock = vi.hoisted(() => vi.fn()); +const tomlToMcpServerMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/utils/tomlUtils", () => ({ + validateToml: (...args: unknown[]) => validateTomlMock(...args), + tomlToMcpServer: (...args: unknown[]) => tomlToMcpServerMock(...args), +})); + +describe("useMcpValidation", () => { + beforeEach(() => { + validateTomlMock.mockReset(); + tomlToMcpServerMock.mockReset(); + validateTomlMock.mockReturnValue(""); + }); + + const getHookResult = () => renderHook(() => useMcpValidation()).result.current; + + describe("validateJson", () => { + it("returns empty string for blank text", () => { + const { validateJson } = getHookResult(); + expect(validateJson(" ")).toBe(""); + }); + + it("returns error key when JSON parsing fails", () => { + const { validateJson } = getHookResult(); + expect(validateJson("{ invalid")).toBe("mcp.error.jsonInvalid"); + }); + + it("returns error key when parsed value is not an object", () => { + const { validateJson } = getHookResult(); + expect(validateJson('"string"')).toBe("mcp.error.jsonInvalid"); + expect(validateJson("[]")).toBe("mcp.error.jsonInvalid"); + }); + + it("accepts valid object payload", () => { + const { validateJson } = getHookResult(); + expect(validateJson('{"id":"demo"}')).toBe(""); + }); + }); + + describe("formatTomlError", () => { + it("maps mustBeObject and parseError to i18n key", () => { + const { formatTomlError } = getHookResult(); + expect(formatTomlError("mustBeObject")).toBe("mcp.error.tomlInvalid"); + expect(formatTomlError("parseError")).toBe("mcp.error.tomlInvalid"); + }); + + it("appends error message when details provided", () => { + const { formatTomlError } = getHookResult(); + expect(formatTomlError("unknown")).toBe("mcp.error.tomlInvalid: unknown"); + }); + }); + + describe("validateTomlConfig", () => { + it("propagates errors returned by validateToml", () => { + validateTomlMock.mockReturnValue("parse-error-detail"); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe("mcp.error.tomlInvalid: parse-error-detail"); + expect(tomlToMcpServerMock).not.toHaveBeenCalled(); + }); + + it("returns command required when stdio server missing command", () => { + tomlToMcpServerMock.mockReturnValue({ + type: "stdio", + command: " ", + }); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe("mcp.error.commandRequired"); + }); + + it("returns url required when http server missing url", () => { + tomlToMcpServerMock.mockReturnValue({ + type: "http", + url: "", + }); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe("mcp.wizard.urlRequired"); + }); + + it("surface tomlToMcpServer errors via formatter", () => { + tomlToMcpServerMock.mockImplementation(() => { + throw new Error("normalize failed"); + }); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe("mcp.error.tomlInvalid: normalize failed"); + }); + + it("returns empty string when validation passes", () => { + tomlToMcpServerMock.mockReturnValue({ + type: "stdio", + command: "run.sh", + }); + const { validateTomlConfig } = getHookResult(); + expect(validateTomlConfig("foo")).toBe(""); + }); + }); + + describe("validateJsonConfig", () => { + it("returns error when JSON invalid", () => { + const { validateJsonConfig } = getHookResult(); + expect(validateJsonConfig("invalid")).toBe("mcp.error.jsonInvalid"); + }); + + it("rejects arrays of servers", () => { + const { validateJsonConfig } = getHookResult(); + expect(validateJsonConfig('{"mcpServers": {}}')).toBe( + "mcp.error.singleServerObjectRequired", + ); + }); + + it("requires command for stdio type", () => { + const { validateJsonConfig } = getHookResult(); + expect(validateJsonConfig('{"type":"stdio"}')).toBe("mcp.error.commandRequired"); + }); + + it("requires url for http type", () => { + const { validateJsonConfig } = getHookResult(); + expect(validateJsonConfig('{"type":"http","url":""}')).toBe("mcp.wizard.urlRequired"); + }); + + it("returns empty string when json config valid", () => { + const { validateJsonConfig } = getHookResult(); + expect( + validateJsonConfig( + JSON.stringify({ + type: "stdio", + command: "node", + args: ["index.js"], + }), + ), + ).toBe(""); + }); + }); +}); +