test: add MCP functionality tests achieving 100% hooks coverage
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
This commit is contained in:
242
tests/hooks/useMcpActions.test.tsx
Normal file
242
tests/hooks/useMcpActions.test.tsx
Normal file
@@ -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> = {}): McpServer => ({
|
||||||
|
id: "server-1",
|
||||||
|
name: "Test Server",
|
||||||
|
description: "desc",
|
||||||
|
enabled: false,
|
||||||
|
server: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "run.sh",
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockConfigResponse = (servers: Record<string, McpServer>) => ({
|
||||||
|
configPath: "/mock/config.json",
|
||||||
|
servers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDeferred = <T,>() => {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
const promise = new Promise<T>((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<ReturnType<typeof mockConfigResponse>>();
|
||||||
|
getConfigMock.mockReturnValueOnce(deferred.promise);
|
||||||
|
const { result } = renderUseMcpActions();
|
||||||
|
|
||||||
|
let reloadPromise: Promise<void> | 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
145
tests/hooks/useMcpValidation.test.tsx
Normal file
145
tests/hooks/useMcpValidation.test.tsx
Normal file
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user