Merge pull request #22698 from BerriAI/litellm_test_projects_hooks

[Test] UI - Projects: Add unit tests for project hooks
This commit is contained in:
yuneng-jiang 2026-03-03 13:42:28 -08:00 committed by GitHub
commit 2d26209d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 583 additions and 0 deletions

View File

@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useCreateProject, ProjectCreateParams } from "./useCreateProject";
import { projectKeys, ProjectResponse } from "./useProjects";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),
handleError: vi.fn(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockProject: ProjectResponse = {
project_id: "proj-1",
project_alias: "Test Project",
description: "A test project",
team_id: "team-1",
budget_id: null,
metadata: null,
models: ["gpt-4"],
spend: 25.0,
model_spend: null,
model_rpm_limit: null,
model_tpm_limit: null,
blocked: false,
object_permission_id: null,
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",
updated_by: "user-1",
litellm_budget_table: null,
};
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useCreateProject", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
vi.clearAllMocks();
global.fetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
});
it("should render", () => {
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.mutate).toBeDefined();
});
it("should POST to /project/new and return the created project", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
const params: ProjectCreateParams = { team_id: "team-1", project_alias: "New Project" };
const data = await result.current.mutateAsync(params);
expect(data).toEqual(mockProject);
const [url, init] = (global.fetch as any).mock.calls[0];
expect(url).toContain("/project/new");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toMatchObject(params);
});
it("should invalidate project queries on success", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
await result.current.mutateAsync({ team_id: "team-1" });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all });
});
it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
json: async () => ({ error: "Server error" }),
});
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
result.current.mutateAsync({ team_id: "team-1" }).catch(() => {});
await waitFor(() => expect(result.current.isError).toBe(true));
});
it("should throw when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(result.current.mutateAsync({ team_id: "team-1" })).rejects.toThrow(
"Access token is required"
);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useDeleteProject } from "./useDeleteProject";
import { projectKeys } from "./useProjects";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),
handleError: vi.fn(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useDeleteProject", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
vi.clearAllMocks();
global.fetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
});
it("should render", () => {
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.mutate).toBeDefined();
});
it("should send DELETE to /project/delete with the given project IDs", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({}) });
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
await result.current.mutateAsync(["proj-1", "proj-2"]);
const [url, init] = (global.fetch as any).mock.calls[0];
expect(url).toContain("/project/delete");
expect(init.method).toBe("DELETE");
expect(JSON.parse(init.body)).toEqual({ project_ids: ["proj-1", "proj-2"] });
});
it("should invalidate project queries on success", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({}) });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
await result.current.mutateAsync(["proj-1"]);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all });
});
it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
json: async () => ({ error: "Not found" }),
});
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
result.current.mutateAsync(["proj-1"]).catch(() => {});
await waitFor(() => expect(result.current.isError).toBe(true));
});
it("should throw when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
const { result } = renderHook(() => useDeleteProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(result.current.mutateAsync(["proj-1"])).rejects.toThrow(
"Access token is required"
);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useProjectDetails } from "./useProjectDetails";
import { projectKeys, ProjectResponse } from "./useProjects";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),
handleError: vi.fn(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockProject: ProjectResponse = {
project_id: "proj-1",
project_alias: "Test Project",
description: "A test project",
team_id: "team-1",
budget_id: null,
metadata: null,
models: ["gpt-4"],
spend: 25.0,
model_spend: null,
model_rpm_limit: null,
model_tpm_limit: null,
blocked: false,
object_permission_id: null,
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",
updated_by: "user-1",
litellm_budget_table: null,
};
const mockProjects: ProjectResponse[] = [
mockProject,
{ ...mockProject, project_id: "proj-2", project_alias: "Test Project 2" },
];
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useProjectDetails", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.clearAllMocks();
global.fetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
});
it("should render", () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
expect(result.current).toBeDefined();
});
it("should return project details when the request succeeds", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProject);
});
it("should call /project/info with the projectId encoded as a query param", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
renderHook(() => useProjectDetails("proj-1"), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
const [url] = (global.fetch as any).mock.calls[0];
expect(url).toContain("/project/info");
expect(url).toContain("project_id=proj-1");
});
it("should not fetch when projectId is missing", () => {
const { result } = renderHook(() => useProjectDetails(undefined), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.isFetched).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should not fetch when accessToken is missing", () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.isFetched).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should not fetch when userRole is not an admin role", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.isFetched).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should seed initialData from the projects list cache", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
queryClient.setQueryData(projectKeys.list({}), mockProjects);
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.data).toEqual(mockProject);
expect(result.current.isLoading).toBe(false);
await waitFor(() => expect(result.current.isFetching).toBe(false));
});
it("should return undefined initialData when projectId is not in the cache", () => {
queryClient.setQueryData(projectKeys.list({}), mockProjects);
const { result } = renderHook(() => useProjectDetails("non-existent"), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.data).toBeUndefined();
});
it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
json: async () => ({ error: "Not found" }),
});
const { result } = renderHook(() => useProjectDetails("proj-1"), {
wrapper: makeWrapper(queryClient),
});
await waitFor(() => expect(result.current.isError).toBe(true));
});
});

View File

@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useProjects, ProjectResponse } from "./useProjects";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),
handleError: vi.fn(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockProjects: ProjectResponse[] = [
{
project_id: "proj-1",
project_alias: "Test Project",
description: "A test project",
team_id: "team-1",
budget_id: null,
metadata: null,
models: ["gpt-4"],
spend: 25.0,
model_spend: null,
model_rpm_limit: null,
model_tpm_limit: null,
blocked: false,
object_permission_id: null,
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",
updated_by: "user-1",
litellm_budget_table: null,
},
{
project_id: "proj-2",
project_alias: "Test Project 2",
description: null,
team_id: "team-1",
budget_id: null,
metadata: null,
models: [],
spend: 0,
model_spend: null,
model_rpm_limit: null,
model_tpm_limit: null,
blocked: false,
object_permission_id: null,
created_at: "2024-01-03T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-03T00:00:00Z",
updated_by: "user-1",
litellm_budget_table: null,
},
];
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useProjects", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.clearAllMocks();
global.fetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
});
it("should render", () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
expect(result.current).toBeDefined();
});
it("should return projects when the request succeeds", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockProjects);
});
it("should call GET /project/list with the auth header", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects });
renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
const [url, init] = (global.fetch as any).mock.calls[0];
expect(url).toContain("/project/list");
expect(init.headers["Authorization"]).toBe("Bearer test-token");
});
it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
json: async () => ({ error: "Not authorized" }),
});
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toBeUndefined();
});
it("should not fetch when accessToken is missing", () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
expect(result.current.isFetched).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should not fetch when userRole is not an admin role", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" });
const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) });
expect(result.current.isFetched).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useUpdateProject } from "./useUpdateProject";
import { projectKeys, ProjectResponse } from "./useProjects";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),
handleError: vi.fn(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockProject: ProjectResponse = {
project_id: "proj-1",
project_alias: "Test Project",
description: "A test project",
team_id: "team-1",
budget_id: null,
metadata: null,
models: ["gpt-4"],
spend: 25.0,
model_spend: null,
model_rpm_limit: null,
model_tpm_limit: null,
blocked: false,
object_permission_id: null,
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",
updated_by: "user-1",
litellm_budget_table: null,
};
function makeWrapper(queryClient: QueryClient) {
return ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useUpdateProject", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
vi.clearAllMocks();
global.fetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
});
it("should render", () => {
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
expect(result.current.mutate).toBeDefined();
});
it("should POST to /project/update and return the updated project", async () => {
const updated = { ...mockProject, project_alias: "Updated Name" };
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => updated });
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
const data = await result.current.mutateAsync({
projectId: "proj-1",
params: { project_alias: "Updated Name" },
});
expect(data).toEqual(updated);
const [url, init] = (global.fetch as any).mock.calls[0];
expect(url).toContain("/project/update");
expect(JSON.parse(init.body)).toMatchObject({
project_id: "proj-1",
project_alias: "Updated Name",
});
});
it("should invalidate project queries on success", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
await result.current.mutateAsync({ projectId: "proj-1", params: {} });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all });
});
it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
json: async () => ({ error: "Server error" }),
});
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
result.current.mutateAsync({ projectId: "proj-1", params: {} }).catch(() => {});
await waitFor(() => expect(result.current.isError).toBe(true));
});
it("should throw when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" });
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
});
await expect(
result.current.mutateAsync({ projectId: "proj-1", params: {} })
).rejects.toThrow("Access token is required");
expect(global.fetch).not.toHaveBeenCalled();
});
});