{t("environments.settings.billing.monthly_identified_users")}
@@ -224,7 +224,7 @@ export const PricingTable = ({
handleMonthlyToggle("yearly")}>
@@ -272,7 +272,7 @@ export const PricingTable = ({
{getCloudPricingData(t).plans.map((plan) => (
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..c5b840640b 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 {
@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({