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) => ( +
+

{title}

:

{id}

+
+ ), +})); +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) => test, +})); + +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(); + }); +});