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
14 changes: 14 additions & 0 deletions ui/litellm-dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/litellm-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"next": "16.2.6",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,104 +1,66 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCustomers, type EndUser } from "./useCustomers";

const mockGet = vi.fn();
const useQueryMock = vi.fn();
vi.mock("@/lib/http/api", () => ({
fetchClient: { GET: (...args: unknown[]) => mockGet(...args) },
$api: { useQuery: (...args: unknown[]) => useQueryMock(...args) },
}));

const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));

const mockCustomers: EndUser[] = [
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
];
const authorized = { accessToken: "test-access-token", userRole: "Admin" };

const authorized = {
accessToken: "test-access-token",
userRole: "Admin",
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
type QueryOptions = { enabled: boolean; select: (data: EndUser[] | undefined) => EndUser[] };

const lastCallOptions = (): QueryOptions => {
const calls = useQueryMock.mock.calls;
return calls[calls.length - 1][3] as QueryOptions;
};

describe("useCustomers", () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
useQueryMock.mockReturnValue({ data: [] });
mockUseAuthorized.mockReturnValue(authorized);
});

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

it("fetches /customer/list and returns the typed list on success", async () => {
mockGet.mockResolvedValue({ data: mockCustomers });

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

expect(result.current.isLoading).toBe(true);

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

expect(result.current.data).toEqual(mockCustomers);
expect(mockGet).toHaveBeenCalledWith("/customer/list");
expect(mockGet).toHaveBeenCalledTimes(1);
it("queries GET /customer/list with a derived key (no hand-written queryKey)", () => {
renderHook(() => useCustomers());
expect(useQueryMock).toHaveBeenCalledWith("get", "/customer/list", {}, expect.any(Object));
});

it("surfaces an error when the request rejects", async () => {
const testError = new Error("Failed to fetch customers");
mockGet.mockRejectedValue(testError);

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

await waitFor(() => {
expect(result.current.isError).toBe(true);
});

expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
it("enables the query only for an admin holding an access token", () => {
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(true);
});

it("falls back to an empty list when the response has no body", async () => {
mockGet.mockResolvedValue({ data: undefined });

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

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

expect(result.current.data).toEqual([]);
it("disables the query when the access token is missing", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null });
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(false);
});

it("does not fetch when the access token is missing", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null, token: null });

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

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

it("does not fetch when the user is not an admin", () => {
it("disables the query for a non-admin role", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, userRole: "member" });
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(false);
});

const { result } = renderHook(() => useCustomers(), { wrapper });
it("selects an empty list when the response body is missing", () => {
renderHook(() => useCustomers());
expect(lastCallOptions().select(undefined)).toEqual([]);
});

expect(result.current.isFetched).toBe(false);
expect(mockGet).not.toHaveBeenCalled();
it("selects the customer list through unchanged", () => {
const customers: EndUser[] = [
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
];
renderHook(() => useCustomers());
expect(lastCallOptions().select(customers)).toEqual(customers);
});
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { fetchClient } from "@/lib/http/api";
import { $api } from "@/lib/http/api";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";

export type EndUser = components["schemas"]["CustomerResponse"];

const customersKeys = createQueryKeys("customers");

export const useCustomers = () => {
const { accessToken, userRole } = useAuthorized();
return useQuery({
queryKey: customersKeys.list({}),
queryFn: async () => (await fetchClient.GET("/customer/list")).data ?? [],
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
});
return $api.useQuery(
"get",
"/customer/list",
{},
{
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
select: (data) => data ?? [],
},
);
};
9 changes: 9 additions & 0 deletions ui/litellm-dashboard/src/lib/http/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import createFetchClient, { type Middleware } from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import type { paths } from "./schema";
import { ApiError, deriveErrorMessage } from "./client";
import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime";
Expand Down Expand Up @@ -46,3 +47,11 @@ const middleware: Middleware = {
*/
export const fetchClient = createFetchClient<paths>({ baseUrl: globalThis.location?.origin ?? "" });
fetchClient.use(middleware);

/**
* TanStack Query bound to the typed client. Callers write
* `$api.useQuery("get", "/path", init, options)`; the query key is derived from
* method + path + init (no hand-maintained key), the request signal is
* forwarded for cancellation, and the response type comes from schema.d.ts.
*/
export const $api = createQueryClient(fetchClient);
Loading