diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx new file mode 100644 index 000000000000..2b9d9ba9f843 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MCPSemanticFilterSettings from "./MCPSemanticFilterSettings"; +import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings"; +import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings"; + +vi.mock( + "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings", + () => ({ useMCPSemanticFilterSettings: vi.fn() }) +); + +vi.mock( + "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings", + () => ({ useUpdateMCPSemanticFilterSettings: vi.fn() }) +); + +vi.mock("@/components/playground/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue([]), +})); + +vi.mock("./MCPSemanticFilterTestPanel", () => ({ + default: () =>
, +})); + +vi.mock("./semanticFilterTestUtils", () => ({ + getCurlCommand: vi.fn().mockReturnValue("curl ..."), + runSemanticFilterTest: vi.fn(), +})); + +const mockMutate = vi.fn(); + +const defaultSettingsData = { + field_schema: { + properties: { + enabled: { description: "Enable semantic filtering for MCP tools" }, + }, + }, + values: { + enabled: false, + embedding_model: "text-embedding-3-small", + top_k: 10, + similarity_threshold: 0.3, + }, +}; + +// Helper that renders the component and flushes the fetchAvailableModels effect +async function renderSettings(props: React.ComponentProps) { + render(); + if (props.accessToken) { + // Let the async fetchAvailableModels effect settle to avoid act() warnings + await act(async () => {}); + } +} + +describe("MCPSemanticFilterSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: defaultSettingsData, + isLoading: false, + isError: false, + error: null, + } as any); + vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({ + mutate: mockMutate, + isPending: false, + error: null, + } as any); + }); + + it("should render", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument(); + }); + + it("should show a login prompt when accessToken is null", () => { + render(); + expect(screen.getByText(/please log in/i)).toBeInTheDocument(); + }); + + it("should not render the form when accessToken is null", () => { + render(); + expect(screen.queryByText("Enable Semantic Filtering")).not.toBeInTheDocument(); + }); + + it("should not show the settings content while loading", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.queryByText("Semantic Tool Filtering")).not.toBeInTheDocument(); + }); + + it("should show an error alert when data fails to load", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("Network error"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect( + screen.getByText("Could not load MCP Semantic Filter settings") + ).toBeInTheDocument(); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + it("should show the error message from the error object when loading fails", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("Connection refused"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + }); + + it("should render the info alert and form fields when data is loaded", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument(); + expect(screen.getByText("Enable Semantic Filtering")).toBeInTheDocument(); + expect(screen.getByText("Top K Results")).toBeInTheDocument(); + expect(screen.getByText("Similarity Threshold")).toBeInTheDocument(); + }); + + it("should render the test panel", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByTestId("mcp-test-panel")).toBeInTheDocument(); + }); + + it("should have Save Settings button disabled initially", async () => { + await renderSettings({ accessToken: "test-token" }); + expect( + screen.getByRole("button", { name: /save settings/i }) + ).toBeDisabled(); + }); + + it("should enable Save Settings button after a form field is changed", async () => { + const user = userEvent.setup(); + await renderSettings({ accessToken: "test-token" }); + + expect(screen.getByRole("button", { name: /save settings/i })).toBeDisabled(); + + await user.click(screen.getByRole("switch")); + + expect(screen.getByRole("button", { name: /save settings/i })).not.toBeDisabled(); + }); + + it("should show an error alert when the mutation fails", async () => { + vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({ + mutate: mockMutate, + isPending: false, + error: new Error("Failed to update settings"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Could not update settings")).toBeInTheDocument(); + expect(screen.getByText("Failed to update settings")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx new file mode 100644 index 000000000000..974a6a7bf044 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MCPSemanticFilterTestPanel from "./MCPSemanticFilterTestPanel"; +import { TestResult } from "./semanticFilterTestUtils"; + +vi.mock("@/components/common_components/ModelSelector", () => ({ + default: ({ onChange, value, labelText, disabled }: any) => ( +
+ + +
+ ), +})); + +const buildProps = ( + overrides: Partial> = {} +) => ({ + accessToken: "test-token", + testQuery: "", + setTestQuery: vi.fn(), + testModel: "gpt-4o", + setTestModel: vi.fn(), + isTesting: false, + onTest: vi.fn(), + filterEnabled: true, + testResult: null as TestResult | null, + curlCommand: "curl --location 'http://localhost:4000/v1/responses'", + ...overrides, +}); + +describe("MCPSemanticFilterTestPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the Test Configuration card", () => { + render(); + expect(screen.getByText("Test Configuration")).toBeInTheDocument(); + }); + + it("should show the test query textarea", () => { + render(); + expect( + screen.getByPlaceholderText(/enter a test query to see which tools/i) + ).toBeInTheDocument(); + }); + + it("should call setTestQuery when user types in the query field", () => { + const mockSetTestQuery = vi.fn(); + render(); + + const textarea = screen.getByPlaceholderText(/enter a test query to see which tools/i); + fireEvent.change(textarea, { target: { value: "find relevant tools" } }); + + expect(mockSetTestQuery).toHaveBeenCalledWith("find relevant tools"); + }); + + it("should disable the Test Filter button when testQuery is empty", () => { + render(); + expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled(); + }); + + it("should disable the Test Filter button when filterEnabled is false", () => { + render( + + ); + expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled(); + }); + + it("should enable the Test Filter button when testQuery is set and filter is enabled", () => { + render( + + ); + expect(screen.getByRole("button", { name: /test filter/i })).not.toBeDisabled(); + }); + + it("should call onTest when the Test Filter button is clicked", async () => { + const mockOnTest = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: /test filter/i })); + expect(mockOnTest).toHaveBeenCalledOnce(); + }); + + it("should show a warning when semantic filtering is disabled", () => { + render(); + expect(screen.getByText("Semantic filtering is disabled")).toBeInTheDocument(); + }); + + it("should not show the disabled warning when filterEnabled is true", () => { + render(); + expect(screen.queryByText("Semantic filtering is disabled")).not.toBeInTheDocument(); + }); + + it("should display test results when testResult is provided", () => { + const testResult: TestResult = { + totalTools: 10, + selectedTools: 3, + tools: ["wiki-fetch", "github-search", "slack-post"], + }; + render(); + + expect(screen.getByText("3 tools selected")).toBeInTheDocument(); + expect(screen.getByText("Filtered from 10 available tools")).toBeInTheDocument(); + expect(screen.getByText("wiki-fetch")).toBeInTheDocument(); + expect(screen.getByText("github-search")).toBeInTheDocument(); + expect(screen.getByText("slack-post")).toBeInTheDocument(); + }); + + it("should not render the results section when testResult is null", () => { + render(); + expect(screen.queryByText("Results")).not.toBeInTheDocument(); + }); + + it("should show the curl command in the API Usage tab", async () => { + const user = userEvent.setup(); + const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'"; + render(); + + await user.click(screen.getByRole("tab", { name: "API Usage" })); + + expect(screen.getByText(curlCommand)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts new file mode 100644 index 000000000000..12acdf8b8bac --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCurlCommand, runSemanticFilterTest } from "./semanticFilterTestUtils"; +import { testMCPSemanticFilter } from "@/components/networking"; +import NotificationManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + testMCPSemanticFilter: vi.fn(), +})); + +describe("getCurlCommand", () => { + it("should include the model name in the curl command", () => { + const result = getCurlCommand("gpt-4o", "test query"); + expect(result).toContain('"gpt-4o"'); + }); + + it("should include the query in the curl command", () => { + const result = getCurlCommand("gpt-4o", "find relevant files"); + expect(result).toContain("find relevant files"); + }); + + it("should use a placeholder when query is empty", () => { + const result = getCurlCommand("gpt-4o", ""); + expect(result).toContain("Your query here"); + }); +}); + +describe("runSemanticFilterTest", () => { + const mockSetIsTesting = vi.fn(); + const mockSetTestResult = vi.fn(); + const baseArgs = { + accessToken: "test-token", + testModel: "gpt-4o", + testQuery: "find relevant files", + setIsTesting: mockSetIsTesting, + setTestResult: mockSetTestResult, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call NotificationManager.error and not set isTesting when testQuery is empty", async () => { + await runSemanticFilterTest({ ...baseArgs, testQuery: "" }); + expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model"); + expect(mockSetIsTesting).not.toHaveBeenCalled(); + }); + + it("should call NotificationManager.error and not set isTesting when testModel is empty", async () => { + await runSemanticFilterTest({ ...baseArgs, testModel: "" }); + expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model"); + expect(mockSetIsTesting).not.toHaveBeenCalled(); + }); + + it("should set isTesting to true then false around the API call", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetIsTesting).toHaveBeenNthCalledWith(1, true); + expect(mockSetIsTesting).toHaveBeenNthCalledWith(2, false); + }); + + it("should clear the previous test result before making a new request", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestResult).toHaveBeenNthCalledWith(1, null); + }); + + it("should set test result with parsed data on success", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "10->3", tools: "wiki,github,slack" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestResult).toHaveBeenCalledWith({ + totalTools: 10, + selectedTools: 3, + tools: ["wiki", "github", "slack"], + }); + expect(NotificationManager.success).toHaveBeenCalledWith( + "Semantic filter test completed successfully" + ); + }); + + it("should show a warning when the filter header is missing", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: null, tools: null }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(NotificationManager.warning).toHaveBeenCalledWith( + "Semantic filter is not enabled or no tools were filtered" + ); + expect(mockSetTestResult).not.toHaveBeenCalledWith(expect.objectContaining({ totalTools: expect.any(Number) })); + }); + + it("should show an error notification and finish testing when the API call fails", async () => { + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("Network error")); + + await runSemanticFilterTest(baseArgs); + + expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter"); + expect(mockSetIsTesting).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index 935d26099cd7..ae93b118799d 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -9,36 +9,6 @@ import NotificationsManager from "./molecules/notifications_manager"; vi.mock("./networking"); -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - const React = await import("react"); - const Card = ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "card" }, children); - Card.displayName = "Card"; - const Title = ({ children }: { children: React.ReactNode }) => React.createElement("h2", {}, children); - Title.displayName = "Title"; - const Text = ({ children }: { children: React.ReactNode }) => React.createElement("span", {}, children); - Text.displayName = "Text"; - const Divider = () => React.createElement("hr", {}); - Divider.displayName = "Divider"; - const TextInput = ({ value, onChange, placeholder, className }: any) => - React.createElement("input", { - type: "text", - value: value || "", - onChange, - placeholder, - className, - }); - TextInput.displayName = "TextInput"; - return { - ...actual, - Card, - Title, - Text, - Divider, - TextInput, - }; -}); - vi.mock("./common_components/budget_duration_dropdown", () => { const BudgetDurationDropdown = ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => (