From 5d45de6bc4aab861ca0be20769629efef9b44cd6 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Tue, 6 May 2025 18:01:43 +0530 Subject: [PATCH] feat: adds unit tests in modules/ee/teams (#5620) --- apps/web/modules/ee/teams/lib/roles.test.ts | 113 ++++++ .../components/access-table.test.tsx | 41 +++ .../components/access-view.test.tsx | 72 ++++ .../components/manage-team.test.tsx | 46 +++ .../ee/teams/project-teams/lib/team.test.ts | 68 ++++ .../ee/teams/project-teams/loading.test.tsx | 41 +++ .../ee/teams/project-teams/page.test.tsx | 73 ++++ .../ee/teams/team-list/actions.test.ts | 86 +++++ .../teams/team-list/{action.ts => actions.ts} | 0 .../components/create-team-button.test.tsx | 27 ++ .../components/create-team-modal.test.tsx | 77 ++++ .../components/create-team-modal.tsx | 2 +- .../components/manage-team-button.test.tsx | 42 +++ .../team-settings/delete-team.test.tsx | 99 +++++ .../components/team-settings/delete-team.tsx | 2 +- .../team-settings-modal.test.tsx | 136 +++++++ .../team-settings/team-settings-modal.tsx | 2 +- .../team-list/components/teams-table.test.tsx | 154 ++++++++ .../team-list/components/teams-table.tsx | 2 +- .../ee/teams/team-list/lib/project.test.ts | 50 +++ .../ee/teams/team-list/lib/team.test.ts | 343 ++++++++++++++++++ .../modules/ee/teams/team-list/lib/team.ts | 2 +- apps/web/modules/ee/teams/utils/teams.test.ts | 67 ++++ apps/web/vite.config.mts | 2 + 24 files changed, 1542 insertions(+), 5 deletions(-) create mode 100644 apps/web/modules/ee/teams/lib/roles.test.ts create mode 100644 apps/web/modules/ee/teams/project-teams/components/access-table.test.tsx create mode 100644 apps/web/modules/ee/teams/project-teams/components/access-view.test.tsx create mode 100644 apps/web/modules/ee/teams/project-teams/components/manage-team.test.tsx create mode 100644 apps/web/modules/ee/teams/project-teams/lib/team.test.ts create mode 100644 apps/web/modules/ee/teams/project-teams/loading.test.tsx create mode 100644 apps/web/modules/ee/teams/project-teams/page.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/actions.test.ts rename apps/web/modules/ee/teams/team-list/{action.ts => actions.ts} (100%) create mode 100644 apps/web/modules/ee/teams/team-list/components/create-team-button.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/manage-team-button.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/components/teams-table.test.tsx create mode 100644 apps/web/modules/ee/teams/team-list/lib/project.test.ts create mode 100644 apps/web/modules/ee/teams/team-list/lib/team.test.ts create mode 100644 apps/web/modules/ee/teams/utils/teams.test.ts diff --git a/apps/web/modules/ee/teams/lib/roles.test.ts b/apps/web/modules/ee/teams/lib/roles.test.ts new file mode 100644 index 0000000000..f75b19d1a2 --- /dev/null +++ b/apps/web/modules/ee/teams/lib/roles.test.ts @@ -0,0 +1,113 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + projectTeam: { findMany: vi.fn() }, + teamUser: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } })); +vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() })); + +const mockUserId = "user-1"; +const mockProjectId = "project-1"; +const mockTeamId = "team-1"; + +describe("roles lib", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getProjectPermissionByUserId", () => { + test("returns null if no memberships", async () => { + vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([]); + const result = await getProjectPermissionByUserId(mockUserId, mockProjectId); + expect(result).toBeNull(); + expect(validateInputs).toHaveBeenCalledWith( + [mockUserId, expect.anything()], + [mockProjectId, expect.anything()] + ); + }); + + test("returns 'manage' if any membership has manage", async () => { + vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([ + { permission: "read" }, + { permission: "manage" }, + { permission: "readWrite" }, + ] as any); + const result = await getProjectPermissionByUserId(mockUserId, mockProjectId); + expect(result).toBe("manage"); + }); + + test("returns 'readWrite' if highest is readWrite", async () => { + vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([ + { permission: "read" }, + { permission: "readWrite" }, + ] as any); + const result = await getProjectPermissionByUserId(mockUserId, mockProjectId); + expect(result).toBe("readWrite"); + }); + + test("returns 'read' if only read", async () => { + vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([{ permission: "read" }] as any); + const result = await getProjectPermissionByUserId(mockUserId, mockProjectId); + expect(result).toBe("read"); + }); + + test("throws DatabaseError on PrismaClientKnownRequestError", async () => { + const error = new Prisma.PrismaClientKnownRequestError("fail", { + code: "P2002", + clientVersion: "1.0.0", + }); + vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error); + await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(error, expect.any(String)); + }); + + test("throws UnknownError on generic error", async () => { + const error = new Error("fail"); + vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error); + await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(UnknownError); + }); + }); + + describe("getTeamRoleByTeamIdUserId", () => { + test("returns null if no teamUser", async () => { + vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce(null); + const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId); + expect(result).toBeNull(); + expect(validateInputs).toHaveBeenCalledWith( + [mockTeamId, expect.anything()], + [mockUserId, expect.anything()] + ); + }); + + test("returns role if teamUser exists", async () => { + vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" }); + const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId); + expect(result).toBe("member"); + }); + + test("throws DatabaseError on PrismaClientKnownRequestError", async () => { + const error = new Prisma.PrismaClientKnownRequestError("fail", { + code: "P2002", + clientVersion: "1.0.0", + }); + vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error); + await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(DatabaseError); + }); + + test("throws error on generic error", async () => { + const error = new Error("fail"); + vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error); + await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error); + }); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/components/access-table.test.tsx b/apps/web/modules/ee/teams/project-teams/components/access-table.test.tsx new file mode 100644 index 0000000000..f08992510b --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/components/access-table.test.tsx @@ -0,0 +1,41 @@ +import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team"; +import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { AccessTable } from "./access-table"; + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ t: (k: string) => k }), +})); + +describe("AccessTable", () => { + afterEach(() => { + cleanup(); + }); + + test("renders no teams found row when teams is empty", () => { + render(); + expect(screen.getByText("environments.project.teams.no_teams_found")).toBeInTheDocument(); + }); + + test("renders team rows with correct data and permission mapping", () => { + const teams: TProjectTeam[] = [ + { id: "1", name: "Team A", memberCount: 1, permission: "readWrite" }, + { id: "2", name: "Team B", memberCount: 2, permission: "read" }, + ]; + render(); + expect(screen.getByText("Team A")).toBeInTheDocument(); + expect(screen.getByText("Team B")).toBeInTheDocument(); + expect(screen.getByText("1 common.member")).toBeInTheDocument(); + expect(screen.getByText("2 common.members")).toBeInTheDocument(); + expect(screen.getByText(TeamPermissionMapping["readWrite"])).toBeInTheDocument(); + expect(screen.getByText(TeamPermissionMapping["read"])).toBeInTheDocument(); + }); + + test("renders table headers with tolgee keys", () => { + render(); + expect(screen.getByText("environments.project.teams.team_name")).toBeInTheDocument(); + expect(screen.getByText("common.size")).toBeInTheDocument(); + expect(screen.getByText("environments.project.teams.permission")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/components/access-view.test.tsx b/apps/web/modules/ee/teams/project-teams/components/access-view.test.tsx new file mode 100644 index 0000000000..fd888856ea --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/components/access-view.test.tsx @@ -0,0 +1,72 @@ +import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { AccessView } from "./access-view"; + +vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({ + SettingsCard: ({ title, description, children }: any) => ( +
+
{title}
+
{description}
+ {children} +
+ ), +})); + +vi.mock("@/modules/ee/teams/project-teams/components/manage-team", () => ({ + ManageTeam: ({ environmentId, isOwnerOrManager }: any) => ( + + ), +})); + +vi.mock("@/modules/ee/teams/project-teams/components/access-table", () => ({ + AccessTable: ({ teams }: any) => ( +
+ {teams.length === 0 ? "No teams" : `Teams: ${teams.map((t: any) => t.name).join(",")}`} +
+ ), +})); + +describe("AccessView", () => { + afterEach(() => { + cleanup(); + }); + + const baseProps = { + environmentId: "env-1", + isOwnerOrManager: true, + teams: [ + { id: "1", name: "Team A", memberCount: 2, permission: "readWrite" } as TProjectTeam, + { id: "2", name: "Team B", memberCount: 1, permission: "read" } as TProjectTeam, + ], + }; + + test("renders SettingsCard with tolgee strings and children", () => { + render(); + expect(screen.getByTestId("SettingsCard")).toBeInTheDocument(); + expect(screen.getByText("common.team_access")).toBeInTheDocument(); + expect(screen.getByText("environments.project.teams.team_settings_description")).toBeInTheDocument(); + }); + + test("renders ManageTeam with correct props", () => { + render(); + expect(screen.getByTestId("ManageTeam")).toHaveTextContent("ManageTeam env-1 owner"); + }); + + test("renders AccessTable with teams", () => { + render(); + expect(screen.getByTestId("AccessTable")).toHaveTextContent("Teams: Team A,Team B"); + }); + + test("renders AccessTable with no teams", () => { + render(); + expect(screen.getByTestId("AccessTable")).toHaveTextContent("No teams"); + }); + + test("renders ManageTeam as not-owner when isOwnerOrManager is false", () => { + render(); + expect(screen.getByTestId("ManageTeam")).toHaveTextContent("not-owner"); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/components/manage-team.test.tsx b/apps/web/modules/ee/teams/project-teams/components/manage-team.test.tsx new file mode 100644 index 0000000000..b74bfb37e5 --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/components/manage-team.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ManageTeam } from "./manage-team"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ tooltipContent, children }: any) => ( +
+ {tooltipContent} + {children} +
+ ), +})); + +describe("ManageTeam", () => { + afterEach(() => { + cleanup(); + }); + + test("renders enabled button and navigates when isOwnerOrManager is true", async () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeEnabled(); + expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument(); + await userEvent.click(button); + }); + + test("renders disabled button with tooltip when isOwnerOrManager is false", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument(); + expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument(); + expect( + screen.getByText("environments.project.teams.only_organization_owners_and_managers_can_manage_teams") + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/lib/team.test.ts b/apps/web/modules/ee/teams/project-teams/lib/team.test.ts new file mode 100644 index 0000000000..77cfeaf50a --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/lib/team.test.ts @@ -0,0 +1,68 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getTeamsByProjectId } from "./team"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { findUnique: vi.fn() }, + team: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/cache/team", () => ({ teamCache: { tag: { byProjectId: vi.fn(), byId: vi.fn() } } })); +vi.mock("@/lib/project/cache", () => ({ projectCache: { tag: { byId: vi.fn() } } })); + +const mockProject = { id: "p1" }; +const mockTeams = [ + { + id: "t1", + name: "Team 1", + projectTeams: [{ permission: "readWrite" }], + _count: { teamUsers: 2 }, + }, + { + id: "t2", + name: "Team 2", + projectTeams: [{ permission: "manage" }], + _count: { teamUsers: 3 }, + }, +]; + +describe("getTeamsByProjectId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns mapped teams for valid project", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject); + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); + const result = await getTeamsByProjectId("p1"); + expect(result).toEqual([ + { id: "t1", name: "Team 1", permission: "readWrite", memberCount: 2 }, + { id: "t2", name: "Team 2", permission: "manage", memberCount: 3 }, + ]); + expect(prisma.project.findUnique).toHaveBeenCalledWith({ where: { id: "p1" } }); + expect(prisma.team.findMany).toHaveBeenCalled(); + }); + + test("throws ResourceNotFoundError if project does not exist", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(null); + await expect(getTeamsByProjectId("p1")).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws DatabaseError on Prisma known error", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject); + vi.mocked(prisma.team.findMany).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getTeamsByProjectId("p1")).rejects.toThrow(DatabaseError); + }); + + test("throws unknown error on unexpected error", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject); + vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("unexpected")); + await expect(getTeamsByProjectId("p1")).rejects.toThrow("unexpected"); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/loading.test.tsx b/apps/web/modules/ee/teams/project-teams/loading.test.tsx new file mode 100644 index 0000000000..dba81dd2dc --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/loading.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TeamsLoading } from "./loading"; + +vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({ + ProjectConfigNavigation: ({ activeId, loading }: any) => ( +
{`${activeId}-${loading}`}
+ ), +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: any) =>
{children}
, +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ children, pageTitle }: any) => ( +
+ {pageTitle} + {children} +
+ ), +})); + +describe("TeamsLoading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading skeletons and navigation", () => { + render(); + expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("PageHeader")).toBeInTheDocument(); + expect(screen.getByTestId("ProjectConfigNavigation")).toHaveTextContent("teams-true"); + + // Check for the presence of multiple skeleton loaders (at least one) + const skeletonLoaders = screen.getAllByRole("generic", { name: "" }); // Assuming skeleton divs don't have specific roles/names + // Filter for elements with animate-pulse class + const pulseElements = skeletonLoaders.filter((el) => el.classList.contains("animate-pulse")); + expect(pulseElements.length).toBeGreaterThan(0); + + expect(screen.getByText("common.project_configuration")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ee/teams/project-teams/page.test.tsx b/apps/web/modules/ee/teams/project-teams/page.test.tsx new file mode 100644 index 0000000000..b044b964b6 --- /dev/null +++ b/apps/web/modules/ee/teams/project-teams/page.test.tsx @@ -0,0 +1,73 @@ +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { getTranslate } from "@/tolgee/server"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getTeamsByProjectId } from "./lib/team"; +import { ProjectTeams } from "./page"; + +vi.mock("@/modules/ee/teams/project-teams/components/access-view", () => ({ + AccessView: (props: any) =>
{JSON.stringify(props)}
, +})); +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); +vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({ + ProjectConfigNavigation: (props: any) => ( +
{JSON.stringify(props)}
+ ), +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: any) =>
{children}
, +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ children, pageTitle }: any) => ( +
+ {pageTitle} + {children} +
+ ), +})); +vi.mock("./lib/team", () => ({ + getTeamsByProjectId: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +describe("ProjectTeams", () => { + const params = Promise.resolve({ environmentId: "env-1" }); + + beforeEach(() => { + vi.mocked(getTeamsByProjectId).mockResolvedValue([ + { id: "team-1", name: "Team 1", memberCount: 2, permission: "readWrite" }, + { id: "team-2", name: "Team 2", memberCount: 1, permission: "read" }, + ]); + vi.mocked(getTranslate).mockResolvedValue((key) => key); + + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + project: { id: "project-1" }, + isOwner: true, + isManager: false, + } as any); + }); + afterEach(() => { + cleanup(); + }); + + test("renders all main components and passes correct props", async () => { + const ui = await ProjectTeams({ params }); + render(ui); + expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("PageHeader")).toBeInTheDocument(); + expect(screen.getByText("common.project_configuration")).toBeInTheDocument(); + expect(screen.getByTestId("ProjectConfigNavigation")).toBeInTheDocument(); + expect(screen.getByTestId("AccessView")).toHaveTextContent('"environmentId":"env-1"'); + expect(screen.getByTestId("AccessView")).toHaveTextContent('"isOwnerOrManager":true'); + }); + + test("throws error if teams is null", async () => { + vi.mocked(getTeamsByProjectId).mockResolvedValue(null); + await expect(ProjectTeams({ params })).rejects.toThrow("common.teams_not_found"); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/actions.test.ts b/apps/web/modules/ee/teams/team-list/actions.test.ts new file mode 100644 index 0000000000..54fcdfb31f --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/actions.test.ts @@ -0,0 +1,86 @@ +import { ZTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + createTeamAction, + deleteTeamAction, + getTeamDetailsAction, + getTeamRoleAction, + updateTeamDetailsAction, +} from "./actions"; + +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { + schema: () => ({ + action: (fn: any) => fn, + }), + }, + checkAuthorizationUpdated: vi.fn(), +})); +vi.mock("@/lib/utils/action-client-middleware", () => ({ + checkAuthorizationUpdated: vi.fn(), +})); +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromTeamId: vi.fn(async (id: string) => `org-${id}`), +})); +vi.mock("@/modules/ee/role-management/actions", () => ({ + checkRoleManagementPermission: vi.fn(), +})); +vi.mock("@/modules/ee/teams/lib/roles", () => ({ + getTeamRoleByTeamIdUserId: vi.fn(async () => "admin"), +})); +vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({ + createTeam: vi.fn(async () => "team-created"), + getTeamDetails: vi.fn(async () => ({ id: "team-1" })), + deleteTeam: vi.fn(async () => true), + updateTeamDetails: vi.fn(async () => ({ updated: true })), +})); + +describe("action.ts", () => { + const ctx = { + user: { id: "user-1" }, + } as any; + afterEach(() => { + cleanup(); + }); + + test("createTeamAction calls dependencies and returns result", async () => { + const result = await createTeamAction({ + ctx, + parsedInput: { organizationId: "org-1", name: "Team X" }, + } as any); + expect(result).toBe("team-created"); + }); + + test("getTeamDetailsAction calls dependencies and returns result", async () => { + const result = await getTeamDetailsAction({ + ctx, + parsedInput: { teamId: "team-1" }, + } as any); + expect(result).toEqual({ id: "team-1" }); + }); + + test("deleteTeamAction calls dependencies and returns result", async () => { + const result = await deleteTeamAction({ + ctx, + parsedInput: { teamId: "team-1" }, + } as any); + expect(result).toBe(true); + }); + + test("updateTeamDetailsAction calls dependencies and returns result", async () => { + const result = await updateTeamDetailsAction({ + ctx, + parsedInput: { teamId: "team-1", data: {} as typeof ZTeamSettingsFormSchema._type }, + } as any); + expect(result).toEqual({ updated: true }); + }); + + test("getTeamRoleAction calls dependencies and returns result", async () => { + const result = await getTeamRoleAction({ + ctx, + parsedInput: { teamId: "team-1" }, + } as any); + expect(result).toBe("admin"); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/action.ts b/apps/web/modules/ee/teams/team-list/actions.ts similarity index 100% rename from apps/web/modules/ee/teams/team-list/action.ts rename to apps/web/modules/ee/teams/team-list/actions.ts diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-button.test.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-button.test.tsx new file mode 100644 index 0000000000..5a9e7cf105 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/create-team-button.test.tsx @@ -0,0 +1,27 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { CreateTeamButton } from "./create-team-button"; + +vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({ + CreateTeamModal: ({ open, setOpen, organizationId }: any) => + open ?
{organizationId}
: null, +})); + +describe("CreateTeamButton", () => { + afterEach(() => { + cleanup(); + }); + + test("renders button with tolgee string", () => { + render(); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument(); + }); + + test("opens CreateTeamModal on button click", async () => { + render(); + await userEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("CreateTeamModal")).toHaveTextContent("org-2"); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx new file mode 100644 index 0000000000..09671e52fc --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/create-team-modal.test.tsx @@ -0,0 +1,77 @@ +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { createTeamAction } from "@/modules/ee/teams/team-list/actions"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { CreateTeamModal } from "./create-team-modal"; + +vi.mock("@/modules/ui/components/modal", () => ({ + Modal: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/modules/ee/teams/team-list/actions", () => ({ + createTeamAction: vi.fn(), +})); +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(() => "error-message"), +})); + +describe("CreateTeamModal", () => { + afterEach(() => { + cleanup(); + }); + + const setOpen = vi.fn(); + + test("renders modal, form, and tolgee strings", () => { + render(); + expect(screen.getByTestId("Modal")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument(); + expect(screen.getByText("common.cancel")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.create")).toBeInTheDocument(); + }); + + test("calls setOpen(false) and resets teamName on cancel", async () => { + render(); + const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name"); + await userEvent.type(input, "My Team"); + await userEvent.click(screen.getByText("common.cancel")); + expect(setOpen).toHaveBeenCalledWith(false); + expect((input as HTMLInputElement).value).toBe(""); + }); + + test("submit button is disabled when input is empty", () => { + render(); + expect(screen.getByText("environments.settings.teams.create")).toBeDisabled(); + }); + + test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => { + vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" }); + const onCreate = vi.fn(); + render(); + const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name"); + await userEvent.type(input, "My Team"); + await userEvent.click(screen.getByText("environments.settings.teams.create")); + await waitFor(() => { + expect(createTeamAction).toHaveBeenCalledWith({ name: "My Team", organizationId: "org-1" }); + expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_created_successfully"); + expect(onCreate).toHaveBeenCalledWith("team-123"); + expect(setOpen).toHaveBeenCalledWith(false); + expect((input as HTMLInputElement).value).toBe(""); + }); + }); + + test("shows error toast if createTeamAction fails", async () => { + vi.mocked(createTeamAction).mockResolvedValue({}); + render(); + const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name"); + await userEvent.type(input, "My Team"); + await userEvent.click(screen.getByText("environments.settings.teams.create")); + await waitFor(() => { + expect(getFormattedErrorMessage).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("error-message"); + }); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx index 41da0a1e1a..65dd5a0a0a 100644 --- a/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx +++ b/apps/web/modules/ee/teams/team-list/components/create-team-modal.tsx @@ -1,7 +1,7 @@ "use client"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { createTeamAction } from "@/modules/ee/teams/team-list/action"; +import { createTeamAction } from "@/modules/ee/teams/team-list/actions"; import { Button } from "@/modules/ui/components/button"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; diff --git a/apps/web/modules/ee/teams/team-list/components/manage-team-button.test.tsx b/apps/web/modules/ee/teams/team-list/components/manage-team-button.test.tsx new file mode 100644 index 0000000000..df0bdc309d --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/manage-team-button.test.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ManageTeamButton } from "./manage-team-button"; + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) => + shouldRender ? ( +
+ {tooltipContent} + {children} +
+ ) : ( + <>{children} + ), +})); + +describe("ManageTeamButton", () => { + afterEach(() => { + cleanup(); + }); + + test("renders enabled button and calls onClick", async () => { + const onClick = vi.fn(); + render(); + const button = screen.getByRole("button"); + expect(button).toBeEnabled(); + expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument(); + await userEvent.click(button); + expect(onClick).toHaveBeenCalled(); + }); + + test("renders disabled button with tooltip", () => { + const onClick = vi.fn(); + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument(); + expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.manage_team_disabled")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.test.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.test.tsx new file mode 100644 index 0000000000..96635bcf9e --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.test.tsx @@ -0,0 +1,99 @@ +import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions"; +import { TTeam } from "@/modules/ee/teams/team-list/types/team"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DeleteTeam } from "./delete-team"; + +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children }: any) => , +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, ...props }: any) => , +})); +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) => + shouldRender ? ( +
+ {tooltipContent} + {children} +
+ ) : ( + <>{children} + ), +})); +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, deleteWhat, text, onDelete, isDeleting }: any) => + open ? ( +
+ {deleteWhat} + {text} + +
+ ) : null, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +vi.mock("@/modules/ee/teams/team-list/actions", () => ({ + deleteTeamAction: vi.fn(), +})); + +describe("DeleteTeam", () => { + afterEach(() => { + cleanup(); + }); + + const baseProps = { + teamId: "team-1" as TTeam["id"], + onDelete: vi.fn(), + isOwnerOrManager: true, + }; + + test("renders danger zone label and delete button enabled for owner/manager", () => { + render(); + expect(screen.getByText("common.danger_zone")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeEnabled(); + }); + + test("renders tooltip and disables button if not owner/manager", () => { + render(); + expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.team_deletion_not_allowed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeDisabled(); + }); + + test("opens dialog on delete button click", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })); + expect(screen.getByTestId("DeleteDialog")).toBeInTheDocument(); + expect(screen.getByText("common.team")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.teams.are_you_sure_you_want_to_delete_this_team") + ).toBeInTheDocument(); + }); + + test("calls deleteTeamAction, shows success toast, calls onDelete, and refreshes on confirm", async () => { + vi.mocked(deleteTeamAction).mockResolvedValue({ data: true }); + const onDelete = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })); + await userEvent.click(screen.getByText("Confirm")); + expect(deleteTeamAction).toHaveBeenCalledWith({ teamId: baseProps.teamId }); + expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_deleted_successfully"); + expect(onDelete).toHaveBeenCalled(); + }); + + test("shows error toast if deleteTeamAction fails", async () => { + vi.mocked(deleteTeamAction).mockResolvedValue({ data: false }); + render(); + await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })); + await userEvent.click(screen.getByText("Confirm")); + expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again"); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx index 2c1a6f75da..629a45a35f 100644 --- a/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx +++ b/apps/web/modules/ee/teams/team-list/components/team-settings/delete-team.tsx @@ -1,6 +1,6 @@ "use client"; -import { deleteTeamAction } from "@/modules/ee/teams/team-list/action"; +import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions"; import { TTeam } from "@/modules/ee/teams/team-list/types/team"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.test.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.test.tsx new file mode 100644 index 0000000000..8fc3466b73 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.test.tsx @@ -0,0 +1,136 @@ +import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; +import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions"; +import { TOrganizationMember, TTeamDetails, ZTeamRole } from "@/modules/ee/teams/team-list/types/team"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TeamSettingsModal } from "./team-settings-modal"; + +vi.mock("@/modules/ui/components/modal", () => ({ + Modal: ({ children, ...props }: any) =>
{children}
, +})); + +vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({ + DeleteTeam: () =>
, +})); +vi.mock("@/modules/ee/teams/team-list/actions", () => ({ + updateTeamDetailsAction: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +describe("TeamSettingsModal", () => { + afterEach(() => { + cleanup(); + }); + + const orgMembers: TOrganizationMember[] = [ + { id: "1", name: "Alice", role: "member" }, + { id: "2", name: "Bob", role: "manager" }, + ]; + const orgProjects = [ + { id: "p1", name: "Project 1" }, + { id: "p2", name: "Project 2" }, + ]; + const team: TTeamDetails = { + id: "t1", + name: "Team 1", + members: [{ name: "Alice", userId: "1", role: ZTeamRole.enum.contributor }], + projects: [ + { projectName: "pro1", projectId: "p1", permission: ZTeamPermission.enum.read }, + { projectName: "pro2", projectId: "p2", permission: ZTeamPermission.enum.readWrite }, + ], + organizationId: "org1", + }; + const setOpen = vi.fn(); + + test("renders modal, form, and tolgee strings", () => { + render( + + ); + expect(screen.getByTestId("Modal")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument(); + expect(screen.getByText("common.team_name")).toBeInTheDocument(); + expect(screen.getByText("common.members")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument(); + expect(screen.getByText("Add member")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.getByText("Add project")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument(); + expect(screen.getByText("common.cancel")).toBeInTheDocument(); + expect(screen.getByText("common.save")).toBeInTheDocument(); + expect(screen.getByTestId("DeleteTeam")).toBeInTheDocument(); + }); + + test("calls setOpen(false) when cancel button is clicked", async () => { + render( + + ); + await userEvent.click(screen.getByText("common.cancel")); + expect(setOpen).toHaveBeenCalledWith(false); + }); + + test("calls updateTeamDetailsAction and shows success toast on submit", async () => { + vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: true }); + render( + + ); + await userEvent.click(screen.getByText("common.save")); + await waitFor(() => { + expect(updateTeamDetailsAction).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_updated_successfully"); + expect(setOpen).toHaveBeenCalledWith(false); + }); + }); + + test("shows error toast if updateTeamDetailsAction fails", async () => { + vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: false }); + render( + + ); + await userEvent.click(screen.getByText("common.save")); + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx index e4c994d834..3eb75fc44f 100644 --- a/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx +++ b/apps/web/modules/ee/teams/team-list/components/team-settings/team-settings-modal.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/cn"; import { getAccessFlags } from "@/lib/membership/utils"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; -import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/action"; +import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions"; import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team"; import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project"; import { diff --git a/apps/web/modules/ee/teams/team-list/components/teams-table.test.tsx b/apps/web/modules/ee/teams/team-list/components/teams-table.test.tsx new file mode 100644 index 0000000000..6791ec770c --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/components/teams-table.test.tsx @@ -0,0 +1,154 @@ +import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions"; +import { TOrganizationMember, TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/team"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TeamsTable } from "./teams-table"; + +vi.mock("@/modules/ee/teams/team-list/components/create-team-button", () => ({ + CreateTeamButton: ({ organizationId }: any) => ( + + ), +})); + +vi.mock("@/modules/ee/teams/team-list/components/manage-team-button", () => ({ + ManageTeamButton: ({ disabled, onClick }: any) => ( + + ), +})); +vi.mock("@/modules/ee/teams/team-list/components/team-settings/team-settings-modal", () => ({ + TeamSettingsModal: (props: any) =>
{props.team?.name}
, +})); + +vi.mock("@/modules/ee/teams/team-list/actions", () => ({ + getTeamDetailsAction: vi.fn(), + getTeamRoleAction: vi.fn(), +})); + +vi.mock("@/modules/ui/components/badge", () => ({ + Badge: ({ text }: any) => {text}, +})); + +const userTeams: TUserTeam[] = [ + { id: "1", name: "Alpha", memberCount: 2, userRole: "admin" }, + { id: "2", name: "Beta", memberCount: 1, userRole: "contributor" }, +]; +const otherTeams: TOtherTeam[] = [ + { id: "3", name: "Gamma", memberCount: 3 }, + { id: "4", name: "Delta", memberCount: 1 }, +]; +const orgMembers: TOrganizationMember[] = [{ id: "u1", name: "User 1", role: "manager" }]; +const orgProjects = [{ id: "p1", name: "Project 1" }]; + +describe("TeamsTable", () => { + afterEach(() => { + cleanup(); + }); + + test("renders CreateTeamButton for owner/manager", () => { + render( + + ); + expect(screen.getByTestId("CreateTeamButton")).toHaveTextContent("org-1"); + }); + + test("does not render CreateTeamButton for non-owner/manager", () => { + render( + + ); + expect(screen.queryByTestId("CreateTeamButton")).toBeNull(); + }); + + test("renders empty state row if no teams", () => { + render( + + ); + expect(screen.getByText("environments.settings.teams.empty_teams_state")).toBeInTheDocument(); + }); + + test("renders userTeams and otherTeams rows", () => { + render( + + ); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + expect(screen.getByText("Gamma")).toBeInTheDocument(); + expect(screen.getByText("Delta")).toBeInTheDocument(); + expect(screen.getAllByTestId("ManageTeamButton").length).toBe(4); + expect(screen.getAllByTestId("Badge")[0]).toHaveTextContent( + "environments.settings.teams.you_are_a_member" + ); + expect(screen.getByText("2 common.members")).toBeInTheDocument(); + }); + + test("opens TeamSettingsModal when ManageTeamButton is clicked and team details are returned", async () => { + vi.mocked(getTeamDetailsAction).mockResolvedValue({ + data: { id: "1", name: "Alpha", organizationId: "org-1", members: [], projects: [] }, + }); + vi.mocked(getTeamRoleAction).mockResolvedValue({ data: "admin" }); + render( + + ); + await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]); + await waitFor(() => { + expect(screen.getByTestId("TeamSettingsModal")).toHaveTextContent("Alpha"); + }); + }); + + test("shows error toast if getTeamDetailsAction fails", async () => { + vi.mocked(getTeamDetailsAction).mockResolvedValue({ data: undefined }); + vi.mocked(getTeamRoleAction).mockResolvedValue({ data: undefined }); + render( + + ); + await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]); + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/components/teams-table.tsx b/apps/web/modules/ee/teams/team-list/components/teams-table.tsx index c897ecc3de..c2544936e3 100644 --- a/apps/web/modules/ee/teams/team-list/components/teams-table.tsx +++ b/apps/web/modules/ee/teams/team-list/components/teams-table.tsx @@ -2,7 +2,7 @@ import { getAccessFlags } from "@/lib/membership/utils"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/action"; +import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions"; import { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button"; import { ManageTeamButton } from "@/modules/ee/teams/team-list/components/manage-team-button"; import { TeamSettingsModal } from "@/modules/ee/teams/team-list/components/team-settings/team-settings-modal"; diff --git a/apps/web/modules/ee/teams/team-list/lib/project.test.ts b/apps/web/modules/ee/teams/team-list/lib/project.test.ts new file mode 100644 index 0000000000..2e0da83fdb --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/lib/project.test.ts @@ -0,0 +1,50 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { getProjectsByOrganizationId } from "./project"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { findMany: vi.fn() }, + }, +})); +vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } })); + +const mockProjects = [ + { id: "p1", name: "Project 1" }, + { id: "p2", name: "Project 2" }, +]; + +describe("getProjectsByOrganizationId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns mapped projects for valid organization", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects); + const result = await getProjectsByOrganizationId("org1"); + expect(result).toEqual([ + { id: "p1", name: "Project 1" }, + { id: "p2", name: "Project 2" }, + ]); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { organizationId: "org1" }, + select: { id: true, name: true }, + }); + }); + + test("throws DatabaseError on Prisma known error", async () => { + const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }); + vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error); + await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(error, "Error fetching projects by organization id"); + }); + + test("throws UnknownError on unknown error", async () => { + const error = new Error("fail"); + vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error); + await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(UnknownError); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/lib/team.test.ts b/apps/web/modules/ee/teams/team-list/lib/team.test.ts new file mode 100644 index 0000000000..70d92ba0f6 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/lib/team.test.ts @@ -0,0 +1,343 @@ +import { organizationCache } from "@/lib/cache/organization"; +import { teamCache } from "@/lib/cache/team"; +import { projectCache } from "@/lib/project/cache"; +import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + createTeam, + deleteTeam, + getOtherTeams, + getTeamDetails, + getTeams, + getTeamsByOrganizationId, + getUserTeams, + updateTeamDetails, +} from "./team"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + team: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + membership: { findUnique: vi.fn(), count: vi.fn() }, + project: { count: vi.fn() }, + environment: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/cache/team", () => ({ + teamCache: { + tag: { byOrganizationId: vi.fn(), byUserId: vi.fn(), byId: vi.fn(), projectId: vi.fn() }, + revalidate: vi.fn(), + }, +})); +vi.mock("@/lib/project/cache", () => ({ + projectCache: { tag: { byId: vi.fn(), byOrganizationId: vi.fn() }, revalidate: vi.fn() }, +})); +vi.mock("@/lib/cache/organization", () => ({ organizationCache: { revalidate: vi.fn() } })); + +const mockTeams = [ + { id: "t1", name: "Team 1" }, + { id: "t2", name: "Team 2" }, +]; +const mockUserTeams = [ + { + id: "t1", + name: "Team 1", + teamUsers: [{ role: "admin" }], + _count: { teamUsers: 2 }, + }, +]; +const mockOtherTeams = [ + { + id: "t2", + name: "Team 2", + _count: { teamUsers: 3 }, + }, +]; +const mockMembership = { role: "admin" }; +const mockTeamDetails = { + id: "t1", + name: "Team 1", + organizationId: "org1", + teamUsers: [ + { userId: "u1", role: "admin", user: { name: "User 1" } }, + { userId: "u2", role: "member", user: { name: "User 2" } }, + ], + projectTeams: [{ projectId: "p1", project: { name: "Project 1" }, permission: "manage" }], +}; + +describe("getTeamsByOrganizationId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("returns mapped teams", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); + const result = await getTeamsByOrganizationId("org1"); + expect(result).toEqual([ + { id: "t1", name: "Team 1" }, + { id: "t2", name: "Team 2" }, + ]); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findMany).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("getUserTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("returns mapped user teams", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams); + + const result = await getUserTeams("u1", "org1"); + expect(result).toEqual([{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }]); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findMany).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getUserTeams("u1", "org1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("getOtherTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("returns mapped other teams", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams); + const result = await getOtherTeams("u1", "org1"); + expect(result).toEqual([{ id: "t2", name: "Team 2", memberCount: 3 }]); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findMany).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getOtherTeams("u1", "org1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("getTeams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("returns userTeams and otherTeams", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(mockMembership); + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams); + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams); + const result = await getTeams("u1", "org1"); + expect(result).toEqual({ + userTeams: [{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }], + otherTeams: [{ id: "t2", name: "Team 2", memberCount: 3 }], + }); + }); + test("throws ResourceNotFoundError if membership not found", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(null); + await expect(getTeams("u1", "org1")).rejects.toThrow(ResourceNotFoundError); + }); +}); + +describe("createTeam", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("creates and returns team id", async () => { + vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.team.create).mockResolvedValueOnce({ + id: "t1", + name: "Team 1", + organizationId: "org1", + createdAt: new Date(), + updatedAt: new Date(), + }); + const result = await createTeam("org1", "Team 1"); + expect(result).toBe("t1"); + expect(teamCache.revalidate).toHaveBeenCalledWith({ organizationId: "org1" }); + }); + test("throws InvalidInputError if team exists", async () => { + vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({ id: "t1" }); + await expect(createTeam("org1", "Team 1")).rejects.toThrow(InvalidInputError); + }); + test("throws InvalidInputError if name too short", async () => { + vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null); + await expect(createTeam("org1", "")).rejects.toThrow(InvalidInputError); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findFirst).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(createTeam("org1", "Team 1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("getTeamDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("returns mapped team details", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails); + const result = await getTeamDetails("t1"); + expect(result).toEqual({ + id: "t1", + name: "Team 1", + organizationId: "org1", + members: [ + { userId: "u1", name: "User 1", role: "admin" }, + { userId: "u2", name: "User 2", role: "member" }, + ], + projects: [{ projectId: "p1", projectName: "Project 1", permission: "manage" }], + }); + }); + test("returns null if team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null); + const result = await getTeamDetails("t1"); + expect(result).toBeNull(); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findUnique).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getTeamDetails("t1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("deleteTeam", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("deletes team and revalidates caches", async () => { + const mockTeam = { + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + projectTeams: [{ projectId: "p1" }], + }; + vi.mocked(prisma.team.delete).mockResolvedValueOnce(mockTeam); + const result = await deleteTeam("t1"); + expect(result).toBe(true); + expect(teamCache.revalidate).toHaveBeenCalledWith({ id: "t1", organizationId: "org1" }); + expect(teamCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" }); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.delete).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(deleteTeam("t1")).rejects.toThrow(DatabaseError); + }); +}); + +describe("updateTeamDetails", () => { + const data: TTeamSettingsFormSchema = { + name: "Team 1 Updated", + members: [{ userId: "u1", role: "admin" }], + projects: [{ projectId: "p1", permission: "manage" }], + }; + beforeEach(() => { + vi.clearAllMocks(); + }); + test("updates team details and revalidates caches", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails); + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams); + + vi.mocked(prisma.membership.count).mockResolvedValueOnce(1); + vi.mocked(prisma.project.count).mockResolvedValueOnce(1); + vi.mocked(prisma.team.update).mockResolvedValueOnce({ + id: "t1", + name: "Team 1 Updated", + organizationId: "org1", + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.environment.findMany).mockResolvedValueOnce([{ id: "env1" }]); + const result = await updateTeamDetails("t1", data); + expect(result).toBe(true); + expect(teamCache.revalidate).toHaveBeenCalled(); + expect(projectCache.revalidate).toHaveBeenCalled(); + expect(organizationCache.revalidate).toHaveBeenCalledWith({ environmentId: "env1" }); + }); + test("throws ResourceNotFoundError if team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null); + await expect(updateTeamDetails("t1", data)).rejects.toThrow(ResourceNotFoundError); + }); + test("throws error if getTeamDetails returns null", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null); + await expect(updateTeamDetails("t1", data)).rejects.toThrow("Team not found"); + }); + test("throws error if user not in org membership", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + name: "Team 1", + organizationId: "org1", + members: [], + projects: [], + }); + vi.mocked(prisma.membership.count).mockResolvedValueOnce(0); + await expect(updateTeamDetails("t1", data)).rejects.toThrow(); + }); + test("throws error if project not in org", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ + id: "t1", + name: "Team 1", + organizationId: "org1", + members: [], + projects: [], + }); + vi.mocked(prisma.membership.count).mockResolvedValueOnce(1); + vi.mocked(prisma.project.count).mockResolvedValueOnce(0); + await expect( + updateTeamDetails("t1", { + name: "x", + members: [], + projects: [{ projectId: "p1", permission: "manage" }], + }) + ).rejects.toThrow(); + }); + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findUnique).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(updateTeamDetails("t1", data)).rejects.toThrow(DatabaseError); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/lib/team.ts b/apps/web/modules/ee/teams/team-list/lib/team.ts index 0b746293db..a135dcdec9 100644 --- a/apps/web/modules/ee/teams/team-list/lib/team.ts +++ b/apps/web/modules/ee/teams/team-list/lib/team.ts @@ -57,7 +57,7 @@ export const getTeamsByOrganizationId = reactCache( )() ); -const getUserTeams = reactCache( +export const getUserTeams = reactCache( async (userId: string, organizationId: string): Promise => cache( async () => { diff --git a/apps/web/modules/ee/teams/utils/teams.test.ts b/apps/web/modules/ee/teams/utils/teams.test.ts new file mode 100644 index 0000000000..074cf8aaf8 --- /dev/null +++ b/apps/web/modules/ee/teams/utils/teams.test.ts @@ -0,0 +1,67 @@ +import { ProjectTeamPermission, TeamUserRole } from "@prisma/client"; +import { describe, expect, test } from "vitest"; +import { TeamPermissionMapping, TeamRoleMapping, getTeamAccessFlags, getTeamPermissionFlags } from "./teams"; + +describe("TeamPermissionMapping", () => { + test("maps ProjectTeamPermission to correct labels", () => { + expect(TeamPermissionMapping[ProjectTeamPermission.read]).toBe("Read"); + expect(TeamPermissionMapping[ProjectTeamPermission.readWrite]).toBe("Read & write"); + expect(TeamPermissionMapping[ProjectTeamPermission.manage]).toBe("Manage"); + }); +}); + +describe("TeamRoleMapping", () => { + test("maps TeamUserRole to correct labels", () => { + expect(TeamRoleMapping[TeamUserRole.admin]).toBe("Team Admin"); + expect(TeamRoleMapping[TeamUserRole.contributor]).toBe("Contributor"); + }); +}); + +describe("getTeamAccessFlags", () => { + test("returns correct flags for admin", () => { + expect(getTeamAccessFlags(TeamUserRole.admin)).toEqual({ isAdmin: true, isContributor: false }); + }); + test("returns correct flags for contributor", () => { + expect(getTeamAccessFlags(TeamUserRole.contributor)).toEqual({ isAdmin: false, isContributor: true }); + }); + test("returns false flags for undefined/null", () => { + expect(getTeamAccessFlags()).toEqual({ isAdmin: false, isContributor: false }); + expect(getTeamAccessFlags(null)).toEqual({ isAdmin: false, isContributor: false }); + }); +}); + +describe("getTeamPermissionFlags", () => { + test("returns correct flags for read", () => { + expect(getTeamPermissionFlags(ProjectTeamPermission.read)).toEqual({ + hasReadAccess: true, + hasReadWriteAccess: false, + hasManageAccess: false, + }); + }); + test("returns correct flags for readWrite", () => { + expect(getTeamPermissionFlags(ProjectTeamPermission.readWrite)).toEqual({ + hasReadAccess: false, + hasReadWriteAccess: true, + hasManageAccess: false, + }); + }); + test("returns correct flags for manage", () => { + expect(getTeamPermissionFlags(ProjectTeamPermission.manage)).toEqual({ + hasReadAccess: false, + hasReadWriteAccess: false, + hasManageAccess: true, + }); + }); + test("returns all false for undefined/null", () => { + expect(getTeamPermissionFlags()).toEqual({ + hasReadAccess: false, + hasReadWriteAccess: false, + hasManageAccess: false, + }); + expect(getTeamPermissionFlags(null)).toEqual({ + hasReadAccess: false, + hasReadWriteAccess: false, + hasManageAccess: false, + }); + }); +}); diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 28c12a0c9d..ec96241a76 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -126,6 +126,8 @@ export default defineConfig({ "modules/survey/editor/components/file-upload-question-form.tsx", "modules/survey/editor/components/how-to-send-card.tsx", "modules/survey/editor/components/image-survey-bg.tsx", + "modules/ee/teams/**/*.ts", + "modules/ee/teams/**/*.tsx", "app/(app)/environments/**/*.tsx", "app/(app)/environments/**/*.ts", ],