From 0e8b5616d662451916d6951f048c23d8d5bf7b4f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 24 Feb 2026 15:26:06 -0800 Subject: [PATCH] [Test] Add unit tests for router_settings components Co-Authored-By: Claude Sonnet 4.6 --- .../LatencyBasedConfiguration.test.tsx | 55 ++++++ .../ReliabilityRetriesSection.test.tsx | 80 +++++++++ .../RouterSettingsForm.test.tsx | 134 ++++++++++++++ .../RoutingStrategySelector.test.tsx | 98 ++++++++++ .../TagFilteringToggle.test.tsx | 113 ++++++++++++ .../components/router_settings/index.test.tsx | 170 ++++++++++++++++++ 6 files changed, 650 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.test.tsx create mode 100644 ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx create mode 100644 ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.test.tsx create mode 100644 ui/litellm-dashboard/src/components/router_settings/index.test.tsx diff --git a/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.test.tsx b/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.test.tsx new file mode 100644 index 00000000000..0176be5ab40 --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/LatencyBasedConfiguration.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import LatencyBasedConfiguration from "./LatencyBasedConfiguration"; + +describe("LatencyBasedConfiguration", () => { + it("should render the section heading", () => { + render(); + expect(screen.getByText("Latency-Based Configuration")).toBeInTheDocument(); + }); + + it("should render default params when no args are provided", () => { + render(); + // Default: ttl=3600, lowest_latency_buffer=0 + expect(screen.getByDisplayValue("3600")).toBeInTheDocument(); + expect(screen.getByDisplayValue("0")).toBeInTheDocument(); + }); + + it("should render the provided routing strategy args as inputs", () => { + const args = { ttl: 7200, lowest_latency_buffer: 0.1 }; + render(); + expect(screen.getByDisplayValue("7200")).toBeInTheDocument(); + expect(screen.getByDisplayValue("0.1")).toBeInTheDocument(); + }); + + it("should render an input with the correct name attribute for each param", () => { + const args = { ttl: 3600, lowest_latency_buffer: 0 }; + render(); + expect(screen.getByRole("textbox", { name: /ttl/i })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /lowest latency buffer/i })).toBeInTheDocument(); + }); + + it("should display the TTL parameter explanation", () => { + render(); + expect( + screen.getByText(/sliding window to look back over/i) + ).toBeInTheDocument(); + }); + + it("should display the lowest_latency_buffer parameter explanation", () => { + render(); + expect( + screen.getByText(/shuffle between deployments within this %/i) + ).toBeInTheDocument(); + }); + + it("should render object values stringified into the input", () => { + const args = { ttl: { nested: true } }; + render(); + // HTML input type=text strips newlines, so check that the key/value appears + const input = document.querySelector('input[name="ttl"]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.value).toContain('"nested"'); + expect(input.value).toContain('true'); + }); +}); diff --git a/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.test.tsx b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.test.tsx new file mode 100644 index 00000000000..0892d39fc29 --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/ReliabilityRetriesSection.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ReliabilityRetriesSection from "./ReliabilityRetriesSection"; + +const baseSettings = { + num_retries: 3, + timeout: 30, + allowed_fails: 2, + fallbacks: ["gpt-3.5"], + context_window_fallbacks: [], + routing_strategy_args: { ttl: 3600 }, + routing_strategy: "simple-shuffle", + enable_tag_filtering: false, +}; + +describe("ReliabilityRetriesSection", () => { + it("should render the section heading", () => { + render(); + expect(screen.getByText("Reliability & Retries")).toBeInTheDocument(); + }); + + it("should render input fields for non-excluded settings", () => { + render(); + expect(screen.getByDisplayValue("3")).toBeInTheDocument(); // num_retries + expect(screen.getByDisplayValue("30")).toBeInTheDocument(); // timeout + expect(screen.getByDisplayValue("2")).toBeInTheDocument(); // allowed_fails + }); + + it("should not render inputs for excluded keys", () => { + render(); + // Each excluded key must not produce a visible input value + const inputs = screen.queryAllByRole("textbox"); + const inputNames = inputs.map((el) => el.getAttribute("name")); + expect(inputNames).not.toContain("fallbacks"); + expect(inputNames).not.toContain("context_window_fallbacks"); + expect(inputNames).not.toContain("routing_strategy_args"); + expect(inputNames).not.toContain("routing_strategy"); + expect(inputNames).not.toContain("enable_tag_filtering"); + }); + + it("should use ui_field_name from metadata as the label", () => { + const metadata = { + num_retries: { ui_field_name: "Number of Retries", field_description: "How many times to retry" }, + }; + render( + + ); + expect(screen.getByText("Number of Retries")).toBeInTheDocument(); + }); + + it("should fall back to the raw param name when no metadata label is available", () => { + render( + + ); + expect(screen.getByText("num_retries")).toBeInTheDocument(); + }); + + it("should render null values as an empty input", () => { + render( + + ); + const input = screen.getByRole("textbox", { name: /timeout/i }) as HTMLInputElement; + expect(input.value).toBe(""); + }); + + it("should render object values stringified into the input", () => { + const settings = { retry_policy: { "rate-limited": 2 } }; + render(); + // HTML input type=text strips newlines, so check that the key/value appears + const input = document.querySelector('input[name="retry_policy"]') as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.value).toContain('"rate-limited"'); + expect(input.value).toContain('2'); + }); + + it("should render no inputs when routerSettings is empty", () => { + render(); + expect(screen.queryAllByRole("textbox")).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx new file mode 100644 index 00000000000..767820cd485 --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/RouterSettingsForm.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import RouterSettingsForm from "./RouterSettingsForm"; +import type { RouterSettingsFormValue } from "./RouterSettingsForm"; + +// Use the same antd mock as RoutingStrategySelector to keep things consistent +vi.mock("antd", () => ({ + Select: Object.assign( + ({ value, onChange, children }: any) => ( + + ), + { + Option: ({ value, children }: any) => ( + + ), + } + ), +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Switch: ({ checked, onChange }: any) => ( + onChange(e.target.checked)} + /> + ), + }; +}); + +const defaultValue: RouterSettingsFormValue = { + routerSettings: {}, + selectedStrategy: null, + enableTagFiltering: false, +}; + +const baseProps = { + value: defaultValue, + onChange: vi.fn(), + routerFieldsMetadata: {}, + availableRoutingStrategies: [], + routingStrategyDescriptions: {}, +}; + +describe("RouterSettingsForm", () => { + it("should render", () => { + render(); + expect(screen.getByText("Routing Settings")).toBeInTheDocument(); + }); + + it("should not show the strategy selector when no strategies are provided", () => { + render(); + expect(screen.queryByTestId("strategy-select")).not.toBeInTheDocument(); + }); + + it("should show the strategy selector when strategies are available", () => { + const props = { + ...baseProps, + availableRoutingStrategies: ["simple-shuffle", "latency-based-routing"], + }; + render(); + expect(screen.getByTestId("strategy-select")).toBeInTheDocument(); + }); + + it("should not render LatencyBasedConfiguration for non-latency strategies", () => { + const props = { + ...baseProps, + value: { ...defaultValue, selectedStrategy: "simple-shuffle" }, + availableRoutingStrategies: ["simple-shuffle"], + }; + render(); + expect(screen.queryByText("Latency-Based Configuration")).not.toBeInTheDocument(); + }); + + it("should render LatencyBasedConfiguration when strategy is latency-based-routing", () => { + const props = { + ...baseProps, + value: { + ...defaultValue, + selectedStrategy: "latency-based-routing", + routerSettings: { routing_strategy_args: { ttl: 3600, lowest_latency_buffer: 0 } }, + }, + availableRoutingStrategies: ["latency-based-routing"], + }; + render(); + expect(screen.getByText("Latency-Based Configuration")).toBeInTheDocument(); + }); + + it("should call onChange with the updated strategy when the selector changes", () => { + const onChange = vi.fn(); + const props = { + ...baseProps, + onChange, + availableRoutingStrategies: ["simple-shuffle", "latency-based-routing"], + }; + render(); + + const select = screen.getByTestId("strategy-select") as HTMLSelectElement; + select.value = "latency-based-routing"; + select.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ selectedStrategy: "latency-based-routing" }) + ); + }); + + it("should call onChange with the updated enableTagFiltering when the toggle changes", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("switch")); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ enableTagFiltering: true }) + ); + }); + + it("should show the Reliability & Retries section", () => { + render(); + expect(screen.getByText("Reliability & Retries")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.test.tsx b/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.test.tsx new file mode 100644 index 00000000000..85b1dc21acf --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/RoutingStrategySelector.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import RoutingStrategySelector from "./RoutingStrategySelector"; + +// Ant Design's Select is complex to drive in JSDOM; swap it for a plain +// onChange(e.target.value)} + > + {children} + + + ), + { + Option: ({ value, children }: any) => ( + + ), + } + ), +})); + +const baseProps = { + selectedStrategy: null, + availableStrategies: ["simple-shuffle", "latency-based-routing", "least-busy"], + routingStrategyDescriptions: { + "simple-shuffle": "Randomly pick a deployment", + "latency-based-routing": "Pick the lowest-latency deployment", + }, + routerFieldsMetadata: {}, + onStrategyChange: vi.fn(), +}; + +describe("RoutingStrategySelector", () => { + it("should render", () => { + render(); + expect(screen.getByTestId("ant-select")).toBeInTheDocument(); + }); + + it("should display default label when no metadata is provided", () => { + render(); + expect(screen.getByText("Routing Strategy")).toBeInTheDocument(); + }); + + it("should display ui_field_name from metadata when provided", () => { + const props = { + ...baseProps, + routerFieldsMetadata: { + routing_strategy: { + ui_field_name: "Strategy", + field_description: "How to pick a deployment", + }, + }, + }; + render(); + expect(screen.getByText("Strategy")).toBeInTheDocument(); + expect(screen.getByText("How to pick a deployment")).toBeInTheDocument(); + }); + + it("should render all available strategies as options", () => { + render(); + expect(screen.getByText("simple-shuffle")).toBeInTheDocument(); + expect(screen.getByText("latency-based-routing")).toBeInTheDocument(); + expect(screen.getByText("least-busy")).toBeInTheDocument(); + }); + + it("should display strategy descriptions alongside option labels", () => { + render(); + expect(screen.getByText("Randomly pick a deployment")).toBeInTheDocument(); + expect(screen.getByText("Pick the lowest-latency deployment")).toBeInTheDocument(); + }); + + it("should not render a description for a strategy that has none", () => { + render(); + // "least-busy" has no entry in routingStrategyDescriptions + const select = screen.getByTestId("strategy-select"); + const leastBusyOption = Array.from(select.querySelectorAll("option")).find( + (o) => o.value === "least-busy" + ); + expect(leastBusyOption).toBeInTheDocument(); + }); + + it("should call onStrategyChange with the selected strategy value", () => { + const onStrategyChange = vi.fn(); + render(); + + const select = screen.getByTestId("strategy-select") as HTMLSelectElement; + select.value = "latency-based-routing"; + select.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onStrategyChange).toHaveBeenCalledWith("latency-based-routing"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.test.tsx b/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.test.tsx new file mode 100644 index 00000000000..593721db023 --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/TagFilteringToggle.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import TagFilteringToggle from "./TagFilteringToggle"; + +// setupTests.ts mocks @tremor/react but leaves Switch as the real implementation. +// Re-mock Switch as a plain checkbox so toggle interactions are trivially testable. +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Switch: ({ checked, onChange, className }: any) => ( + onChange(e.target.checked)} + className={className} + /> + ), + }; +}); + +const baseMetadata = { + enable_tag_filtering: { + ui_field_name: "Tag Filtering", + field_description: "Route requests based on tags", + link: null, + }, +}; + +describe("TagFilteringToggle", () => { + it("should render", () => { + render( + + ); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("should display default label when no metadata is provided", () => { + render( + + ); + expect(screen.getByText("Enable Tag Filtering")).toBeInTheDocument(); + }); + + it("should display the label from metadata when provided", () => { + render( + + ); + expect(screen.getByText("Tag Filtering")).toBeInTheDocument(); + }); + + it("should display the description from metadata", () => { + render( + + ); + expect(screen.getByText("Route requests based on tags")).toBeInTheDocument(); + }); + + it("should render a Learn more link when metadata provides one", () => { + const metadata = { + enable_tag_filtering: { + ...baseMetadata.enable_tag_filtering, + link: "https://docs.example.com/tag-filtering", + }, + }; + render( + + ); + const link = screen.getByRole("link", { name: /learn more/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://docs.example.com/tag-filtering"); + }); + + it("should not render a Learn more link when metadata has no link", () => { + render( + + ); + expect(screen.queryByRole("link", { name: /learn more/i })).not.toBeInTheDocument(); + }); + + it("should reflect the enabled=true state on the switch", () => { + render( + + ); + expect(screen.getByRole("switch")).toBeChecked(); + }); + + it("should call onToggle with the new value when the switch is toggled", async () => { + const onToggle = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("switch")); + + expect(onToggle).toHaveBeenCalledWith(true); + }); +}); diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx new file mode 100644 index 00000000000..1920268207d --- /dev/null +++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import RouterSettings from "./index"; + +vi.mock("antd", () => ({ + Select: Object.assign( + ({ value, onChange, children }: any) => ( + + ), + { + Option: ({ value, children }: any) => ( + + ), + } + ), +})); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Switch: ({ checked, onChange }: any) => ( + onChange(e.target.checked)} + /> + ), + }; +}); + +vi.mock("@/components/networking", () => ({ + getCallbacksCall: vi.fn(), + getRouterSettingsCall: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +import { + getCallbacksCall, + getRouterSettingsCall, + setCallbacksCall, +} from "@/components/networking"; + +const mockCallbacksResponse = { + router_settings: { + routing_strategy: "simple-shuffle", + num_retries: 3, + timeout: 30, + }, +}; + +const mockRouterSettingsResponse = { + fields: [ + { + field_name: "routing_strategy", + ui_field_name: "Routing Strategy", + field_description: "How requests are distributed", + options: ["simple-shuffle", "latency-based-routing"], + link: null, + }, + { + field_name: "enable_tag_filtering", + ui_field_name: "Tag Filtering", + field_description: "Route by tag", + field_value: false, + link: null, + }, + ], + routing_strategy_descriptions: { + "simple-shuffle": "Randomly pick a deployment", + "latency-based-routing": "Pick the lowest-latency deployment", + }, +}; + +const defaultProps = { + accessToken: "test-token", + userRole: "Admin", + userID: "user-1", + modelData: null, +}; + +describe("RouterSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCallbacksCall).mockResolvedValue(mockCallbacksResponse); + vi.mocked(getRouterSettingsCall).mockResolvedValue(mockRouterSettingsResponse); + vi.mocked(setCallbacksCall).mockResolvedValue({}); + }); + + it("should render nothing when accessToken is null", () => { + const { container } = renderWithProviders( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("should render the Save Changes and Reset buttons when authenticated", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + it("should fetch callbacks and router settings on mount", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(getCallbacksCall).toHaveBeenCalledWith("test-token", "user-1", "Admin"); + }); + expect(getRouterSettingsCall).toHaveBeenCalledWith("test-token"); + }); + + it("should not fetch data when any required prop is missing", () => { + renderWithProviders( + + ); + expect(getCallbacksCall).not.toHaveBeenCalled(); + }); + + it("should render routing strategies loaded from the API", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("strategy-select")).toBeInTheDocument(); + }); + + const select = screen.getByTestId("strategy-select") as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toContain("simple-shuffle"); + expect(optionValues).toContain("latency-based-routing"); + }); + + it("should call setCallbacksCall with updated settings on Save Changes", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(setCallbacksCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ router_settings: expect.any(Object) }) + ); + }); + + it("should show a success notification after saving", async () => { + const NotificationsManager = await import("@/components/molecules/notifications_manager"); + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument() + ); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(NotificationsManager.default.success).toHaveBeenCalledWith( + "router settings updated successfully" + ); + }); +});