chore: adds unit tests in module/projects (#5701)

This commit is contained in:
Piyush Gupta
2025-05-07 22:04:06 +05:30
committed by GitHub
parent faabd371f5
commit b0495a8a42
29 changed files with 2958 additions and 4 deletions

View File

@@ -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 ? (
<div data-testid="dialog" onClick={() => onOpenChange(false)}>
{children}
</div>
) : null,
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: ({ title, description, buttons }: any) => (
<div data-testid="upgrade-prompt">
<div>{title}</div>
<div>{description}</div>
<button onClick={buttons[0].onClick}>{buttons[0].text}</button>
<button onClick={buttons[1].onClick}>{buttons[1].text}</button>
</div>
),
}));
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(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
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(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
await userEvent.click(screen.getByTestId("dialog"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("calls button onClick handlers", async () => {
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
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(<ProjectLimitModal open={false} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});

View File

@@ -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) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div data-testid="dropdown-trigger">{children}</div>,
DropdownMenuContent: ({ children }: any) => <div data-testid="dropdown-content">{children}</div>,
DropdownMenuRadioGroup: ({ children, ...props }: any) => (
<div data-testid="dropdown-radio-group" {...props}>
{children}
</div>
),
DropdownMenuRadioItem: ({ children, ...props }: any) => (
<div data-testid="dropdown-radio-item" {...props}>
{children}
</div>
),
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
DropdownMenuItem: ({ children, ...props }: any) => (
<div data-testid="dropdown-item" {...props}>
{children}
</div>
),
}));
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
open ? (
<div data-testid="project-limit-modal">
<button onClick={() => setOpen(false)} data-testid="close-modal">
Close
</button>
<div data-testid="modal-buttons">
{buttons[0].text} {buttons[1].text}
</div>
<div data-testid="modal-project-limit">{projectLimit}</div>
</div>
) : 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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={true}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
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(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects.slice(0, 1)}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(mockPush).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/organizations/org1/projects/new/mode");
});
});

View File

@@ -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) => (
<div data-testid="project-config-navigation">
{activeId} {loading ? "loading" : "not-loading"}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: any) => (
<div data-testid="page-header">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: (props: any) => (
<div data-testid="loading-card">
{props.title} {props.description}
</div>
),
}));
describe("AppConnectionLoading", () => {
afterEach(() => {
cleanup();
});
test("renders wrapper, header, navigation, and all loading cards with correct tolgee keys", () => {
render(<AppConnectionLoading />);
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(<AppConnectionLoading />);
expect(screen.getByText((_, element) => element!.className.includes("bg-blue-50"))).toBeInTheDocument();
expect(
screen.getByText((_, element) => element!.className.includes("animate-pulse"))
).toBeInTheDocument();
});
});

View File

@@ -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) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: any) => (
<div data-testid="page-header">
{pageTitle}
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
<div data-testid="project-config-navigation">
{environmentId} {activeId}
</div>
),
}));
vi.mock("@/modules/ui/components/environment-notice", () => ({
EnvironmentNotice: ({ environmentId, subPageUrl }: any) => (
<div data-testid="environment-notice">
{environmentId} {subPageUrl}
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="settings-card">
{title} {description} {children}
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator", () => ({
WidgetStatusIndicator: ({ environment }: any) => (
<div data-testid="widget-status-indicator">{environment.id}</div>
),
}));
vi.mock("@/modules/projects/settings/(setup)/components/setup-instructions", () => ({
SetupInstructions: ({ environmentId, webAppUrl }: any) => (
<div data-testid="setup-instructions">
{environmentId} {webAppUrl}
</div>
),
}));
vi.mock("@/modules/projects/settings/(setup)/components/environment-id-field", () => ({
EnvironmentIdField: ({ environmentId }: any) => (
<div data-testid="environment-id-field">{environmentId}</div>
),
}));
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
});
});

View File

@@ -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) => (
<pre data-testid="code-block" data-language={language}>
{children}
</pre>
),
}));
describe("EnvironmentIdField", () => {
afterEach(() => {
cleanup();
});
test("renders the environment id in a code block", () => {
const envId = "env-123";
render(<EnvironmentIdField environmentId={envId} />);
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(<EnvironmentIdField environmentId="env-abc" />);
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;
}

View File

@@ -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(() => <div data-testid="secondary-navigation" />),
}));
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(<ProjectConfigNavigation activeId="irrelevant" environmentId="env-1" />);
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();
}
});
});

View File

@@ -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) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }: any) =>
open ? (
<div data-testid="delete-dialog">
<span>{text}</span>
<button onClick={onDelete} disabled={isDeleting} data-testid="confirm-delete">
Delete
</button>
<button onClick={() => setOpen(false)} data-testid="cancel-delete">
Cancel
</button>
</div>
) : 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(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
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(
<DeleteProjectRender
isDeleteDisabled={true}
isOwnerOrManager={false}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
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(
<DeleteProjectRender
isDeleteDisabled={true}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
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(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
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(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject, otherProject]}
/>
);
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(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
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();
});
});

View File

@@ -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) => (
<div data-testid="delete-project-render">
<p>isDeleteDisabled: {String(props.isDeleteDisabled)}</p>
<p>isOwnerOrManager: {String(props.isOwnerOrManager)}</p>
</div>
),
}));
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");
});
});

View File

@@ -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) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
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(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
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(<EditProjectNameForm project={baseProject} isReadOnly={true} />);
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(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
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(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
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(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
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");
});
});

View File

@@ -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) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
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(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
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(<EditWaitingTimeForm project={baseProject} isReadOnly={true} />);
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(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
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(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
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(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
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");
});
});

View File

@@ -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) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: (props: any) => (
<div data-testid="loading-card">
<p>{props.title}</p>
<p>{props.description}</p>
</div>
),
}));
describe("GeneralSettingsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main UI elements", () => {
render(<GeneralSettingsLoading />);
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();
});
});

View File

@@ -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) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: any) => (
<div data-testid="settings-id">
<p>{title}</p>:<p>{id}</p>
</div>
),
}));
vi.mock("./components/edit-project-name-form", () => ({
EditProjectNameForm: (props: any) => <div data-testid="edit-project-name-form">{props.project.id}</div>,
}));
vi.mock("./components/edit-waiting-time-form", () => ({
EditWaitingTimeForm: (props: any) => <div data-testid="edit-waiting-time-form">{props.project.id}</div>,
}));
vi.mock("./components/delete-project", () => ({
DeleteProject: (props: any) => <div data-testid="delete-project">{props.environmentId}</div>,
}));
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();
});
});

View File

@@ -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: <div>child</div> };
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: <div>child</div> };
const result = await ProjectSettingsLayout(props);
expect(result).toEqual(<div>child</div>);
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: <div>child</div> };
await expect(ProjectSettingsLayout(props)).rejects.toThrow(error);
});
});

View File

@@ -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");
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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) => <img alt="test" {...props} />,
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children }: any) => <div data-testid="advanced-option-toggle">{children}</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color }: any) => <div data-testid="color-picker">{color}</div>,
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button data-testid="confirm-delete" onClick={onDelete}>
Delete
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: () => <div data-testid="file-input" />,
}));
vi.mock("@/modules/ui/components/input", () => ({ Input: (props: any) => <input {...props} /> }));
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
expect(screen.getByAltText("Logo")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument();
});
test("renders file input if no logo", () => {
render(<EditLogo project={{ ...baseProject, logo: null }} environmentId="env1" isReadOnly={false} />);
expect(screen.getByTestId("file-input")).toBeInTheDocument();
});
test("shows alert if isReadOnly", () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={true} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
expect(screen.getByTestId("color-picker")).toBeInTheDocument();
});
test("saveChanges with isEditing false enables editing", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
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();
});
});

View File

@@ -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) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
describe("EditPlacementForm", () => {
afterEach(() => {
cleanup();
});
test("renders all placement radio buttons and save button", () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.save"));
expect(toast.error).toHaveBeenCalledWith("error");
});
test("renders overlay and disables save when isReadOnly", () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={true} />);
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(
<EditPlacementForm
project={{ ...baseProject, placement: "center", darkOverlay: true, clickOutsideClose: true }}
environmentId="env1"
isReadOnly={false}
/>
);
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(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
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(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={true} />);
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();
});
});

View File

@@ -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) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/switch", () => ({
Switch: ({ checked, onCheckedChange }: any) => (
<input type="checkbox" checked={checked} onChange={(e) => onCheckedChange(e.target.checked)} />
),
}));
vi.mock("@/modules/ui/components/alert-dialog", () => ({
AlertDialog: ({ open, onConfirm, onDecline, headerText, mainText, confirmBtnLabel }: any) =>
open ? (
<div data-testid="alert-dialog">
<div>{headerText}</div>
<div>{mainText}</div>
<button onClick={onConfirm}>{confirmBtnLabel}</button>
<button onClick={onDecline}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/background-styling-card", () => ({
BackgroundStylingCard: () => <div data-testid="background-styling-card" />,
}));
vi.mock("@/modules/ui/components/card-styling-settings", () => ({
CardStylingSettings: () => <div data-testid="card-styling-settings" />,
}));
vi.mock("@/modules/survey/editor/components/form-styling-settings", () => ({
FormStylingSettings: () => <div data-testid="form-styling-settings" />,
}));
vi.mock("@/modules/ui/components/theme-styling-preview-survey", () => ({
ThemeStylingPreviewSurvey: () => <div data-testid="theme-styling-preview-survey" />,
}));
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.save"));
expect(toast.error).toHaveBeenCalled();
});
test("opens and confirms reset styling modal", async () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
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(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={true}
/>
);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
});
});

View File

@@ -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");
});
});

View File

@@ -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) => (
<div data-testid="settings-card" {...props}>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
// 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(<ProjectLookSettingsLoading />);
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();
});
});

View File

@@ -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) => (
<div data-testid="settings-card" {...props}>
{children}
</div>
),
}));
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) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ee/license-check/lib/utils", async () => ({
getWhiteLabelPermission: vi.fn(),
}));
vi.mock("@/modules/ee/whitelabel/remove-branding/components/branding-settings-card", () => ({
BrandingSettingsCard: () => <div data-testid="branding-settings-card" />,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("./components/edit-logo", () => ({
EditLogo: () => <div data-testid="edit-logo" />,
}));
vi.mock("@/modules/projects/settings/look/lib/project", async () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
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: () => <div data-testid="edit-placement-form" />,
}));
vi.mock("./components/theme-styling", () => ({
ThemeStyling: () => <div data-testid="theme-styling" />,
}));
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");
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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) => <div data-testid={`single-tag-${props.tagId}`}>{props.tagName}</div>,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: () => <div data-testid="empty-space-filler" />,
}));
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(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={false}
/>
);
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(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={true}
/>
);
expect(screen.queryByText("common.actions")).not.toBeInTheDocument();
});
test("renders EmptySpaceFiller if no tags", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={[]}
environmentTagsCount={[]}
isReadOnly={false}
/>
);
expect(screen.getByTestId("empty-space-filler")).toBeInTheDocument();
});
test("renders SingleTag for each tag", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={false}
/>
);
expect(screen.getByTestId("single-tag-tag1")).toHaveTextContent("Tag 1");
expect(screen.getByTestId("single-tag-tag2")).toHaveTextContent("Tag 2");
});
});

View File

@@ -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) => <div data-testid="command">{children}</div>,
CommandEmpty: ({ children }: any) => <div data-testid="command-empty">{children}</div>,
CommandGroup: ({ children }: any) => <div data-testid="command-group">{children}</div>,
CommandInput: (props: any) => <input data-testid="command-input" {...props} />,
CommandItem: ({ children, onSelect, ...props }: any) => (
<div data-testid="command-item" tabIndex={0} onClick={() => onSelect && onSelect(children)} {...props}>
{children}
</div>
),
CommandList: ({ children }: any) => <div data-testid="command-list">{children}</div>,
}));
vi.mock("@/modules/ui/components/popover", () => ({
Popover: ({ children }: any) => <div data-testid="popover">{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
}));
describe("MergeTagsCombobox", () => {
afterEach(() => {
cleanup();
});
const tags = [
{ label: "Tag 1", value: "tag1" },
{ label: "Tag 2", value: "tag2" },
];
test("renders button with tolgee string", () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
expect(screen.getByText("environments.project.tags.merge")).toBeInTheDocument();
});
test("shows popover and all tag items when button is clicked", async () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
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(<MergeTagsCombobox tags={tags} onSelect={onSelect} />);
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(<MergeTagsCombobox tags={[]} onSelect={vi.fn()} />);
await userEvent.click(screen.getByText("environments.project.tags.merge"));
expect(screen.getByTestId("command-empty")).toBeInTheDocument();
});
test("filters tags using input", async () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
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();
});
});

View File

@@ -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 ? (
<div data-testid="delete-dialog">
<button data-testid="confirm-delete" onClick={onDelete}>
Delete
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div data-testid="loading-spinner" />,
}));
vi.mock("@/modules/projects/settings/tags/components/merge-tags-combobox", () => ({
MergeTagsCombobox: ({ tags, onSelect }: any) => (
<div data-testid="merge-tags-combobox">
{tags.map((t: any) => (
<button key={t.value} onClick={() => onSelect(t.value)}>
{t.label}
</button>
))}
</div>
),
}));
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(
<SingleTag tagId={baseTag.id} tagName={baseTag.name} tagCount={5} environmentTags={environmentTags} />
);
expect(screen.getByDisplayValue("Tag 1")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
});
test("shows loading spinner if tagCountLoading", () => {
render(
<SingleTag
tagId={baseTag.id}
tagName={baseTag.name}
tagCountLoading={true}
environmentTags={environmentTags}
/>
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
test("calls updateTagNameAction and shows success toast on blur", async () => {
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
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(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
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(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
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(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
const mergeBtn = screen.getByText("Tag 2");
await userEvent.click(mergeBtn);
expect(getFormattedErrorMessage).toHaveBeenCalled();
});
test("shows delete dialog and calls deleteTagAction on confirm", async () => {
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
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(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
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(
<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} isReadOnly />
);
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
expect(screen.queryByTestId("merge-tags-combobox")).not.toBeInTheDocument();
});
});

View File

@@ -78,7 +78,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
} 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<SingleTagProps> = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
{!isReadOnly && (
<div className="col-span-1 my-auto flex items-center justify-center gap-2 text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
@@ -139,7 +139,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
<Button
variant="destructive"
size="sm"
className="font-medium text-slate-50 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent"
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")}
</Button>

View File

@@ -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) => (
<div data-testid="settings-card">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId }: any) => (
<div data-testid="project-config-navigation">{activeId}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
describe("TagsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and skeletons", () => {
render(<TagsLoading />);
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);
});
});

View File

@@ -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) => (
<div data-testid="settings-card">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
<div data-testid="project-config-navigation">
{environmentId}-{activeId}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("./components/edit-tags-wrapper", () => ({
EditTagsWrapper: () => <div data-testid="edit-tags-wrapper">edit-tags-wrapper</div>,
}));
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();
});
});