From ca2ce4a946f37af3543a8843abe9ab9fb3a8468d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 16:30:24 -0800 Subject: [PATCH] adding tests --- .../accessGroups/useAccessGroups.test.ts | 242 +++++++++++ .../AccessGroupsDetailsPage.test.tsx | 384 ++++++++++++++++++ .../AccessGroups/AccessGroupsPage.test.tsx | 321 +++++++++++++++ 3 files changed, 947 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts new file mode 100644 index 00000000000..587064353ac --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -0,0 +1,242 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAccessGroups, AccessGroupResponse } from "./useAccessGroups"; +import * as networking from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => "http://proxy.example"), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: unknown) => (data as { detail?: string })?.detail ?? "Unknown error"), + handleError: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token-123", + userRole: "Admin", + })), +})); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = createQueryClient(); + return React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +const mockAccessToken = "test-token-123"; +const mockAccessGroups: AccessGroupResponse[] = [ + { + access_group_id: "ag-1", + access_group_name: "Group One", + description: "First group", + access_model_ids: [], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2025-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2025-01-01T00:00:00Z", + updated_by: "user-1", + }, +]; + +const fetchMock = vi.fn(); + +describe("useAccessGroups", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(networking.getProxyBaseUrl).mockReturnValue("http://proxy.example"); + vi.mocked(networking.getGlobalLitellmHeaderName).mockReturnValue("Authorization"); + + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "Admin", + } as any); + + global.fetch = fetchMock; + }); + + it("should return hook result without errors", () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current).toHaveProperty("data"); + expect(result.current).toHaveProperty("isSuccess"); + expect(result.current).toHaveProperty("isError"); + expect(result.current).toHaveProperty("status"); + }); + + it("should return access groups when access token and admin role are present", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockAccessGroups), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://proxy.example/v1/access_group", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }), + }), + ); + expect(result.current.data).toEqual(mockAccessGroups); + }); + + it("should not fetch when access token is null", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: null, + userRole: "Admin", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when access token is empty string", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: "", + userRole: "Admin", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when user role is not an admin role", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "Viewer", + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not fetch when user role is null", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: null, + } as any); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should fetch when user role is proxy_admin", async () => { + const useAuthorizedModule = await import("@/app/(dashboard)/hooks/useAuthorized"); + vi.mocked(useAuthorizedModule.default).mockReturnValue({ + accessToken: mockAccessToken, + userRole: "proxy_admin", + } as any); + + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockAccessGroups), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(fetchMock).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockAccessGroups); + }); + + it("should expose error state when fetch fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ detail: "Forbidden" }), + } as Response); + vi.mocked(networking.deriveErrorMessage).mockReturnValue("Forbidden"); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect((result.current.error as Error).message).toBe("Forbidden"); + expect(result.current.data).toBeUndefined(); + expect(networking.handleError).toHaveBeenCalledWith("Forbidden"); + }); + + it("should return empty array when API returns empty list", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + } as Response); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + + it("should propagate network errors", async () => { + const networkError = new Error("Network failure"); + fetchMock.mockRejectedValue(networkError); + + const { result } = renderHook(() => useAccessGroups(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(networkError); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx new file mode 100644 index 00000000000..db9d25d886f --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.test.tsx @@ -0,0 +1,384 @@ +import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; +import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"); +vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({ + AccessGroupEditModal: ({ + visible, + onCancel, + }: { + visible: boolean; + onCancel: () => void; + }) => + visible ? ( +
+ +
+ ) : null, +})); + +const mockUseAccessGroupDetails = vi.mocked(useAccessGroupDetails); + +const baseMockReturnValue = { + data: undefined, + isLoading: false, + isError: false, + error: null, + isFetching: false, + isPending: false, + isSuccess: true, + status: "success" as const, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isRefetching: false, + isLoadingError: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isStale: false, + fetchStatus: "idle" as const, + refetch: vi.fn(), +} as unknown as ReturnType; + +const createMockAccessGroup = ( + overrides: Partial = {} +): AccessGroupResponse => ({ + access_group_id: "ag-1", + access_group_name: "Test Group", + description: "A test access group", + access_model_ids: ["model-1", "model-2"], + access_mcp_server_ids: ["mcp-1"], + access_agent_ids: ["agent-1"], + assigned_team_ids: ["team-1"], + assigned_key_ids: ["key-1", "key-2"], + created_at: "2025-01-01T00:00:00Z", + created_by: null, + updated_at: "2025-01-02T00:00:00Z", + updated_by: null, + ...overrides, +}); + +describe("AccessGroupDetail", () => { + const mockOnBack = vi.fn(); + const accessGroupId = "ag-1"; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup(), + } as ReturnType); + }); + + it("should render the component", () => { + renderWithProviders( + + ); + expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument(); + }); + + it("should not show access group content when loading", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: undefined, + isLoading: true, + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.queryByRole("heading", { name: "Test Group" })).not.toBeInTheDocument(); + }); + + it("should show empty state when access group is not found", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: undefined, + isLoading: false, + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("Access group not found")).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("should call onBack when back button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const buttons = screen.getAllByRole("button"); + const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit")); + await user.click(backButton!); + + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it("should display access group name and ID", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("heading", { name: "Test Group" })).toBeInTheDocument(); + expect(screen.getByText(/ID:/)).toBeInTheDocument(); + }); + + it("should display description in Group Details", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Group Details")).toBeInTheDocument(); + expect(screen.getByText("A test access group")).toBeInTheDocument(); + }); + + it("should display em dash when description is empty", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ description: null }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("should open edit modal when Edit Access Group button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); + + const editButton = screen.getByRole("button", { name: /Edit Access Group/i }); + await user.click(editButton); + + expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument(); + }); + + it("should close edit modal when Close Modal is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: /Edit Access Group/i })); + expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Close Modal" })); + expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); + }); + + it("should display attached keys", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Attached Keys")).toBeInTheDocument(); + expect(screen.getByText("key-1")).toBeInTheDocument(); + expect(screen.getByText("key-2")).toBeInTheDocument(); + }); + + it("should display attached teams", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Attached Teams")).toBeInTheDocument(); + expect(screen.getByText("team-1")).toBeInTheDocument(); + }); + + it("should show View All button for keys when more than 5", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should toggle between View All and Show Less for keys", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "View All (6)" })); + expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show Less" })); + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show View All button for teams when more than 5", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ + assigned_team_ids: ["t1", "t2", "t3", "t4", "t5", "t6"], + }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show empty state when no keys attached", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_key_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No keys attached")).toBeInTheDocument(); + }); + + it("should show empty state when no teams attached", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_team_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No teams attached")).toBeInTheDocument(); + }); + + it("should display Models tab with model IDs", () => { + renderWithProviders( + + ); + + expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument(); + expect(screen.getByText("model-1")).toBeInTheDocument(); + expect(screen.getByText("model-2")).toBeInTheDocument(); + }); + + it("should display MCP Servers tab with server IDs", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i }); + expect(mcpTab).toBeInTheDocument(); + await user.click(mcpTab); + expect(screen.getByText("mcp-1")).toBeInTheDocument(); + }); + + it("should display Agents tab with agent IDs", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + const agentsTab = screen.getByRole("tab", { name: /Agents/i }); + expect(agentsTab).toBeInTheDocument(); + await user.click(agentsTab); + expect(screen.getByText("agent-1")).toBeInTheDocument(); + }); + + it("should show empty state in Models tab when no models assigned", () => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_model_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText("No models assigned to this group")).toBeInTheDocument(); + }); + + it("should show empty state in MCP Servers tab when none assigned", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_mcp_server_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument(); + }); + + it("should show empty state in Agents tab when none assigned", async () => { + const user = userEvent.setup(); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ access_agent_ids: [] }), + } as ReturnType); + + renderWithProviders( + + ); + + await user.click(screen.getByRole("tab", { name: /Agents/i })); + expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument(); + }); + + it("should truncate long key IDs with ellipsis", () => { + const longKeyId = "a".repeat(25); + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }), + } as ReturnType); + + renderWithProviders( + + ); + + expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument(); + }); + + it("should display created and last updated timestamps", () => { + renderWithProviders( + + ); + + expect(screen.getByText("Created")).toBeInTheDocument(); + expect(screen.getByText("Last Updated")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx new file mode 100644 index 00000000000..dd38cd61d92 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.test.tsx @@ -0,0 +1,321 @@ +import { renderWithProviders, screen, within } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AccessGroupsPage } from "./AccessGroupsPage"; +import type { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; + +const mockAccessGroups: AccessGroupResponse[] = [ + { + access_group_id: "ag-1", + access_group_name: "Admin Group", + description: "Administrators with full access", + access_model_ids: ["m1", "m2"], + access_mcp_server_ids: ["s1"], + access_agent_ids: ["a1"], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2024-01-15T10:00:00Z", + created_by: "user-1", + updated_at: "2024-01-20T12:00:00Z", + updated_by: "user-1", + }, + { + access_group_id: "ag-2", + access_group_name: "Read Only", + description: "Read-only access to models", + access_model_ids: ["m1"], + access_mcp_server_ids: [], + access_agent_ids: [], + assigned_team_ids: [], + assigned_key_ids: [], + created_at: "2024-01-10T09:00:00Z", + created_by: null, + updated_at: "2024-01-12T11:00:00Z", + updated_by: null, + }, +]; + +const mockUseAccessGroups = vi.fn(); +const mockUseDeleteAccessGroup = vi.fn(); +const mockMutate = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ + useAccessGroups: () => mockUseAccessGroups(), +})); + +vi.mock("@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup", () => ({ + useDeleteAccessGroup: () => mockUseDeleteAccessGroup(), +})); + +vi.mock("./AccessGroupsDetailsPage", () => ({ + AccessGroupDetail: ({ + accessGroupId, + onBack, + }: { + accessGroupId: string; + onBack: () => void; + }) => ( +
+ Detail for {accessGroupId} + +
+ ), +})); + +vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({ + AccessGroupCreateModal: ({ + visible, + onCancel, + }: { + visible: boolean; + onCancel: () => void; + }) => + visible ? ( +
+ +
+ ) : null, +})); + +vi.mock("../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({ + default: ({ + variant, + tooltipText, + onClick, + }: { + variant: string; + tooltipText: string; + onClick: () => void; + }) => ( + + ), +})); + +describe("AccessGroupsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAccessGroups.mockReturnValue({ + data: mockAccessGroups, + isLoading: false, + }); + mockUseDeleteAccessGroup.mockReturnValue({ + mutate: mockMutate, + isPending: false, + }); + }); + + it("should render", () => { + renderWithProviders(); + expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); + }); + + it("should display page title and subtitle", () => { + renderWithProviders(); + expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); + expect( + screen.getByText("Manage resource permissions for your organization"), + ).toBeInTheDocument(); + }); + + it("should display Create Access Group button", () => { + renderWithProviders(); + expect( + screen.getByRole("button", { name: /create access group/i }), + ).toBeInTheDocument(); + }); + + it("should display search input with placeholder", () => { + renderWithProviders(); + expect( + screen.getByPlaceholderText("Search groups by name, ID, or description..."), + ).toBeInTheDocument(); + }); + + it("should display access groups in table", () => { + renderWithProviders(); + expect(screen.getByText("ag-1")).toBeInTheDocument(); + expect(screen.getByText("Admin Group")).toBeInTheDocument(); + expect(screen.getByText("ag-2")).toBeInTheDocument(); + expect(screen.getByText("Read Only")).toBeInTheDocument(); + }); + + it("should display resource counts for each group", () => { + renderWithProviders(); + const table = screen.getByRole("table"); + expect(table).toHaveTextContent("2"); + expect(table).toHaveTextContent("1"); + }); + + it("should filter groups by search text matching name", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const searchInput = screen.getByPlaceholderText( + "Search groups by name, ID, or description...", + ); + await user.type(searchInput, "Admin"); + expect(screen.getByText("Admin Group")).toBeInTheDocument(); + expect(screen.queryByText("Read Only")).not.toBeInTheDocument(); + }); + + it("should filter groups by search text matching ID", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const searchInput = screen.getByPlaceholderText( + "Search groups by name, ID, or description...", + ); + await user.type(searchInput, "ag-2"); + expect(screen.getByText("Read Only")).toBeInTheDocument(); + expect(screen.queryByText("Admin Group")).not.toBeInTheDocument(); + }); + + it("should filter groups by search text matching description", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const searchInput = screen.getByPlaceholderText( + "Search groups by name, ID, or description...", + ); + await user.type(searchInput, "read-only"); + expect(screen.getByText("Read Only")).toBeInTheDocument(); + expect(screen.queryByText("Admin Group")).not.toBeInTheDocument(); + }); + + it("should reset to first page when search text changes", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const searchInput = screen.getByPlaceholderText( + "Search groups by name, ID, or description...", + ); + await user.type(searchInput, "Admin"); + const pagination = screen.getByText(/groups/); + expect(pagination).toHaveTextContent("1 groups"); + }); + + it("should open create modal when Create Access Group button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /create access group/i })); + expect(screen.getByTestId("create-access-group-modal")).toBeInTheDocument(); + }); + + it("should close create modal when cancel is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /create access group/i })); + expect(screen.getByTestId("create-access-group-modal")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByTestId("create-access-group-modal")).not.toBeInTheDocument(); + }); + + it("should navigate to detail view when group ID is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("ag-1")); + expect(screen.getByTestId("access-group-detail")).toBeInTheDocument(); + expect(screen.getByText("Detail for ag-1")).toBeInTheDocument(); + }); + + it("should return to list view when Back is clicked from detail", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("ag-1")); + expect(screen.getByTestId("access-group-detail")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(screen.queryByTestId("access-group-detail")).not.toBeInTheDocument(); + expect(screen.getByText("Admin Group")).toBeInTheDocument(); + }); + + it("should open delete modal when delete action is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete access group", + }); + await user.click(deleteButtons[0]); + const dialog = screen.getByRole("dialog", { name: "Delete Access Group" }); + expect(dialog).toBeInTheDocument(); + expect( + within(dialog).getByText( + "Are you sure you want to delete this access group? This action cannot be undone.", + ), + ).toBeInTheDocument(); + expect(within(dialog).getByText("Access Group Information")).toBeInTheDocument(); + expect(within(dialog).getByText("ag-1")).toBeInTheDocument(); + expect(within(dialog).getByText("Admin Group")).toBeInTheDocument(); + }); + + it("should close delete modal when cancel is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete access group", + }); + await user.click(deleteButtons[0]); + const dialog = screen.getByRole("dialog", { name: "Delete Access Group" }); + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument(); + }); + + it("should call delete mutation when delete is confirmed", async () => { + const user = userEvent.setup(); + mockMutate.mockImplementation((_id: string, opts?: { onSuccess?: () => void }) => { + opts?.onSuccess?.(); + }); + renderWithProviders(); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete access group", + }); + await user.click(deleteButtons[0]); + const dialog = screen.getByRole("dialog", { name: "Delete Access Group" }); + const deleteConfirmButton = within(dialog).getByRole("button", { name: /delete/i }); + await user.click(deleteConfirmButton); + expect(mockMutate).toHaveBeenCalledWith("ag-1", expect.any(Object)); + }); + + it("should display pagination with total count", () => { + renderWithProviders(); + expect(screen.getByText("2 groups")).toBeInTheDocument(); + }); + + it("should show table headers for ID, Name, Resources, and Actions", () => { + renderWithProviders(); + expect(screen.getByRole("columnheader", { name: /ID/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Name/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Resources/i })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Actions/i })).toBeInTheDocument(); + }); + + it("should display loading state when data is loading", () => { + mockUseAccessGroups.mockReturnValue({ + data: undefined, + isLoading: true, + }); + renderWithProviders(); + const table = screen.getByRole("table"); + expect(table).toBeInTheDocument(); + }); + + it("should display empty state when no groups match search", async () => { + const user = userEvent.setup(); + renderWithProviders(); + const searchInput = screen.getByPlaceholderText( + "Search groups by name, ID, or description...", + ); + await user.type(searchInput, "nonexistent-group-xyz"); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); + + it("should display empty data when useAccessGroups returns empty array", () => { + mockUseAccessGroups.mockReturnValue({ + data: [], + isLoading: false, + }); + renderWithProviders(); + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +});