diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 8dde1486d2..7027e6d279 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -14,7 +14,8 @@ When generating test files inside the "/app/web" path, follow these rules:
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
-- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
+- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
+- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type.
If it's a test for a ".tsx" file, follow these extra instructions:
diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx
new file mode 100644
index 0000000000..65ca595b02
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx
@@ -0,0 +1,34 @@
+import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
+import { describe, expect, test, vi } from "vitest";
+import Page from "./page";
+
+// mock constants
+vi.mock("@/lib/constants", () => ({
+ IS_FORMBRICKS_CLOUD: false,
+ ENCRYPTION_KEY: "test",
+ ENTERPRISE_LICENSE_KEY: "test",
+ GITHUB_ID: "test",
+ GITHUB_SECRET: "test",
+ GOOGLE_CLIENT_ID: "test",
+ GOOGLE_CLIENT_SECRET: "test",
+ AZUREAD_CLIENT_ID: "mock-azuread-client-id",
+ AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
+ AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
+ OIDC_CLIENT_ID: "mock-oidc-client-id",
+ OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
+ OIDC_ISSUER: "mock-oidc-issuer",
+ OIDC_DISPLAY_NAME: "mock-oidc-display-name",
+ OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
+ WEBAPP_URL: "mock-webapp-url",
+ IS_PRODUCTION: true,
+ FB_LOGO_URL: "https://example.com/mock-logo.png",
+ SMTP_HOST: "mock-smtp-host",
+ SMTP_PORT: "mock-smtp-port",
+ IS_POSTHOG_CONFIGURED: true,
+}));
+
+describe("Contact Page Re-export", () => {
+ test("should re-export SingleContactPage", () => {
+ expect(Page).toBe(SingleContactPage);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx
new file mode 100644
index 0000000000..921bf9edf3
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx
@@ -0,0 +1,15 @@
+import { ContactsPage } from "@/modules/ee/contacts/page";
+import { describe, expect, test, vi } from "vitest";
+import Page from "./page";
+
+// Mock the actual ContactsPage component
+vi.mock("@/modules/ee/contacts/page", () => ({
+ ContactsPage: () =>
Mock Contacts Page
,
+}));
+
+describe("Contacts Page Re-export", () => {
+ test("should re-export ContactsPage from the EE module", () => {
+ // Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
+ expect(Page).toBe(ContactsPage);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx
new file mode 100644
index 0000000000..97a4e0ca21
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx
@@ -0,0 +1,18 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import SegmentsPageWrapper from "./page";
+
+vi.mock("@/modules/ee/contacts/segments/page", () => ({
+ SegmentsPage: vi.fn(() => SegmentsPageMock
),
+}));
+
+describe("SegmentsPageWrapper", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders the SegmentsPage component", () => {
+ render( );
+ expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx
new file mode 100644
index 0000000000..68165b03d0
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx
@@ -0,0 +1,343 @@
+import { createActionClassAction } from "@/modules/survey/editor/actions";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import toast from "react-hot-toast";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { TEnvironment } from "@formbricks/types/environment";
+import { getActiveInactiveSurveysAction } from "../actions";
+import { ActionActivityTab } from "./ActionActivityTab";
+
+// Mock dependencies
+vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
+ ACTION_TYPE_ICON_LOOKUP: {
+ noCode: NoCodeIcon
,
+ automatic: AutomaticIcon
,
+ code: CodeIcon
,
+ },
+}));
+
+vi.mock("@/lib/time", () => ({
+ convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
+}));
+
+vi.mock("@/lib/utils/helper", () => ({
+ getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
+}));
+
+vi.mock("@/lib/utils/strings", () => ({
+ capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
+}));
+
+vi.mock("@/modules/survey/editor/actions", () => ({
+ createActionClassAction: vi.fn(),
+}));
+
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, variant, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/modules/ui/components/error-component", () => ({
+ ErrorComponent: () => ErrorComponent
,
+}));
+
+vi.mock("@/modules/ui/components/label", () => ({
+ Label: ({ children, ...props }: any) => {children} ,
+}));
+
+vi.mock("@/modules/ui/components/loading-spinner", () => ({
+ LoadingSpinner: () => LoadingSpinner
,
+}));
+
+vi.mock("../actions", () => ({
+ getActiveInactiveSurveysAction: vi.fn(),
+}));
+
+const mockActionClass = {
+ id: "action1",
+ createdAt: new Date("2023-01-01T10:00:00Z"),
+ updatedAt: new Date("2023-01-10T11:00:00Z"),
+ name: "Test Action",
+ description: "Test Description",
+ type: "noCode",
+ environmentId: "env1_dev",
+ noCodeConfig: {
+ /* ... */
+ } as any,
+} as unknown as TActionClass;
+
+const mockEnvironmentDev = {
+ id: "env1_dev",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development",
+} as unknown as TEnvironment;
+
+const mockEnvironmentProd = {
+ id: "env1_prod",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+} as unknown as TEnvironment;
+
+const mockOtherEnvActionClasses: TActionClass[] = [
+ {
+ id: "action2",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Existing Action Prod",
+ type: "noCode",
+ environmentId: "env1_prod",
+ } as unknown as TActionClass,
+ {
+ id: "action3",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Existing Code Action Prod",
+ type: "code",
+ key: "existing-key",
+ environmentId: "env1_prod",
+ } as unknown as TActionClass,
+];
+
+describe("ActionActivityTab", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
+ data: {
+ activeSurveys: ["Active Survey 1"],
+ inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
+ },
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders loading state initially", () => {
+ // Don't resolve the promise immediately
+ vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {}));
+ render(
+
+ );
+ expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
+ });
+
+ test("renders error state if fetching surveys fails", async () => {
+ vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
+ data: undefined,
+ });
+ render(
+
+ );
+ // Wait for the component to update after the promise resolves
+ await screen.findByText("ErrorComponent");
+ expect(screen.getByText("ErrorComponent")).toBeInTheDocument();
+ });
+
+ test("renders survey lists and action details correctly", async () => {
+ render(
+
+ );
+
+ // Wait for loading to finish
+ await screen.findByText("common.active_surveys");
+
+ // Check survey lists
+ expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
+ expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
+ expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
+
+ // Check action details
+ // Use the actual Date.toString() output that the mock receives
+ expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
+ expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
+ expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
+ expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
+ expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
+ expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
+ });
+
+ test("calls copyAction with correct data on button click", async () => {
+ vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
+ render(
+
+ );
+
+ await screen.findByText("Copy to Production");
+ const copyButton = screen.getByText("Copy to Production");
+ await userEvent.click(copyButton);
+
+ expect(createActionClassAction).toHaveBeenCalledTimes(1);
+ // Include the extra properties that the component sends due to spreading mockActionClass
+ const expectedActionInput = {
+ ...mockActionClass, // Spread the original object
+ name: "Test Action", // Keep the original name as it doesn't conflict
+ environmentId: "env1_prod", // Target environment ID
+ };
+ // Remove properties not expected by the action call itself, even if sent by component
+ delete (expectedActionInput as any).id;
+ delete (expectedActionInput as any).createdAt;
+ delete (expectedActionInput as any).updatedAt;
+
+ // The assertion now checks against the structure sent by the component
+ expect(createActionClassAction).toHaveBeenCalledWith({
+ action: {
+ ...mockActionClass, // Include id, createdAt, updatedAt etc.
+ name: "Test Action",
+ environmentId: "env1_prod",
+ },
+ });
+ expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
+ });
+
+ test("handles name conflict during copy", async () => {
+ vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
+ const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" };
+ render(
+
+ );
+
+ await screen.findByText("Copy to Production");
+ const copyButton = screen.getByText("Copy to Production");
+ await userEvent.click(copyButton);
+
+ expect(createActionClassAction).toHaveBeenCalledTimes(1);
+
+ // The assertion now checks against the structure sent by the component
+ expect(createActionClassAction).toHaveBeenCalledWith({
+ action: {
+ ...conflictingActionClass, // Include id, createdAt, updatedAt etc.
+ name: "Existing Action Prod (copy)",
+ environmentId: "env1_prod",
+ },
+ });
+ expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
+ });
+
+ test("handles key conflict during copy for 'code' type", async () => {
+ const codeActionClass: TActionClass = {
+ ...mockActionClass,
+ id: "codeAction1",
+ type: "code",
+ key: "existing-key", // Conflicting key
+ noCodeConfig: {
+ /* ... */
+ } as any,
+ };
+ render(
+
+ );
+
+ await screen.findByText("Copy to Production");
+ const copyButton = screen.getByText("Copy to Production");
+ await userEvent.click(copyButton);
+
+ expect(createActionClassAction).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists");
+ });
+
+ test("shows error if copy action fails server-side", async () => {
+ vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined });
+ render(
+
+ );
+
+ await screen.findByText("Copy to Production");
+ const copyButton = screen.getByText("Copy to Production");
+ await userEvent.click(copyButton);
+
+ expect(createActionClassAction).toHaveBeenCalledTimes(1);
+ expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed");
+ });
+
+ test("shows error and prevents copy if user is read-only", async () => {
+ render(
+
+ );
+
+ await screen.findByText("Copy to Production");
+ const copyButton = screen.getByText("Copy to Production");
+ await userEvent.click(copyButton);
+
+ expect(createActionClassAction).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action");
+ });
+
+ test("renders correct copy button text for production environment", async () => {
+ render(
+
+ );
+ await screen.findByText("Copy to Development");
+ expect(screen.getByText("Copy to Development")).toBeInTheDocument();
+ expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx
new file mode 100644
index 0000000000..6e8212fe50
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx
@@ -0,0 +1,122 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { TEnvironment } from "@formbricks/types/environment";
+import { ActionClassesTable } from "./ActionClassesTable";
+
+// Mock the ActionDetailModal
+vi.mock("./ActionDetailModal", () => ({
+ ActionDetailModal: ({ open, actionClass, setOpen }: any) =>
+ open ? (
+
+ Modal for {actionClass.name}
+ setOpen(false)}>Close Modal
+
+ ) : null,
+}));
+
+const mockActionClasses: TActionClass[] = [
+ { id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass,
+ { id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass,
+];
+
+const mockEnvironment: TEnvironment = {
+ id: "env1",
+ name: "Test Environment",
+ type: "development",
+} as unknown as TEnvironment;
+const mockOtherEnvironment: TEnvironment = {
+ id: "env2",
+ name: "Other Environment",
+ type: "production",
+} as unknown as TEnvironment;
+
+const mockTableHeading = Table Heading
;
+const mockActionRows = mockActionClasses.map((action) => (
+
+ {action.name} Row
+
+));
+
+describe("ActionClassesTable", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders table heading and action rows when actions exist", () => {
+ render(
+
+ {[mockTableHeading, mockActionRows]}
+
+ );
+
+ expect(screen.getByTestId("table-heading")).toBeInTheDocument();
+ expect(screen.getByTestId("action-row-1")).toBeInTheDocument();
+ expect(screen.getByTestId("action-row-2")).toBeInTheDocument();
+ expect(screen.queryByText("No actions found")).not.toBeInTheDocument();
+ });
+
+ test("renders 'No actions found' message when no actions exist", () => {
+ render(
+
+ {[mockTableHeading, []]}
+
+ );
+
+ expect(screen.getByTestId("table-heading")).toBeInTheDocument();
+ expect(screen.getByText("No actions found")).toBeInTheDocument();
+ expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument();
+ });
+
+ test("opens ActionDetailModal with correct action when a row is clicked", async () => {
+ render(
+
+ {[mockTableHeading, mockActionRows]}
+
+ );
+
+ // Modal should not be open initially
+ expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
+
+ // Find the button wrapping the first action row
+ const actionButton1 = screen.getByTitle("Action 1");
+ await userEvent.click(actionButton1);
+
+ // Modal should now be open with the correct action name
+ const modal = screen.getByTestId("action-detail-modal");
+ expect(modal).toBeInTheDocument();
+ expect(modal).toHaveTextContent("Modal for Action 1");
+
+ // Close the modal
+ await userEvent.click(screen.getByText("Close Modal"));
+ expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
+
+ // Click the second action button
+ const actionButton2 = screen.getByTitle("Action 2");
+ await userEvent.click(actionButton2);
+
+ // Modal should open for the second action
+ const modal2 = screen.getByTestId("action-detail-modal");
+ expect(modal2).toBeInTheDocument();
+ expect(modal2).toHaveTextContent("Modal for Action 2");
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx
new file mode 100644
index 0000000000..317bfde390
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.test.tsx
@@ -0,0 +1,180 @@
+import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { TEnvironment } from "@formbricks/types/environment";
+import { ActionActivityTab } from "./ActionActivityTab";
+import { ActionDetailModal } from "./ActionDetailModal";
+// Import mocked components
+import { ActionSettingsTab } from "./ActionSettingsTab";
+
+// Mock child components
+vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
+ ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
+
+
{label}
+
{description}
+
{open.toString()}
+
setOpen(false)}>Close
+ {icon}
+ {tabs.map((tab) => (
+
+
{tab.title}
+ {tab.children}
+
+ ))}
+
+ )),
+}));
+
+vi.mock("./ActionActivityTab", () => ({
+ ActionActivityTab: vi.fn(() => ActionActivityTab
),
+}));
+
+vi.mock("./ActionSettingsTab", () => ({
+ ActionSettingsTab: vi.fn(() => ActionSettingsTab
),
+}));
+
+// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
+vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
+ ACTION_TYPE_ICON_LOOKUP: {
+ code: Code Icon Mock
,
+ noCode: No Code Icon Mock
,
+ // Add other types if needed by other tests or default props
+ },
+}));
+
+const mockEnvironmentId = "test-env-id";
+const mockSetOpen = vi.fn();
+
+const mockEnvironment = {
+ id: mockEnvironmentId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production", // Use string literal as TEnvironmentType is not exported
+ appSetupCompleted: false,
+} as unknown as TEnvironment;
+
+const mockActionClass: TActionClass = {
+ id: "action-class-1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test Action",
+ description: "This is a test action",
+ type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
+ environmentId: mockEnvironmentId,
+ noCodeConfig: null,
+ key: "test-action-key",
+};
+
+const mockActionClasses: TActionClass[] = [mockActionClass];
+const mockOtherEnvActionClasses: TActionClass[] = [];
+const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
+
+const defaultProps = {
+ environmentId: mockEnvironmentId,
+ environment: mockEnvironment,
+ open: true,
+ setOpen: mockSetOpen,
+ actionClass: mockActionClass,
+ actionClasses: mockActionClasses,
+ isReadOnly: false,
+ otherEnvironment: mockOtherEnvironment,
+ otherEnvActionClasses: mockOtherEnvActionClasses,
+};
+
+describe("ActionDetailModal", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks(); // Clear mocks after each test
+ });
+
+ test("renders ModalWithTabs with correct props", () => {
+ render( );
+
+ const mockedModalWithTabs = vi.mocked(ModalWithTabs);
+
+ expect(mockedModalWithTabs).toHaveBeenCalled();
+ const props = mockedModalWithTabs.mock.calls[0][0];
+
+ // Check basic props
+ expect(props.open).toBe(true);
+ expect(props.setOpen).toBe(mockSetOpen);
+ expect(props.label).toBe(mockActionClass.name);
+ expect(props.description).toBe(mockActionClass.description);
+
+ // Check icon data-testid based on the mock for the default 'code' type
+ expect(props.icon).toBeDefined();
+ if (!props.icon) {
+ throw new Error("Icon prop is not defined");
+ }
+ expect((props.icon as any).props["data-testid"]).toBe("code-icon");
+
+ // Check tabs structure
+ expect(props.tabs).toHaveLength(2);
+ expect(props.tabs[0].title).toBe("common.activity");
+ expect(props.tabs[1].title).toBe("common.settings");
+
+ // Check if the correct mocked components are used as children
+ // Access the mocked functions directly
+ const mockedActionActivityTab = vi.mocked(ActionActivityTab);
+ const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
+
+ if (!props.tabs[0].children || !props.tabs[1].children) {
+ throw new Error("Tabs children are not defined");
+ }
+
+ expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
+ expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
+
+ // Check props passed to child components
+ const activityTabProps = (props.tabs[0].children as any).props;
+ expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
+ expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
+ expect(activityTabProps.isReadOnly).toBe(false);
+ expect(activityTabProps.environment).toBe(mockEnvironment);
+ expect(activityTabProps.actionClass).toBe(mockActionClass);
+ expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
+
+ const settingsTabProps = (props.tabs[1].children as any).props;
+ expect(settingsTabProps.actionClass).toBe(mockActionClass);
+ expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
+ expect(settingsTabProps.setOpen).toBe(mockSetOpen);
+ expect(settingsTabProps.isReadOnly).toBe(false);
+ });
+
+ test("renders correct icon based on action type", () => {
+ // Test with 'noCode' type
+ const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
+ render( );
+
+ const mockedModalWithTabs = vi.mocked(ModalWithTabs);
+ const props = mockedModalWithTabs.mock.calls[0][0];
+
+ // Expect the 'nocode-icon' based on the updated mock and action type
+ expect(props.icon).toBeDefined();
+
+ if (!props.icon) {
+ throw new Error("Icon prop is not defined");
+ }
+
+ expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
+ });
+
+ test("passes isReadOnly prop correctly", () => {
+ render( );
+ // Access the mocked component directly
+ const mockedModalWithTabs = vi.mocked(ModalWithTabs);
+ const props = mockedModalWithTabs.mock.calls[0][0];
+
+ if (!props.tabs[0].children || !props.tabs[1].children) {
+ throw new Error("Tabs children are not defined");
+ }
+
+ const activityTabProps = (props.tabs[0].children as any).props;
+ expect(activityTabProps.isReadOnly).toBe(true);
+
+ const settingsTabProps = (props.tabs[1].children as any).props;
+ expect(settingsTabProps.isReadOnly).toBe(true);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx
new file mode 100644
index 0000000000..1d44306363
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx
@@ -0,0 +1,63 @@
+import { timeSince } from "@/lib/time";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { ActionClassDataRow } from "./ActionRowData";
+
+vi.mock("@/lib/time", () => ({
+ timeSince: vi.fn(),
+}));
+
+const mockActionClass: TActionClass = {
+ id: "testId",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test Action",
+ description: "This is a test action",
+ type: "code",
+ noCodeConfig: null,
+ environmentId: "envId",
+ key: null,
+};
+
+const locale = "en-US";
+const timeSinceOutput = "2 hours ago";
+
+describe("ActionClassDataRow", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders code action correctly", () => {
+ vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
+ const actionClass = { ...mockActionClass, type: "code" } as TActionClass;
+ render( );
+
+ expect(screen.getByText(actionClass.name)).toBeInTheDocument();
+ expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
+ expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
+ expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
+ });
+
+ test("renders no-code action correctly", () => {
+ vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
+ const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
+ render( );
+
+ expect(screen.getByText(actionClass.name)).toBeInTheDocument();
+ expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
+ expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
+ expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
+ });
+
+ test("renders without description", () => {
+ vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
+ const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass;
+ render( );
+
+ expect(screen.getByText(actionClass.name)).toBeInTheDocument();
+ expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
+ expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx
new file mode 100644
index 0000000000..61ac93b11c
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx
@@ -0,0 +1,265 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { toast } from "react-hot-toast";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes";
+import { ActionSettingsTab } from "./ActionSettingsTab";
+
+// Mock actions
+vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
+ deleteActionClassAction: vi.fn(),
+ updateActionClassAction: vi.fn(),
+}));
+
+// Mock utils
+vi.mock("@/app/lib/actionClass/actionClass", () => ({
+ isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
+}));
+
+// Mock UI components
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, variant, loading, ...props }: any) => (
+
+ {loading ? "Loading..." : children}
+
+ ),
+}));
+vi.mock("@/modules/ui/components/code-action-form", () => ({
+ CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
+
+ Code Action Form
+
+ ),
+}));
+vi.mock("@/modules/ui/components/delete-dialog", () => ({
+ DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
+ open ? (
+
+ Delete Dialog
+
+ {isDeleting ? "Deleting..." : "Confirm Delete"}
+
+ setOpen(false)}>Cancel
+
+ ) : null,
+}));
+vi.mock("@/modules/ui/components/no-code-action-form", () => ({
+ NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
+
+ No Code Action Form
+
+ ),
+}));
+
+// Mock icons
+vi.mock("lucide-react", () => ({
+ TrashIcon: () => Trash
,
+}));
+
+const mockSetOpen = vi.fn();
+const mockActionClasses: TActionClass[] = [
+ {
+ id: "action1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Existing Action",
+ description: "An existing action",
+ type: "noCode",
+ environmentId: "env1",
+ noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
+ } as unknown as TActionClass,
+];
+
+const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass =>
+ ({
+ id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name,
+ description: `${name} description`,
+ type,
+ environmentId: "env1",
+ ...(type === "code" && { key: `${name}-key` }),
+ ...(type === "noCode" && {
+ noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` },
+ }),
+ }) as unknown as TActionClass;
+
+describe("ActionSettingsTab", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders correctly for 'code' action type", () => {
+ const actionClass = createMockActionClass("code1", "code", "Code Action");
+ render(
+
+ );
+
+ // Use getByPlaceholderText or getByLabelText now that Input isn't mocked
+ expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
+ actionClass.name
+ );
+ expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
+ actionClass.description
+ );
+ expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
+ });
+
+ test("renders correctly for 'noCode' action type", () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ render(
+
+ );
+
+ // Use getByPlaceholderText or getByLabelText now that Input isn't mocked
+ expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
+ actionClass.name
+ );
+ expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
+ actionClass.description
+ );
+ expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
+ });
+
+ test("handles successful deletion", async () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ const { deleteActionClassAction } = await import(
+ "@/app/(app)/environments/[environmentId]/actions/actions"
+ );
+ vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any);
+
+ render(
+
+ );
+
+ const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
+ await userEvent.click(deleteButtonTrigger);
+
+ expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
+
+ const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
+ await userEvent.click(confirmDeleteButton);
+
+ await waitFor(() => {
+ expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id });
+ expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully");
+ expect(mockSetOpen).toHaveBeenCalledWith(false);
+ });
+ });
+
+ test("handles deletion failure", async () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ const { deleteActionClassAction } = await import(
+ "@/app/(app)/environments/[environmentId]/actions/actions"
+ );
+ vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed"));
+
+ render(
+
+ );
+
+ const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
+ await userEvent.click(deleteButtonTrigger);
+ const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
+ await userEvent.click(confirmDeleteButton);
+
+ await waitFor(() => {
+ expect(deleteActionClassAction).toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
+ });
+ expect(mockSetOpen).not.toHaveBeenCalled();
+ });
+
+ test("renders read-only state correctly", () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ render(
+
+ );
+
+ // Use getByPlaceholderText or getByLabelText now that Input isn't mocked
+ expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
+ expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
+ expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
+ expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
+ });
+
+ test("prevents delete when read-only", async () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ const { deleteActionClassAction } = await import(
+ "@/app/(app)/environments/[environmentId]/actions/actions"
+ );
+
+ // Render with isReadOnly=true, but simulate a delete attempt
+ render(
+
+ );
+
+ // Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
+ // This test primarily checks the logic within handleDeleteAction if it were called.
+ // A better approach might be to export handleDeleteAction for direct testing,
+ // but for now, we assume the UI prevents calling it.
+
+ // We can assert that the delete button isn't there to prevent the flow
+ expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
+ expect(deleteActionClassAction).not.toHaveBeenCalled();
+ });
+
+ test("renders docs link correctly", () => {
+ const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
+ render(
+
+ );
+ const docsLink = screen.getByRole("link", { name: "common.read_docs" });
+ expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
+ expect(docsLink).toHaveAttribute("target", "_blank");
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading.test.tsx
new file mode 100644
index 0000000000..f2070498ab
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading.test.tsx
@@ -0,0 +1,26 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ActionTableHeading } from "./ActionTableHeading";
+
+// Mock the server-side translation function
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: async () => (key: string) => key,
+}));
+
+describe("ActionTableHeading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders the table heading with correct column names", async () => {
+ // Render the async component
+ const ResolvedComponent = await ActionTableHeading();
+ render(ResolvedComponent);
+
+ // Check if the translated column headers are present
+ expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
+ expect(screen.getByText("common.created")).toBeInTheDocument();
+ // Check for the screen reader only text
+ expect(screen.getByText("common.edit")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.test.tsx
new file mode 100644
index 0000000000..a8c44c459c
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.test.tsx
@@ -0,0 +1,142 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
+import { AddActionModal } from "./AddActionModal";
+
+// Mock child components and hooks
+vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
+ CreateNewActionTab: vi.fn(({ setOpen }) => (
+
+ CreateNewActionTab Content
+ setOpen(false)}>Close from Tab
+
+ )),
+}));
+
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock("@/modules/ui/components/modal", () => ({
+ Modal: ({ children, open, setOpen, ...props }: any) =>
+ open ? (
+
+ {children}
+ setOpen(false)}>Close Modal
+
+ ) : null,
+}));
+
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+vi.mock("lucide-react", () => ({
+ MousePointerClickIcon: () =>
,
+ PlusIcon: () =>
,
+}));
+
+const mockActionClasses: TActionClass[] = [
+ {
+ id: "action1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Action 1",
+ description: "Description 1",
+ type: "noCode",
+ environmentId: "env1",
+ noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
+ } as unknown as TActionClass,
+];
+
+const environmentId = "env1";
+
+describe("AddActionModal", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders the 'Add Action' button initially", () => {
+ render(
+
+ );
+ expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
+ expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ });
+
+ test("opens the modal when the 'Add Action' button is clicked", async () => {
+ render(
+
+ );
+ const addButton = screen.getByRole("button", { name: "common.add_action" });
+ await userEvent.click(addButton);
+
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
+ expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
+ expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
+ });
+
+ test("passes correct props to CreateNewActionTab", async () => {
+ const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
+ const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
+
+ render(
+
+ );
+ const addButton = screen.getByRole("button", { name: "common.add_action" });
+ await userEvent.click(addButton);
+
+ expect(mockedCreateNewActionTab).toHaveBeenCalled();
+ const props = mockedCreateNewActionTab.mock.calls[0][0];
+ expect(props.environmentId).toBe(environmentId);
+ expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
+ expect(props.isReadOnly).toBe(false);
+ expect(props.setOpen).toBeInstanceOf(Function);
+ expect(props.setActionClasses).toBeInstanceOf(Function);
+ });
+
+ test("closes the modal when the close button (simulated) is clicked", async () => {
+ render(
+
+ );
+ const addButton = screen.getByRole("button", { name: "common.add_action" });
+ await userEvent.click(addButton);
+
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
+
+ // Simulate closing via the mocked Modal's close button
+ const closeModalButton = screen.getByText("Close Modal");
+ await userEvent.click(closeModalButton);
+
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ });
+
+ test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
+ render(
+
+ );
+ const addButton = screen.getByRole("button", { name: "common.add_action" });
+ await userEvent.click(addButton);
+
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
+
+ // Simulate closing via the mocked CreateNewActionTab's button
+ const closeFromTabButton = screen.getByText("Close from Tab");
+ await userEvent.click(closeFromTabButton);
+
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx
new file mode 100644
index 0000000000..0a024ce20f
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx
@@ -0,0 +1,44 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import Loading from "./loading";
+
+// Mock child components
+vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
+ PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/modules/ui/components/page-header", () => ({
+ PageHeader: ({ pageTitle }: { pageTitle: string }) => {pageTitle}
,
+}));
+
+describe("Loading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders loading state correctly", () => {
+ render( );
+
+ // Check if mocked components are rendered
+ expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toBeInTheDocument();
+ expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
+
+ // Check for translated table headers
+ expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
+ expect(screen.getByText("common.created")).toBeInTheDocument();
+ expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
+
+ // Check for skeleton elements (presence of animate-pulse class)
+ const skeletonElements = document.querySelectorAll(".animate-pulse");
+ expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
+
+ // Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
+ const pulseDivs = screen.getAllByText((_, element) => {
+ return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
+ });
+ expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/loading.tsx
index ead337d5e3..b3cc1c9e45 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions/loading.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/loading.tsx
@@ -33,7 +33,7 @@ const Loading = () => {
-
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx
new file mode 100644
index 0000000000..ed5be4ba19
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx
@@ -0,0 +1,161 @@
+import { getActionClasses } from "@/lib/actionClass/service";
+import { getEnvironments } from "@/lib/environment/service";
+import { findMatchingLocale } from "@/lib/utils/locale";
+import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
+import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
+import { cleanup, render, screen } from "@testing-library/react";
+import { redirect } from "next/navigation";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TProject } from "@formbricks/types/project";
+// Import the component after mocks
+import Page from "./page";
+
+// Mock dependencies
+vi.mock("@/lib/actionClass/service", () => ({
+ getActionClasses: vi.fn(),
+}));
+vi.mock("@/lib/environment/service", () => ({
+ getEnvironments: vi.fn(),
+}));
+vi.mock("@/lib/utils/locale", () => ({
+ findMatchingLocale: vi.fn(),
+}));
+vi.mock("@/modules/environments/lib/utils", () => ({
+ getEnvironmentAuth: vi.fn(),
+}));
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: async () => (key: string) => key,
+}));
+vi.mock("next/navigation", () => ({
+ redirect: vi.fn(),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
+ ActionClassesTable: ({ children }) => ActionClassesTable Mock{children}
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
+ ActionClassDataRow: ({ actionClass }) => ActionClassDataRow Mock: {actionClass.name}
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
+ ActionTableHeading: () => ActionTableHeading Mock
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
+ AddActionModal: () => AddActionModal Mock
,
+}));
+vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
+ PageContentWrapper: ({ children }) => PageContentWrapper Mock{children}
,
+}));
+vi.mock("@/modules/ui/components/page-header", () => ({
+ PageHeader: ({ pageTitle, cta }) => (
+
+ PageHeader Mock: {pageTitle} {cta &&
CTA Mock
}
+
+ ),
+}));
+
+// Mock data
+const mockEnvironmentId = "test-env-id";
+const mockProjectId = "test-project-id";
+const mockEnvironment = {
+ id: mockEnvironmentId,
+ name: "Test Environment",
+ type: "development",
+} as unknown as TEnvironment;
+const mockOtherEnvironment = {
+ id: "other-env-id",
+ name: "Other Environment",
+ type: "production",
+} as unknown as TEnvironment;
+const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
+const mockActionClasses = [
+ { id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
+ { id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
+];
+const mockOtherEnvActionClasses = [
+ { id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
+];
+const mockLocale = "en-US";
+
+const mockParams = { environmentId: mockEnvironmentId };
+const mockProps = { params: mockParams };
+
+describe("Actions Page", () => {
+ beforeEach(() => {
+ vi.mocked(getActionClasses)
+ .mockResolvedValueOnce(mockActionClasses) // First call for current env
+ .mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
+ vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
+ vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+ });
+
+ test("renders the page correctly with actions", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ project: mockProject,
+ isBilling: false,
+ environment: mockEnvironment,
+ } as TEnvironmentAuth);
+
+ const PageComponent = await Page(mockProps);
+ render(PageComponent);
+
+ expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
+ expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
+ expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
+ expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
+ expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
+ expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
+ expect(vi.mocked(redirect)).not.toHaveBeenCalled();
+ });
+
+ test("redirects if isBilling is true", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ project: mockProject,
+ isBilling: true,
+ environment: mockEnvironment,
+ } as TEnvironmentAuth);
+
+ await Page(mockProps);
+
+ expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
+ });
+
+ test("does not render AddActionModal CTA if isReadOnly is true", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: true,
+ project: mockProject,
+ isBilling: false,
+ environment: mockEnvironment,
+ } as TEnvironmentAuth);
+
+ const PageComponent = await Page(mockProps);
+ render(PageComponent);
+
+ expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
+ expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
+ expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
+ });
+
+ test("renders AddActionModal CTA if isReadOnly is false", async () => {
+ vi.mocked(getEnvironmentAuth).mockResolvedValue({
+ isReadOnly: false,
+ project: mockProject,
+ isBilling: false,
+ environment: mockEnvironment,
+ } as TEnvironmentAuth);
+
+ const PageComponent = await Page(mockProps);
+ render(PageComponent);
+
+ expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
+ expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
+ expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx
new file mode 100644
index 0000000000..58624e6351
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx
@@ -0,0 +1,39 @@
+import { cleanup, render } from "@testing-library/react";
+import { Code2Icon, MousePointerClickIcon } from "lucide-react";
+import React from "react";
+import { afterEach, describe, expect, test } from "vitest";
+import { ACTION_TYPE_ICON_LOOKUP } from "./utils";
+
+describe("ACTION_TYPE_ICON_LOOKUP", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("should contain the correct icon for 'code'", () => {
+ expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code");
+ const IconComponent = ACTION_TYPE_ICON_LOOKUP.code;
+ expect(React.isValidElement(IconComponent)).toBe(true);
+
+ // Render the icon and check if it's the correct Lucide icon
+ const { container } = render(IconComponent);
+ const svgElement = container.querySelector("svg");
+ expect(svgElement).toBeInTheDocument();
+ // Check for a class or attribute specific to Code2Icon if possible,
+ // or compare the rendered output structure if necessary.
+ // For simplicity, we check the component type directly (though this is less robust)
+ expect(IconComponent.type).toBe(Code2Icon);
+ });
+
+ test("should contain the correct icon for 'noCode'", () => {
+ expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode");
+ const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode;
+ expect(React.isValidElement(IconComponent)).toBe(true);
+
+ // Render the icon and check if it's the correct Lucide icon
+ const { container } = render(IconComponent);
+ const svgElement = container.querySelector("svg");
+ expect(svgElement).toBeInTheDocument();
+ // Similar check as above for MousePointerClickIcon
+ expect(IconComponent.type).toBe(MousePointerClickIcon);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx
new file mode 100644
index 0000000000..efad9034b1
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx
@@ -0,0 +1,298 @@
+import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
+import { getEnvironment, getEnvironments } from "@/lib/environment/service";
+import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
+import { getAccessFlags } from "@/lib/membership/utils";
+import {
+ getMonthlyActiveOrganizationPeopleCount,
+ getMonthlyOrganizationResponseCount,
+ getOrganizationByEnvironmentId,
+ getOrganizationsByUserId,
+} from "@/lib/organization/service";
+import { getUserProjects } from "@/lib/project/service";
+import { getUser } from "@/lib/user/service";
+import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
+import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
+import { cleanup, render, screen } from "@testing-library/react";
+import type { Session } from "next-auth";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TMembership } from "@formbricks/types/memberships";
+import {
+ TOrganization,
+ TOrganizationBilling,
+ TOrganizationBillingPlanLimits,
+} from "@formbricks/types/organizations";
+import { TProject } from "@formbricks/types/project";
+import { TUser } from "@formbricks/types/user";
+
+// Mock services and utils
+vi.mock("@/lib/environment/service", () => ({
+ getEnvironment: vi.fn(),
+ getEnvironments: vi.fn(),
+}));
+vi.mock("@/lib/organization/service", () => ({
+ getOrganizationByEnvironmentId: vi.fn(),
+ getOrganizationsByUserId: vi.fn(),
+ getMonthlyActiveOrganizationPeopleCount: vi.fn(),
+ getMonthlyOrganizationResponseCount: vi.fn(),
+}));
+vi.mock("@/lib/user/service", () => ({
+ getUser: vi.fn(),
+}));
+vi.mock("@/lib/project/service", () => ({
+ getUserProjects: vi.fn(),
+}));
+vi.mock("@/lib/membership/service", () => ({
+ getMembershipByUserIdOrganizationId: vi.fn(),
+}));
+vi.mock("@/lib/membership/utils", () => ({
+ getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
+}));
+vi.mock("@/modules/ee/license-check/lib/utils", () => ({
+ getEnterpriseLicense: vi.fn(),
+ getOrganizationProjectsLimit: vi.fn(),
+}));
+vi.mock("@/modules/ee/teams/lib/roles", () => ({
+ getProjectPermissionByUserId: vi.fn(),
+}));
+vi.mock("@/tolgee/server", () => ({
+ getTranslate: async () => (key: string) => key,
+}));
+
+let mockIsFormbricksCloud = false;
+let mockIsDevelopment = false;
+
+vi.mock("@/lib/constants", () => ({
+ get IS_FORMBRICKS_CLOUD() {
+ return mockIsFormbricksCloud;
+ },
+ get IS_DEVELOPMENT() {
+ return mockIsDevelopment;
+ },
+}));
+
+// Mock components
+vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
+ MainNavigation: () => MainNavigation
,
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
+ TopControlBar: () => TopControlBar
,
+}));
+vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
+ DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
+ environment.type === "development" ? DevEnvironmentBanner
: null,
+}));
+vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
+ LimitsReachedBanner: () => LimitsReachedBanner
,
+}));
+vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
+ PendingDowngradeBanner: ({
+ isPendingDowngrade,
+ active,
+ }: {
+ isPendingDowngrade: boolean;
+ active: boolean;
+ }) =>
+ isPendingDowngrade && active ? PendingDowngradeBanner
: null,
+}));
+
+const mockUser = {
+ id: "user-1",
+ name: "Test User",
+ email: "test@example.com",
+ emailVerified: new Date(),
+ imageUrl: "",
+ twoFactorEnabled: false,
+ identityProvider: "email",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ notificationSettings: { alert: {}, weeklySummary: {} },
+} as unknown as TUser;
+
+const mockOrganization = {
+ id: "org-1",
+ name: "Test Org",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: {
+ stripeCustomerId: null,
+ limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
+ } as unknown as TOrganizationBilling,
+} as unknown as TOrganization;
+
+const mockEnvironment: TEnvironment = {
+ id: "env-1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj-1",
+ appSetupCompleted: true,
+};
+
+const mockProject: TProject = {
+ id: "proj-1",
+ name: "Test Project",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ organizationId: "org-1",
+ environments: [mockEnvironment],
+} as unknown as TProject;
+
+const mockMembership: TMembership = {
+ organizationId: "org-1",
+ userId: "user-1",
+ accepted: true,
+ role: "owner",
+};
+
+const mockLicense = {
+ plan: "free",
+ active: false,
+ lastChecked: new Date(),
+ features: { isMultiOrgEnabled: false },
+} as any;
+
+const mockProjectPermission = {
+ userId: "user-1",
+ projectId: "proj-1",
+ role: "admin",
+} as any;
+
+const mockSession: Session = {
+ user: {
+ id: "user-1",
+ },
+ expires: new Date(Date.now() + 3600 * 1000).toISOString(),
+};
+
+describe("EnvironmentLayout", () => {
+ beforeEach(() => {
+ vi.mocked(getUser).mockResolvedValue(mockUser);
+ vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
+ vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
+ vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
+ vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
+ vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
+ vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
+ vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
+ vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
+ vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
+ mockIsDevelopment = false;
+ mockIsFormbricksCloud = false;
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+ });
+
+ test("renders correctly with default props", async () => {
+ // Ensure the default mockLicense has isPendingDowngrade: false and active: false
+ vi.mocked(getEnterpriseLicense).mockResolvedValue({
+ ...mockLicense,
+ isPendingDowngrade: false,
+ active: false,
+ });
+
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
+ expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
+ expect(screen.getByText("Child Content")).toBeInTheDocument();
+ expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
+ });
+
+ test("renders DevEnvironmentBanner in development environment", async () => {
+ const devEnvironment = { ...mockEnvironment, type: "development" as const };
+ vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
+ mockIsDevelopment = true;
+
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
+ });
+
+ test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
+ mockIsFormbricksCloud = true;
+
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
+ expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
+ expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
+ });
+
+ test("renders PendingDowngradeBanner when pending downgrade", async () => {
+ // Ensure the license mock reflects the condition needed for the banner
+ const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
+ vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
+
+ render(
+ await EnvironmentLayout({
+ environmentId: "env-1",
+ session: mockSession,
+ children: Child Content
,
+ })
+ );
+
+ expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
+ });
+
+ test("throws error if user not found", async () => {
+ vi.mocked(getUser).mockResolvedValue(null);
+ await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
+ "common.user_not_found"
+ );
+ });
+
+ test("throws error if organization not found", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
+ await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
+ "common.organization_not_found"
+ );
+ });
+
+ test("throws error if environment not found", async () => {
+ vi.mocked(getEnvironment).mockResolvedValue(null);
+ await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
+ "common.environment_not_found"
+ );
+ });
+
+ test("throws error if projects, environments or organizations not found", async () => {
+ vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
+ await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
+ "environments.projects_environments_organizations_not_found"
+ );
+ });
+
+ test("throws error if member has no project permission", async () => {
+ vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
+ await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
+ "common.project_permission_not_found"
+ );
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx
new file mode 100644
index 0000000000..20fe547b83
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx
@@ -0,0 +1,33 @@
+import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
+import { render } from "@testing-library/react";
+import { describe, expect, test, vi } from "vitest";
+import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
+
+describe("EnvironmentStorageHandler", () => {
+ test("sets environmentId in localStorage on mount", () => {
+ const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
+ const testEnvironmentId = "test-env-123";
+
+ render( );
+
+ expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
+ setItemSpy.mockRestore();
+ });
+
+ test("updates environmentId in localStorage when prop changes", () => {
+ const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
+ const initialEnvironmentId = "test-env-initial";
+ const updatedEnvironmentId = "test-env-updated";
+
+ const { rerender } = render( );
+
+ expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
+
+ rerender( );
+
+ expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
+ expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
+
+ setItemSpy.mockRestore();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx
new file mode 100644
index 0000000000..6f817ea581
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx
@@ -0,0 +1,149 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { EnvironmentSwitch } from "./EnvironmentSwitch";
+
+// Mock next/navigation
+const mockPush = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: vi.fn(() => ({
+ push: mockPush,
+ })),
+}));
+
+// Mock @tolgee/react
+vi.mock("@tolgee/react", () => ({
+ useTranslate: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const mockEnvironmentDev: TEnvironment = {
+ id: "dev-env-id",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development",
+ projectId: "project-id",
+ appSetupCompleted: true,
+};
+
+const mockEnvironmentProd: TEnvironment = {
+ id: "prod-env-id",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "project-id",
+ appSetupCompleted: true,
+};
+
+const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
+
+describe("EnvironmentSwitch", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders checked when environment is development", () => {
+ render( );
+ const switchElement = screen.getByRole("switch");
+ expect(switchElement).toBeChecked();
+ expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
+ });
+
+ test("renders unchecked when environment is production", () => {
+ render( );
+ const switchElement = screen.getByRole("switch");
+ expect(switchElement).not.toBeChecked();
+ expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
+ });
+
+ test("calls router.push with development environment ID when toggled from production", async () => {
+ render( );
+ const switchElement = screen.getByRole("switch");
+
+ expect(switchElement).not.toBeChecked();
+ await userEvent.click(switchElement);
+
+ // Check loading state (switch disabled)
+ expect(switchElement).toBeDisabled();
+
+ // Check router push call
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
+ });
+
+ // Check visual state change (though state update happens before navigation)
+ // In a real scenario, the component would re-render with the new environment prop after navigation.
+ // Here, we simulate the state change directly for testing the toggle logic.
+ await waitFor(() => {
+ // Re-render or check internal state if possible, otherwise check mock calls
+ // Since the component manages its own state, we can check the visual state after click
+ expect(switchElement).toBeChecked(); // State updates immediately
+ });
+ });
+
+ test("calls router.push with production environment ID when toggled from development", async () => {
+ render( );
+ const switchElement = screen.getByRole("switch");
+
+ expect(switchElement).toBeChecked();
+ await userEvent.click(switchElement);
+
+ // Check loading state (switch disabled)
+ expect(switchElement).toBeDisabled();
+
+ // Check router push call
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
+ });
+
+ // Check visual state change
+ await waitFor(() => {
+ expect(switchElement).not.toBeChecked(); // State updates immediately
+ });
+ });
+
+ test("does not call router.push if target environment is not found", async () => {
+ const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
+ render( );
+ const switchElement = screen.getByRole("switch");
+
+ await userEvent.click(switchElement); // Try to toggle to development
+
+ await waitFor(() => {
+ expect(switchElement).toBeDisabled(); // Loading state still set
+ });
+
+ // router.push should not be called because dev env is missing
+ expect(mockPush).not.toHaveBeenCalled();
+
+ // State still updates visually
+ await waitFor(() => {
+ expect(switchElement).toBeChecked();
+ });
+ });
+
+ test("toggles using the label click", async () => {
+ render( );
+ const labelElement = screen.getByText("common.dev_env");
+ const switchElement = screen.getByRole("switch");
+
+ expect(switchElement).not.toBeChecked();
+ await userEvent.click(labelElement); // Click the label
+
+ // Check loading state (switch disabled)
+ expect(switchElement).toBeDisabled();
+
+ // Check router push call
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
+ });
+
+ // Check visual state change
+ await waitFor(() => {
+ expect(switchElement).toBeChecked();
+ });
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx
new file mode 100644
index 0000000000..5d5a48d5cc
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx
@@ -0,0 +1,311 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { signOut } from "next-auth/react";
+import { usePathname, useRouter } from "next/navigation";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TOrganization } from "@formbricks/types/organizations";
+import { TProject } from "@formbricks/types/project";
+import { TUser } from "@formbricks/types/user";
+import { getLatestStableFbReleaseAction } from "../actions/actions";
+import { MainNavigation } from "./MainNavigation";
+
+// Mock dependencies
+vi.mock("next/navigation", () => ({
+ useRouter: vi.fn(() => ({ push: vi.fn() })),
+ usePathname: vi.fn(() => "/environments/env1/surveys"),
+}));
+vi.mock("next-auth/react", () => ({
+ signOut: vi.fn(),
+}));
+vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
+ getLatestStableFbReleaseAction: vi.fn(),
+}));
+vi.mock("@/app/lib/formbricks", () => ({
+ formbricksLogout: vi.fn(),
+}));
+vi.mock("@/lib/membership/utils", () => ({
+ getAccessFlags: (role?: string) => ({
+ isAdmin: role === "admin",
+ isOwner: role === "owner",
+ isManager: role === "manager",
+ isMember: role === "member",
+ isBilling: role === "billing",
+ }),
+}));
+vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
+ CreateOrganizationModal: ({ open }: { open: boolean }) =>
+ open ? Create Org Modal
: null,
+}));
+vi.mock("@/modules/projects/components/project-switcher", () => ({
+ ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
+
+ Project Switcher
+
+ ),
+}));
+vi.mock("@/modules/ui/components/avatars", () => ({
+ ProfileAvatar: () => Avatar
,
+}));
+vi.mock("next/image", () => ({
+ // eslint-disable-next-line @next/next/no-img-element
+ default: (props: any) => ,
+}));
+vi.mock("../../../../../package.json", () => ({
+ version: "1.0.0",
+}));
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: (key: string) => store[key] || null,
+ setItem: (key: string, value: string) => {
+ store[key] = value.toString();
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ store = {};
+ },
+ };
+})();
+Object.defineProperty(window, "localStorage", { value: localStorageMock });
+
+// Mock data
+const mockEnvironment: TEnvironment = {
+ id: "env1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj1",
+ appSetupCompleted: true,
+};
+const mockUser = {
+ id: "user1",
+ name: "Test User",
+ email: "test@example.com",
+ imageUrl: "http://example.com/avatar.png",
+ emailVerified: new Date(),
+ twoFactorEnabled: false,
+ identityProvider: "email",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ notificationSettings: { alert: {}, weeklySummary: {} },
+ role: "project_manager",
+ objective: "other",
+} as unknown as TUser;
+
+const mockOrganization = {
+ id: "org1",
+ name: "Test Org",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
+} as unknown as TOrganization;
+
+const mockOrganizations: TOrganization[] = [
+ mockOrganization,
+ { ...mockOrganization, id: "org2", name: "Another Org" },
+];
+const mockProject: TProject = {
+ id: "proj1",
+ name: "Test Project",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ organizationId: "org1",
+ environments: [mockEnvironment],
+ config: { channel: "website" },
+} as unknown as TProject;
+const mockProjects: TProject[] = [mockProject];
+
+const defaultProps = {
+ environment: mockEnvironment,
+ organizations: mockOrganizations,
+ user: mockUser,
+ organization: mockOrganization,
+ projects: mockProjects,
+ isMultiOrgEnabled: true,
+ isFormbricksCloud: false,
+ isDevelopment: false,
+ membershipRole: "owner" as const,
+ organizationProjectsLimit: 5,
+ isLicenseActive: true,
+};
+
+describe("MainNavigation", () => {
+ let mockRouterPush: ReturnType;
+
+ beforeEach(() => {
+ mockRouterPush = vi.fn();
+ vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
+ vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
+ vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders expanded by default and collapses on toggle", async () => {
+ render( );
+ const projectSwitcher = screen.getByTestId("project-switcher");
+ // Assuming the toggle button is the only one initially without an accessible name
+ // A more specific selector like data-testid would be better if available.
+ const toggleButton = screen.getByRole("button", { name: "" });
+
+ // Check initial state (expanded)
+ expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
+ expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
+ // Check localStorage is not set initially after clear()
+ expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
+
+ // Click to collapse
+ await userEvent.click(toggleButton);
+
+ // Check state after first toggle (collapsed)
+ await waitFor(() => {
+ // Check that the attribute eventually becomes true
+ expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
+ // Check that localStorage is updated
+ expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
+ });
+ // Check that the logo is eventually hidden
+ await waitFor(() => {
+ expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
+ });
+
+ // Click to expand
+ await userEvent.click(toggleButton);
+
+ // Check state after second toggle (expanded)
+ await waitFor(() => {
+ // Check that the attribute eventually becomes false
+ expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
+ // Check that localStorage is updated
+ expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
+ });
+ // Check that the logo is eventually visible
+ await waitFor(() => {
+ expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
+ });
+ });
+
+ test("renders correct active navigation link", () => {
+ vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
+ render( );
+ const actionsLink = screen.getByRole("link", { name: /common.actions/ });
+ // Check if the parent li has the active class styling
+ expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
+ });
+
+ test("renders user dropdown and handles logout", async () => {
+ vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
+ render( );
+
+ // Find the avatar and get its parent div which acts as the trigger
+ const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
+ expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
+ await userEvent.click(userTrigger);
+
+ // Wait for the dropdown content to appear
+ await waitFor(() => {
+ expect(screen.getByText("common.account")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("common.organization")).toBeInTheDocument();
+ expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
+ expect(screen.getByText("common.documentation")).toBeInTheDocument();
+ expect(screen.getByText("common.logout")).toBeInTheDocument();
+
+ const logoutButton = screen.getByText("common.logout");
+ await userEvent.click(logoutButton);
+
+ expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
+ await waitFor(() => {
+ expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
+ });
+ });
+
+ test("handles organization switching", async () => {
+ render( );
+
+ const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
+ await userEvent.click(userTrigger);
+
+ // Wait for the initial dropdown items
+ await waitFor(() => {
+ expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
+ });
+
+ const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
+ await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
+
+ const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
+ await userEvent.click(org2Item);
+
+ expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
+ });
+
+ test("opens create organization modal", async () => {
+ render( );
+
+ const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
+ await userEvent.click(userTrigger);
+
+ // Wait for the initial dropdown items
+ await waitFor(() => {
+ expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
+ });
+
+ const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
+ await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
+
+ const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
+ await userEvent.click(createOrgButton);
+
+ expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
+ });
+
+ test("hides new version banner for members or if no new version", async () => {
+ // Test for member
+ vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
+ render( );
+ let toggleButton = screen.getByRole("button", { name: "" });
+ await userEvent.click(toggleButton);
+ await waitFor(() => {
+ expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
+ });
+ cleanup(); // Clean up before next render
+
+ // Test for no new version
+ vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
+ render( );
+ toggleButton = screen.getByRole("button", { name: "" });
+ await userEvent.click(toggleButton);
+ await waitFor(() => {
+ expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
+ });
+ });
+
+ test("hides main nav and project switcher if user role is billing", () => {
+ render( );
+ expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
+ expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
+ });
+
+ test("shows billing link and hides license link in cloud", async () => {
+ render( );
+ const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
+ await userEvent.click(userTrigger);
+
+ // Wait for dropdown items
+ await waitFor(() => {
+ expect(screen.getByText("common.billing")).toBeInTheDocument();
+ });
+ expect(screen.queryByText("common.license")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
index a1ae639a63..ba884367df 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
@@ -264,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
- "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
+ "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx
new file mode 100644
index 0000000000..ecb0261618
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx
@@ -0,0 +1,21 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { NavbarLoading } from "./NavbarLoading";
+
+describe("NavbarLoading", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders the correct number of skeleton elements", () => {
+ render( );
+
+ // Find all divs with the animate-pulse class
+ const skeletonElements = screen.getAllByText((content, element) => {
+ return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
+ });
+
+ // There are 8 skeleton divs in the component
+ expect(skeletonElements).toHaveLength(8);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx
new file mode 100644
index 0000000000..7d17cc66e2
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx
@@ -0,0 +1,105 @@
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { NavigationLink } from "./NavigationLink";
+
+// Mock next/link
+vi.mock("next/link", () => ({
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+// Mock tooltip components
+vi.mock("@/modules/ui/components/tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => {children}
,
+ TooltipContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TooltipProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+const defaultProps = {
+ href: "/test-link",
+ isActive: false,
+ isCollapsed: false,
+ children: ,
+ linkText: "Test Link Text",
+ isTextVisible: true,
+};
+
+describe("NavigationLink", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders expanded link correctly (inactive, text visible)", () => {
+ render( );
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+ const textSpan = screen.getByText(defaultProps.linkText);
+
+ expect(linkElement).toHaveAttribute("href", defaultProps.href);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ expect(textSpan).toBeInTheDocument();
+ expect(textSpan).toHaveClass("opacity-0");
+ expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
+ expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
+ expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
+ });
+
+ test("renders expanded link correctly (active, text hidden)", () => {
+ render( );
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+ const textSpan = screen.getByText(defaultProps.linkText);
+
+ expect(linkElement).toHaveAttribute("href", defaultProps.href);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ expect(textSpan).toBeInTheDocument();
+ expect(textSpan).toHaveClass("opacity-100");
+ expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
+ expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
+ expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
+ });
+
+ test("renders collapsed link correctly (inactive)", () => {
+ render( );
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+
+ expect(linkElement).toHaveAttribute("href", defaultProps.href);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ // Check text is NOT directly within the list item
+ expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
+ expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
+ expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
+
+ // Check tooltip elements
+ expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
+ // Check text IS within the tooltip content mock
+ expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
+ });
+
+ test("renders collapsed link correctly (active)", () => {
+ render( );
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+
+ expect(linkElement).toHaveAttribute("href", defaultProps.href);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ // Check text is NOT directly within the list item
+ expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
+ expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
+ expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
+
+ // Check tooltip elements
+ expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
+ // Check text IS within the tooltip content mock
+ expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx
new file mode 100644
index 0000000000..d3f7548825
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx
@@ -0,0 +1,40 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ProjectNavItem } from "./ProjectNavItem";
+
+describe("ProjectNavItem", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ const defaultProps = {
+ href: "/test-path",
+ children: Test Child ,
+ };
+
+ test("renders correctly when active", () => {
+ render( );
+
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+
+ expect(linkElement).toHaveAttribute("href", "/test-path");
+ expect(screen.getByText("Test Child")).toBeInTheDocument();
+ expect(listItem).toHaveClass("bg-slate-50");
+ expect(listItem).toHaveClass("font-semibold");
+ expect(listItem).not.toHaveClass("hover:bg-slate-50");
+ });
+
+ test("renders correctly when inactive", () => {
+ render( );
+
+ const linkElement = screen.getByRole("link");
+ const listItem = linkElement.closest("li");
+
+ expect(linkElement).toHaveAttribute("href", "/test-path");
+ expect(screen.getByText("Test Child")).toBeInTheDocument();
+ expect(listItem).not.toHaveClass("bg-slate-50");
+ expect(listItem).not.toHaveClass("font-semibold");
+ expect(listItem).toHaveClass("hover:bg-slate-50");
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx
new file mode 100644
index 0000000000..764e1c7043
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx
@@ -0,0 +1,140 @@
+import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
+import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
+import { getTodayDate } from "@/app/lib/surveys/surveys";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext";
+
+// Mock the getTodayDate function
+vi.mock("@/app/lib/surveys/surveys", () => ({
+ getTodayDate: vi.fn(),
+}));
+
+const mockToday = new Date("2024-01-15T00:00:00.000Z");
+const mockFromDate = new Date("2024-01-01T00:00:00.000Z");
+
+// Test component to use the hook
+const TestComponent = () => {
+ const {
+ selectedFilter,
+ setSelectedFilter,
+ selectedOptions,
+ setSelectedOptions,
+ dateRange,
+ setDateRange,
+ resetState,
+ } = useResponseFilter();
+
+ return (
+
+
{selectedFilter.onlyComplete.toString()}
+
{selectedFilter.filter.length}
+
{selectedOptions.questionOptions.length}
+
{selectedOptions.questionFilterOptions.length}
+
{dateRange.from?.toISOString()}
+
{dateRange.to?.toISOString()}
+
+
+ setSelectedFilter({
+ filter: [
+ {
+ questionType: { id: "q1", label: "Question 1" },
+ filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
+ },
+ ],
+ onlyComplete: true,
+ })
+ }>
+ Update Filter
+
+
+ setSelectedOptions({
+ questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
+ questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
+ })
+ }>
+ Update Options
+
+
setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range
+
Reset State
+
+ );
+};
+
+describe("ResponseFilterContext", () => {
+ beforeEach(() => {
+ vi.mocked(getTodayDate).mockReturnValue(mockToday);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+ });
+
+ test("should provide initial state values", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
+ expect(screen.getByTestId("filterLength").textContent).toBe("0");
+ expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
+ expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
+ expect(screen.getByTestId("dateFrom").textContent).toBe("");
+ expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
+ });
+
+ test("should update selectedFilter state", async () => {
+ render(
+
+
+
+ );
+
+ const updateButton = screen.getByText("Update Filter");
+ await userEvent.click(updateButton);
+
+ expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
+ expect(screen.getByTestId("filterLength").textContent).toBe("1");
+ });
+
+ test("should update selectedOptions state", async () => {
+ render(
+
+
+
+ );
+
+ const updateButton = screen.getByText("Update Options");
+ await userEvent.click(updateButton);
+
+ expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1");
+ expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1");
+ });
+
+ test("should update dateRange state", async () => {
+ render(
+
+
+
+ );
+
+ const updateButton = screen.getByText("Update Date Range");
+ await userEvent.click(updateButton);
+
+ expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString());
+ expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
+ });
+
+ test("should throw error when useResponseFilter is used outside of Provider", () => {
+ // Hide console error temporarily
+ const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render( )).toThrow("useFilterDate must be used within a FilterDateProvider");
+ consoleErrorMock.mockRestore();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx
new file mode 100644
index 0000000000..414961b97f
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx
@@ -0,0 +1,66 @@
+import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TOrganizationRole } from "@formbricks/types/memberships";
+import { TopControlBar } from "./TopControlBar";
+
+// Mock the child component
+vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
+ TopControlButtons: vi.fn(() => Mocked TopControlButtons
),
+}));
+
+const mockEnvironment: TEnvironment = {
+ id: "env1",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj1",
+ appSetupCompleted: true,
+};
+
+const mockEnvironments: TEnvironment[] = [
+ mockEnvironment,
+ { ...mockEnvironment, id: "env2", type: "development" },
+];
+
+const mockMembershipRole: TOrganizationRole = "owner";
+const mockProjectPermission = "manage";
+
+describe("TopControlBar", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders correctly and passes props to TopControlButtons", () => {
+ render(
+
+ );
+
+ // Check if the main div is rendered
+ const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
+ expect(mainDiv).toHaveClass(
+ "fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
+ );
+
+ // Check if the mocked child component is rendered
+ expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
+
+ // Check if the child component received the correct props
+ expect(TopControlButtons).toHaveBeenCalledWith(
+ {
+ environment: mockEnvironment,
+ environments: mockEnvironments,
+ membershipRole: mockMembershipRole,
+ projectPermission: mockProjectPermission,
+ },
+ undefined // Updated from {} to undefined
+ );
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.test.tsx
new file mode 100644
index 0000000000..49b7c7e99e
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.test.tsx
@@ -0,0 +1,182 @@
+import { getAccessFlags } from "@/lib/membership/utils";
+import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { TOrganizationRole } from "@formbricks/types/memberships";
+import { TopControlButtons } from "./TopControlButtons";
+
+// Mock dependencies
+const mockPush = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: vi.fn(() => ({ push: mockPush })),
+}));
+
+vi.mock("@/lib/membership/utils", () => ({
+ getAccessFlags: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/teams/utils/teams", () => ({
+ getTeamPermissionFlags: vi.fn(),
+}));
+
+vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
+ EnvironmentSwitch: vi.fn(() => EnvironmentSwitch
),
+}));
+
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
+ const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+vi.mock("@/modules/ui/components/tooltip", () => ({
+ TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("lucide-react", () => ({
+ BugIcon: () =>
,
+ CircleUserIcon: () =>
,
+ PlusIcon: () =>
,
+}));
+
+vi.mock("next/link", () => ({
+ default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock data
+const mockEnvironmentDev: TEnvironment = {
+ id: "dev-env-id",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development",
+ projectId: "project-id",
+ appSetupCompleted: true,
+};
+
+const mockEnvironmentProd: TEnvironment = {
+ id: "prod-env-id",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "project-id",
+ appSetupCompleted: true,
+};
+
+const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
+
+describe("TopControlButtons", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Default mocks for access flags
+ vi.mocked(getAccessFlags).mockReturnValue({
+ isOwner: false,
+ isMember: false,
+ isBilling: false,
+ } as any);
+ vi.mocked(getTeamPermissionFlags).mockReturnValue({
+ hasReadAccess: false,
+ } as any);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ const renderComponent = (
+ membershipRole?: TOrganizationRole,
+ projectPermission: any = null,
+ isBilling = false,
+ hasReadAccess = false
+ ) => {
+ vi.mocked(getAccessFlags).mockReturnValue({
+ isMember: membershipRole === "member",
+ isBilling: isBilling,
+ isOwner: membershipRole === "owner",
+ } as any);
+ vi.mocked(getTeamPermissionFlags).mockReturnValue({
+ hasReadAccess: hasReadAccess,
+ } as any);
+
+ return render(
+
+ );
+ };
+
+ test("renders correctly for Owner role", async () => {
+ renderComponent("owner");
+
+ expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
+ expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
+ expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
+ expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
+
+ // Check link
+ const link = screen.getByTestId("link-mock");
+ expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
+ expect(link).toHaveAttribute("target", "_blank");
+
+ // Click account button
+ const accountButton = screen.getByTestId("circle-user-icon").closest("button");
+ await userEvent.click(accountButton!);
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
+ });
+
+ // Click new survey button
+ const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
+ await userEvent.click(newSurveyButton!);
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
+ });
+ });
+
+ test("hides EnvironmentSwitch for Billing role", () => {
+ renderComponent(undefined, null, true); // isBilling = true
+ expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
+ expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
+ });
+
+ test("hides New Survey button for Billing role", () => {
+ renderComponent(undefined, null, true); // isBilling = true
+ expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
+ });
+
+ test("hides New Survey button for read-only Member", () => {
+ renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
+ expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
+ expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
+ expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
+ });
+
+ test("shows New Survey button for Member with write access", () => {
+ renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
+ expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
+ expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx
new file mode 100644
index 0000000000..e46a908694
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx
@@ -0,0 +1,104 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { TEnvironment } from "@formbricks/types/environment";
+import { WidgetStatusIndicator } from "./WidgetStatusIndicator";
+
+// Mock next/navigation
+const mockRefresh = vi.fn();
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ refresh: mockRefresh,
+ }),
+}));
+
+// Mock lucide-react icons
+vi.mock("lucide-react", () => ({
+ AlertTriangleIcon: () => AlertTriangleIcon
,
+ CheckIcon: () => CheckIcon
,
+ RotateCcwIcon: () => RotateCcwIcon
,
+}));
+
+// Mock Button component
+vi.mock("@/modules/ui/components/button", () => ({
+ Button: ({ children, onClick, ...props }: any) => (
+
+ {children}
+
+ ),
+}));
+
+const mockEnvironmentNotImplemented: TEnvironment = {
+ id: "env-not-implemented",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "development",
+ projectId: "proj1",
+ appSetupCompleted: false, // Not implemented state
+};
+
+const mockEnvironmentRunning: TEnvironment = {
+ id: "env-running",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ type: "production",
+ projectId: "proj1",
+ appSetupCompleted: true, // Running state
+};
+
+describe("WidgetStatusIndicator", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ test("renders correctly for 'notImplemented' state", () => {
+ render( );
+
+ // Check icon
+ expect(screen.getByTestId("alert-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
+
+ // Check texts
+ expect(
+ screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected")
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description")
+ ).toBeInTheDocument();
+
+ // Check button
+ const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
+ expect(recheckButton).toBeInTheDocument();
+ expect(screen.getByTestId("refresh-icon")).toBeInTheDocument();
+ });
+
+ test("renders correctly for 'running' state", () => {
+ render( );
+
+ // Check icon
+ expect(screen.getByTestId("check-icon")).toBeInTheDocument();
+ expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument();
+
+ // Check texts
+ expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument();
+ expect(
+ screen.getByText("environments.project.app-connection.formbricks_sdk_connected")
+ ).toBeInTheDocument();
+
+ // Check button absence
+ expect(
+ screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ })
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument();
+ });
+
+ test("calls router.refresh when 'Recheck' button is clicked", async () => {
+ render( );
+
+ const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
+ await userEvent.click(recheckButton);
+
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts
index 79dcc0cd27..807e9fa473 100644
--- a/apps/web/vite.config.mts
+++ b/apps/web/vite.config.mts
@@ -160,6 +160,8 @@ export default defineConfig({
"modules/survey/editor/components/file-upload-question-form.tsx",
"modules/survey/editor/components/how-to-send-card.tsx",
"modules/survey/editor/components/image-survey-bg.tsx",
+ "app/(app)/environments/**/*.tsx",
+ "app/(app)/environments/**/*.ts",
],
exclude: [
"**/.next/**",
@@ -169,7 +171,8 @@ export default defineConfig({
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"modules/**/types/**", // Exclude types
- "**/stories.tsx", // Exclude story files
+ "**/actions.ts", // Exclude action files
+ "**/stories.tsx" // Exclude story files
],
},
},