From 5d9136bb71be4c6f58a5e5f849600bf207ce13b6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Mar 2026 21:33:12 -0700 Subject: [PATCH] [Test] UI: Add unit tests for 5 untested dashboard components Add Vitest + RTL unit tests for WorkerDropdown, AccessGroupSelector, MemberTable, ModelSelector, and AutoRotationView (37 tests total). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../WorkerDropdown/WorkerDropdown.test.tsx | 78 ++++++++ .../AccessGroupSelector.test.tsx | 114 +++++++++++ .../AutoRotationView.test.tsx | 105 ++++++++++ .../common_components/MemberTable.test.tsx | 183 ++++++++++++++++++ .../common_components/ModelSelector.test.tsx | 78 ++++++++ 5 files changed, 558 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/AutoRotationView.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx new file mode 100644 index 00000000000..79b47adc3a1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -0,0 +1,78 @@ +import { renderWithProviders, screen } from "../../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import WorkerDropdown from "./WorkerDropdown"; + +const mockUseWorker = vi.fn(); + +vi.mock("@/hooks/useWorker", () => ({ + useWorker: () => mockUseWorker(), +})); + +const workers = [ + { worker_id: "w1", name: "Worker 1", url: "http://w1" }, + { worker_id: "w2", name: "Worker 2", url: "http://w2" }, + { worker_id: "w3", name: "Worker 3", url: "http://w3" }, +]; + +describe("WorkerDropdown", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should render", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + + renderWithProviders(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should return null when not a control plane", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: false, + selectedWorker: null, + workers: [], + }); + + const { container } = renderWithProviders( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("should return null when there is no selected worker", () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: null, + workers, + }); + + const { container } = renderWithProviders( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("should call onWorkerSwitch when a different worker is selected", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[0], + workers, + }); + const onWorkerSwitch = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders(); + + // Open the dropdown + await user.click(screen.getByRole("combobox")); + // Select Worker 2 + await user.click(await screen.findByText("Worker 2")); + + expect(onWorkerSwitch).toHaveBeenCalledWith("w2"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.test.tsx new file mode 100644 index 00000000000..f8604ecb320 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/AccessGroupSelector.test.tsx @@ -0,0 +1,114 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import AccessGroupSelector from "./AccessGroupSelector"; + +const mockAccessGroups = [ + { + access_group_id: "ag-1", + access_group_name: "Engineering", + description: null, + access_model_names: [], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2024-01-01", + created_by: null, + updated_at: "2024-01-01", + updated_by: null, + }, + { + access_group_id: "ag-2", + access_group_name: "Product", + description: null, + access_model_names: [], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2024-01-01", + created_by: null, + updated_at: "2024-01-01", + updated_by: null, + }, +]; + +const mockUseAccessGroups = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ + useAccessGroups: () => mockUseAccessGroups(), + // Re-export the type for imports to work + get AccessGroupResponse() { + return {}; + }, +})); + +describe("AccessGroupSelector", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should render", () => { + mockUseAccessGroups.mockReturnValue({ + data: mockAccessGroups, + isLoading: false, + isError: false, + }); + + renderWithProviders(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should show a loading skeleton when data is loading", () => { + mockUseAccessGroups.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + + renderWithProviders(); + // Ant Design Skeleton renders with class ant-skeleton + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); + + it("should show the label text when showLabel is true", () => { + mockUseAccessGroups.mockReturnValue({ + data: mockAccessGroups, + isLoading: false, + isError: false, + }); + + renderWithProviders( + + ); + expect(screen.getByText("My Groups")).toBeInTheDocument(); + }); + + it("should not show the label by default", () => { + mockUseAccessGroups.mockReturnValue({ + data: mockAccessGroups, + isLoading: false, + isError: false, + }); + + renderWithProviders(); + expect(screen.queryByText("Access Group")).not.toBeInTheDocument(); + }); + + it("should show error content when data fails to load", async () => { + mockUseAccessGroups.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + + const user = (await import("@testing-library/user-event")).default.setup(); + renderWithProviders(); + + // Open the dropdown to see notFoundContent + await user.click(screen.getByRole("combobox")); + expect( + await screen.findByText("Failed to load access groups") + ).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/AutoRotationView.test.tsx b/ui/litellm-dashboard/src/components/common_components/AutoRotationView.test.tsx new file mode 100644 index 00000000000..c98cfb0ed0c --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/AutoRotationView.test.tsx @@ -0,0 +1,105 @@ +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi, describe, it, expect } from "vitest"; +import AutoRotationView from "./AutoRotationView"; + +// Mock heroicons since jsdom doesn't support SVG rendering +vi.mock("@heroicons/react/outline", () => ({ + RefreshIcon: (props: Record) => , + ClockIcon: (props: Record) => , +})); + +describe("AutoRotationView", () => { + it("should render", () => { + renderWithProviders(); + expect(screen.getAllByText("Auto-Rotation").length).toBeGreaterThan(0); + }); + + it("should show Disabled badge when autoRotate is false", () => { + renderWithProviders(); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + }); + + it("should show Enabled badge when autoRotate is true", () => { + renderWithProviders(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + }); + + it("should show the rotation interval when autoRotate is true", () => { + renderWithProviders( + + ); + expect(screen.getByText("Every 7d")).toBeInTheDocument(); + }); + + it("should show 'No rotation history available' when autoRotate is enabled but no timestamps", () => { + renderWithProviders(); + expect( + screen.getByText("No rotation history available") + ).toBeInTheDocument(); + }); + + it("should show disabled message when autoRotate is off and no rotation data", () => { + renderWithProviders(); + expect( + screen.getByText("Auto-rotation is not enabled for this key") + ).toBeInTheDocument(); + }); + + it("should show last rotation timestamp when provided", () => { + renderWithProviders( + + ); + expect(screen.getByText("Last Rotation")).toBeInTheDocument(); + }); + + it("should show next scheduled rotation when nextRotationAt is provided", () => { + renderWithProviders( + + ); + expect(screen.getByText("Next Scheduled Rotation")).toBeInTheDocument(); + }); + + it("should show next scheduled rotation when keyRotationAt is provided", () => { + renderWithProviders( + + ); + expect(screen.getByText("Next Scheduled Rotation")).toBeInTheDocument(); + }); + + describe("when variant is 'inline'", () => { + it("should render without the card wrapper", () => { + renderWithProviders( + + ); + expect(screen.getByText("Disabled")).toBeInTheDocument(); + // The card variant has a subtitle; inline does not + expect( + screen.queryByText( + "Automatic key rotation settings and status for this key" + ) + ).not.toBeInTheDocument(); + }); + }); + + describe("when variant is 'card'", () => { + it("should render with the card subtitle", () => { + renderWithProviders( + + ); + expect( + screen.getByText( + "Automatic key rotation settings and status for this key" + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx new file mode 100644 index 00000000000..b36a78a7362 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.test.tsx @@ -0,0 +1,183 @@ +import { renderWithProviders, screen, within } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import MemberTable from "./MemberTable"; +import type { Member } from "@/components/networking"; + +vi.mock( + "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", + () => ({ + default: ({ + onClick, + dataTestId, + variant, + }: { + onClick: () => void; + dataTestId?: string; + variant: string; + }) => ( + + ), + }) +); + +const members: Member[] = [ + { user_id: "user-1", user_email: "alice@example.com", role: "admin" }, + { user_id: "user-2", user_email: "bob@example.com", role: "user" }, + { + user_id: "default_user_id", + user_email: "proxy@admin.com", + role: "admin", + }, +]; + +describe("MemberTable", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should render", () => { + renderWithProviders( + + ); + expect(screen.getByText("3 Members")).toBeInTheDocument(); + }); + + it("should display member emails in the table", () => { + renderWithProviders( + + ); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); + }); + + it("should show Default Proxy Admin tag for default_user_id", () => { + renderWithProviders( + + ); + expect(screen.getByText("Default Proxy Admin")).toBeInTheDocument(); + }); + + it("should show singular 'Member' label when there is only one member", () => { + renderWithProviders( + + ); + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + + it("should call onEdit when the edit button is clicked", async () => { + const onEdit = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + + ); + + await user.click(screen.getByTestId("edit-member")); + expect(onEdit).toHaveBeenCalledWith(members[0]); + }); + + it("should call onDelete when the delete button is clicked", async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + + ); + + await user.click(screen.getByTestId("delete-member")); + expect(onDelete).toHaveBeenCalledWith(members[0]); + }); + + it("should not show action buttons when canEdit is false", () => { + renderWithProviders( + + ); + expect(screen.queryByTestId("edit-member")).not.toBeInTheDocument(); + expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument(); + }); + + it("should show the Add Member button when onAddMember is provided and canEdit is true", () => { + renderWithProviders( + + ); + expect( + screen.getByRole("button", { name: /add member/i }) + ).toBeInTheDocument(); + }); + + it("should call onAddMember when the Add Member button is clicked", async () => { + const onAddMember = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: /add member/i })); + expect(onAddMember).toHaveBeenCalled(); + }); + + it("should hide delete for a member when showDeleteForMember returns false", () => { + renderWithProviders( + false} + /> + ); + expect(screen.queryByTestId("delete-member")).not.toBeInTheDocument(); + expect(screen.getByTestId("edit-member")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx new file mode 100644 index 00000000000..246fcec3e2b --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx @@ -0,0 +1,78 @@ +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import ModelSelector from "./ModelSelector"; +import { fetchAvailableModels } from "@/components/playground/llm_calls/fetch_models"; + +const mockModels = [ + { model_group: "gpt-4" }, + { model_group: "claude-3-opus" }, + { model_group: "gemini-pro" }, +]; + +vi.mock("@/components/playground/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn(), +})); + +describe("ModelSelector", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(fetchAvailableModels).mockResolvedValue(mockModels); + }); + + it("should render", () => { + renderWithProviders(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should show the label by default", () => { + renderWithProviders(); + expect(screen.getByText("Select Model")).toBeInTheDocument(); + }); + + it("should hide the label when showLabel is false", () => { + renderWithProviders( + + ); + expect(screen.queryByText("Select Model")).not.toBeInTheDocument(); + }); + + it("should show custom label text", () => { + renderWithProviders( + + ); + expect(screen.getByText("Pick a Model")).toBeInTheDocument(); + }); + + it("should show the custom model input when 'Enter custom model' is selected", async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + // Wait for models to load + await waitFor(() => { + expect(fetchAvailableModels).toHaveBeenCalledWith("test-token"); + }); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Enter custom model")); + + expect( + screen.getByPlaceholderText("Enter custom model name") + ).toBeInTheDocument(); + }); + + it("should fetch models when accessToken is provided", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(fetchAvailableModels).toHaveBeenCalledWith("test-token"); + }); + }); + + it("should not fetch models when accessToken is empty", () => { + renderWithProviders(); + + expect(fetchAvailableModels).not.toHaveBeenCalled(); + }); +});