mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
commit
e4dd3efe11
8 changed files with 873 additions and 0 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue