From b0495a8a429dc4d3155faaa677c817a544ba990a Mon Sep 17 00:00:00 2001
From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Date: Wed, 7 May 2025 22:04:06 +0530
Subject: [PATCH] chore: adds unit tests in `module/projects` (#5701)
---
.../project-limit-modal/index.test.tsx | 74 ++++++
.../project-switcher/index.test.tsx | 177 ++++++++++++++
.../(setup)/app-connection/loading.test.tsx | 59 +++++
.../(setup)/app-connection/page.test.tsx | 97 ++++++++
.../components/environment-id-field.test.tsx | 38 +++
.../project-config-navigation.test.tsx | 48 ++++
.../components/delete-project-render.test.tsx | 195 +++++++++++++++
.../components/delete-project.test.tsx | 139 +++++++++++
.../edit-project-name-form.test.tsx | 107 +++++++++
.../edit-waiting-time-form.test.tsx | 114 +++++++++
.../settings/general/loading.test.tsx | 53 ++++
.../projects/settings/general/page.test.tsx | 128 ++++++++++
.../modules/projects/settings/layout.test.tsx | 41 ++++
.../projects/settings/lib/project.test.ts | 226 ++++++++++++++++++
.../modules/projects/settings/lib/tag.test.ts | 143 +++++++++++
.../look/components/edit-logo.test.tsx | 202 ++++++++++++++++
.../components/edit-placement-form.test.tsx | 117 +++++++++
.../look/components/theme-styling.test.tsx | 209 ++++++++++++++++
.../settings/look/lib/project.test.ts | 65 +++++
.../projects/settings/look/loading.test.tsx | 66 +++++
.../projects/settings/look/page.test.tsx | 121 ++++++++++
.../modules/projects/settings/page.test.tsx | 20 ++
.../projects/settings/tags/actions.test.ts | 76 ++++++
.../components/edit-tags-wrapper.test.tsx | 88 +++++++
.../components/merge-tags-combobox.test.tsx | 70 ++++++
.../tags/components/single-tag.test.tsx | 150 ++++++++++++
.../settings/tags/components/single-tag.tsx | 8 +-
.../projects/settings/tags/loading.test.tsx | 51 ++++
.../projects/settings/tags/page.test.tsx | 80 +++++++
29 files changed, 2958 insertions(+), 4 deletions(-)
create mode 100644 apps/web/modules/projects/components/project-limit-modal/index.test.tsx
create mode 100644 apps/web/modules/projects/components/project-switcher/index.test.tsx
create mode 100644 apps/web/modules/projects/settings/(setup)/app-connection/loading.test.tsx
create mode 100644 apps/web/modules/projects/settings/(setup)/app-connection/page.test.tsx
create mode 100644 apps/web/modules/projects/settings/(setup)/components/environment-id-field.test.tsx
create mode 100644 apps/web/modules/projects/settings/components/project-config-navigation.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/components/delete-project-render.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/components/delete-project.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/components/edit-project-name-form.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/components/edit-waiting-time-form.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/loading.test.tsx
create mode 100644 apps/web/modules/projects/settings/general/page.test.tsx
create mode 100644 apps/web/modules/projects/settings/layout.test.tsx
create mode 100644 apps/web/modules/projects/settings/lib/project.test.ts
create mode 100644 apps/web/modules/projects/settings/lib/tag.test.ts
create mode 100644 apps/web/modules/projects/settings/look/components/edit-logo.test.tsx
create mode 100644 apps/web/modules/projects/settings/look/components/edit-placement-form.test.tsx
create mode 100644 apps/web/modules/projects/settings/look/components/theme-styling.test.tsx
create mode 100644 apps/web/modules/projects/settings/look/lib/project.test.ts
create mode 100644 apps/web/modules/projects/settings/look/loading.test.tsx
create mode 100644 apps/web/modules/projects/settings/look/page.test.tsx
create mode 100644 apps/web/modules/projects/settings/page.test.tsx
create mode 100644 apps/web/modules/projects/settings/tags/actions.test.ts
create mode 100644 apps/web/modules/projects/settings/tags/components/edit-tags-wrapper.test.tsx
create mode 100644 apps/web/modules/projects/settings/tags/components/merge-tags-combobox.test.tsx
create mode 100644 apps/web/modules/projects/settings/tags/components/single-tag.test.tsx
create mode 100644 apps/web/modules/projects/settings/tags/loading.test.tsx
create mode 100644 apps/web/modules/projects/settings/tags/page.test.tsx
diff --git a/apps/web/modules/projects/components/project-limit-modal/index.test.tsx b/apps/web/modules/projects/components/project-limit-modal/index.test.tsx
new file mode 100644
index 0000000000..afe3694972
--- /dev/null
+++ b/apps/web/modules/projects/components/project-limit-modal/index.test.tsx
@@ -0,0 +1,74 @@
+import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectLimitModal } from "./index";
+
+vi.mock("@/modules/ui/components/dialog", () => ({
+ Dialog: ({ open, onOpenChange, children }: any) =>
+ open ? (
+
onOpenChange(false)}>
+ {children}
+
+ ) : null,
+ DialogContent: ({ children, className }: any) => (
+
+ {children}
+
+ ),
+ DialogTitle: ({ children }: any) => {children}
,
+}));
+
+vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
+ UpgradePrompt: ({ title, description, buttons }: any) => (
+
+
{title}
+
{description}
+
+
+
+ ),
+}));
+
+describe("ProjectLimitModal", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ const setOpen = vi.fn();
+ const buttons: [ModalButton, ModalButton] = [
+ { text: "Start Trial", onClick: vi.fn() },
+ { text: "Upgrade", onClick: vi.fn() },
+ ];
+
+ test("renders dialog and upgrade prompt with correct props", () => {
+ render();
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ expect(screen.getByTestId("dialog-content")).toHaveClass("bg-white");
+ expect(screen.getByTestId("dialog-title")).toHaveTextContent("common.projects_limit_reached");
+ expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
+ expect(screen.getByText("common.unlock_more_projects_with_a_higher_plan")).toBeInTheDocument();
+ expect(screen.getByText("common.you_have_reached_your_limit_of_project_limit")).toBeInTheDocument();
+ expect(screen.getByText("Start Trial")).toBeInTheDocument();
+ expect(screen.getByText("Upgrade")).toBeInTheDocument();
+ });
+
+ test("calls setOpen(false) when dialog is closed", async () => {
+ render();
+ await userEvent.click(screen.getByTestId("dialog"));
+ expect(setOpen).toHaveBeenCalledWith(false);
+ });
+
+ test("calls button onClick handlers", async () => {
+ render();
+ await userEvent.click(screen.getByText("Start Trial"));
+ expect(vi.mocked(buttons[0].onClick)).toHaveBeenCalled();
+ await userEvent.click(screen.getByText("Upgrade"));
+ expect(vi.mocked(buttons[1].onClick)).toHaveBeenCalled();
+ });
+
+ test("does not render when open is false", () => {
+ render();
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/components/project-switcher/index.test.tsx b/apps/web/modules/projects/components/project-switcher/index.test.tsx
new file mode 100644
index 0000000000..c8cf003753
--- /dev/null
+++ b/apps/web/modules/projects/components/project-switcher/index.test.tsx
@@ -0,0 +1,177 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TProject } from "@formbricks/types/project";
+import { ProjectSwitcher } from "./index";
+
+const mockPush = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: vi.fn(() => ({
+ push: mockPush,
+ })),
+}));
+
+vi.mock("@/modules/ui/components/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: any) => {children}
,
+ DropdownMenuTrigger: ({ children }: any) => {children}
,
+ DropdownMenuContent: ({ children }: any) => {children}
,
+ DropdownMenuRadioGroup: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+ DropdownMenuRadioItem: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+ DropdownMenuSeparator: () => ,
+ DropdownMenuItem: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/modules/projects/components/project-limit-modal", () => ({
+ ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
+ open ? (
+
+
+
+ {buttons[0].text} {buttons[1].text}
+
+
{projectLimit}
+
+ ) : null,
+}));
+
+describe("ProjectSwitcher", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ const organization: TOrganization = {
+ id: "org1",
+ name: "Org 1",
+ billing: { plan: "free" },
+ } as TOrganization;
+ const project: TProject = {
+ id: "proj1",
+ name: "Project 1",
+ config: { channel: "website" },
+ } as TProject;
+ const projects: TProject[] = [project, { ...project, id: "proj2", name: "Project 2" }];
+
+ test("renders dropdown and project name", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
+ expect(screen.getByTitle("Project 1")).toBeInTheDocument();
+ expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
+ expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
+ expect(screen.getByTestId("dropdown-radio-group")).toBeInTheDocument();
+ expect(screen.getAllByTestId("dropdown-radio-item").length).toBe(2);
+ });
+
+ test("opens ProjectLimitModal when project limit reached and add project is clicked", async () => {
+ render(
+
+ );
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
+ });
+
+ test("closes ProjectLimitModal when close button is clicked", async () => {
+ render(
+
+ );
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ const closeButton = screen.getByTestId("close-modal");
+ await userEvent.click(closeButton);
+ expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
+ });
+
+ test("renders correct modal buttons and project limit", async () => {
+ render(
+
+ );
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ expect(screen.getByTestId("modal-buttons")).toHaveTextContent(
+ "common.start_free_trial common.learn_more"
+ );
+ expect(screen.getByTestId("modal-project-limit")).toHaveTextContent("2");
+ });
+
+ test("handleAddProject navigates if under limit", async () => {
+ render(
+
+ );
+ const addButton = screen.getByText("common.add_project");
+ await userEvent.click(addButton);
+ expect(mockPush).toHaveBeenCalled();
+ expect(mockPush).toHaveBeenCalledWith("/organizations/org1/projects/new/mode");
+ });
+});
diff --git a/apps/web/modules/projects/settings/(setup)/app-connection/loading.test.tsx b/apps/web/modules/projects/settings/(setup)/app-connection/loading.test.tsx
new file mode 100644
index 0000000000..38185b7836
--- /dev/null
+++ b/apps/web/modules/projects/settings/(setup)/app-connection/loading.test.tsx
@@ -0,0 +1,59 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { AppConnectionLoading } from "./loading";
+
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: ({ activeId, loading }: any) => (
+
+ {activeId} {loading ? "loading" : "not-loading"}
+
+ ),
+}));
+vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
+ PageContentWrapper: ({ children }: any) => {children}
,
+}));
+vi.mock("@/modules/ui/components/page-header", () => ({
+ PageHeader: ({ pageTitle, children }: any) => (
+
+ {pageTitle}
+ {children}
+
+ ),
+}));
+vi.mock("@/app/(app)/components/LoadingCard", () => ({
+ LoadingCard: (props: any) => (
+
+ {props.title} {props.description}
+
+ ),
+}));
+
+describe("AppConnectionLoading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders wrapper, header, navigation, and all loading cards with correct tolgee keys", () => {
+ render();
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toHaveTextContent("common.project_configuration");
+ expect(screen.getByTestId("project-config-navigation")).toHaveTextContent("app-connection loading");
+ const cards = screen.getAllByTestId("loading-card");
+ expect(cards.length).toBe(3);
+ expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection");
+ expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection_description");
+ expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup");
+ expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup_description");
+ expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id");
+ expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id_description");
+ });
+
+ test("renders the blue info bar", () => {
+ render();
+ expect(screen.getByText((_, element) => element!.className.includes("bg-blue-50"))).toBeInTheDocument();
+
+ expect(
+ screen.getByText((_, element) => element!.className.includes("animate-pulse"))
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/(setup)/app-connection/page.test.tsx b/apps/web/modules/projects/settings/(setup)/app-connection/page.test.tsx
new file mode 100644
index 0000000000..bec46bafa9
--- /dev/null
+++ b/apps/web/modules/projects/settings/(setup)/app-connection/page.test.tsx
@@ -0,0 +1,97 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { AppConnectionPage } from "./page";
+
+vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
+ PageContentWrapper: ({ children }: any) => {children}
,
+}));
+vi.mock("@/modules/ui/components/page-header", () => ({
+ PageHeader: ({ pageTitle, children }: any) => (
+
+ {pageTitle}
+ {children}
+
+ ),
+}));
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
+
+ {environmentId} {activeId}
+
+ ),
+}));
+vi.mock("@/modules/ui/components/environment-notice", () => ({
+ EnvironmentNotice: ({ environmentId, subPageUrl }: any) => (
+
+ {environmentId} {subPageUrl}
+
+ ),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
+ SettingsCard: ({ title, description, children }: any) => (
+
+ {title} {description} {children}
+
+ ),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator", () => ({
+ WidgetStatusIndicator: ({ environment }: any) => (
+ {environment.id}
+ ),
+}));
+vi.mock("@/modules/projects/settings/(setup)/components/setup-instructions", () => ({
+ SetupInstructions: ({ environmentId, webAppUrl }: any) => (
+
+ {environmentId} {webAppUrl}
+
+ ),
+}));
+vi.mock("@/modules/projects/settings/(setup)/components/environment-id-field", () => ({
+ EnvironmentIdField: ({ environmentId }: any) => (
+ {environmentId}
+ ),
+}));
+
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: async () => (key: string) => key,
+}));
+
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(async (environmentId: string) => ({ environment: { id: environmentId } })),
+}));
+
+let mockWebappUrl = "https://example.com";
+
+vi.mock("@/lib/constants", () => ({
+ get WEBAPP_URL() {
+ return mockWebappUrl;
+ },
+}));
+
+describe("AppConnectionPage", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all sections and passes correct props", async () => {
+ const params = { environmentId: "env-123" };
+ const props = { params };
+ const { findByTestId, findAllByTestId } = render(await AppConnectionPage(props));
+ expect(await findByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(await findByTestId("page-header")).toHaveTextContent("common.project_configuration");
+ expect(await findByTestId("project-config-navigation")).toHaveTextContent("env-123 app-connection");
+ expect(await findByTestId("environment-notice")).toHaveTextContent("env-123 /project/app-connection");
+ const cards = await findAllByTestId("settings-card");
+ expect(cards.length).toBe(3);
+ expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection");
+ expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection_description");
+ expect(cards[0]).toHaveTextContent("env-123"); // WidgetStatusIndicator
+ expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup");
+ expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup_description");
+ expect(cards[1]).toHaveTextContent("env-123"); // SetupInstructions
+ expect(cards[1]).toHaveTextContent(mockWebappUrl);
+ expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id");
+ expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id_description");
+ expect(cards[2]).toHaveTextContent("env-123"); // EnvironmentIdField
+ });
+});
diff --git a/apps/web/modules/projects/settings/(setup)/components/environment-id-field.test.tsx b/apps/web/modules/projects/settings/(setup)/components/environment-id-field.test.tsx
new file mode 100644
index 0000000000..bd8e242412
--- /dev/null
+++ b/apps/web/modules/projects/settings/(setup)/components/environment-id-field.test.tsx
@@ -0,0 +1,38 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { EnvironmentIdField } from "./environment-id-field";
+
+vi.mock("@/modules/ui/components/code-block", () => ({
+ CodeBlock: ({ children, language }: any) => (
+
+ {children}
+
+ ),
+}));
+
+describe("EnvironmentIdField", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders the environment id in a code block", () => {
+ const envId = "env-123";
+ render();
+ const codeBlock = screen.getByTestId("code-block");
+ expect(codeBlock).toBeInTheDocument();
+ expect(codeBlock).toHaveAttribute("data-language", "js");
+ expect(codeBlock).toHaveTextContent(envId);
+ });
+
+ test("applies the correct wrapper class", () => {
+ render();
+ const wrapper = codeBlockParent();
+ expect(wrapper).toHaveClass("prose");
+ expect(wrapper).toHaveClass("prose-slate");
+ expect(wrapper).toHaveClass("-mt-3");
+ });
+});
+
+function codeBlockParent() {
+ return screen.getByTestId("code-block").parentElement as HTMLElement;
+}
diff --git a/apps/web/modules/projects/settings/components/project-config-navigation.test.tsx b/apps/web/modules/projects/settings/components/project-config-navigation.test.tsx
new file mode 100644
index 0000000000..4c948d8593
--- /dev/null
+++ b/apps/web/modules/projects/settings/components/project-config-navigation.test.tsx
@@ -0,0 +1,48 @@
+import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectConfigNavigation } from "./project-config-navigation";
+
+vi.mock("@/modules/ui/components/secondary-navigation", () => ({
+ SecondaryNavigation: vi.fn(() => ),
+}));
+
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({ t: (key: string) => key }),
+}));
+
+let mockPathname = "/environments/env-1/project/look";
+vi.mock("next/navigation", () => ({
+ usePathname: vi.fn(() => mockPathname),
+}));
+
+describe("ProjectConfigNavigation", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("sets current to true for the correct nav item based on pathname", () => {
+ const cases = [
+ { path: "/environments/env-1/project/general", idx: 0 },
+ { path: "/environments/env-1/project/look", idx: 1 },
+ { path: "/environments/env-1/project/languages", idx: 2 },
+ { path: "/environments/env-1/project/tags", idx: 3 },
+ { path: "/environments/env-1/project/app-connection", idx: 4 },
+ { path: "/environments/env-1/project/teams", idx: 5 },
+ ];
+ for (const { path, idx } of cases) {
+ mockPathname = path;
+ render();
+ const navArg = SecondaryNavigation.mock.calls[0][0].navigation;
+
+ navArg.forEach((item: any, i: number) => {
+ if (i === idx) {
+ expect(item.current).toBe(true);
+ } else {
+ expect(item.current).toBe(false);
+ }
+ });
+ SecondaryNavigation.mockClear();
+ }
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/components/delete-project-render.test.tsx b/apps/web/modules/projects/settings/general/components/delete-project-render.test.tsx
new file mode 100644
index 0000000000..06c14aa218
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/components/delete-project-render.test.tsx
@@ -0,0 +1,195 @@
+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 { TProject } from "@formbricks/types/project";
+import { DeleteProjectRender } from "./delete-project-render";
+
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, ...props }: any) => ,
+}));
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+vi.mock("@/modules/ui/components/delete-dialog", () => ({
+ DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }: any) =>
+ open ? (
+
+ {text}
+
+
+
+ ) : null,
+}));
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string, params?: any) => (params?.projectName ? `${key} ${params.projectName}` : key),
+ }),
+}));
+
+const mockPush = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(() => "error-message"),
+}));
+vi.mock("@/lib/utils/strings", () => ({
+ truncate: (str: string) => str,
+}));
+
+const mockDeleteProjectAction = vi.fn();
+vi.mock("@/modules/projects/settings/general/actions", () => ({
+ deleteProjectAction: (...args: any[]) => mockDeleteProjectAction(...args),
+}));
+
+const mockLocalStorage = {
+ removeItem: vi.fn(),
+ setItem: vi.fn(),
+};
+global.localStorage = mockLocalStorage as any;
+
+const baseProject: TProject = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [
+ {
+ id: "env1",
+ type: "production",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ projectId: "p1",
+ appSetupCompleted: false,
+ },
+ ],
+ languages: [],
+ logo: null,
+};
+
+describe("DeleteProjectRender", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("shows delete button and dialog when enabled", async () => {
+ render(
+
+ );
+ expect(
+ screen.getByText(
+ "environments.project.general.delete_project_name_includes_surveys_responses_people_and_more Project 1"
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.project.general.this_action_cannot_be_undone")).toBeInTheDocument();
+ const deleteBtn = screen.getByText("common.delete");
+ expect(deleteBtn).toBeInTheDocument();
+ await userEvent.click(deleteBtn);
+ expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
+ });
+
+ test("shows alert if delete is disabled and not owner/manager", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "environments.project.general.only_owners_or_managers_can_delete_projects"
+ );
+ });
+
+ test("shows alert if delete is disabled and is owner/manager", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "environments.project.general.cannot_delete_only_project"
+ );
+ });
+
+ test("successful delete with one project removes env id and redirects", async () => {
+ mockDeleteProjectAction.mockResolvedValue({ data: true });
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.delete"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(mockLocalStorage.removeItem).toHaveBeenCalled();
+ expect(toast.success).toHaveBeenCalledWith("environments.project.general.project_deleted_successfully");
+ expect(mockPush).toHaveBeenCalledWith("/");
+ });
+
+ test("successful delete with multiple projects sets env id and redirects", async () => {
+ const otherProject: TProject = {
+ ...baseProject,
+ id: "p2",
+ environments: [{ ...baseProject.environments[0], id: "env2" }],
+ };
+ mockDeleteProjectAction.mockResolvedValue({ data: true });
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.delete"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith("formbricks-environment-id", "env2");
+ expect(toast.success).toHaveBeenCalledWith("environments.project.general.project_deleted_successfully");
+ expect(mockPush).toHaveBeenCalledWith("/");
+ });
+
+ test("delete error shows error toast and closes dialog", async () => {
+ mockDeleteProjectAction.mockResolvedValue({ data: false });
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.delete"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(toast.error).toHaveBeenCalledWith("error-message");
+ expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/components/delete-project.test.tsx b/apps/web/modules/projects/settings/general/components/delete-project.test.tsx
new file mode 100644
index 0000000000..fa140f6a5c
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/components/delete-project.test.tsx
@@ -0,0 +1,139 @@
+import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
+import { getUserProjects } from "@/lib/project/service";
+import { cleanup, render, screen } from "@testing-library/react";
+import { getServerSession } from "next-auth";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TProject } from "@formbricks/types/project";
+import { DeleteProject } from "./delete-project";
+
+vi.mock("@/modules/projects/settings/general/components/delete-project-render", () => ({
+ DeleteProjectRender: (props: any) => (
+
+
isDeleteDisabled: {String(props.isDeleteDisabled)}
+
isOwnerOrManager: {String(props.isOwnerOrManager)}
+
+ ),
+}));
+
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+
+const mockProject = {
+ id: "proj-1",
+ name: "Project 1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ organizationId: "org-1",
+ environments: [],
+} as any;
+
+const mockOrganization = {
+ id: "org-1",
+ name: "Org 1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { plan: "free" } as any,
+} as any;
+
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ // Return a mock translator that just returns the key
+ return (key: string) => key;
+ }),
+}));
+vi.mock("@/modules/auth/lib/authOptions", () => ({
+ authOptions: {},
+}));
+vi.mock("@/lib/organization/service", () => ({
+ getOrganizationByEnvironmentId: vi.fn(),
+}));
+vi.mock("@/lib/project/service", () => ({
+ getUserProjects: vi.fn(),
+}));
+
+describe("/modules/projects/settings/general/components/delete-project.tsx", () => {
+ beforeEach(() => {
+ vi.mocked(getServerSession).mockResolvedValue({
+ expires: new Date(Date.now() + 3600 * 1000).toISOString(),
+ user: { id: "user1" },
+ });
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
+ vi.mocked(getUserProjects).mockResolvedValue([mockProject, { ...mockProject, id: "proj-2" }]);
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders DeleteProjectRender with correct props when delete is enabled", async () => {
+ const result = await DeleteProject({
+ environmentId: "env-1",
+ currentProject: mockProject,
+ organizationProjects: [mockProject, { ...mockProject, id: "proj-2" }],
+ isOwnerOrManager: true,
+ });
+ render(result);
+ const el = screen.getByTestId("delete-project-render");
+ expect(el).toBeInTheDocument();
+ expect(screen.getByText("isDeleteDisabled: false")).toBeInTheDocument();
+ expect(screen.getByText("isOwnerOrManager: true")).toBeInTheDocument();
+ });
+
+ test("renders DeleteProjectRender with delete disabled if only one project", async () => {
+ vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
+ const result = await DeleteProject({
+ environmentId: "env-1",
+ currentProject: mockProject,
+ organizationProjects: [mockProject],
+ isOwnerOrManager: true,
+ });
+ render(result);
+ const el = screen.getByTestId("delete-project-render");
+ expect(el).toBeInTheDocument();
+ expect(screen.getByText("isDeleteDisabled: true")).toBeInTheDocument();
+ });
+
+ test("renders DeleteProjectRender with delete disabled if not owner or manager", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
+ vi.mocked(getUserProjects).mockResolvedValue([mockProject, { ...mockProject, id: "proj-2" }]);
+ const result = await DeleteProject({
+ environmentId: "env-1",
+ currentProject: mockProject,
+ organizationProjects: [mockProject, { ...mockProject, id: "proj-2" }],
+ isOwnerOrManager: false,
+ });
+ render(result);
+ const el = screen.getByTestId("delete-project-render");
+ expect(el).toBeInTheDocument();
+ expect(screen.getByText("isDeleteDisabled: true")).toBeInTheDocument();
+ expect(screen.getByText("isOwnerOrManager: false")).toBeInTheDocument();
+ });
+
+ test("throws error if session is missing", async () => {
+ vi.mocked(getServerSession).mockResolvedValue(null);
+ await expect(
+ DeleteProject({
+ environmentId: "env-1",
+ currentProject: mockProject,
+ organizationProjects: [mockProject],
+ isOwnerOrManager: true,
+ })
+ ).rejects.toThrow("common.session_not_found");
+ });
+
+ test("throws error if organization is missing", async () => {
+ vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
+ await expect(
+ DeleteProject({
+ environmentId: "env-1",
+ currentProject: mockProject,
+ organizationProjects: [mockProject],
+ isOwnerOrManager: true,
+ })
+ ).rejects.toThrow("common.organization_not_found");
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/components/edit-project-name-form.test.tsx b/apps/web/modules/projects/settings/general/components/edit-project-name-form.test.tsx
new file mode 100644
index 0000000000..a4bf74bc6c
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/components/edit-project-name-form.test.tsx
@@ -0,0 +1,107 @@
+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 { anyString } from "vitest-mock-extended";
+import { TProject } from "@formbricks/types/project";
+import { EditProjectNameForm } from "./edit-project-name-form";
+
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+
+const mockUpdateProjectAction = vi.fn();
+vi.mock("@/modules/projects/settings/actions", () => ({
+ updateProjectAction: (...args: any[]) => mockUpdateProjectAction(...args),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(() => "error-message"),
+}));
+
+const baseProject: TProject = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [
+ {
+ id: "env1",
+ type: "production",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ projectId: "p1",
+ appSetupCompleted: false,
+ },
+ ],
+ languages: [],
+ logo: null,
+};
+
+describe("EditProjectNameForm", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders form with project name and update button", () => {
+ render();
+ expect(
+ screen.getByLabelText("environments.project.general.whats_your_project_called")
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("common.project_name")).toHaveValue("Project 1");
+ expect(screen.getByText("common.update")).toBeInTheDocument();
+ });
+
+ test("shows warning alert if isReadOnly", () => {
+ render();
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "common.only_owners_managers_and_manage_access_members_can_perform_this_action"
+ );
+ expect(
+ screen.getByLabelText("environments.project.general.whats_your_project_called")
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("common.project_name")).toBeDisabled();
+ expect(screen.getByText("common.update")).toBeDisabled();
+ });
+
+ test("calls updateProjectAction and shows success toast on valid submit", async () => {
+ mockUpdateProjectAction.mockResolvedValue({ data: { name: "New Name" } });
+ render();
+ const input = screen.getByPlaceholderText("common.project_name");
+ await userEvent.clear(input);
+ await userEvent.type(input, "New Name");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(mockUpdateProjectAction).toHaveBeenCalledWith({ projectId: "p1", data: { name: "New Name" } });
+ expect(toast.success).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction returns no data", async () => {
+ mockUpdateProjectAction.mockResolvedValue({ data: null });
+ render();
+ const input = screen.getByPlaceholderText("common.project_name");
+ await userEvent.clear(input);
+ await userEvent.type(input, "Another Name");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(toast.error).toHaveBeenCalledWith(anyString());
+ });
+
+ test("shows error toast if updateProjectAction throws", async () => {
+ mockUpdateProjectAction.mockRejectedValue(new Error("fail"));
+ render();
+ const input = screen.getByPlaceholderText("common.project_name");
+ await userEvent.clear(input);
+ await userEvent.type(input, "Error Name");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(toast.error).toHaveBeenCalledWith("environments.project.general.error_saving_project_information");
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/components/edit-waiting-time-form.test.tsx b/apps/web/modules/projects/settings/general/components/edit-waiting-time-form.test.tsx
new file mode 100644
index 0000000000..7bbb63bc6e
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/components/edit-waiting-time-form.test.tsx
@@ -0,0 +1,114 @@
+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 { TProject } from "@formbricks/types/project";
+import { EditWaitingTimeForm } from "./edit-waiting-time-form";
+
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+
+const mockUpdateProjectAction = vi.fn();
+vi.mock("../../actions", () => ({
+ updateProjectAction: (...args: any[]) => mockUpdateProjectAction(...args),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(() => "error-message"),
+}));
+
+const baseProject: TProject = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 7,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [
+ {
+ id: "env1",
+ type: "production",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ projectId: "p1",
+ appSetupCompleted: false,
+ },
+ ],
+ languages: [],
+ logo: null,
+};
+
+describe("EditWaitingTimeForm", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders form with current waiting time and update button", () => {
+ render();
+ expect(
+ screen.getByLabelText("environments.project.general.wait_x_days_before_showing_next_survey")
+ ).toBeInTheDocument();
+ expect(screen.getByDisplayValue("7")).toBeInTheDocument();
+ expect(screen.getByText("common.update")).toBeInTheDocument();
+ });
+
+ test("shows warning alert and disables input/button if isReadOnly", () => {
+ render();
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "common.only_owners_managers_and_manage_access_members_can_perform_this_action"
+ );
+ expect(
+ screen.getByLabelText("environments.project.general.wait_x_days_before_showing_next_survey")
+ ).toBeInTheDocument();
+ expect(screen.getByDisplayValue("7")).toBeDisabled();
+ expect(screen.getByText("common.update")).toBeDisabled();
+ });
+
+ test("calls updateProjectAction and shows success toast on valid submit", async () => {
+ mockUpdateProjectAction.mockResolvedValue({ data: { recontactDays: 10 } });
+ render();
+ const input = screen.getByLabelText(
+ "environments.project.general.wait_x_days_before_showing_next_survey"
+ );
+ await userEvent.clear(input);
+ await userEvent.type(input, "10");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(mockUpdateProjectAction).toHaveBeenCalledWith({ projectId: "p1", data: { recontactDays: 10 } });
+ expect(toast.success).toHaveBeenCalledWith(
+ "environments.project.general.waiting_period_updated_successfully"
+ );
+ });
+
+ test("shows error toast if updateProjectAction returns no data", async () => {
+ mockUpdateProjectAction.mockResolvedValue({ data: null });
+ render();
+ const input = screen.getByLabelText(
+ "environments.project.general.wait_x_days_before_showing_next_survey"
+ );
+ await userEvent.clear(input);
+ await userEvent.type(input, "5");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(toast.error).toHaveBeenCalledWith("error-message");
+ });
+
+ test("shows error toast if updateProjectAction throws", async () => {
+ mockUpdateProjectAction.mockRejectedValue(new Error("fail"));
+ render();
+ const input = screen.getByLabelText(
+ "environments.project.general.wait_x_days_before_showing_next_survey"
+ );
+ await userEvent.clear(input);
+ await userEvent.type(input, "3");
+ await userEvent.click(screen.getByText("common.update"));
+ expect(toast.error).toHaveBeenCalledWith("Error: fail");
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/loading.test.tsx b/apps/web/modules/projects/settings/general/loading.test.tsx
new file mode 100644
index 0000000000..deab26f263
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/loading.test.tsx
@@ -0,0 +1,53 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { GeneralSettingsLoading } from "./loading";
+
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: (props: any) => ,
+}));
+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("@/app/(app)/components/LoadingCard", () => ({
+ LoadingCard: (props: any) => (
+
+
{props.title}
+
{props.description}
+
+ ),
+}));
+
+describe("GeneralSettingsLoading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and main UI elements", () => {
+ render();
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
+ expect(screen.getAllByTestId("loading-card").length).toBe(3);
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ expect(screen.getByText("common.project_name")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.project_name_settings_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.project.general.recontact_waiting_time")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.recontact_waiting_time_settings_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.project.general.delete_project")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.delete_project_settings_description")
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/general/page.test.tsx b/apps/web/modules/projects/settings/general/page.test.tsx
new file mode 100644
index 0000000000..4c635edec6
--- /dev/null
+++ b/apps/web/modules/projects/settings/general/page.test.tsx
@@ -0,0 +1,128 @@
+import { getProjects } from "@/lib/project/service";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TOrganization } from "@formbricks/types/organizations";
+import { GeneralSettingsPage } from "./page";
+
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: (props: any) => ,
+}));
+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("@/modules/ui/components/settings-id", () => ({
+ SettingsId: ({ title, id }: any) => (
+
+ ),
+}));
+vi.mock("./components/edit-project-name-form", () => ({
+ EditProjectNameForm: (props: any) => {props.project.id}
,
+}));
+vi.mock("./components/edit-waiting-time-form", () => ({
+ EditWaitingTimeForm: (props: any) => {props.project.id}
,
+}));
+vi.mock("./components/delete-project", () => ({
+ DeleteProject: (props: any) => {props.environmentId}
,
+}));
+
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ // Return a mock translator that just returns the key
+ return (key: string) => key;
+ }),
+}));
+const mockProject = {
+ id: "proj-1",
+ name: "Project 1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ organizationId: "org-1",
+ environments: [],
+} as any;
+
+const mockOrganization: TOrganization = {
+ id: "org-1",
+ name: "Org 1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: {
+ plan: "free",
+ limits: { monthly: { miu: 10, responses: 10 }, projects: 4 },
+ period: "monthly",
+ periodStart: new Date(),
+ stripeCustomerId: null,
+ },
+ isAIEnabled: false,
+};
+
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
+}));
+vi.mock("@/lib/project/service", () => ({
+ getProjects: vi.fn(),
+}));
+vi.mock("@/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ IS_DEVELOPMENT: false,
+}));
+vi.mock("@/package.json", () => ({
+ default: {
+ version: "1.2.3",
+ },
+}));
+
+describe("GeneralSettingsPage", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and main UI elements", async () => {
+ const props = { params: { environmentId: "env1" } } as any;
+
+ vi.mocked(getProjects).mockResolvedValue([mockProject]);
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ isOwner: true,
+ isManager: false,
+ project: mockProject,
+ organization: mockOrganization,
+ } as any);
+
+ const Page = await GeneralSettingsPage(props);
+ render(Page);
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
+ expect(screen.getAllByTestId("settings-id").length).toBe(2);
+ expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
+ expect(screen.getByTestId("delete-project")).toBeInTheDocument();
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ expect(screen.getByText("common.project_name")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.project_name_settings_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.project.general.recontact_waiting_time")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.recontact_waiting_time_settings_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.project.general.delete_project")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.general.delete_project_settings_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("common.project_id")).toBeInTheDocument();
+ expect(screen.getByText("common.formbricks_version")).toBeInTheDocument();
+ expect(screen.getByText("1.2.3")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/layout.test.tsx b/apps/web/modules/projects/settings/layout.test.tsx
new file mode 100644
index 0000000000..00f6bd02fe
--- /dev/null
+++ b/apps/web/modules/projects/settings/layout.test.tsx
@@ -0,0 +1,41 @@
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
+import { cleanup } from "@testing-library/react";
+import { redirect } from "next/navigation";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectSettingsLayout } from "./layout";
+
+vi.mock("next/navigation", () => ({
+ redirect: vi.fn(),
+}));
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
+}));
+
+describe("ProjectSettingsLayout", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("redirects to billing if isBilling is true", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({ isBilling: true } as TEnvironmentAuth);
+ const props = { params: { environmentId: "env-1" }, children: child
};
+ await ProjectSettingsLayout(props);
+ expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-1/settings/billing");
+ });
+
+ test("renders children if isBilling is false", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({ isBilling: false } as TEnvironmentAuth);
+ const props = { params: { environmentId: "env-2" }, children: child
};
+ const result = await ProjectSettingsLayout(props);
+ expect(result).toEqual(child
);
+ expect(vi.mocked(redirect)).not.toHaveBeenCalled();
+ });
+
+ test("throws error if getEnvironmentAuth throws", async () => {
+ const error = new Error("fail");
+ vi.mocked(getEnvironmentAuth).mockRejectedValue(error);
+ const props = { params: { environmentId: "env-3" }, children: child
};
+ await expect(ProjectSettingsLayout(props)).rejects.toThrow(error);
+ });
+});
diff --git a/apps/web/modules/projects/settings/lib/project.test.ts b/apps/web/modules/projects/settings/lib/project.test.ts
new file mode 100644
index 0000000000..18eaaa7027
--- /dev/null
+++ b/apps/web/modules/projects/settings/lib/project.test.ts
@@ -0,0 +1,226 @@
+import { environmentCache } from "@/lib/environment/cache";
+import { createEnvironment } from "@/lib/environment/service";
+import { projectCache } from "@/lib/project/cache";
+import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service";
+import { Prisma } from "@prisma/client";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { logger } from "@formbricks/logger";
+import { TEnvironment } from "@formbricks/types/environment";
+import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
+import { ZProject } from "@formbricks/types/project";
+import { createProject, deleteProject, updateProject } from "./project";
+
+const baseProject = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ languages: [],
+ recontactDays: 0,
+ linkSurveyBranding: false,
+ inAppSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [
+ {
+ id: "prodenv",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production" as TEnvironment["type"],
+ projectId: "p1",
+ appSetupCompleted: false,
+ },
+ {
+ id: "devenv",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development" as TEnvironment["type"],
+ projectId: "p1",
+ appSetupCompleted: false,
+ },
+ ],
+ styling: { allowStyleOverwrite: true },
+ logo: null,
+};
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ project: {
+ update: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn(),
+ },
+ projectTeam: {
+ createMany: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}));
+vi.mock("@/lib/project/cache", () => ({
+ projectCache: {
+ revalidate: vi.fn(),
+ },
+}));
+vi.mock("@/lib/environment/cache", () => ({
+ environmentCache: {
+ revalidate: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/storage/service", () => ({
+ deleteLocalFilesByEnvironmentId: vi.fn(),
+ deleteS3FilesByEnvironmentId: vi.fn(),
+}));
+
+vi.mock("@/lib/environment/service", () => ({
+ createEnvironment: vi.fn(),
+}));
+
+let mockIsS3Configured = true;
+vi.mock("@/lib/constants", () => ({
+ isS3Configured: () => {
+ return mockIsS3Configured;
+ },
+}));
+
+describe("project lib", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("updateProject", () => {
+ test("updates project and revalidates cache", async () => {
+ vi.mocked(prisma.project.update).mockResolvedValueOnce(baseProject as any);
+ vi.mocked(projectCache.revalidate).mockImplementation(() => {});
+ const result = await updateProject("p1", { name: "Project 1", environments: baseProject.environments });
+ expect(result).toEqual(ZProject.parse(baseProject));
+ expect(prisma.project.update).toHaveBeenCalled();
+ expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ vi.mocked(prisma.project.update).mockRejectedValueOnce(
+ new (class extends Error {
+ constructor() {
+ super();
+ this.message = "fail";
+ }
+ })()
+ );
+ await expect(updateProject("p1", { name: "Project 1" })).rejects.toThrow();
+ });
+
+ test("throws ValidationError on Zod error", async () => {
+ vi.mocked(prisma.project.update).mockResolvedValueOnce({ ...baseProject, id: 123 } as any);
+ await expect(
+ updateProject("p1", { name: "Project 1", environments: baseProject.environments })
+ ).rejects.toThrow(ValidationError);
+ });
+ });
+
+ describe("createProject", () => {
+ test("creates project, environments, and revalidates cache", async () => {
+ vi.mocked(prisma.project.create).mockResolvedValueOnce({ ...baseProject, id: "p2" } as any);
+ vi.mocked(prisma.projectTeam.createMany).mockResolvedValueOnce({} as any);
+ vi.mocked(createEnvironment).mockResolvedValueOnce(baseProject.environments[0] as any);
+ vi.mocked(createEnvironment).mockResolvedValueOnce(baseProject.environments[1] as any);
+ vi.mocked(prisma.project.update).mockResolvedValueOnce(baseProject as any);
+ vi.mocked(projectCache.revalidate).mockImplementation(() => {});
+ const result = await createProject("org1", { name: "Project 1", teamIds: ["t1"] });
+ expect(result).toEqual(baseProject);
+ expect(prisma.project.create).toHaveBeenCalled();
+ expect(prisma.projectTeam.createMany).toHaveBeenCalled();
+ expect(createEnvironment).toHaveBeenCalled();
+ expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p2", organizationId: "org1" });
+ });
+
+ test("throws ValidationError if name is missing", async () => {
+ await expect(createProject("org1", {})).rejects.toThrow(ValidationError);
+ });
+
+ test("throws InvalidInputError on unique constraint", async () => {
+ const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
+ code: "P2002",
+ clientVersion: "5.0.0",
+ });
+ vi.mocked(prisma.project.create).mockRejectedValueOnce(prismaError);
+ await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow(InvalidInputError);
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
+ code: "P2001",
+ clientVersion: "5.0.0",
+ });
+ vi.mocked(prisma.project.create).mockRejectedValueOnce(prismaError);
+ await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws unknown error", async () => {
+ vi.mocked(prisma.project.create).mockRejectedValueOnce(new Error("fail"));
+ await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow("fail");
+ });
+ });
+
+ describe("deleteProject", () => {
+ test("deletes project, deletes files, and revalidates cache (S3)", async () => {
+ vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
+
+ vi.mocked(deleteS3FilesByEnvironmentId).mockResolvedValue(undefined);
+ vi.mocked(projectCache.revalidate).mockImplementation(() => {});
+ vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
+ const result = await deleteProject("p1");
+ expect(result).toEqual(baseProject);
+ expect(deleteS3FilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
+ expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
+ expect(environmentCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
+ });
+
+ test("deletes project, deletes files, and revalidates cache (local)", async () => {
+ vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
+ mockIsS3Configured = false;
+ vi.mocked(deleteLocalFilesByEnvironmentId).mockResolvedValue(undefined);
+ vi.mocked(projectCache.revalidate).mockImplementation(() => {});
+ vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
+ const result = await deleteProject("p1");
+ expect(result).toEqual(baseProject);
+ expect(deleteLocalFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
+ expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
+ expect(environmentCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
+ });
+
+ test("logs error if file deletion fails", async () => {
+ vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
+ mockIsS3Configured = true;
+ vi.mocked(deleteS3FilesByEnvironmentId).mockRejectedValueOnce(new Error("fail"));
+ vi.mocked(logger.error).mockImplementation(() => {});
+ vi.mocked(projectCache.revalidate).mockImplementation(() => {});
+ vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
+ await deleteProject("p1");
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ const err = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
+ code: "P2001",
+ clientVersion: "5.0.0",
+ });
+ vi.mocked(prisma.project.delete).mockRejectedValueOnce(err as any);
+ await expect(deleteProject("p1")).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws unknown error", async () => {
+ vi.mocked(prisma.project.delete).mockRejectedValueOnce(new Error("fail"));
+ await expect(deleteProject("p1")).rejects.toThrow("fail");
+ });
+ });
+});
diff --git a/apps/web/modules/projects/settings/lib/tag.test.ts b/apps/web/modules/projects/settings/lib/tag.test.ts
new file mode 100644
index 0000000000..ba46a5d899
--- /dev/null
+++ b/apps/web/modules/projects/settings/lib/tag.test.ts
@@ -0,0 +1,143 @@
+import { tagCache } from "@/lib/tag/cache";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { TTag } from "@formbricks/types/tags";
+import { deleteTag, mergeTags, updateTagName } from "./tag";
+
+const baseTag: TTag = {
+ id: "cltag1234567890",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Tag1",
+ environmentId: "clenv1234567890",
+};
+
+const newTag: TTag = {
+ ...baseTag,
+ id: "cltag0987654321",
+ name: "Tag2",
+};
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ tag: {
+ delete: vi.fn(),
+ update: vi.fn(),
+ findUnique: vi.fn(),
+ },
+ response: {
+ findMany: vi.fn(),
+ },
+
+ $transaction: vi.fn(),
+ tagsOnResponses: {
+ deleteMany: vi.fn(),
+ create: vi.fn(),
+ updateMany: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+vi.mock("@/lib/tag/cache", () => ({
+ tagCache: {
+ revalidate: vi.fn(),
+ },
+}));
+vi.mock("@/lib/utils/validate", () => ({
+ validateInputs: vi.fn(),
+}));
+
+describe("tag lib", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("deleteTag", () => {
+ test("deletes tag and revalidates cache", async () => {
+ vi.mocked(prisma.tag.delete).mockResolvedValueOnce(baseTag);
+ vi.mocked(tagCache.revalidate).mockImplementation(() => {});
+ const result = await deleteTag(baseTag.id);
+ expect(result).toEqual(baseTag);
+ expect(prisma.tag.delete).toHaveBeenCalledWith({ where: { id: baseTag.id } });
+ expect(tagCache.revalidate).toHaveBeenCalledWith({
+ id: baseTag.id,
+ environmentId: baseTag.environmentId,
+ });
+ });
+ test("throws error on prisma error", async () => {
+ vi.mocked(prisma.tag.delete).mockRejectedValueOnce(new Error("fail"));
+ await expect(deleteTag(baseTag.id)).rejects.toThrow("fail");
+ });
+ });
+
+ describe("updateTagName", () => {
+ test("updates tag name and revalidates cache", async () => {
+ vi.mocked(prisma.tag.update).mockResolvedValueOnce(baseTag);
+ vi.mocked(tagCache.revalidate).mockImplementation(() => {});
+ const result = await updateTagName(baseTag.id, "Tag1");
+ expect(result).toEqual(baseTag);
+ expect(prisma.tag.update).toHaveBeenCalledWith({ where: { id: baseTag.id }, data: { name: "Tag1" } });
+ expect(tagCache.revalidate).toHaveBeenCalledWith({
+ id: baseTag.id,
+ environmentId: baseTag.environmentId,
+ });
+ });
+ test("throws error on prisma error", async () => {
+ vi.mocked(prisma.tag.update).mockRejectedValueOnce(new Error("fail"));
+ await expect(updateTagName(baseTag.id, "Tag1")).rejects.toThrow("fail");
+ });
+ });
+
+ describe("mergeTags", () => {
+ test("merges tags with responses with both tags", async () => {
+ vi.mocked(prisma.tag.findUnique)
+ .mockResolvedValueOnce(baseTag as any)
+ .mockResolvedValueOnce(newTag as any);
+ vi.mocked(prisma.response.findMany).mockResolvedValueOnce([{ id: "resp1" }] as any);
+ vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
+ vi.mocked(tagCache.revalidate).mockImplementation(() => {});
+ const result = await mergeTags(baseTag.id, newTag.id);
+ expect(result).toEqual(newTag);
+ expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: baseTag.id } });
+ expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: newTag.id } });
+ expect(prisma.response.findMany).toHaveBeenCalled();
+ expect(prisma.$transaction).toHaveBeenCalledTimes(2);
+ });
+ test("merges tags with no responses with both tags", async () => {
+ vi.mocked(prisma.tag.findUnique)
+ .mockResolvedValueOnce(baseTag as any)
+ .mockResolvedValueOnce(newTag as any);
+ vi.mocked(prisma.response.findMany).mockResolvedValueOnce([] as any);
+ vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined);
+ vi.mocked(tagCache.revalidate).mockImplementation(() => {});
+ const result = await mergeTags(baseTag.id, newTag.id);
+ expect(result).toEqual(newTag);
+ expect(tagCache.revalidate).toHaveBeenCalledWith({
+ id: baseTag.id,
+ environmentId: baseTag.environmentId,
+ });
+ expect(tagCache.revalidate).toHaveBeenCalledWith({ id: newTag.id });
+ });
+ test("throws if original tag not found", async () => {
+ vi.mocked(prisma.tag.findUnique).mockResolvedValueOnce(null);
+ await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
+ });
+ test("throws if new tag not found", async () => {
+ vi.mocked(prisma.tag.findUnique)
+ .mockResolvedValueOnce(baseTag as any)
+ .mockResolvedValueOnce(null);
+ await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
+ });
+ test("throws on prisma error", async () => {
+ vi.mocked(prisma.tag.findUnique).mockRejectedValueOnce(new Error("fail"));
+ await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("fail");
+ });
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/components/edit-logo.test.tsx b/apps/web/modules/projects/settings/look/components/edit-logo.test.tsx
new file mode 100644
index 0000000000..176cf033f7
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/components/edit-logo.test.tsx
@@ -0,0 +1,202 @@
+import { Project } from "@prisma/client";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { EditLogo } from "./edit-logo";
+
+const baseProject: Project = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [],
+ languages: [],
+ logo: { url: "https://logo.com/logo.png", bgColor: "#fff" },
+} as any;
+
+vi.mock("next/image", () => ({
+ // eslint-disable-next-line @next/next/no-img-element
+ default: (props: any) =>
,
+}));
+
+vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
+ AdvancedOptionToggle: ({ children }: any) => {children}
,
+}));
+
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+
+vi.mock("@/modules/ui/components/color-picker", () => ({
+ ColorPicker: ({ color }: any) => {color}
,
+}));
+vi.mock("@/modules/ui/components/delete-dialog", () => ({
+ DeleteDialog: ({ open, onDelete }: any) =>
+ open ? (
+
+
+
+ ) : null,
+}));
+vi.mock("@/modules/ui/components/file-input", () => ({
+ FileInput: () => ,
+}));
+vi.mock("@/modules/ui/components/input", () => ({ Input: (props: any) => }));
+
+const mockUpdateProjectAction = vi.fn(async () => ({ data: true }));
+
+const mockGetFormattedErrorMessage = vi.fn(() => "error-message");
+
+vi.mock("@/modules/projects/settings/actions", () => ({
+ updateProjectAction: () => mockUpdateProjectAction(),
+}));
+
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: () => mockGetFormattedErrorMessage(),
+}));
+
+describe("EditLogo", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders logo and edit button", () => {
+ render();
+ expect(screen.getByAltText("Logo")).toBeInTheDocument();
+ expect(screen.getByText("common.edit")).toBeInTheDocument();
+ });
+
+ test("renders file input if no logo", () => {
+ render();
+ expect(screen.getByTestId("file-input")).toBeInTheDocument();
+ });
+
+ test("shows alert if isReadOnly", () => {
+ render();
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "common.only_owners_managers_and_manage_access_members_can_perform_this_action"
+ );
+ });
+
+ test("clicking edit enables editing and shows save button", async () => {
+ render();
+ const editBtn = screen.getByText("common.edit");
+ await userEvent.click(editBtn);
+ expect(screen.getByText("common.save")).toBeInTheDocument();
+ });
+
+ test("clicking save calls updateProjectAction and shows success toast", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("common.save"));
+ expect(mockUpdateProjectAction).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction returns no data", async () => {
+ mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("common.save"));
+ expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction throws", async () => {
+ mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("common.save"));
+ // error toast is called
+ });
+
+ test("clicking remove logo opens dialog and confirms removal", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(mockUpdateProjectAction).toHaveBeenCalled();
+ });
+
+ test("shows error toast if removeLogo returns no data", async () => {
+ mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows error toast if removeLogo throws", async () => {
+ mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ });
+
+ test("toggle background color enables/disables color picker", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ expect(screen.getByTestId("color-picker")).toBeInTheDocument();
+ });
+
+ test("saveChanges with isEditing false enables editing", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ // Save button should now be visible
+ expect(screen.getByText("common.save")).toBeInTheDocument();
+ });
+
+ test("saveChanges error toast on update failure", async () => {
+ mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("common.save"));
+ // error toast is called
+ });
+
+ test("removeLogo with isEditing false enables editing", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
+ });
+
+ test("removeLogo error toast on update failure", async () => {
+ mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ // error toast is called
+ });
+
+ test("toggleBackgroundColor disables and resets color", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ const toggle = screen.getByTestId("advanced-option-toggle");
+ await userEvent.click(toggle);
+ expect(screen.getByTestId("color-picker")).toBeInTheDocument();
+ });
+
+ test("DeleteDialog closes after confirming removal", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.edit"));
+ await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/components/edit-placement-form.test.tsx b/apps/web/modules/projects/settings/look/components/edit-placement-form.test.tsx
new file mode 100644
index 0000000000..c1c272b59e
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/components/edit-placement-form.test.tsx
@@ -0,0 +1,117 @@
+import { getFormattedErrorMessage } from "@/lib/utils/helper";
+import { updateProjectAction } from "@/modules/projects/settings/actions";
+import { Project } from "@prisma/client";
+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 { EditPlacementForm } from "./edit-placement-form";
+
+const baseProject: Project = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [],
+ languages: [],
+ logo: null,
+} as any;
+
+vi.mock("@/modules/projects/settings/actions", () => ({
+ updateProjectAction: vi.fn(),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(),
+}));
+
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+
+describe("EditPlacementForm", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all placement radio buttons and save button", () => {
+ render();
+ expect(screen.getByText("common.save")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.bottom_right")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.top_right")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.top_left")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.bottom_left")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.centered_modal")).toBeInTheDocument();
+ });
+
+ test("submits form and shows success toast", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.save"));
+ expect(updateProjectAction).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction returns no data", async () => {
+ vi.mocked(updateProjectAction).mockResolvedValueOnce({ data: false } as any);
+ render();
+ await userEvent.click(screen.getByText("common.save"));
+ expect(getFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction throws", async () => {
+ vi.mocked(updateProjectAction).mockResolvedValueOnce({ data: false } as any);
+ vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("error");
+ render();
+ await userEvent.click(screen.getByText("common.save"));
+ expect(toast.error).toHaveBeenCalledWith("error");
+ });
+
+ test("renders overlay and disables save when isReadOnly", () => {
+ render();
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "common.only_owners_managers_and_manage_access_members_can_perform_this_action"
+ );
+ expect(screen.getByText("common.save")).toBeDisabled();
+ });
+
+ test("shows darkOverlay and clickOutsideClose options for centered modal", async () => {
+ render(
+
+ );
+ expect(screen.getByLabelText("common.light_overlay")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.dark_overlay")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.disallow")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.allow")).toBeInTheDocument();
+ });
+
+ test("changing placement to center shows overlay and clickOutsideClose options", async () => {
+ render();
+ await userEvent.click(screen.getByLabelText("common.centered_modal"));
+ expect(screen.getByLabelText("common.light_overlay")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.dark_overlay")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.disallow")).toBeInTheDocument();
+ expect(screen.getByLabelText("common.allow")).toBeInTheDocument();
+ });
+
+ test("radio buttons are disabled when isReadOnly", () => {
+ render();
+ expect(screen.getByLabelText("common.bottom_right")).toBeDisabled();
+ expect(screen.getByLabelText("common.top_right")).toBeDisabled();
+ expect(screen.getByLabelText("common.top_left")).toBeDisabled();
+ expect(screen.getByLabelText("common.bottom_left")).toBeDisabled();
+ expect(screen.getByLabelText("common.centered_modal")).toBeDisabled();
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/components/theme-styling.test.tsx b/apps/web/modules/projects/settings/look/components/theme-styling.test.tsx
new file mode 100644
index 0000000000..4e3ae5f3ec
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/components/theme-styling.test.tsx
@@ -0,0 +1,209 @@
+import { updateProjectAction } from "@/modules/projects/settings/actions";
+import { Project } from "@prisma/client";
+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 { ThemeStyling } from "./theme-styling";
+
+const baseProject: Project = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true },
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null },
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ environments: [],
+ languages: [],
+ logo: null,
+} as any;
+
+const colors = ["#fff", "#000"];
+
+const mockGetFormattedErrorMessage = vi.fn(() => "error-message");
+const mockRouter = { refresh: vi.fn() };
+
+vi.mock("@/modules/projects/settings/actions", () => ({
+ updateProjectAction: vi.fn(),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: () => mockGetFormattedErrorMessage(),
+}));
+vi.mock("next/navigation", () => ({ useRouter: () => mockRouter }));
+vi.mock("@/modules/ui/components/alert", () => ({
+ Alert: ({ children }: any) => {children}
,
+ AlertDescription: ({ children }: any) => {children}
,
+}));
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, ...props }: any) => ,
+}));
+
+vi.mock("@/modules/ui/components/switch", () => ({
+ Switch: ({ checked, onCheckedChange }: any) => (
+ onCheckedChange(e.target.checked)} />
+ ),
+}));
+vi.mock("@/modules/ui/components/alert-dialog", () => ({
+ AlertDialog: ({ open, onConfirm, onDecline, headerText, mainText, confirmBtnLabel }: any) =>
+ open ? (
+
+
{headerText}
+
{mainText}
+
+
+
+ ) : null,
+}));
+vi.mock("@/modules/ui/components/background-styling-card", () => ({
+ BackgroundStylingCard: () => ,
+}));
+vi.mock("@/modules/ui/components/card-styling-settings", () => ({
+ CardStylingSettings: () => ,
+}));
+vi.mock("@/modules/survey/editor/components/form-styling-settings", () => ({
+ FormStylingSettings: () => ,
+}));
+vi.mock("@/modules/ui/components/theme-styling-preview-survey", () => ({
+ ThemeStylingPreviewSurvey: () => ,
+}));
+vi.mock("@/app/lib/templates", () => ({ previewSurvey: () => ({}) }));
+vi.mock("@/lib/styling/constants", () => ({ defaultStyling: { allowStyleOverwrite: false } }));
+
+describe("ThemeStyling", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all main sections and save/reset buttons", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("form-styling-settings")).toBeInTheDocument();
+ expect(screen.getByTestId("card-styling-settings")).toBeInTheDocument();
+ expect(screen.getByTestId("background-styling-card")).toBeInTheDocument();
+ expect(screen.getByTestId("theme-styling-preview-survey")).toBeInTheDocument();
+ expect(screen.getByText("common.save")).toBeInTheDocument();
+ expect(screen.getByText("common.reset_to_default")).toBeInTheDocument();
+ });
+
+ test("submits form and shows success toast", async () => {
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.save"));
+ expect(updateProjectAction).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction returns no data on submit", async () => {
+ vi.mocked(updateProjectAction).mockResolvedValueOnce({});
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.save"));
+ expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows error toast if updateProjectAction throws on submit", async () => {
+ vi.mocked(updateProjectAction).mockResolvedValueOnce({});
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.save"));
+ expect(toast.error).toHaveBeenCalled();
+ });
+
+ test("opens and confirms reset styling modal", async () => {
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.reset_to_default"));
+ expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
+ await userEvent.click(screen.getByText("common.confirm"));
+ expect(updateProjectAction).toHaveBeenCalled();
+ });
+
+ test("opens and cancels reset styling modal", async () => {
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.reset_to_default"));
+ expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
+ await userEvent.click(screen.getByText("Cancel"));
+ expect(screen.queryByTestId("alert-dialog")).not.toBeInTheDocument();
+ });
+
+ test("shows error toast if updateProjectAction returns no data on reset", async () => {
+ vi.mocked(updateProjectAction).mockResolvedValueOnce({});
+ render(
+
+ );
+ await userEvent.click(screen.getByText("common.reset_to_default"));
+ await userEvent.click(screen.getByText("common.confirm"));
+ expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("renders alert if isReadOnly", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("alert")).toBeInTheDocument();
+ expect(screen.getByTestId("alert-description")).toHaveTextContent(
+ "common.only_owners_managers_and_manage_access_members_can_perform_this_action"
+ );
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/lib/project.test.ts b/apps/web/modules/projects/settings/look/lib/project.test.ts
new file mode 100644
index 0000000000..7a6c9fc7ee
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/lib/project.test.ts
@@ -0,0 +1,65 @@
+import { Prisma, Project } from "@prisma/client";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { DatabaseError } from "@formbricks/types/errors";
+import { getProjectByEnvironmentId } from "./project";
+
+vi.mock("@/lib/cache", () => ({ cache: (fn: any) => fn }));
+vi.mock("@/lib/project/cache", () => ({
+ projectCache: { tag: { byEnvironmentId: vi.fn(() => "env-tag") } },
+}));
+vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
+vi.mock("react", () => ({ cache: (fn: any) => fn }));
+vi.mock("@formbricks/database", () => ({ prisma: { project: { findFirst: vi.fn() } } }));
+vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
+
+const baseProject: Project = {
+ id: "p1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Project 1",
+ organizationId: "org1",
+ styling: { allowStyleOverwrite: true } as any,
+ recontactDays: 0,
+ inAppSurveyBranding: false,
+ linkSurveyBranding: false,
+ config: { channel: null, industry: null } as any,
+ placement: "bottomRight",
+ clickOutsideClose: false,
+ darkOverlay: false,
+ logo: null,
+ brandColor: null,
+ highlightBorderColor: null,
+};
+
+describe("getProjectByEnvironmentId", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("returns project when found", async () => {
+ vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(baseProject);
+ const result = await getProjectByEnvironmentId("env1");
+ expect(result).toEqual(baseProject);
+ expect(prisma.project.findFirst).toHaveBeenCalledWith({
+ where: { environments: { some: { id: "env1" } } },
+ });
+ });
+
+ test("returns null when not found", async () => {
+ vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(null);
+ const result = await getProjectByEnvironmentId("env1");
+ expect(result).toBeNull();
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
+ vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(error);
+ await expect(getProjectByEnvironmentId("env1")).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws unknown error", async () => {
+ vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(new Error("fail"));
+ await expect(getProjectByEnvironmentId("env1")).rejects.toThrow("fail");
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/loading.test.tsx b/apps/web/modules/projects/settings/look/loading.test.tsx
new file mode 100644
index 0000000000..f754f76f85
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/loading.test.tsx
@@ -0,0 +1,66 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectLookSettingsLoading } from "./loading";
+
+vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
+ SettingsCard: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: (props: any) => ,
+}));
+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}
+
+ ),
+}));
+
+// Badge, Button, Label, RadioGroup, RadioGroupItem, Switch are simple enough, no need to mock
+
+describe("ProjectLookSettingsLoading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and main UI elements", () => {
+ render();
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
+ expect(screen.getAllByTestId("settings-card").length).toBe(4);
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.look.enable_custom_styling")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.look.enable_custom_styling_description")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.surveys.edit.form_styling")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")
+ ).toBeInTheDocument();
+ expect(screen.getByText("environments.surveys.edit.card_styling")).toBeInTheDocument();
+ expect(screen.getByText("environments.surveys.edit.style_the_survey_card")).toBeInTheDocument();
+ expect(screen.getByText("environments.surveys.edit.background_styling")).toBeInTheDocument();
+ expect(screen.getByText("common.link_surveys")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")
+ ).toBeInTheDocument();
+ expect(screen.getAllByText("common.loading").length).toBeGreaterThanOrEqual(3);
+ expect(screen.getByText("common.preview")).toBeInTheDocument();
+ expect(screen.getByText("common.restart")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.look.show_powered_by_formbricks")).toBeInTheDocument();
+ expect(screen.getByText("common.bottom_right")).toBeInTheDocument();
+ expect(screen.getByText("common.top_right")).toBeInTheDocument();
+ expect(screen.getByText("common.top_left")).toBeInTheDocument();
+ expect(screen.getByText("common.bottom_left")).toBeInTheDocument();
+ expect(screen.getByText("common.centered_modal")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/look/page.test.tsx b/apps/web/modules/projects/settings/look/page.test.tsx
new file mode 100644
index 0000000000..940fe5c678
--- /dev/null
+++ b/apps/web/modules/projects/settings/look/page.test.tsx
@@ -0,0 +1,121 @@
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/project";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TOrganization } from "@formbricks/types/organizations";
+import { ProjectLookSettingsPage } from "./page";
+
+vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
+ SettingsCard: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/lib/constants", () => ({
+ SURVEY_BG_COLORS: ["#fff", "#000"],
+ IS_FORMBRICKS_CLOUD: 1,
+ UNSPLASH_ACCESS_KEY: "unsplash-key",
+}));
+
+vi.mock("@/lib/cn", () => ({
+ cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
+}));
+
+vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
+ PageContentWrapper: ({ children }: any) => {children}
,
+}));
+
+vi.mock("@/modules/ee/license-check/lib/utils", async () => ({
+ getWhiteLabelPermission: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/whitelabel/remove-branding/components/branding-settings-card", () => ({
+ BrandingSettingsCard: () => ,
+}));
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
+}));
+
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: (props: any) => ,
+}));
+
+vi.mock("./components/edit-logo", () => ({
+ EditLogo: () => ,
+}));
+vi.mock("@/modules/projects/settings/look/lib/project", async () => ({
+ getProjectByEnvironmentId: vi.fn(),
+}));
+
+vi.mock("@/modules/ui/components/page-header", () => ({
+ PageHeader: ({ children, pageTitle }: any) => (
+
+
{pageTitle}
+ {children}
+
+ ),
+}));
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: vi.fn(() => {
+ // Return a mock translator that just returns the key
+ return (key: string) => key;
+ }),
+}));
+vi.mock("./components/edit-placement-form", () => ({
+ EditPlacementForm: () => ,
+}));
+vi.mock("./components/theme-styling", () => ({
+ ThemeStyling: () => ,
+}));
+
+describe("ProjectLookSettingsPage", () => {
+ const props = { params: Promise.resolve({ environmentId: "env1" }) };
+ const mockOrg = {
+ id: "org1",
+ name: "Test Org",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { plan: "pro" } as any,
+ } as TOrganization;
+
+ beforeEach(() => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ organization: mockOrg,
+ } as any);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and main UI elements", async () => {
+ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
+ id: "project1",
+ name: "Test Project",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environments: [],
+ } as any);
+
+ const Page = await ProjectLookSettingsPage(props);
+ render(Page);
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
+ expect(screen.getAllByTestId("settings-card").length).toBe(3);
+ expect(screen.getByTestId("theme-styling")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-logo")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-placement-form")).toBeInTheDocument();
+ expect(screen.getByTestId("branding-settings-card")).toBeInTheDocument();
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ });
+
+ test("throws error if project is not found", async () => {
+ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
+ const props = { params: Promise.resolve({ environmentId: "env1" }) };
+ await expect(ProjectLookSettingsPage(props)).rejects.toThrow("Project not found");
+ });
+});
diff --git a/apps/web/modules/projects/settings/page.test.tsx b/apps/web/modules/projects/settings/page.test.tsx
new file mode 100644
index 0000000000..ce3df3e750
--- /dev/null
+++ b/apps/web/modules/projects/settings/page.test.tsx
@@ -0,0 +1,20 @@
+import { cleanup } from "@testing-library/react";
+import { redirect } from "next/navigation";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectSettingsPage } from "./page";
+
+vi.mock("next/navigation", () => ({
+ redirect: vi.fn(),
+}));
+
+describe("ProjectSettingsPage", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("redirects to the general project settings page", async () => {
+ const params = { environmentId: "env-123" };
+ await ProjectSettingsPage({ params: Promise.resolve(params) });
+ expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/project/general");
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/actions.test.ts b/apps/web/modules/projects/settings/tags/actions.test.ts
new file mode 100644
index 0000000000..dc48570c3b
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/actions.test.ts
@@ -0,0 +1,76 @@
+import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
+import { getEnvironmentIdFromTagId } from "@/lib/utils/helper";
+import { deleteTag, mergeTags, updateTagName } from "@/modules/projects/settings/lib/tag";
+import { cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { deleteTagAction, mergeTagsAction, updateTagNameAction } from "./actions";
+
+vi.mock("@/lib/utils/action-client", () => ({
+ authenticatedActionClient: {
+ schema: () => ({
+ action: (fn: any) => fn,
+ }),
+ },
+}));
+vi.mock("@/lib/utils/action-client-middleware", () => ({
+ checkAuthorizationUpdated: vi.fn(),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getEnvironmentIdFromTagId: vi.fn(async (tagId: string) => tagId + "-env"),
+ getOrganizationIdFromEnvironmentId: vi.fn(async (envId: string) => envId + "-org"),
+ getOrganizationIdFromTagId: vi.fn(async (tagId: string) => tagId + "-org"),
+ getProjectIdFromEnvironmentId: vi.fn(async (envId: string) => envId + "-proj"),
+ getProjectIdFromTagId: vi.fn(async (tagId: string) => tagId + "-proj"),
+}));
+vi.mock("@/modules/projects/settings/lib/tag", () => ({
+ deleteTag: vi.fn(async (tagId: string) => ({ deleted: tagId })),
+ updateTagName: vi.fn(async (tagId: string, name: string) => ({ updated: tagId, name })),
+ mergeTags: vi.fn(async (originalTagId: string, newTagId: string) => ({
+ merged: [originalTagId, newTagId],
+ })),
+}));
+
+const ctx = { user: { id: "user1" } };
+const validTagId = "tag_123";
+const validTagId2 = "tag_456";
+
+describe("/modules/projects/settings/tags/actions.ts", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ cleanup();
+ });
+
+ test("deleteTagAction calls authorization and deleteTag", async () => {
+ const result = await deleteTagAction({ ctx, parsedInput: { tagId: validTagId } } as any);
+ expect(result).toEqual({ deleted: validTagId });
+ expect(checkAuthorizationUpdated).toHaveBeenCalled();
+ expect(deleteTag).toHaveBeenCalledWith(validTagId);
+ });
+
+ test("updateTagNameAction calls authorization and updateTagName", async () => {
+ const name = "New Name";
+ const result = await updateTagNameAction({ ctx, parsedInput: { tagId: validTagId, name } } as any);
+ expect(result).toEqual({ updated: validTagId, name });
+ expect(checkAuthorizationUpdated).toHaveBeenCalled();
+ expect(updateTagName).toHaveBeenCalledWith(validTagId, name);
+ });
+
+ test("mergeTagsAction throws if tags are in different environments", async () => {
+ vi.mocked(getEnvironmentIdFromTagId).mockImplementationOnce(async (id) => id + "-env1");
+ vi.mocked(getEnvironmentIdFromTagId).mockImplementationOnce(async (id) => id + "-env2");
+ await expect(
+ mergeTagsAction({ ctx, parsedInput: { originalTagId: validTagId, newTagId: validTagId2 } } as any)
+ ).rejects.toThrow("Tags must be in the same environment");
+ });
+
+ test("mergeTagsAction calls authorization and mergeTags if environments match", async () => {
+ vi.mocked(getEnvironmentIdFromTagId).mockResolvedValue("env1");
+ const result = await mergeTagsAction({
+ ctx,
+ parsedInput: { originalTagId: validTagId, newTagId: validTagId },
+ } as any);
+ expect(result).toEqual({ merged: [validTagId, validTagId] });
+ expect(checkAuthorizationUpdated).toHaveBeenCalled();
+ expect(mergeTags).toHaveBeenCalledWith(validTagId, validTagId);
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/components/edit-tags-wrapper.test.tsx b/apps/web/modules/projects/settings/tags/components/edit-tags-wrapper.test.tsx
new file mode 100644
index 0000000000..16f14779fb
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/components/edit-tags-wrapper.test.tsx
@@ -0,0 +1,88 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TTag, TTagsCount } from "@formbricks/types/tags";
+import { EditTagsWrapper } from "./edit-tags-wrapper";
+
+vi.mock("@/modules/projects/settings/tags/components/single-tag", () => ({
+ SingleTag: (props: any) => {props.tagName}
,
+}));
+vi.mock("@/modules/ui/components/empty-space-filler", () => ({
+ EmptySpaceFiller: () => ,
+}));
+
+describe("EditTagsWrapper", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ const environment: TEnvironment = {
+ id: "env1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "p1",
+ appSetupCompleted: true,
+ };
+
+ const tags: TTag[] = [
+ { id: "tag1", createdAt: new Date(), updatedAt: new Date(), name: "Tag 1", environmentId: "env1" },
+ { id: "tag2", createdAt: new Date(), updatedAt: new Date(), name: "Tag 2", environmentId: "env1" },
+ ];
+
+ const tagsCount: TTagsCount = [
+ { tagId: "tag1", count: 5 },
+ { tagId: "tag2", count: 0 },
+ ];
+
+ test("renders table headers and actions column if not readOnly", () => {
+ render(
+
+ );
+ expect(screen.getByText("environments.project.tags.tag")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.count")).toBeInTheDocument();
+ expect(screen.getByText("common.actions")).toBeInTheDocument();
+ });
+
+ test("does not render actions column if readOnly", () => {
+ render(
+
+ );
+ expect(screen.queryByText("common.actions")).not.toBeInTheDocument();
+ });
+
+ test("renders EmptySpaceFiller if no tags", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("empty-space-filler")).toBeInTheDocument();
+ });
+
+ test("renders SingleTag for each tag", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("single-tag-tag1")).toHaveTextContent("Tag 1");
+ expect(screen.getByTestId("single-tag-tag2")).toHaveTextContent("Tag 2");
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.test.tsx b/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.test.tsx
new file mode 100644
index 0000000000..c97d1fcbb3
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/components/merge-tags-combobox.test.tsx
@@ -0,0 +1,70 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { MergeTagsCombobox } from "./merge-tags-combobox";
+
+vi.mock("@/modules/ui/components/command", () => ({
+ Command: ({ children }: any) => {children}
,
+ CommandEmpty: ({ children }: any) => {children}
,
+ CommandGroup: ({ children }: any) => {children}
,
+ CommandInput: (props: any) => ,
+ CommandItem: ({ children, onSelect, ...props }: any) => (
+ onSelect && onSelect(children)} {...props}>
+ {children}
+
+ ),
+ CommandList: ({ children }: any) => {children}
,
+}));
+
+vi.mock("@/modules/ui/components/popover", () => ({
+ Popover: ({ children }: any) => {children}
,
+ PopoverContent: ({ children }: any) => {children}
,
+ PopoverTrigger: ({ children }: any) => {children}
,
+}));
+
+describe("MergeTagsCombobox", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ const tags = [
+ { label: "Tag 1", value: "tag1" },
+ { label: "Tag 2", value: "tag2" },
+ ];
+
+ test("renders button with tolgee string", () => {
+ render();
+ expect(screen.getByText("environments.project.tags.merge")).toBeInTheDocument();
+ });
+
+ test("shows popover and all tag items when button is clicked", async () => {
+ render();
+ await userEvent.click(screen.getByText("environments.project.tags.merge"));
+ expect(screen.getByTestId("popover-content")).toBeInTheDocument();
+ expect(screen.getAllByTestId("command-item").length).toBe(2);
+ expect(screen.getByText("Tag 1")).toBeInTheDocument();
+ expect(screen.getByText("Tag 2")).toBeInTheDocument();
+ });
+
+ test("calls onSelect with tag value and closes popover", async () => {
+ const onSelect = vi.fn();
+ render();
+ await userEvent.click(screen.getByText("environments.project.tags.merge"));
+ await userEvent.click(screen.getByText("Tag 1"));
+ expect(onSelect).toHaveBeenCalledWith("tag1");
+ });
+
+ test("shows no tag found if tags is empty", async () => {
+ render();
+ await userEvent.click(screen.getByText("environments.project.tags.merge"));
+ expect(screen.getByTestId("command-empty")).toBeInTheDocument();
+ });
+
+ test("filters tags using input", async () => {
+ render();
+ await userEvent.click(screen.getByText("environments.project.tags.merge"));
+ const input = screen.getByTestId("command-input");
+ await userEvent.type(input, "Tag 2");
+ expect(screen.getByText("Tag 2")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/components/single-tag.test.tsx b/apps/web/modules/projects/settings/tags/components/single-tag.test.tsx
new file mode 100644
index 0000000000..a7f53c66cb
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/components/single-tag.test.tsx
@@ -0,0 +1,150 @@
+import { getFormattedErrorMessage } from "@/lib/utils/helper";
+import {
+ deleteTagAction,
+ mergeTagsAction,
+ updateTagNameAction,
+} from "@/modules/projects/settings/tags/actions";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TTag } from "@formbricks/types/tags";
+import { SingleTag } from "./single-tag";
+
+vi.mock("@/modules/ui/components/delete-dialog", () => ({
+ DeleteDialog: ({ open, setOpen, onDelete }: any) =>
+ open ? (
+
+
+
+ ) : null,
+}));
+
+vi.mock("@/modules/ui/components/loading-spinner", () => ({
+ LoadingSpinner: () => ,
+}));
+
+vi.mock("@/modules/projects/settings/tags/components/merge-tags-combobox", () => ({
+ MergeTagsCombobox: ({ tags, onSelect }: any) => (
+
+ {tags.map((t: any) => (
+
+ ))}
+
+ ),
+}));
+
+const mockRouter = { refresh: vi.fn() };
+
+vi.mock("@/modules/projects/settings/tags/actions", () => ({
+ updateTagNameAction: vi.fn(() => Promise.resolve({ data: {} })),
+ deleteTagAction: vi.fn(() => Promise.resolve({ data: {} })),
+ mergeTagsAction: vi.fn(() => Promise.resolve({ data: {} })),
+}));
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: vi.fn(),
+}));
+vi.mock("next/navigation", () => ({ useRouter: () => mockRouter }));
+
+const baseTag: TTag = {
+ id: "tag1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Tag 1",
+ environmentId: "env1",
+};
+
+const environmentTags: TTag[] = [
+ baseTag,
+ { id: "tag2", createdAt: new Date(), updatedAt: new Date(), name: "Tag 2", environmentId: "env1" },
+];
+
+describe("SingleTag", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders tag name and count", () => {
+ render(
+
+ );
+ expect(screen.getByDisplayValue("Tag 1")).toBeInTheDocument();
+ expect(screen.getByText("5")).toBeInTheDocument();
+ });
+
+ test("shows loading spinner if tagCountLoading", () => {
+ render(
+
+ );
+ expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
+ });
+
+ test("calls updateTagNameAction and shows success toast on blur", async () => {
+ render();
+ const input = screen.getByDisplayValue("Tag 1");
+ await userEvent.clear(input);
+ await userEvent.type(input, "Tag 1 Updated");
+ fireEvent.blur(input);
+ expect(updateTagNameAction).toHaveBeenCalledWith({ tagId: baseTag.id, name: "Tag 1 Updated" });
+ });
+
+ test("shows error toast and sets error state if updateTagNameAction fails", async () => {
+ vi.mocked(updateTagNameAction).mockResolvedValueOnce({ serverError: "Error occurred" });
+ render();
+ const input = screen.getByDisplayValue("Tag 1");
+ fireEvent.blur(input);
+ });
+
+ test("shows merge tags combobox and calls mergeTagsAction", async () => {
+ vi.mocked(mergeTagsAction).mockImplementationOnce(() => Promise.resolve({ data: undefined }));
+ vi.mocked(getFormattedErrorMessage).mockReturnValue("Error occurred");
+ render();
+ const mergeBtn = screen.getByText("Tag 2");
+ await userEvent.click(mergeBtn);
+ expect(mergeTagsAction).toHaveBeenCalledWith({ originalTagId: baseTag.id, newTagId: "tag2" });
+ expect(getFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows error toast if mergeTagsAction fails", async () => {
+ vi.mocked(mergeTagsAction).mockResolvedValueOnce({});
+ render();
+ const mergeBtn = screen.getByText("Tag 2");
+ await userEvent.click(mergeBtn);
+ expect(getFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("shows delete dialog and calls deleteTagAction on confirm", async () => {
+ render();
+ await userEvent.click(screen.getByText("common.delete"));
+ expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(deleteTagAction).toHaveBeenCalledWith({ tagId: baseTag.id });
+ });
+
+ test("shows error toast if deleteTagAction fails", async () => {
+ vi.mocked(deleteTagAction).mockResolvedValueOnce({});
+ render();
+ await userEvent.click(screen.getByText("common.delete"));
+ await userEvent.click(screen.getByTestId("confirm-delete"));
+ expect(getFormattedErrorMessage).toHaveBeenCalled();
+ });
+
+ test("does not render actions if isReadOnly", () => {
+ render(
+
+ );
+ expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("merge-tags-combobox")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/components/single-tag.tsx b/apps/web/modules/projects/settings/tags/components/single-tag.tsx
index bb358d1ffa..02a6f00ccc 100644
--- a/apps/web/modules/projects/settings/tags/components/single-tag.tsx
+++ b/apps/web/modules/projects/settings/tags/components/single-tag.tsx
@@ -78,7 +78,7 @@ export const SingleTag: React.FC = ({
} else {
const errorMessage = getFormattedErrorMessage(updateTagNameResponse);
if (
- errorMessage.includes(
+ errorMessage?.includes(
t("environments.project.tags.unique_constraint_failed_on_the_fields")
)
) {
@@ -99,12 +99,12 @@ export const SingleTag: React.FC = ({
-
+
{tagCountLoading ?
:
{tagCount}
}
{!isReadOnly && (
-
+
{isMergingTags ? (
@@ -139,7 +139,7 @@ export const SingleTag: React.FC
= ({
diff --git a/apps/web/modules/projects/settings/tags/loading.test.tsx b/apps/web/modules/projects/settings/tags/loading.test.tsx
new file mode 100644
index 0000000000..70035f789b
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/loading.test.tsx
@@ -0,0 +1,51 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TagsLoading } from "./loading";
+
+vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
+ SettingsCard: ({ children, title, description }: any) => (
+
+
{title}
+
{description}
+ {children}
+
+ ),
+}));
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: ({ activeId }: any) => (
+ {activeId}
+ ),
+}));
+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("TagsLoading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and skeletons", () => {
+ render();
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("settings-card")).toBeInTheDocument();
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.manage_tags")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.manage_tags_description")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.tag")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.count")).toBeInTheDocument();
+ expect(screen.getByText("common.actions")).toBeInTheDocument();
+ expect(
+ screen.getAllByText((_, node) => node!.className?.includes("animate-pulse")).length
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/modules/projects/settings/tags/page.test.tsx b/apps/web/modules/projects/settings/tags/page.test.tsx
new file mode 100644
index 0000000000..691f6b3b7d
--- /dev/null
+++ b/apps/web/modules/projects/settings/tags/page.test.tsx
@@ -0,0 +1,80 @@
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TagsPage } from "./page";
+
+vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
+ SettingsCard: ({ children, title, description }: any) => (
+
+
{title}
+
{description}
+ {children}
+
+ ),
+}));
+vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
+ ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
+
+ {environmentId}-{activeId}
+
+ ),
+}));
+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("./components/edit-tags-wrapper", () => ({
+ EditTagsWrapper: () => edit-tags-wrapper
,
+}));
+
+const mockGetTranslate = vi.fn(async () => (key: string) => key);
+
+vi.mock("@/tolgee/server", () => ({ getTranslate: () => mockGetTranslate() }));
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
+}));
+vi.mock("@/lib/tag/service", () => ({
+ getTagsByEnvironmentId: vi.fn(),
+}));
+vi.mock("@/lib/tagOnResponse/service", () => ({
+ getTagsOnResponsesCount: vi.fn(),
+}));
+
+describe("TagsPage", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders all tolgee strings and main components", async () => {
+ const props = { params: { environmentId: "env1" } };
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ environment: {
+ id: "env1",
+ appSetupCompleted: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ projectId: "project1",
+ type: "development",
+ },
+ } as any);
+
+ const Page = await TagsPage(props);
+ render(Page);
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("settings-card")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-tags-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("project-config-navigation")).toHaveTextContent("env1-tags");
+ expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.manage_tags")).toBeInTheDocument();
+ expect(screen.getByText("environments.project.tags.manage_tags_description")).toBeInTheDocument();
+ });
+});