From c8c4656e0e709f0de172a619ac2dcfeee5b356ce Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 26 Oct 2025 11:56:24 +0800 Subject: [PATCH] test: add MCP functionality tests achieving 100% hooks coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone Achievement: 100% Hooks Coverage 🎉 - Hooks coverage: 87.5% (7/8) → 100% (8/8) - Total tests: 81 → 105 (+29.6%) - MCP functionality: 0 → 24 tests useMcpActions Tests (8 tests): - Test server reload with loading state management - Use deferred promise pattern to verify loading state transitions - Verify intermediate loading=true state during async operation - Verify error toast (duration: 6s) on reload failure - Ensure loading returns to false after error - Test optimistic toggle with rollback on failure - Immediately update enabled flag before API confirmation - Verify success toast messages for enable/disable - Roll back state to original value on API failure - Show error toast (duration: 6s) when toggle fails - Test server save with list refresh - Verify ID rewrite logic: saveServer(newId, {...input, id: oldId}) → {id: newId} - Verify syncOtherSide option correctly propagated to API - Refresh server list after successful save - Propagate errors to caller while showing error toast - Do not refresh list when save fails - Test server delete with state management - Verify deleteServerInConfig called with correct parameters - Verify list refresh removes deleted server - Show success toast (duration: 1.5s) on delete - Keep state unchanged on delete failure - Propagate error to caller with error toast useMcpValidation Tests (16 tests): - Test JSON validation (4 tests) - Return empty string for blank/whitespace text - Return "mcp.error.jsonInvalid" for parsing failures - Reject non-object types (string, array) - Accept valid object payloads - Test TOML error formatting (2 tests) - Map mustBeObject/parseError to "mcp.error.tomlInvalid" - Append error details for unknown errors - Test TOML config validation (5 tests) - Propagate errors from validateToml utility - Return "mcp.error.commandRequired" for stdio without command - Return "mcp.wizard.urlRequired" for http without url - Catch and format tomlToMcpServer exceptions - Return empty string when validation passes - Test JSON config validation (5 tests) - Reject invalid JSON syntax - Reject mcpServers array format (single object required) - Require command field for stdio type servers - Require url field for http type servers - Accept valid server configurations Technical Highlights: - Deferred Promise Pattern: Precise async timing control - Optimistic Updates: Test immediate feedback + rollback - Error Propagation: Distinguish caller errors vs toast notifications - i18n Validation: All validators return translation keys - Factory Functions: Reusable test data builders All tests passing: 105/105 --- tests/hooks/useMcpActions.test.tsx | 242 ++++++++++++++++++++++++++ tests/hooks/useMcpValidation.test.tsx | 145 +++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 tests/hooks/useMcpActions.test.tsx create mode 100644 tests/hooks/useMcpValidation.test.tsx 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(""); + }); + }); +}); +