diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.test.tsx
new file mode 100644
index 00000000000..f9614fdabb9
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectDetailsPage.test.tsx
@@ -0,0 +1,180 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen } from "../../../tests/test-utils";
+import { ProjectDetail } from "./ProjectDetailsPage";
+import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
+
+const mockUseProjectDetails = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/projects/useProjectDetails", () => ({
+ useProjectDetails: (id: string) => mockUseProjectDetails(id),
+}));
+
+const mockUseTeam = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
+ useTeam: (id?: string) => mockUseTeam(id),
+}));
+
+vi.mock("./ProjectModals/EditProjectModal", () => ({
+ EditProjectModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ?
: null,
+}));
+
+vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
+ default: ({ userId }: { userId: string }) => {userId},
+}));
+
+const mockProject: ProjectResponse = {
+ project_id: "proj-1",
+ project_alias: "My Project",
+ description: "A sample project",
+ team_id: "team-1",
+ budget_id: null,
+ metadata: null,
+ models: ["gpt-4"],
+ spend: 12.5,
+ model_spend: { "gpt-4": 12.5 },
+ model_rpm_limit: null,
+ model_tpm_limit: null,
+ blocked: false,
+ object_permission_id: null,
+ created_at: "2024-01-15T08:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-02-01T12:00:00Z",
+ updated_by: "user-2",
+ litellm_budget_table: null,
+};
+
+describe("ProjectDetail", () => {
+ const onBack = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseTeam.mockReturnValue({ data: undefined, isLoading: false });
+ });
+
+ describe("when loading", () => {
+ it("should show a loading spinner", () => {
+ mockUseProjectDetails.mockReturnValue({ data: undefined, isLoading: true });
+ renderWithProviders();
+ expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
+ });
+ });
+
+ describe("when the project is not found", () => {
+ it("should display 'Project not found'", () => {
+ mockUseProjectDetails.mockReturnValue({ data: undefined, isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("Project not found")).toBeInTheDocument();
+ });
+
+ it("should call onBack when the back button is clicked in the not-found state", async () => {
+ const user = userEvent.setup();
+ mockUseProjectDetails.mockReturnValue({ data: undefined, isLoading: false });
+ renderWithProviders();
+ await user.click(screen.getByRole("button"));
+ expect(onBack).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("when the project loads successfully", () => {
+ beforeEach(() => {
+ mockUseProjectDetails.mockReturnValue({ data: mockProject, isLoading: false });
+ });
+
+ it("should render", () => {
+ renderWithProviders();
+ expect(screen.getByText("My Project")).toBeInTheDocument();
+ });
+
+ it("should display the project alias as the page title", () => {
+ renderWithProviders();
+ expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument();
+ });
+
+ it("should display 'Active' for a non-blocked project", () => {
+ renderWithProviders();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+
+ it("should display 'Blocked' for a blocked project", () => {
+ mockUseProjectDetails.mockReturnValue({
+ data: { ...mockProject, blocked: true },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Blocked")).toBeInTheDocument();
+ });
+
+ it("should call onBack when the back button is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByRole("button", { name: "" }));
+ expect(onBack).toHaveBeenCalledOnce();
+ });
+
+ it("should show the current spend amount", () => {
+ renderWithProviders();
+ expect(screen.getByText("$12.50")).toBeInTheDocument();
+ });
+
+ it("should show 'No budget limit' when no max budget is set", () => {
+ renderWithProviders();
+ expect(screen.getByText("No budget limit")).toBeInTheDocument();
+ });
+
+ it("should show the budget limit when one is set", () => {
+ mockUseProjectDetails.mockReturnValue({
+ data: {
+ ...mockProject,
+ litellm_budget_table: { max_budget: 100 },
+ },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("of $100.00 budget")).toBeInTheDocument();
+ });
+
+ it("should show the project description", () => {
+ renderWithProviders();
+ expect(screen.getByText("A sample project")).toBeInTheDocument();
+ });
+
+ it("should show an 'Edit Project' button", () => {
+ renderWithProviders();
+ expect(screen.getByRole("button", { name: /edit project/i })).toBeInTheDocument();
+ });
+
+ it("should open the edit modal when 'Edit Project' is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByRole("button", { name: /edit project/i }));
+ expect(screen.getByTestId("edit-modal")).toBeInTheDocument();
+ });
+
+ it("should show 'No team assigned' when the project has no team", () => {
+ mockUseProjectDetails.mockReturnValue({
+ data: { ...mockProject, team_id: null },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("No team assigned")).toBeInTheDocument();
+ });
+
+ it("should show team information when team data is available", () => {
+ mockUseTeam.mockReturnValue({
+ data: {
+ team_info: {
+ team_id: "team-1",
+ team_alias: "Engineering",
+ models: ["gpt-4"],
+ spend: 50,
+ members_with_roles: [],
+ },
+ },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("Engineering")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectKeysSection.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectKeysSection.test.tsx
new file mode 100644
index 00000000000..f9a2722da94
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectKeysSection.test.tsx
@@ -0,0 +1,72 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders, screen } from "../../../tests/test-utils";
+import { ProjectKeysSection } from "./ProjectKeysSection";
+
+const mockUseKeys = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
+ useKeys: (...args: unknown[]) => mockUseKeys(...args),
+}));
+
+vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
+ default: ({ userId }: { userId: string }) => {userId},
+}));
+
+const emptyKeysResponse = {
+ data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
+ isLoading: false,
+};
+
+describe("ProjectKeysSection", () => {
+ it("should render", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(screen.getByRole("table")).toBeInTheDocument();
+ });
+
+ it("should show the Keys card title", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(screen.getByText("Keys")).toBeInTheDocument();
+ });
+
+ it("should display the total key count from the API response", () => {
+ mockUseKeys.mockReturnValue({
+ data: { keys: [], total_count: 42, current_page: 1, total_pages: 9 },
+ isLoading: false,
+ });
+ renderWithProviders();
+ expect(screen.getByText("42 keys")).toBeInTheDocument();
+ });
+
+ it("should show 'No keys found' when the project has no keys", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(screen.getByText("No keys found")).toBeInTheDocument();
+ });
+
+ it("should render a search input for filtering by key name", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(screen.getByPlaceholderText("Filter by key name...")).toBeInTheDocument();
+ });
+
+ it("should call useKeys with the projectId", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(mockUseKeys).toHaveBeenCalledWith(
+ expect.any(Number),
+ expect.any(Number),
+ expect.objectContaining({ projectID: "proj-abc" })
+ );
+ });
+
+ it("should pass null for selectedKeyAlias when the filter input is empty", () => {
+ mockUseKeys.mockReturnValue(emptyKeysResponse);
+ renderWithProviders();
+ expect(mockUseKeys).toHaveBeenCalledWith(
+ expect.any(Number),
+ expect.any(Number),
+ expect.objectContaining({ selectedKeyAlias: null })
+ );
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.test.tsx
new file mode 100644
index 00000000000..4d077a6fb3a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectKeysTable.test.tsx
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders, screen } from "../../../tests/test-utils";
+import { ProjectKeysTable } from "./ProjectKeysTable";
+import { KeyResponse } from "@/components/key_team_helpers/key_list";
+
+vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
+ default: ({ userId }: { userId: string }) => {userId},
+}));
+
+function makeKey(overrides: Partial = {}): KeyResponse {
+ return {
+ token: "tok-abc123",
+ token_id: "tid-abc123",
+ key_name: "sk-...abc",
+ key_alias: "Test Key",
+ spend: 0,
+ max_budget: 0,
+ expires: "",
+ models: [],
+ aliases: {},
+ config: {},
+ user_id: null as any,
+ team_id: null,
+ project_id: null,
+ max_parallel_requests: 0,
+ metadata: {},
+ tpm_limit: 0,
+ rpm_limit: 0,
+ duration: "",
+ budget_duration: "",
+ budget_reset_at: "",
+ allowed_cache_controls: [],
+ allowed_routes: [],
+ permissions: {},
+ model_spend: {},
+ model_max_budget: {},
+ soft_budget_cooldown: false,
+ blocked: false,
+ litellm_budget_table: {},
+ organization_id: null,
+ created_at: "2024-03-01T00:00:00Z",
+ updated_at: "2024-03-01T00:00:00Z",
+ last_active: null,
+ team_spend: 0,
+ team_alias: "",
+ team_tpm_limit: 0,
+ team_rpm_limit: 0,
+ team_max_budget: 0,
+ team_models: [],
+ team_blocked: false,
+ soft_budget: 0,
+ team_model_aliases: {},
+ team_member_spend: 0,
+ team_metadata: {},
+ end_user_id: "",
+ end_user_tpm_limit: 0,
+ end_user_rpm_limit: 0,
+ end_user_max_budget: 0,
+ last_refreshed_at: 0,
+ api_key: "",
+ user_role: "user",
+ rpm_limit_per_model: {},
+ tpm_limit_per_model: {},
+ user_tpm_limit: 0,
+ user_rpm_limit: 0,
+ user_email: "",
+ ...overrides,
+ } as KeyResponse;
+}
+
+describe("ProjectKeysTable", () => {
+ it("should render", () => {
+ renderWithProviders();
+ expect(screen.getByRole("table")).toBeInTheDocument();
+ });
+
+ it("should display 'No keys found' when the keys list is empty", () => {
+ renderWithProviders();
+ expect(screen.getByText("No keys found")).toBeInTheDocument();
+ });
+
+ it("should display the key alias when provided", () => {
+ renderWithProviders();
+ expect(screen.getByText("My API Key")).toBeInTheDocument();
+ });
+
+ it("should display '—' when the key alias is null", () => {
+ // Provide a user_id so only the alias column shows "—" (not the owner column too)
+ renderWithProviders(
+
+ );
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("should display the owner using user.user_email when available", () => {
+ const key = makeKey({ user: { user_id: "u1", user_email: "alice@example.com" } });
+ renderWithProviders();
+ expect(screen.getByTestId("owner-tag")).toHaveTextContent("alice@example.com");
+ });
+
+ it("should fall back to user_id when user.user_email is absent", () => {
+ const key = makeKey({ user_id: "user-99" });
+ renderWithProviders();
+ expect(screen.getByTestId("owner-tag")).toHaveTextContent("user-99");
+ });
+
+ it("should display 'Never' in the Last Active column when last_active is null", () => {
+ renderWithProviders();
+ expect(screen.getByText("Never")).toBeInTheDocument();
+ });
+
+ it("should display a formatted date in the Last Active column when last_active is provided", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.queryByText("Never")).not.toBeInTheDocument();
+ });
+
+ it("should render multiple keys as separate rows", () => {
+ const keys = [
+ makeKey({ token: "tok-1", key_alias: "Key One" }),
+ makeKey({ token: "tok-2", key_alias: "Key Two" }),
+ ];
+ renderWithProviders();
+ expect(screen.getByText("Key One")).toBeInTheDocument();
+ expect(screen.getByText("Key Two")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx
new file mode 100644
index 00000000000..12fd80179b0
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen } from "../../../../tests/test-utils";
+import { CreateProjectModal } from "./CreateProjectModal";
+
+const mockMutate = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/projects/useCreateProject", () => ({
+ useCreateProject: () => ({ mutate: mockMutate, isPending: false }),
+}));
+
+// Mock the form to keep tests focused on modal behavior
+vi.mock("./ProjectBaseForm", () => ({
+ ProjectBaseForm: () => ,
+}));
+
+describe("CreateProjectModal", () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should not render modal content when closed", () => {
+ renderWithProviders();
+ expect(screen.queryByText("Create New Project")).not.toBeInTheDocument();
+ });
+
+ it("should render the modal when open", () => {
+ renderWithProviders();
+ expect(screen.getByText("Create New Project")).toBeInTheDocument();
+ });
+
+ it("should show a 'Create Project' submit button", () => {
+ renderWithProviders();
+ expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
+ });
+
+ it("should show a 'Cancel' button", () => {
+ renderWithProviders();
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it("should call onClose when the Cancel button is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("should render the project form inside the modal", () => {
+ renderWithProviders();
+ expect(screen.getByTestId("project-base-form")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx
new file mode 100644
index 00000000000..905dbcd985e
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/EditProjectModal.test.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen } from "../../../../tests/test-utils";
+import { EditProjectModal } from "./EditProjectModal";
+import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
+
+const mockMutate = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/projects/useUpdateProject", () => ({
+ useUpdateProject: () => ({ mutate: mockMutate, isPending: false }),
+}));
+
+vi.mock("./ProjectBaseForm", () => ({
+ ProjectBaseForm: () => ,
+}));
+
+const mockProject: ProjectResponse = {
+ project_id: "proj-1",
+ project_alias: "My Project",
+ description: "A test project",
+ team_id: "team-1",
+ budget_id: null,
+ metadata: null,
+ models: ["gpt-4"],
+ spend: 10.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,
+};
+
+describe("EditProjectModal", () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should not render modal content when closed", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.queryByText("Edit Project")).not.toBeInTheDocument();
+ });
+
+ it("should render the modal when open", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByText("Edit Project")).toBeInTheDocument();
+ });
+
+ it("should show a 'Save Changes' submit button", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ it("should show a 'Cancel' button", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it("should call onClose when the Cancel button is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+
+ );
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it("should render the project form inside the modal", () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByTestId("project-base-form")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx
new file mode 100644
index 00000000000..04e3ed64f47
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/ProjectBaseForm.test.tsx
@@ -0,0 +1,89 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
+import { Form } from "antd";
+import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
+
+const mockUseTeams = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
+ useTeams: () => mockUseTeams(),
+}));
+
+vi.mock("@/components/organisms/create_key_button", () => ({
+ fetchTeamModels: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => ({
+ getModelDisplayName: (model: string) => model,
+}));
+
+function FormWrapper() {
+ const [form] = Form.useForm();
+ return ;
+}
+
+describe("ProjectBaseForm", () => {
+ beforeEach(() => {
+ mockUseTeams.mockReturnValue({ data: [], isLoading: false });
+ });
+
+ it("should render", () => {
+ renderWithProviders();
+ expect(screen.getByLabelText("Project Name")).toBeInTheDocument();
+ });
+
+ it("should show a 'Basic Information' section heading", () => {
+ renderWithProviders();
+ expect(screen.getByText("Basic Information")).toBeInTheDocument();
+ });
+
+ it("should show a Project Name input", () => {
+ renderWithProviders();
+ expect(screen.getByPlaceholderText("e.g. Customer Support Bot")).toBeInTheDocument();
+ });
+
+ it("should show a Team select", () => {
+ renderWithProviders();
+ expect(screen.getByText("Team")).toBeInTheDocument();
+ });
+
+ it("should show a Description textarea", () => {
+ renderWithProviders();
+ expect(screen.getByPlaceholderText("Describe the purpose of this project")).toBeInTheDocument();
+ });
+
+ it("should show the models select as disabled when no team is selected", () => {
+ renderWithProviders();
+ // The models select should be disabled — its placeholder indicates no team yet
+ expect(screen.getByText("Select a team first")).toBeInTheDocument();
+ });
+
+ it("should show available team options when the Team dropdown is opened", async () => {
+ const user = userEvent.setup();
+ mockUseTeams.mockReturnValue({
+ data: [
+ { team_id: "team-1", team_alias: "Engineering", models: [] },
+ { team_id: "team-2", team_alias: "Sales", models: [] },
+ ],
+ isLoading: false,
+ });
+ renderWithProviders();
+ // The form label "Team" is associated with the combobox input inside the Select
+ await user.click(screen.getByLabelText("Team"));
+ await waitFor(() => {
+ expect(screen.getByText("Engineering")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Sales")).toBeInTheDocument();
+ });
+
+ it("should show the Max Budget field", () => {
+ renderWithProviders();
+ expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument();
+ });
+
+ it("should show the Advanced Settings collapse panel", () => {
+ renderWithProviders();
+ expect(screen.getByText("Advanced Settings")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/projectFormUtils.test.ts b/ui/litellm-dashboard/src/components/Projects/ProjectModals/projectFormUtils.test.ts
new file mode 100644
index 00000000000..f910fd7f819
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/projectFormUtils.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from "vitest";
+import { buildProjectApiParams } from "./projectFormUtils";
+import { ProjectFormValues } from "./ProjectBaseForm";
+
+const baseValues: ProjectFormValues = {
+ project_alias: "My Project",
+ team_id: "team-1",
+ models: [],
+ isBlocked: false,
+};
+
+describe("buildProjectApiParams", () => {
+ it("should map basic fields to the API shape", () => {
+ const result = buildProjectApiParams(baseValues);
+ expect(result.project_alias).toBe("My Project");
+ expect(result.blocked).toBe(false);
+ expect(result.models).toEqual([]);
+ });
+
+ it("should set blocked=true when isBlocked is true", () => {
+ const result = buildProjectApiParams({ ...baseValues, isBlocked: true });
+ expect(result.blocked).toBe(true);
+ });
+
+ it("should pass through description when provided", () => {
+ const result = buildProjectApiParams({ ...baseValues, description: "A description" });
+ expect(result.description).toBe("A description");
+ });
+
+ it("should pass through max_budget when provided", () => {
+ const result = buildProjectApiParams({ ...baseValues, max_budget: 50.0 });
+ expect(result.max_budget).toBe(50.0);
+ });
+
+ it("should build model_rpm_limit from modelLimits entries", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ modelLimits: [{ model: "gpt-4", rpm: 100, tpm: 200 }],
+ });
+ expect(result.model_rpm_limit).toEqual({ "gpt-4": 100 });
+ });
+
+ it("should build model_tpm_limit from modelLimits entries", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ modelLimits: [{ model: "gpt-4", rpm: 100, tpm: 200 }],
+ });
+ expect(result.model_tpm_limit).toEqual({ "gpt-4": 200 });
+ });
+
+ it("should omit model_rpm_limit when no modelLimits are provided", () => {
+ const result = buildProjectApiParams(baseValues);
+ expect(result).not.toHaveProperty("model_rpm_limit");
+ });
+
+ it("should omit model_tpm_limit when no modelLimits are provided", () => {
+ const result = buildProjectApiParams(baseValues);
+ expect(result).not.toHaveProperty("model_tpm_limit");
+ });
+
+ it("should skip a modelLimits entry that has no model name", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ modelLimits: [{ model: "", rpm: 100 }],
+ });
+ expect(result).not.toHaveProperty("model_rpm_limit");
+ });
+
+ it("should handle multiple model limit entries", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ modelLimits: [
+ { model: "gpt-4", rpm: 100 },
+ { model: "gpt-3.5-turbo", tpm: 5000 },
+ ],
+ });
+ expect(result.model_rpm_limit).toEqual({ "gpt-4": 100 });
+ expect(result.model_tpm_limit).toEqual({ "gpt-3.5-turbo": 5000 });
+ });
+
+ it("should build metadata from key-value entries", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ metadata: [{ key: "env", value: "production" }],
+ });
+ expect(result.metadata).toEqual({ env: "production" });
+ });
+
+ it("should omit metadata when no entries are provided", () => {
+ const result = buildProjectApiParams(baseValues);
+ expect(result).not.toHaveProperty("metadata");
+ });
+
+ it("should skip metadata entries with no key", () => {
+ const result = buildProjectApiParams({
+ ...baseValues,
+ metadata: [{ key: "", value: "something" }],
+ });
+ expect(result).not.toHaveProperty("metadata");
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.test.tsx
new file mode 100644
index 00000000000..f804b9b3ee6
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.test.tsx
@@ -0,0 +1,162 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
+import { ProjectsPage } from "./ProjectsPage";
+import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
+
+const mockUseProjects = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
+ useProjects: () => mockUseProjects(),
+}));
+
+const mockUseTeams = vi.fn();
+vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
+ useTeams: () => mockUseTeams(),
+}));
+
+// Stub modals and the detail page to keep tests focused on the list page
+vi.mock("./ProjectModals/CreateProjectModal", () => ({
+ CreateProjectModal: ({ isOpen }: { isOpen: boolean }) =>
+ isOpen ? : null,
+}));
+
+vi.mock("./ProjectDetailsPage", () => ({
+ ProjectDetail: ({ projectId }: { projectId: string }) => (
+ {projectId}
+ ),
+}));
+
+const mockProjects: ProjectResponse[] = [
+ {
+ project_id: "proj-1",
+ project_alias: "Alpha Project",
+ description: "First project",
+ team_id: "team-1",
+ budget_id: null,
+ metadata: null,
+ models: ["gpt-4", "claude-3"],
+ spend: 5.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-01T00:00:00Z",
+ updated_by: "user-1",
+ litellm_budget_table: null,
+ },
+ {
+ project_id: "proj-2",
+ project_alias: "Beta Project",
+ description: "Second project",
+ team_id: "team-2",
+ budget_id: null,
+ metadata: null,
+ models: [],
+ spend: 0,
+ model_spend: null,
+ model_rpm_limit: null,
+ model_tpm_limit: null,
+ blocked: true,
+ object_permission_id: null,
+ created_at: "2024-02-01T00:00:00Z",
+ created_by: "user-2",
+ updated_at: "2024-02-01T00:00:00Z",
+ updated_by: "user-2",
+ litellm_budget_table: null,
+ },
+];
+
+describe("ProjectsPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseTeams.mockReturnValue({ data: [], isLoading: false });
+ });
+
+ it("should render the Projects heading", () => {
+ mockUseProjects.mockReturnValue({ data: [], isLoading: false });
+ renderWithProviders();
+ expect(screen.getByRole("heading", { name: /projects/i })).toBeInTheDocument();
+ });
+
+ it("should show a 'Create Project' button", () => {
+ mockUseProjects.mockReturnValue({ data: [], isLoading: false });
+ renderWithProviders();
+ expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
+ });
+
+ it("should render the projects table", () => {
+ mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+ expect(screen.getByText("Beta Project")).toBeInTheDocument();
+ });
+
+ it("should show the model count for each project", () => {
+ mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
+ renderWithProviders();
+ // proj-1 has 2 models, proj-2 has 0
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+
+ it("should display 'Active' tag for non-blocked projects", () => {
+ mockUseProjects.mockReturnValue({ data: [mockProjects[0]], isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+
+ it("should display 'Blocked' tag for blocked projects", () => {
+ mockUseProjects.mockReturnValue({ data: [mockProjects[1]], isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("Blocked")).toBeInTheDocument();
+ });
+
+ it("should open the create modal when 'Create Project' is clicked", async () => {
+ const user = userEvent.setup();
+ mockUseProjects.mockReturnValue({ data: [], isLoading: false });
+ renderWithProviders();
+ await user.click(screen.getByRole("button", { name: /create project/i }));
+ expect(screen.getByTestId("create-modal")).toBeInTheDocument();
+ });
+
+ it("should show the project detail view when a project ID is clicked", async () => {
+ const user = userEvent.setup();
+ mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
+ renderWithProviders();
+ await user.click(screen.getByText("proj-1"));
+ expect(screen.getByTestId("project-detail")).toHaveTextContent("proj-1");
+ });
+
+ it("should filter displayed projects when the search input has a value", async () => {
+ const user = userEvent.setup();
+ mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
+ renderWithProviders();
+ await user.type(
+ screen.getByPlaceholderText(/search projects/i),
+ "Alpha"
+ );
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+ expect(screen.queryByText("Beta Project")).not.toBeInTheDocument();
+ });
+ });
+
+ it("should show the total project count in the pagination", () => {
+ mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("2 projects")).toBeInTheDocument();
+ });
+
+ it("should resolve team alias from the teams list in the Team column", () => {
+ mockUseTeams.mockReturnValue({
+ data: [{ team_id: "team-1", team_alias: "Engineering", models: [] }],
+ isLoading: false,
+ });
+ mockUseProjects.mockReturnValue({ data: [mockProjects[0]], isLoading: false });
+ renderWithProviders();
+ expect(screen.getByText("Engineering")).toBeInTheDocument();
+ });
+});