Merge pull request #22829 from BerriAI/litellm_projects_vitest

[Test] UI - Projects: add Vitest unit tests for all Projects components
This commit is contained in:
yuneng-jiang 2026-03-04 17:39:59 -08:00 committed by GitHub
commit e4dd3efe11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 873 additions and 0 deletions

View file

@ -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 ? <div data-testid="edit-modal" /> : null,
}));
vi.mock("@/components/common_components/DefaultProxyAdminTag", () => ({
default: ({ userId }: { userId: string }) => <span>{userId}</span>,
}));
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("My Project")).toBeInTheDocument();
});
it("should display the project alias as the page title", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByRole("heading", { name: "My Project" })).toBeInTheDocument();
});
it("should display 'Active' for a non-blocked project", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("should display 'Blocked' for a blocked project", () => {
mockUseProjectDetails.mockReturnValue({
data: { ...mockProject, blocked: true },
isLoading: false,
});
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("Blocked")).toBeInTheDocument();
});
it("should call onBack when the back button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
await user.click(screen.getByRole("button", { name: "" }));
expect(onBack).toHaveBeenCalledOnce();
});
it("should show the current spend amount", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("$12.50")).toBeInTheDocument();
});
it("should show 'No budget limit' when no max budget is set", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("of $100.00 budget")).toBeInTheDocument();
});
it("should show the project description", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("A sample project")).toBeInTheDocument();
});
it("should show an 'Edit Project' button", () => {
renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
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(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("Engineering")).toBeInTheDocument();
});
});
});

View file

@ -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 }) => <span>{userId}</span>,
}));
const emptyKeysResponse = {
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isLoading: false,
};
describe("ProjectKeysSection", () => {
it("should render", () => {
mockUseKeys.mockReturnValue(emptyKeysResponse);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("should show the Keys card title", () => {
mockUseKeys.mockReturnValue(emptyKeysResponse);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />);
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(<ProjectKeysSection projectId="proj-1" />);
expect(screen.getByText("42 keys")).toBeInTheDocument();
});
it("should show 'No keys found' when the project has no keys", () => {
mockUseKeys.mockReturnValue(emptyKeysResponse);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />);
expect(screen.getByText("No keys found")).toBeInTheDocument();
});
it("should render a search input for filtering by key name", () => {
mockUseKeys.mockReturnValue(emptyKeysResponse);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />);
expect(screen.getByPlaceholderText("Filter by key name...")).toBeInTheDocument();
});
it("should call useKeys with the projectId", () => {
mockUseKeys.mockReturnValue(emptyKeysResponse);
renderWithProviders(<ProjectKeysSection projectId="proj-abc" />);
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(<ProjectKeysSection projectId="proj-1" />);
expect(mockUseKeys).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
expect.objectContaining({ selectedKeyAlias: null })
);
});
});

View file

@ -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 }) => <span data-testid="owner-tag">{userId}</span>,
}));
function makeKey(overrides: Partial<KeyResponse> = {}): 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(<ProjectKeysTable keys={[]} />);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("should display 'No keys found' when the keys list is empty", () => {
renderWithProviders(<ProjectKeysTable keys={[]} />);
expect(screen.getByText("No keys found")).toBeInTheDocument();
});
it("should display the key alias when provided", () => {
renderWithProviders(<ProjectKeysTable keys={[makeKey({ key_alias: "My API Key" })]} />);
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(
<ProjectKeysTable keys={[makeKey({ key_alias: null as any, user_id: "owner-1" })]} />
);
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(<ProjectKeysTable keys={[key]} />);
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(<ProjectKeysTable keys={[key]} />);
expect(screen.getByTestId("owner-tag")).toHaveTextContent("user-99");
});
it("should display 'Never' in the Last Active column when last_active is null", () => {
renderWithProviders(<ProjectKeysTable keys={[makeKey({ last_active: null })]} />);
expect(screen.getByText("Never")).toBeInTheDocument();
});
it("should display a formatted date in the Last Active column when last_active is provided", () => {
renderWithProviders(
<ProjectKeysTable keys={[makeKey({ last_active: "2024-06-15T10:00:00Z" })]} />
);
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(<ProjectKeysTable keys={keys} />);
expect(screen.getByText("Key One")).toBeInTheDocument();
expect(screen.getByText("Key Two")).toBeInTheDocument();
});
});

View file

@ -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: () => <div data-testid="project-base-form" />,
}));
describe("CreateProjectModal", () => {
const onClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should not render modal content when closed", () => {
renderWithProviders(<CreateProjectModal isOpen={false} onClose={onClose} />);
expect(screen.queryByText("Create New Project")).not.toBeInTheDocument();
});
it("should render the modal when open", () => {
renderWithProviders(<CreateProjectModal isOpen={true} onClose={onClose} />);
expect(screen.getByText("Create New Project")).toBeInTheDocument();
});
it("should show a 'Create Project' submit button", () => {
renderWithProviders(<CreateProjectModal isOpen={true} onClose={onClose} />);
expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
});
it("should show a 'Cancel' button", () => {
renderWithProviders(<CreateProjectModal isOpen={true} onClose={onClose} />);
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
it("should call onClose when the Cancel button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CreateProjectModal isOpen={true} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it("should render the project form inside the modal", () => {
renderWithProviders(<CreateProjectModal isOpen={true} onClose={onClose} />);
expect(screen.getByTestId("project-base-form")).toBeInTheDocument();
});
});

View file

@ -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: () => <div data-testid="project-base-form" />,
}));
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(
<EditProjectModal isOpen={false} project={mockProject} onClose={onClose} />
);
expect(screen.queryByText("Edit Project")).not.toBeInTheDocument();
});
it("should render the modal when open", () => {
renderWithProviders(
<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />
);
expect(screen.getByText("Edit Project")).toBeInTheDocument();
});
it("should show a 'Save Changes' submit button", () => {
renderWithProviders(
<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />
);
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
it("should show a 'Cancel' button", () => {
renderWithProviders(
<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />
);
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
it("should call onClose when the Cancel button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />
);
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledOnce();
});
it("should render the project form inside the modal", () => {
renderWithProviders(
<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />
);
expect(screen.getByTestId("project-base-form")).toBeInTheDocument();
});
});

View file

@ -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<ProjectFormValues>();
return <ProjectBaseForm form={form} />;
}
describe("ProjectBaseForm", () => {
beforeEach(() => {
mockUseTeams.mockReturnValue({ data: [], isLoading: false });
});
it("should render", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByLabelText("Project Name")).toBeInTheDocument();
});
it("should show a 'Basic Information' section heading", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByText("Basic Information")).toBeInTheDocument();
});
it("should show a Project Name input", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByPlaceholderText("e.g. Customer Support Bot")).toBeInTheDocument();
});
it("should show a Team select", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByText("Team")).toBeInTheDocument();
});
it("should show a Description textarea", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByPlaceholderText("Describe the purpose of this project")).toBeInTheDocument();
});
it("should show the models select as disabled when no team is selected", () => {
renderWithProviders(<FormWrapper />);
// 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(<FormWrapper />);
// 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(<FormWrapper />);
expect(screen.getByPlaceholderText("0.00")).toBeInTheDocument();
});
it("should show the Advanced Settings collapse panel", () => {
renderWithProviders(<FormWrapper />);
expect(screen.getByText("Advanced Settings")).toBeInTheDocument();
});
});

View file

@ -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");
});
});

View file

@ -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 ? <div data-testid="create-modal" /> : null,
}));
vi.mock("./ProjectDetailsPage", () => ({
ProjectDetail: ({ projectId }: { projectId: string }) => (
<div data-testid="project-detail">{projectId}</div>
),
}));
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(<ProjectsPage />);
expect(screen.getByRole("heading", { name: /projects/i })).toBeInTheDocument();
});
it("should show a 'Create Project' button", () => {
mockUseProjects.mockReturnValue({ data: [], isLoading: false });
renderWithProviders(<ProjectsPage />);
expect(screen.getByRole("button", { name: /create project/i })).toBeInTheDocument();
});
it("should render the projects table", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />);
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(<ProjectsPage />);
// 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(<ProjectsPage />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("should display 'Blocked' tag for blocked projects", () => {
mockUseProjects.mockReturnValue({ data: [mockProjects[1]], isLoading: false });
renderWithProviders(<ProjectsPage />);
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(<ProjectsPage />);
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(<ProjectsPage />);
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(<ProjectsPage />);
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(<ProjectsPage />);
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(<ProjectsPage />);
expect(screen.getByText("Engineering")).toBeInTheDocument();
});
});