Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useTeams, useTeam, useDeletedTeams, DeletedTeam, teamListCall } from "./useTeams";
import { useTeams, useTeam, useAllTeams, useDeletedTeams, DeletedTeam, teamListCall } from "./useTeams";
import { fetchTeams } from "@/app/(dashboard)/networking";
import { teamInfoCall } from "@/components/networking";
import type { Team } from "@/components/key_team_helpers/key_list";
Expand Down Expand Up @@ -87,7 +87,7 @@
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("should render", () => {
(fetchTeams as any).mockResolvedValue(mockTeams);

Check warning on line 90 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeams(), { wrapper });

Expand All @@ -96,7 +96,7 @@

it("should return teams data when query is successful", async () => {
// Mock successful API call
(fetchTeams as any).mockResolvedValue(mockTeams);

Check warning on line 99 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeams(), { wrapper });

Expand All @@ -121,7 +121,7 @@
const testError = new Error(errorMessage);

// Mock failed API call
(fetchTeams as any).mockRejectedValue(testError);

Check warning on line 124 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeams(), { wrapper });

Expand Down Expand Up @@ -190,7 +190,7 @@

it("should execute query when accessToken is present", async () => {
// Mock successful API call
(fetchTeams as any).mockResolvedValue(mockTeams);

Check warning on line 193 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Ensure auth values are set (already done in beforeEach)
const { result } = renderHook(() => useTeams(), { wrapper });
Expand All @@ -206,7 +206,7 @@

it("should return empty teams array when API returns empty data", async () => {
// Mock API returning empty teams array
(fetchTeams as any).mockResolvedValue([]);

Check warning on line 209 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeams(), { wrapper });

Expand All @@ -224,7 +224,7 @@
const timeoutError = new Error("Network timeout");

// Mock network timeout
(fetchTeams as any).mockRejectedValue(timeoutError);

Check warning on line 227 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeams(), { wrapper });

Expand All @@ -239,7 +239,7 @@

it("should pass userId and userRole to fetchTeams", async () => {
// Mock successful API call
(fetchTeams as any).mockResolvedValue(mockTeams);

Check warning on line 242 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Mock specific userId and userRole
mockUseAuthorized.mockReturnValue({
Expand All @@ -265,7 +265,7 @@

it("should handle null userId", async () => {
// Mock successful API call
(fetchTeams as any).mockResolvedValue(mockTeams);

Check warning on line 268 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

// Mock null userId
mockUseAuthorized.mockReturnValue({
Expand Down Expand Up @@ -320,7 +320,7 @@
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("should render", () => {
(teamInfoCall as any).mockResolvedValue(mockTeams[0]);

Check warning on line 323 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeam("team-1"), { wrapper });

Expand All @@ -328,7 +328,7 @@
});

it("should return team data when query is successful", async () => {
(teamInfoCall as any).mockResolvedValue(mockTeams[0]);

Check warning on line 331 in ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useTeam("team-1"), { wrapper });

Expand Down Expand Up @@ -792,3 +792,107 @@
expect(result.current.error).toBeNull();
});
});

describe("useAllTeams", () => {
let queryClient: QueryClient;
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});

const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);

const pageResponse = (teams: Team[], page: number, totalPages: number) => ({
ok: true,
json: async () => ({ teams, page, page_size: 100, total_pages: totalPages }),
});

const requestedPage = (url: string) => new URLSearchParams(url.split("?")[1]).get("page");

it("paginates /v2/team/list to completion and concatenates every page", async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
requestedPage(url) === "1" ? pageResponse([mockTeams[0]], 1, 2) : pageResponse([mockTeams[1]], 2, 2),
),
);

const { result } = renderHook(() => useAllTeams(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual(mockTeams);
expect(fetchMock).toHaveBeenCalledTimes(2);
const requestedPages = fetchMock.mock.calls.map((call) => requestedPage(call[0] as string)).sort();
expect(requestedPages).toEqual(["1", "2"]);
const firstUrl = fetchMock.mock.calls[0][0] as string;
expect(firstUrl).toContain("/v2/team/list");
expect(firstUrl).toContain("page_size=100");
});

it("issues exactly one request for a single-page result", async () => {
fetchMock.mockResolvedValue(pageResponse(mockTeams, 1, 1));

const { result } = renderHook(() => useAllTeams(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual(mockTeams);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not execute when accessToken is missing", () => {
mockUseAuthorized.mockReturnValue({
accessToken: null,
userId: "test-user-id",
userRole: "Admin",
token: null,
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});

const { result } = renderHook(() => useAllTeams(), { wrapper });

expect(result.current.isLoading).toBe(false);
expect(result.current.isFetched).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});

it("scopes the cache per access token so a switch of identity refetches", async () => {
fetchMock.mockResolvedValue(pageResponse(mockTeams, 1, 1));

const { result, rerender } = renderHook(() => useAllTeams(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(1);

mockUseAuthorized.mockReturnValue({
accessToken: "a-different-users-token",
userId: "other-user-id",
userRole: "Admin",
token: "a-different-users-token",
userEmail: "other@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
rerender();

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
});
});
25 changes: 25 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ export const useTeams = (): UseQueryResult<Team[]> => {
});
};

const ALL_TEAMS_PAGE_SIZE = 100;

const fetchAllTeamsPaged = async (accessToken: string): Promise<Team[]> => {
const firstPage: TeamsResponse = await teamListCall(accessToken, 1, ALL_TEAMS_PAGE_SIZE);
const totalPages = firstPage.total_pages ?? 1;
if (totalPages <= 1) return firstPage.teams;

const remainingPages: TeamsResponse[] = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) => teamListCall(accessToken, i + 2, ALL_TEAMS_PAGE_SIZE)),
);
return [firstPage, ...remainingPages].flatMap((page) => page.teams);
};

export const useAllTeams = (): UseQueryResult<Team[]> => {
const { accessToken } = useAuthorized();
return useQuery<Team[]>({
queryKey: teamKeys.list({
filters: { scope: "all", pageSize: ALL_TEAMS_PAGE_SIZE, accessToken: accessToken ?? "" },
}),
queryFn: async () => await fetchAllTeamsPaged(accessToken!),
enabled: Boolean(accessToken),
staleTime: 30000,
});
};

export const useTeam = (teamId?: string) => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, screen, waitFor, fireEvent } from "@testing-library/react";
import { act, screen, waitFor, within, fireEvent } from "@testing-library/react";
import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { VirtualKeysTable } from "./VirtualKeysTable";
Expand Down Expand Up @@ -28,8 +28,11 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
})),
}));

vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([{ team_id: "team-1", team_alias: "Test Team" }]),
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useAllTeams: vi.fn(() => ({
data: [{ team_id: "team-1", team_alias: "Test Team" }],
isLoading: false,
})),
}));

vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
Expand Down Expand Up @@ -309,10 +312,11 @@ it("should display created_by_user alias over email when both are available", as

renderWithProviders(<VirtualKeysTable />);

await waitFor(() => {
expect(screen.getByText("The Creator")).toBeInTheDocument();
});
expect(screen.queryByText("creator@example.com")).not.toBeInTheDocument();
// Scope to the key's row so we assert the visible cell value: the hover popover that
// also holds the email is portaled out of the row, not the displayed "Created By" text.
const row = (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;
expect(within(row).getByText("The Creator")).toBeInTheDocument();
expect(within(row).queryByText("creator@example.com")).not.toBeInTheDocument();
});

it("should render table without crashing when models is null", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client";
import { useKeys, KeyListCallOptions } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useQuery } from "@tanstack/react-query";
import { useAllTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
Expand Down Expand Up @@ -30,7 +29,6 @@ import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons";
import { Button as AntButton, Popover, Skeleton, Tag, Tooltip, Typography } from "antd";
import React, { useDeferredValue, useMemo, useState } from "react";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { fetchAllTeams } from "../key_team_helpers/filter_helpers";
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
import FilterComponent, { FilterOption } from "../molecules/filter";
Expand Down Expand Up @@ -67,7 +65,6 @@ const toKeyListFilters = (filters: KeyFilterState): KeyListFilterOptions => ({
});

export function VirtualKeysTable() {
const { accessToken } = useAuthorized();
const { data: fetchedOrganizations, isLoading: isOrgsLoading } = useOrganizations();
const resolvedOrganizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
Expand Down Expand Up @@ -98,12 +95,7 @@ export function VirtualKeysTable() {

const keyList = useMemo(() => keys?.keys ?? [], [keys]);

const { data: fetchedTeams, isLoading: isTeamsLoading } = useQuery<Team[]>({
queryKey: ["allTeamsForKeyFilters", accessToken],
queryFn: async () => (accessToken ? await fetchAllTeams(accessToken) : []),
enabled: !!accessToken,
staleTime: 30000,
});
const { data: fetchedTeams, isLoading: isTeamsLoading } = useAllTeams();
const allTeams = useMemo<Team[]>(() => fetchedTeams ?? [], [fetchedTeams]);

// Defer the transition so the button stays in loading state until the table
Expand Down
Loading