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(); + }); +});