Compare commits

...

9 Commits

Author SHA1 Message Date
Johannes
7056466ce9 tweak button size 2025-05-06 22:27:28 +08:00
Piyush Gupta
5d45de6bc4 feat: adds unit tests in modules/ee/teams (#5620) 2025-05-06 12:31:43 +00:00
Piyush Gupta
cf5bc51e94 fix: strict recaptcha checks (#5674) 2025-05-06 12:13:28 +00:00
Dhruwang Jariwala
9a7d24ea4e chore: updated open telemtry package versions (#5672) 2025-05-06 11:59:54 +00:00
victorvhs017
649f28ff8d chore: add tests to survey editor components - part 4 (#5592)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-06 08:04:44 +00:00
dependabot[bot]
bc5a81d146 chore(deps): bump actions/github-script from 5.2.0 to 7.0.1 (#5652)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 09:28:04 +02:00
dependabot[bot]
7dce35bde4 chore(deps): bump actions/labeler from 4.3.0 to 5.0.0 (#5657)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 09:27:46 +02:00
victorvhs017
f30ebc32ec chore: add tests to environments path (#5617) 2025-05-06 04:19:50 +00:00
Piyush Gupta
027bc20975 fix: project list fetching logic in the copy to modal in survey list (#5609) 2025-05-06 04:05:05 +00:00
89 changed files with 9959 additions and 151 deletions

View File

@@ -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:

View File

@@ -24,7 +24,7 @@ jobs:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -20,7 +20,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481

View File

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

View File

@@ -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: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
}));
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);
});
});

View File

@@ -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(() => <div>SegmentsPageMock</div>),
}));
describe("SegmentsPageWrapper", () => {
afterEach(() => {
cleanup();
});
test("renders the SegmentsPage component", () => {
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
});
});

View File

@@ -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: <div>NoCodeIcon</div>,
automatic: <div>AutomaticIcon</div>,
code: <div>CodeIcon</div>,
},
}));
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) => (
<button onClick={onClick} data-variant={variant} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div>ErrorComponent</div>,
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div>LoadingSpinner</div>,
}));
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(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
});
test("renders error state if fetching surveys fails", async () => {
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
data: undefined,
});
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// 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(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// 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(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
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(
<ActionActivityTab
actionClass={conflictingActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
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(
<ActionActivityTab
actionClass={codeActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
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(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
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(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={true} // Set to read-only
/>
);
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(
<ActionActivityTab
actionClass={{ ...mockActionClass, environmentId: "env1_prod" }}
environmentId="env1_prod"
environment={mockEnvironmentProd} // Current env is Production
otherEnvActionClasses={[]} // Assume dev env has no actions for simplicity
otherEnvironment={mockEnvironmentDev} // Target env is Development
isReadOnly={false}
/>
);
await screen.findByText("Copy to Development");
expect(screen.getByText("Copy to Development")).toBeInTheDocument();
expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
});
});

View File

@@ -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 ? (
<div data-testid="action-detail-modal">
Modal for {actionClass.name}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : 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 = <div data-testid="table-heading">Table Heading</div>;
const mockActionRows = mockActionClasses.map((action) => (
<div key={action.id} data-testid={`action-row-${action.id}`}>
{action.name} Row
</div>
));
describe("ActionClassesTable", () => {
afterEach(() => {
cleanup();
});
test("renders table heading and action rows when actions exist", () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
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(
<ActionClassesTable
environmentId="env1"
actionClasses={[]}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, []]}
</ActionClassesTable>
);
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(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
// 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");
});
});

View File

@@ -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 }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
}));
vi.mock("./ActionActivityTab", () => ({
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
}));
vi.mock("./ActionSettingsTab", () => ({
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
}));
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
code: <div data-testid="code-icon">Code Icon Mock</div>,
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
// 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(<ActionDetailModal {...defaultProps} />);
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(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
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(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// 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);
});
});

View File

@@ -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(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
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(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
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(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
});
});

View File

@@ -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) => (
<button onClick={onClick} data-variant={variant} disabled={loading} {...props}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
Code Action Form
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<span>Delete Dialog</span>
<button onClick={onDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Confirm Delete"}
</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
No Code Action Form
</div>
),
}));
// Mock icons
vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// 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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// 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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only
/>
);
// 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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true}
/>
);
// 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(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
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");
});
});

View File

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

View File

@@ -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 }) => (
<div data-testid="create-new-action-tab">
<span>CreateNewActionTab Content</span>
<button onClick={() => setOpen(false)}>Close from Tab</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
open ? (
<div data-testid="modal" {...props}>
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("lucide-react", () => ({
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
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(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
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();
});
});

View File

@@ -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 }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// 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)
});
});

View File

@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>

View File

@@ -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 }) => <div>ActionClassesTable Mock{children}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
AddActionModal: () => <div>AddActionModal Mock</div>,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, cta }) => (
<div>
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
</div>
),
}));
// 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();
});
});

View File

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

View File

@@ -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: () => <div data-testid="main-navigation">MainNavigation</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
}));
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
}));
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
PendingDowngradeBanner: ({
isPendingDowngrade,
active,
}: {
isPendingDowngrade: boolean;
active: boolean;
}) =>
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : 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: <div>Child Content</div>,
})
);
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: <div>Child Content</div>,
})
);
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
});
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true;
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
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: <div>Child Content</div>,
})
);
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"
);
});
});

View File

@@ -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(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
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(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
setItemSpy.mockRestore();
});
});

View File

@@ -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(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
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(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
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(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
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(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
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(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
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(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
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();
});
});
});

View File

@@ -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 ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: any) => <img alt="test" {...props} />,
}));
vi.mock("../../../../../package.json", () => ({
version: "1.0.0",
}));
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
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<typeof vi.fn>;
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(<MainNavigation {...defaultProps} />);
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(<MainNavigation {...defaultProps} />);
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(<MainNavigation {...defaultProps} />);
// 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(<MainNavigation {...defaultProps} />);
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(<MainNavigation {...defaultProps} />);
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(<MainNavigation {...defaultProps} membershipRole="member" />);
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(<MainNavigation {...defaultProps} membershipRole="owner" />);
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(<MainNavigation {...defaultProps} membershipRole="billing" />);
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(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
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();
});
});

View File

@@ -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 ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

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

View File

@@ -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 }) => <a href={href}>{children}</a>,
}));
// Mock tooltip components
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-provider">{children}</div>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-trigger">{children}</div>
),
}));
const defaultProps = {
href: "/test-link",
isActive: false,
isCollapsed: false,
children: <svg data-testid="icon" />,
linkText: "Test Link Text",
isTextVisible: true,
};
describe("NavigationLink", () => {
afterEach(() => {
cleanup();
});
test("renders expanded link correctly (inactive, text visible)", () => {
render(<NavigationLink {...defaultProps} />);
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(<NavigationLink {...defaultProps} isActive={true} isTextVisible={false} />);
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(<NavigationLink {...defaultProps} isCollapsed={true} />);
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(<NavigationLink {...defaultProps} isCollapsed={true} isActive={true} />);
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);
});
});

View File

@@ -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: <span>Test Child</span>,
};
test("renders correctly when active", () => {
render(<ProjectNavItem {...defaultProps} isActive={true} />);
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(<ProjectNavItem {...defaultProps} isActive={false} />);
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");
});
});

View File

@@ -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 (
<div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
<div data-testid="dateFrom">{dateRange.from?.toISOString()}</div>
<div data-testid="dateTo">{dateRange.to?.toISOString()}</div>
<button
onClick={() =>
setSelectedFilter({
filter: [
{
questionType: { id: "q1", label: "Question 1" },
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
onlyComplete: true,
})
}>
Update Filter
</button>
<button
onClick={() =>
setSelectedOptions({
questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
})
}>
Update Options
</button>
<button onClick={() => setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range</button>
<button onClick={resetState}>Reset State</button>
</div>
);
};
describe("ResponseFilterContext", () => {
beforeEach(() => {
vi.mocked(getTodayDate).mockReturnValue(mockToday);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("should provide initial state values", () => {
render(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
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(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
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(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
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(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
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(<TestComponent />)).toThrow("useFilterDate must be used within a FilterDateProvider");
consoleErrorMock.mockRestore();
});
});

View File

@@ -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(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
}));
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(
<TopControlBar
environment={mockEnvironment}
environments={mockEnvironments}
membershipRole={mockMembershipRole}
projectPermission={mockProjectPermission}
/>
);
// 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
);
});
});

View File

@@ -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(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
}));
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 (
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
{children}
</Tag>
);
},
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
),
}));
vi.mock("lucide-react", () => ({
BugIcon: () => <div data-testid="bug-icon" />,
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="link-mock">
{children}
</a>
),
}));
// 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(
<TopControlButtons
environment={mockEnvironmentDev}
environments={mockEnvironments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
);
};
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();
});
});

View File

@@ -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: () => <div data-testid="alert-icon">AlertTriangleIcon</div>,
CheckIcon: () => <div data-testid="check-icon">CheckIcon</div>,
RotateCcwIcon: () => <div data-testid="refresh-icon">RotateCcwIcon</div>,
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
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(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
// 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(<WidgetStatusIndicator environment={mockEnvironmentRunning} />);
// 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(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
await userEvent.click(recheckButton);
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,450 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import {
TIntegrationAirtable,
TIntegrationAirtableConfig,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
} from "@formbricks/types/integration/airtable";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfig,
TIntegrationGoogleSheetsConfigData,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
} from "@formbricks/types/integration/notion";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { handleIntegrations } from "./handleIntegrations";
// Mock dependencies
vi.mock("@/lib/airtable/service");
vi.mock("@/lib/googleSheet/service");
vi.mock("@/lib/i18n/utils");
vi.mock("@/lib/notion/service");
vi.mock("@/lib/responses");
vi.mock("@/lib/slack/service");
vi.mock("@/lib/utils/datetime");
vi.mock("@/lib/utils/recall");
vi.mock("@/lib/utils/strings");
vi.mock("@formbricks/logger");
// Mock data
const surveyId = "survey1";
const questionId1 = "q1";
const questionId2 = "q2";
const questionId3 = "q3_picture";
const hiddenFieldId = "hidden1";
const variableId = "var1";
const mockPipelineInput = {
environmentId: "env1",
surveyId: surveyId,
response: {
id: "response1",
createdAt: new Date("2024-01-01T12:00:00Z"),
updatedAt: new Date("2024-01-01T12:00:00Z"),
finished: true,
surveyId: surveyId,
data: {
[questionId1]: "Answer 1",
[questionId2]: ["Choice 1", "Choice 2"],
[questionId3]: ["picChoice1"],
[hiddenFieldId]: "Hidden Value",
},
meta: {
url: "http://example.com",
source: "web",
userAgent: {
browser: "Chrome",
os: "Mac OS",
device: "Desktop",
},
country: "USA",
action: "Action Name",
} as TResponseMeta,
personAttributes: {},
singleUseId: null,
personId: "person1",
notes: [],
tags: [],
variables: {
[variableId]: "Variable Value",
},
ttc: {},
} as unknown as TResponse,
} as TPipelineInput;
const mockSurvey = {
id: surveyId,
name: "Test Survey",
questions: [
{
id: questionId1,
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
} as unknown as TSurveyOpenTextQuestion,
{
id: questionId2,
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
},
{
id: questionId3,
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
} as unknown as TSurveyPictureSelectionQuestion,
],
hiddenFields: {
enabled: true,
fieldIds: [hiddenFieldId],
},
variables: [{ id: variableId, name: "Variable 1" } as unknown as TSurvey["variables"][0]],
autoClose: null,
triggers: [],
status: "inProgress",
type: "app",
languages: [],
styling: {},
segment: null,
recontactDays: null,
autoComplete: null,
closeOnDate: null,
createdAt: new Date(),
updatedAt: new Date(),
displayOption: "displayOnce",
displayPercentage: null,
environmentId: "env1",
singleUse: null,
surveyClosedMessage: null,
resultShareKey: null,
pin: null,
} as unknown as TSurvey;
const mockAirtableIntegration: TIntegrationAirtable = {
id: "int_airtable",
type: "airtable",
environmentId: "env1",
config: {
key: { access_token: "airtable_key" } as TIntegrationAirtableCredential,
data: [
{
surveyId: surveyId,
questionIds: [questionId1, questionId2],
baseId: "base1",
tableId: "table1",
createdAt: new Date(),
includeHiddenFields: true,
includeMetadata: true,
includeCreatedAt: true,
includeVariables: true,
} as TIntegrationAirtableConfigData,
],
} as TIntegrationAirtableConfig,
};
const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
id: "int_gsheets",
type: "googleSheets",
environmentId: "env1",
config: {
key: { refresh_token: "gsheet_key" } as TIntegrationGoogleSheetsCredential,
data: [
{
surveyId: surveyId,
spreadsheetId: "sheet1",
spreadsheetName: "Sheet Name",
questionIds: [questionId1],
questions: "What is Q1?",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: false,
includeVariables: false,
} as TIntegrationGoogleSheetsConfigData,
],
} as TIntegrationGoogleSheetsConfig,
};
const mockSlackIntegration: TIntegrationSlack = {
id: "int_slack",
type: "slack",
environmentId: "env1",
config: {
key: { access_token: "slack_key", app_id: "A1" } as TIntegrationSlackCredential,
data: [
{
surveyId: surveyId,
channelId: "channel1",
channelName: "Channel 1",
questionIds: [questionId1, questionId2, questionId3],
questions: "Q1, Q2, Q3",
createdAt: new Date(),
includeHiddenFields: true,
includeMetadata: true,
includeCreatedAt: true,
includeVariables: true,
} as TIntegrationSlackConfigData,
],
},
};
const mockNotionIntegration: TIntegrationNotion = {
id: "int_notion",
type: "notion",
environmentId: "env1",
config: {
key: {
access_token: "notion_key",
workspace_name: "ws",
workspace_icon: "",
workspace_id: "w1",
} as TIntegrationNotionCredential,
data: [
{
surveyId: surveyId,
databaseId: "db1",
databaseName: "DB 1",
mapping: [
{
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col1", name: "Column 1", type: "rich_text" },
},
{
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
column: { id: "col3", name: "Column 3", type: "url" },
},
{
question: { id: "metadata", name: "Metadata", type: "metadata" },
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
},
{
question: { id: "createdAt", name: "Created At", type: "createdAt" },
column: { id: "col_created", name: "Created Col", type: "date" },
},
],
createdAt: new Date(),
} as TIntegrationNotionConfigData,
],
},
};
describe("handleIntegrations", () => {
beforeEach(() => {
vi.resetAllMocks();
// Refine mock to explicitly handle string inputs
vi.mocked(processResponseData).mockImplementation((data) => {
if (typeof data === "string") {
return data; // Directly return string inputs
}
// Handle arrays and null/undefined as before
return String(Array.isArray(data) ? data.join(", ") : (data ?? ""));
});
vi.mocked(getLocalizedValue).mockImplementation((value, _) => value?.default || "");
vi.mocked(parseRecallInfo).mockImplementation((text, _, __) => text || "");
vi.mocked(getFormattedDateTimeString).mockReturnValue("2024-01-01 12:00");
vi.mocked(truncateText).mockImplementation((text, limit) => text.slice(0, limit));
});
afterEach(() => {
vi.clearAllMocks();
});
test("should call correct handlers for each integration type", async () => {
const integrations = [
mockAirtableIntegration,
mockGoogleSheetsIntegration,
mockSlackIntegration,
mockNotionIntegration,
];
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
vi.mocked(writeDataToSlack).mockResolvedValue(undefined);
vi.mocked(writeNotionData).mockResolvedValue(undefined);
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
expect(writeNotionData).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
});
test("should log errors when integration handlers fail", async () => {
const integrations = [mockAirtableIntegration, mockSlackIntegration];
const airtableError = new Error("Airtable failed");
const slackError = new Error("Slack failed");
vi.mocked(airtableWriteData).mockRejectedValue(airtableError);
vi.mocked(writeDataToSlack).mockRejectedValue(slackError);
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(airtableError, "Error in airtable integration");
expect(logger.error).toHaveBeenCalledWith(slackError, "Error in slack integration");
});
test("should handle empty integrations array", async () => {
await handleIntegrations([], mockPipelineInput, mockSurvey);
expect(airtableWriteData).not.toHaveBeenCalled();
expect(googleSheetWriteData).not.toHaveBeenCalled();
expect(writeDataToSlack).not.toHaveBeenCalled();
expect(writeNotionData).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
// Test individual handlers by calling the main function with a single integration
describe("Airtable Integration", () => {
test("should call airtableWriteData with correct parameters", async () => {
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
// Adjust expectations for metadata and recalled question
const expectedMetadataString =
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
expect(airtableWriteData).toHaveBeenCalledWith(
mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0],
[
[
"Answer 1",
"Choice 1, Choice 2",
"Hidden Value",
expectedMetadataString,
"Variable Value",
"2024-01-01 12:00",
], // responses + hidden + meta + var + created
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
]
);
});
test("should not call airtableWriteData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockAirtableIntegration], differentSurveyInput, mockSurvey);
expect(airtableWriteData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Airtable API error");
vi.mocked(airtableWriteData).mockRejectedValue(error);
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in airtable integration");
});
});
describe("Google Sheets Integration", () => {
test("should call googleSheetWriteData with correct parameters", async () => {
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
// Check that createdAt is converted to Date object
const expectedIntegrationData = structuredClone(mockGoogleSheetsIntegration);
expectedIntegrationData.config.data[0].createdAt = new Date(
mockGoogleSheetsIntegration.config.data[0].createdAt
);
expect(googleSheetWriteData).toHaveBeenCalledWith(
expectedIntegrationData,
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
[
["Answer 1"], // responses
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
]
);
});
test("should not call googleSheetWriteData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockGoogleSheetsIntegration], differentSurveyInput, mockSurvey);
expect(googleSheetWriteData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Google Sheets API error");
vi.mocked(googleSheetWriteData).mockRejectedValue(error);
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in google sheets integration");
});
});
describe("Slack Integration", () => {
test("should not call writeDataToSlack if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockSlackIntegration], differentSurveyInput, mockSurvey);
expect(writeDataToSlack).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Slack API error");
vi.mocked(writeDataToSlack).mockRejectedValue(error);
await handleIntegrations([mockSlackIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in slack integration");
});
});
describe("Notion Integration", () => {
test("should not call writeNotionData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockNotionIntegration], differentSurveyInput, mockSurvey);
expect(writeNotionData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Notion API error");
vi.mocked(writeNotionData).mockRejectedValue(error);
await handleIntegrations([mockNotionIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in notion integration");
});
});
});

View File

@@ -8,14 +8,14 @@ import {
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({

View File

@@ -0,0 +1,77 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
// Mock cache module
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/contact", () => ({
contactCache: {
tag: {
byId: vi.fn((id) => `contact-${id}`),
},
},
}));
// Mock react cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
};
});
const contactId = "test-contact-id";
describe("doesContactExist", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contactId });
const result = await doesContactExist(contactId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
select: { id: true },
});
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
tags: [contactCache.tag.byId(contactId)],
});
});
test("should return false if contact does not exist", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
select: { id: true },
});
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
tags: [contactCache.tag.byId(contactId)],
});
});
});

View File

@@ -0,0 +1,178 @@
import { displayCache } from "@/lib/display/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { createDisplay } from "./display";
// Mock dependencies
vi.mock("@/lib/display/cache", () => ({
displayCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
create: vi.fn(),
},
},
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
}));
const environmentId = "test-env-id";
const surveyId = "test-survey-id";
const contactId = "test-contact-id";
const displayId = "test-display-id";
const displayInput: TDisplayCreateInputV2 = {
environmentId,
surveyId,
contactId,
};
const displayInputWithoutContact: TDisplayCreateInputV2 = {
environmentId,
surveyId,
};
const mockDisplay = {
id: displayId,
contactId,
surveyId,
};
const mockDisplayWithoutContact = {
id: displayId,
contactId: null,
surveyId,
};
describe("createDisplay", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
contact: { connect: { id: contactId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId,
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplay); // Changed this line
});
test("should create a display without contactId successfully", async () => {
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact);
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId: null,
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should create a display even if contact does not exist", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
// No contact connection expected here
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId: null, // Assuming prisma returns null if contact wasn't connected
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should throw ValidationError if validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw original error if doesContactExist fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
import { cache } from "@/lib/cache";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContact } from "./contact";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/cache");
const contactId = "test-contact-id";
const mockContact = {
id: contactId,
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
};
const expectedContactAttributes: TContactAttributes = {
email: "test@example.com",
name: "Test User",
};
describe("getContact", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should return contact with formatted attributes when found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result).toEqual({
id: contactId,
attributes: expectedContactAttributes,
});
// Check if cache wrapper was called (though mocked to pass through)
expect(cache).toHaveBeenCalled();
});
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result).toBeNull();
// Check if cache wrapper was called (though mocked to pass through)
expect(cache).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,224 @@
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
import { createResponse } from "./response";
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
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",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/cache");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/responseNote/cache");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("./contact");
const environmentId = "test-environment-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
const contactId = "test-contact-id";
const userId = "test-user-id";
const displayId = "test-display-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
const mockContact: { id: string; attributes: TContactAttributes } = {
id: contactId,
attributes: { userId: userId, email: "test@example.com" },
};
const mockResponseInput: TResponseInputV2 = {
environmentId,
surveyId,
contactId: null,
displayId: null,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
singleUseId: null,
language: "en",
variables: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: null,
tags: [],
notes: [],
};
const expectedResponse: TResponse = {
...mockResponsePrisma,
contact: null,
tags: [],
};
describe("createResponse V2", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(validateInputs).mockImplementation(() => {});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(responseCache.revalidate).mockResolvedValue(undefined);
vi.mocked(responseNoteCache.revalidate).mockResolvedValue(undefined);
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
});
afterEach(() => {
mockIsFormbricksCloud = false;
});
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = {
...mockResponsePrisma,
tags: [{ tag: mockTag }],
};
vi.mocked(prisma.response.create).mockResolvedValue(prismaResponseWithTags as any);
const result = await createResponse(mockResponseInput);
expect(result.tags).toEqual([mockTag]);
});
});

View File

@@ -6,11 +6,11 @@ import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
Resource,
detectResourcesSync,
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
@@ -21,11 +21,11 @@ const exporter = new PrometheusExporter({
host: "0.0.0.0", // Listen on all network interfaces
});
const detectedResources = detectResourcesSync({
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
const customResources = new Resource({});
const customResources = resourceFromAttributes({});
const resources = detectedResources.merge(customResources);

View File

@@ -0,0 +1,113 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
describe("roles lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getProjectPermissionByUserId", () => {
test("returns null if no memberships", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([]);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockProjectId, expect.anything()]
);
});
test("returns 'manage' if any membership has manage", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "manage" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("manage");
});
test("returns 'readWrite' if highest is readWrite", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("readWrite");
});
test("returns 'read' if only read", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([{ permission: "read" }] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("read");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(error, expect.any(String));
});
test("throws UnknownError on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(UnknownError);
});
});
describe("getTeamRoleByTeamIdUserId", () => {
test("returns null if no teamUser", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce(null);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockTeamId, expect.anything()],
[mockUserId, expect.anything()]
);
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
});

View File

@@ -0,0 +1,41 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessTable } from "./access-table";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k: string) => k }),
}));
describe("AccessTable", () => {
afterEach(() => {
cleanup();
});
test("renders no teams found row when teams is empty", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.no_teams_found")).toBeInTheDocument();
});
test("renders team rows with correct data and permission mapping", () => {
const teams: TProjectTeam[] = [
{ id: "1", name: "Team A", memberCount: 1, permission: "readWrite" },
{ id: "2", name: "Team B", memberCount: 2, permission: "read" },
];
render(<AccessTable teams={teams} />);
expect(screen.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Team B")).toBeInTheDocument();
expect(screen.getByText("1 common.member")).toBeInTheDocument();
expect(screen.getByText("2 common.members")).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["readWrite"])).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["read"])).toBeInTheDocument();
});
test("renders table headers with tolgee keys", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.size")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.permission")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessView } from "./access-view";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="SettingsCard">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/manage-team", () => ({
ManageTeam: ({ environmentId, isOwnerOrManager }: any) => (
<button data-testid="ManageTeam">
ManageTeam {environmentId} {isOwnerOrManager ? "owner" : "not-owner"}
</button>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/access-table", () => ({
AccessTable: ({ teams }: any) => (
<div data-testid="AccessTable">
{teams.length === 0 ? "No teams" : `Teams: ${teams.map((t: any) => t.name).join(",")}`}
</div>
),
}));
describe("AccessView", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
environmentId: "env-1",
isOwnerOrManager: true,
teams: [
{ id: "1", name: "Team A", memberCount: 2, permission: "readWrite" } as TProjectTeam,
{ id: "2", name: "Team B", memberCount: 1, permission: "read" } as TProjectTeam,
],
};
test("renders SettingsCard with tolgee strings and children", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("SettingsCard")).toBeInTheDocument();
expect(screen.getByText("common.team_access")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.team_settings_description")).toBeInTheDocument();
});
test("renders ManageTeam with correct props", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("ManageTeam env-1 owner");
});
test("renders AccessTable with teams", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("Teams: Team A,Team B");
});
test("renders AccessTable with no teams", () => {
render(<AccessView {...baseProps} teams={[]} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("No teams");
});
test("renders ManageTeam as not-owner when isOwnerOrManager is false", () => {
render(<AccessView {...baseProps} isOwnerOrManager={false} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("not-owner");
});
});

View File

@@ -0,0 +1,46 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ManageTeam } from "./manage-team";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ tooltipContent, children }: any) => (
<div data-testid="TooltipRenderer">
<span>{tooltipContent}</span>
{children}
</div>
),
}));
describe("ManageTeam", () => {
afterEach(() => {
cleanup();
});
test("renders enabled button and navigates when isOwnerOrManager is true", async () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={true} />);
const button = screen.getByRole("button");
expect(button).toBeEnabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
await userEvent.click(button);
});
test("renders disabled button with tooltip when isOwnerOrManager is false", () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={false} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(
screen.getByText("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getTeamsByProjectId } from "./team";
vi.mock("@formbricks/database", () => ({
prisma: {
project: { findUnique: vi.fn() },
team: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache/team", () => ({ teamCache: { tag: { byProjectId: vi.fn(), byId: vi.fn() } } }));
vi.mock("@/lib/project/cache", () => ({ projectCache: { tag: { byId: vi.fn() } } }));
const mockProject = { id: "p1" };
const mockTeams = [
{
id: "t1",
name: "Team 1",
projectTeams: [{ permission: "readWrite" }],
_count: { teamUsers: 2 },
},
{
id: "t2",
name: "Team 2",
projectTeams: [{ permission: "manage" }],
_count: { teamUsers: 3 },
},
];
describe("getTeamsByProjectId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams for valid project", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByProjectId("p1");
expect(result).toEqual([
{ id: "t1", name: "Team 1", permission: "readWrite", memberCount: 2 },
{ id: "t2", name: "Team 2", permission: "manage", memberCount: 3 },
]);
expect(prisma.project.findUnique).toHaveBeenCalledWith({ where: { id: "p1" } });
expect(prisma.team.findMany).toHaveBeenCalled();
});
test("throws ResourceNotFoundError if project does not exist", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(null);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on Prisma known error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(DatabaseError);
});
test("throws unknown error on unexpected error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("unexpected"));
await expect(getTeamsByProjectId("p1")).rejects.toThrow("unexpected");
});
});

View File

@@ -0,0 +1,41 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsLoading } from "./loading";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId, loading }: any) => (
<div data-testid="ProjectConfigNavigation">{`${activeId}-${loading}`}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="PageContentWrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="PageHeader">
<span>{pageTitle}</span>
{children}
</div>
),
}));
describe("TeamsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons and navigation", () => {
render(<TeamsLoading />);
expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument();
expect(screen.getByTestId("PageHeader")).toBeInTheDocument();
expect(screen.getByTestId("ProjectConfigNavigation")).toHaveTextContent("teams-true");
// Check for the presence of multiple skeleton loaders (at least one)
const skeletonLoaders = screen.getAllByRole("generic", { name: "" }); // Assuming skeleton divs don't have specific roles/names
// Filter for elements with animate-pulse class
const pulseElements = skeletonLoaders.filter((el) => el.classList.contains("animate-pulse"));
expect(pulseElements.length).toBeGreaterThan(0);
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getTeamsByProjectId } from "./lib/team";
import { ProjectTeams } from "./page";
vi.mock("@/modules/ee/teams/project-teams/components/access-view", () => ({
AccessView: (props: any) => <div data-testid="AccessView">{JSON.stringify(props)}</div>,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => (
<div data-testid="ProjectConfigNavigation">{JSON.stringify(props)}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="PageContentWrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="PageHeader">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("./lib/team", () => ({
getTeamsByProjectId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
describe("ProjectTeams", () => {
const params = Promise.resolve({ environmentId: "env-1" });
beforeEach(() => {
vi.mocked(getTeamsByProjectId).mockResolvedValue([
{ id: "team-1", name: "Team 1", memberCount: 2, permission: "readWrite" },
{ id: "team-2", name: "Team 2", memberCount: 1, permission: "read" },
]);
vi.mocked(getTranslate).mockResolvedValue((key) => key);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
project: { id: "project-1" },
isOwner: true,
isManager: false,
} as any);
});
afterEach(() => {
cleanup();
});
test("renders all main components and passes correct props", async () => {
const ui = await ProjectTeams({ params });
render(ui);
expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument();
expect(screen.getByTestId("PageHeader")).toBeInTheDocument();
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByTestId("ProjectConfigNavigation")).toBeInTheDocument();
expect(screen.getByTestId("AccessView")).toHaveTextContent('"environmentId":"env-1"');
expect(screen.getByTestId("AccessView")).toHaveTextContent('"isOwnerOrManager":true');
});
test("throws error if teams is null", async () => {
vi.mocked(getTeamsByProjectId).mockResolvedValue(null);
await expect(ProjectTeams({ params })).rejects.toThrow("common.teams_not_found");
});
});

View File

@@ -0,0 +1,86 @@
import { ZTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createTeamAction,
deleteTeamAction,
getTeamDetailsAction,
getTeamRoleAction,
updateTeamDetailsAction,
} from "./actions";
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => fn,
}),
},
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromTeamId: vi.fn(async (id: string) => `org-${id}`),
}));
vi.mock("@/modules/ee/role-management/actions", () => ({
checkRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getTeamRoleByTeamIdUserId: vi.fn(async () => "admin"),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
createTeam: vi.fn(async () => "team-created"),
getTeamDetails: vi.fn(async () => ({ id: "team-1" })),
deleteTeam: vi.fn(async () => true),
updateTeamDetails: vi.fn(async () => ({ updated: true })),
}));
describe("action.ts", () => {
const ctx = {
user: { id: "user-1" },
} as any;
afterEach(() => {
cleanup();
});
test("createTeamAction calls dependencies and returns result", async () => {
const result = await createTeamAction({
ctx,
parsedInput: { organizationId: "org-1", name: "Team X" },
} as any);
expect(result).toBe("team-created");
});
test("getTeamDetailsAction calls dependencies and returns result", async () => {
const result = await getTeamDetailsAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toEqual({ id: "team-1" });
});
test("deleteTeamAction calls dependencies and returns result", async () => {
const result = await deleteTeamAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toBe(true);
});
test("updateTeamDetailsAction calls dependencies and returns result", async () => {
const result = await updateTeamDetailsAction({
ctx,
parsedInput: { teamId: "team-1", data: {} as typeof ZTeamSettingsFormSchema._type },
} as any);
expect(result).toEqual({ updated: true });
});
test("getTeamRoleAction calls dependencies and returns result", async () => {
const result = await getTeamRoleAction({
ctx,
parsedInput: { teamId: "team-1" },
} as any);
expect(result).toBe("admin");
});
});

View File

@@ -0,0 +1,27 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamButton } from "./create-team-button";
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
CreateTeamModal: ({ open, setOpen, organizationId }: any) =>
open ? <div data-testid="CreateTeamModal">{organizationId}</div> : null,
}));
describe("CreateTeamButton", () => {
afterEach(() => {
cleanup();
});
test("renders button with tolgee string", () => {
render(<CreateTeamButton organizationId="org-1" />);
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
});
test("opens CreateTeamModal on button click", async () => {
render(<CreateTeamButton organizationId="org-2" />);
await userEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("CreateTeamModal")).toHaveTextContent("org-2");
});
});

View File

@@ -0,0 +1,77 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamModal } from "./create-team-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children }: any) => <div data-testid="Modal">{children}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
createTeamAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "error-message"),
}));
describe("CreateTeamModal", () => {
afterEach(() => {
cleanup();
});
const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create")).toBeInTheDocument();
});
test("calls setOpen(false) and resets teamName on cancel", async () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("common.cancel"));
expect(setOpen).toHaveBeenCalledWith(false);
expect((input as HTMLInputElement).value).toBe("");
});
test("submit button is disabled when input is empty", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
});
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => {
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
const onCreate = vi.fn();
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("environments.settings.teams.create"));
await waitFor(() => {
expect(createTeamAction).toHaveBeenCalledWith({ name: "My Team", organizationId: "org-1" });
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_created_successfully");
expect(onCreate).toHaveBeenCalledWith("team-123");
expect(setOpen).toHaveBeenCalledWith(false);
expect((input as HTMLInputElement).value).toBe("");
});
});
test("shows error toast if createTeamAction fails", async () => {
vi.mocked(createTeamAction).mockResolvedValue({});
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
const input = screen.getByPlaceholderText("environments.settings.teams.enter_team_name");
await userEvent.type(input, "My Team");
await userEvent.click(screen.getByText("environments.settings.teams.create"));
await waitFor(() => {
expect(getFormattedErrorMessage).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("error-message");
});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/action";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";

View File

@@ -0,0 +1,42 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ManageTeamButton } from "./manage-team-button";
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) =>
shouldRender ? (
<div data-testid="TooltipRenderer">
{tooltipContent}
{children}
</div>
) : (
<>{children}</>
),
}));
describe("ManageTeamButton", () => {
afterEach(() => {
cleanup();
});
test("renders enabled button and calls onClick", async () => {
const onClick = vi.fn();
render(<ManageTeamButton onClick={onClick} disabled={false} />);
const button = screen.getByRole("button");
expect(button).toBeEnabled();
expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument();
await userEvent.click(button);
expect(onClick).toHaveBeenCalled();
});
test("renders disabled button with tooltip", () => {
const onClick = vi.fn();
render(<ManageTeamButton onClick={onClick} disabled={true} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(screen.getByText("environments.settings.teams.manage_team")).toBeInTheDocument();
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.manage_team_disabled")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,99 @@
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DeleteTeam } from "./delete-team";
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: any) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ shouldRender, tooltipContent, children }: any) =>
shouldRender ? (
<div data-testid="TooltipRenderer">
{tooltipContent}
{children}
</div>
) : (
<>{children}</>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, deleteWhat, text, onDelete, isDeleting }: any) =>
open ? (
<div data-testid="DeleteDialog">
<span>{deleteWhat}</span>
<span>{text}</span>
<button onClick={onDelete} disabled={isDeleting}>
Confirm
</button>
</div>
) : null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
deleteTeamAction: vi.fn(),
}));
describe("DeleteTeam", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
teamId: "team-1" as TTeam["id"],
onDelete: vi.fn(),
isOwnerOrManager: true,
};
test("renders danger zone label and delete button enabled for owner/manager", () => {
render(<DeleteTeam {...baseProps} />);
expect(screen.getByText("common.danger_zone")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeEnabled();
});
test("renders tooltip and disables button if not owner/manager", () => {
render(<DeleteTeam {...baseProps} isOwnerOrManager={false} />);
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_deletion_not_allowed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "environments.settings.teams.delete_team" })).toBeDisabled();
});
test("opens dialog on delete button click", async () => {
render(<DeleteTeam {...baseProps} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
expect(screen.getByTestId("DeleteDialog")).toBeInTheDocument();
expect(screen.getByText("common.team")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.teams.are_you_sure_you_want_to_delete_this_team")
).toBeInTheDocument();
});
test("calls deleteTeamAction, shows success toast, calls onDelete, and refreshes on confirm", async () => {
vi.mocked(deleteTeamAction).mockResolvedValue({ data: true });
const onDelete = vi.fn();
render(<DeleteTeam {...baseProps} onDelete={onDelete} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
await userEvent.click(screen.getByText("Confirm"));
expect(deleteTeamAction).toHaveBeenCalledWith({ teamId: baseProps.teamId });
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_deleted_successfully");
expect(onDelete).toHaveBeenCalled();
});
test("shows error toast if deleteTeamAction fails", async () => {
vi.mocked(deleteTeamAction).mockResolvedValue({ data: false });
render(<DeleteTeam {...baseProps} />);
await userEvent.click(screen.getByRole("button", { name: "environments.settings.teams.delete_team" }));
await userEvent.click(screen.getByText("Confirm"));
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/action";
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";

View File

@@ -0,0 +1,136 @@
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { TOrganizationMember, TTeamDetails, ZTeamRole } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamSettingsModal } from "./team-settings-modal";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, ...props }: any) => <div data-testid="Modal">{children}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
DeleteTeam: () => <div data-testid="DeleteTeam" />,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
updateTeamDetailsAction: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
describe("TeamSettingsModal", () => {
afterEach(() => {
cleanup();
});
const orgMembers: TOrganizationMember[] = [
{ id: "1", name: "Alice", role: "member" },
{ id: "2", name: "Bob", role: "manager" },
];
const orgProjects = [
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
];
const team: TTeamDetails = {
id: "t1",
name: "Team 1",
members: [{ name: "Alice", userId: "1", role: ZTeamRole.enum.contributor }],
projects: [
{ projectName: "pro1", projectId: "p1", permission: ZTeamPermission.enum.read },
{ projectName: "pro2", projectId: "p2", permission: ZTeamPermission.enum.readWrite },
],
organizationId: "org1",
};
const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => {
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
expect(screen.getByTestId("Modal")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument();
expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Add project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByTestId("DeleteTeam")).toBeInTheDocument();
});
test("calls setOpen(false) when cancel button is clicked", async () => {
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.cancel"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("calls updateTeamDetailsAction and shows success toast on submit", async () => {
vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: true });
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(updateTeamDetailsAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.settings.teams.team_updated_successfully");
expect(setOpen).toHaveBeenCalledWith(false);
});
});
test("shows error toast if updateTeamDetailsAction fails", async () => {
vi.mocked(updateTeamDetailsAction).mockResolvedValue({ data: false });
render(
<TeamSettingsModal
open={true}
setOpen={setOpen}
team={team}
orgMembers={orgMembers}
orgProjects={orgProjects}
userTeamRole={ZTeamRole.enum.admin}
membershipRole={"owner"}
currentUserId="1"
/>
);
await userEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,7 @@ import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/action";
import { updateTeamDetailsAction } from "@/modules/ee/teams/team-list/actions";
import { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
import {

View File

@@ -0,0 +1,154 @@
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions";
import { TOrganizationMember, TOtherTeam, TUserTeam } from "@/modules/ee/teams/team-list/types/team";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsTable } from "./teams-table";
vi.mock("@/modules/ee/teams/team-list/components/create-team-button", () => ({
CreateTeamButton: ({ organizationId }: any) => (
<button data-testid="CreateTeamButton">{organizationId}</button>
),
}));
vi.mock("@/modules/ee/teams/team-list/components/manage-team-button", () => ({
ManageTeamButton: ({ disabled, onClick }: any) => (
<button data-testid="ManageTeamButton" disabled={disabled} onClick={onClick}>
environments.settings.teams.manage_team
</button>
),
}));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/team-settings-modal", () => ({
TeamSettingsModal: (props: any) => <div data-testid="TeamSettingsModal">{props.team?.name}</div>,
}));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
getTeamDetailsAction: vi.fn(),
getTeamRoleAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: ({ text }: any) => <span data-testid="Badge">{text}</span>,
}));
const userTeams: TUserTeam[] = [
{ id: "1", name: "Alpha", memberCount: 2, userRole: "admin" },
{ id: "2", name: "Beta", memberCount: 1, userRole: "contributor" },
];
const otherTeams: TOtherTeam[] = [
{ id: "3", name: "Gamma", memberCount: 3 },
{ id: "4", name: "Delta", memberCount: 1 },
];
const orgMembers: TOrganizationMember[] = [{ id: "u1", name: "User 1", role: "manager" }];
const orgProjects = [{ id: "p1", name: "Project 1" }];
describe("TeamsTable", () => {
afterEach(() => {
cleanup();
});
test("renders CreateTeamButton for owner/manager", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByTestId("CreateTeamButton")).toHaveTextContent("org-1");
});
test("does not render CreateTeamButton for non-owner/manager", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole={undefined}
currentUserId="u1"
/>
);
expect(screen.queryByTestId("CreateTeamButton")).toBeNull();
});
test("renders empty state row if no teams", () => {
render(
<TeamsTable
teams={{ userTeams: [], otherTeams: [] }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByText("environments.settings.teams.empty_teams_state")).toBeInTheDocument();
});
test("renders userTeams and otherTeams rows", () => {
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
expect(screen.getByText("Delta")).toBeInTheDocument();
expect(screen.getAllByTestId("ManageTeamButton").length).toBe(4);
expect(screen.getAllByTestId("Badge")[0]).toHaveTextContent(
"environments.settings.teams.you_are_a_member"
);
expect(screen.getByText("2 common.members")).toBeInTheDocument();
});
test("opens TeamSettingsModal when ManageTeamButton is clicked and team details are returned", async () => {
vi.mocked(getTeamDetailsAction).mockResolvedValue({
data: { id: "1", name: "Alpha", organizationId: "org-1", members: [], projects: [] },
});
vi.mocked(getTeamRoleAction).mockResolvedValue({ data: "admin" });
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]);
await waitFor(() => {
expect(screen.getByTestId("TeamSettingsModal")).toHaveTextContent("Alpha");
});
});
test("shows error toast if getTeamDetailsAction fails", async () => {
vi.mocked(getTeamDetailsAction).mockResolvedValue({ data: undefined });
vi.mocked(getTeamRoleAction).mockResolvedValue({ data: undefined });
render(
<TeamsTable
teams={{ userTeams, otherTeams }}
organizationId="org-1"
orgMembers={orgMembers}
orgProjects={orgProjects}
membershipRole="owner"
currentUserId="u1"
/>
);
await userEvent.click(screen.getAllByTestId("ManageTeamButton")[0]);
await waitFor(() => {
expect(toast.error).toHaveBeenCalled();
});
});
});

View File

@@ -2,7 +2,7 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/action";
import { getTeamDetailsAction, getTeamRoleAction } from "@/modules/ee/teams/team-list/actions";
import { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button";
import { ManageTeamButton } from "@/modules/ee/teams/team-list/components/manage-team-button";
import { TeamSettingsModal } from "@/modules/ee/teams/team-list/components/team-settings/team-settings-modal";

View File

@@ -0,0 +1,50 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { getProjectsByOrganizationId } from "./project";
vi.mock("@formbricks/database", () => ({
prisma: {
project: { findMany: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
const mockProjects = [
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
];
describe("getProjectsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped projects for valid organization", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const result = await getProjectsByOrganizationId("org1");
expect(result).toEqual([
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { organizationId: "org1" },
select: { id: true, name: true },
});
});
test("throws DatabaseError on Prisma known error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error);
await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(error, "Error fetching projects by organization id");
});
test("throws UnknownError on unknown error", async () => {
const error = new Error("fail");
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error);
await expect(getProjectsByOrganizationId("org1")).rejects.toThrow(UnknownError);
});
});

View File

@@ -0,0 +1,343 @@
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import { projectCache } from "@/lib/project/cache";
import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
createTeam,
deleteTeam,
getOtherTeams,
getTeamDetails,
getTeams,
getTeamsByOrganizationId,
getUserTeams,
updateTeamDetails,
} from "./team";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
membership: { findUnique: vi.fn(), count: vi.fn() },
project: { count: vi.fn() },
environment: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn(), byUserId: vi.fn(), byId: vi.fn(), projectId: vi.fn() },
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: { tag: { byId: vi.fn(), byOrganizationId: vi.fn() }, revalidate: vi.fn() },
}));
vi.mock("@/lib/cache/organization", () => ({ organizationCache: { revalidate: vi.fn() } }));
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
const mockUserTeams = [
{
id: "t1",
name: "Team 1",
teamUsers: [{ role: "admin" }],
_count: { teamUsers: 2 },
},
];
const mockOtherTeams = [
{
id: "t2",
name: "Team 2",
_count: { teamUsers: 3 },
},
];
const mockMembership = { role: "admin" };
const mockTeamDetails = {
id: "t1",
name: "Team 1",
organizationId: "org1",
teamUsers: [
{ userId: "u1", role: "admin", user: { name: "User 1" } },
{ userId: "u2", role: "member", user: { name: "User 2" } },
],
projectTeams: [{ projectId: "p1", project: { name: "Project 1" }, permission: "manage" }],
};
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
expect(result).toEqual([
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
});
describe("getUserTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped user teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
const result = await getUserTeams("u1", "org1");
expect(result).toEqual([{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getUserTeams("u1", "org1")).rejects.toThrow(DatabaseError);
});
});
describe("getOtherTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped other teams", async () => {
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams);
const result = await getOtherTeams("u1", "org1");
expect(result).toEqual([{ id: "t2", name: "Team 2", memberCount: 3 }]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getOtherTeams("u1", "org1")).rejects.toThrow(DatabaseError);
});
});
describe("getTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns userTeams and otherTeams", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(mockMembership);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockOtherTeams);
const result = await getTeams("u1", "org1");
expect(result).toEqual({
userTeams: [{ id: "t1", name: "Team 1", userRole: "admin", memberCount: 2 }],
otherTeams: [{ id: "t2", name: "Team 2", memberCount: 3 }],
});
});
test("throws ResourceNotFoundError if membership not found", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValueOnce(null);
await expect(getTeams("u1", "org1")).rejects.toThrow(ResourceNotFoundError);
});
});
describe("createTeam", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("creates and returns team id", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.team.create).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await createTeam("org1", "Team 1");
expect(result).toBe("t1");
expect(teamCache.revalidate).toHaveBeenCalledWith({ organizationId: "org1" });
});
test("throws InvalidInputError if team exists", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({ id: "t1" });
await expect(createTeam("org1", "Team 1")).rejects.toThrow(InvalidInputError);
});
test("throws InvalidInputError if name too short", async () => {
vi.mocked(prisma.team.findFirst).mockResolvedValueOnce(null);
await expect(createTeam("org1", "")).rejects.toThrow(InvalidInputError);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findFirst).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(createTeam("org1", "Team 1")).rejects.toThrow(DatabaseError);
});
});
describe("getTeamDetails", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped team details", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails);
const result = await getTeamDetails("t1");
expect(result).toEqual({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [
{ userId: "u1", name: "User 1", role: "admin" },
{ userId: "u2", name: "User 2", role: "member" },
],
projects: [{ projectId: "p1", projectName: "Project 1", permission: "manage" }],
});
});
test("returns null if team not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
const result = await getTeamDetails("t1");
expect(result).toBeNull();
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamDetails("t1")).rejects.toThrow(DatabaseError);
});
});
describe("deleteTeam", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes team and revalidates caches", async () => {
const mockTeam = {
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
projectTeams: [{ projectId: "p1" }],
};
vi.mocked(prisma.team.delete).mockResolvedValueOnce(mockTeam);
const result = await deleteTeam("t1");
expect(result).toBe(true);
expect(teamCache.revalidate).toHaveBeenCalledWith({ id: "t1", organizationId: "org1" });
expect(teamCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.delete).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(deleteTeam("t1")).rejects.toThrow(DatabaseError);
});
});
describe("updateTeamDetails", () => {
const data: TTeamSettingsFormSchema = {
name: "Team 1 Updated",
members: [{ userId: "u1", role: "admin" }],
projects: [{ projectId: "p1", permission: "manage" }],
};
beforeEach(() => {
vi.clearAllMocks();
});
test("updates team details and revalidates caches", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(mockTeamDetails);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockUserTeams);
vi.mocked(prisma.membership.count).mockResolvedValueOnce(1);
vi.mocked(prisma.project.count).mockResolvedValueOnce(1);
vi.mocked(prisma.team.update).mockResolvedValueOnce({
id: "t1",
name: "Team 1 Updated",
organizationId: "org1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.environment.findMany).mockResolvedValueOnce([{ id: "env1" }]);
const result = await updateTeamDetails("t1", data);
expect(result).toBe(true);
expect(teamCache.revalidate).toHaveBeenCalled();
expect(projectCache.revalidate).toHaveBeenCalled();
expect(organizationCache.revalidate).toHaveBeenCalledWith({ environmentId: "env1" });
});
test("throws ResourceNotFoundError if team not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
await expect(updateTeamDetails("t1", data)).rejects.toThrow(ResourceNotFoundError);
});
test("throws error if getTeamDetails returns null", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
await expect(updateTeamDetails("t1", data)).rejects.toThrow("Team not found");
});
test("throws error if user not in org membership", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
vi.mocked(prisma.membership.count).mockResolvedValueOnce(0);
await expect(updateTeamDetails("t1", data)).rejects.toThrow();
});
test("throws error if project not in org", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
organizationId: "org1",
name: "Team 1",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
id: "t1",
name: "Team 1",
organizationId: "org1",
members: [],
projects: [],
});
vi.mocked(prisma.membership.count).mockResolvedValueOnce(1);
vi.mocked(prisma.project.count).mockResolvedValueOnce(0);
await expect(
updateTeamDetails("t1", {
name: "x",
members: [],
projects: [{ projectId: "p1", permission: "manage" }],
})
).rejects.toThrow();
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(updateTeamDetails("t1", data)).rejects.toThrow(DatabaseError);
});
});

View File

@@ -57,7 +57,7 @@ export const getTeamsByOrganizationId = reactCache(
)()
);
const getUserTeams = reactCache(
export const getUserTeams = reactCache(
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
cache(
async () => {

View File

@@ -0,0 +1,67 @@
import { ProjectTeamPermission, TeamUserRole } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { TeamPermissionMapping, TeamRoleMapping, getTeamAccessFlags, getTeamPermissionFlags } from "./teams";
describe("TeamPermissionMapping", () => {
test("maps ProjectTeamPermission to correct labels", () => {
expect(TeamPermissionMapping[ProjectTeamPermission.read]).toBe("Read");
expect(TeamPermissionMapping[ProjectTeamPermission.readWrite]).toBe("Read & write");
expect(TeamPermissionMapping[ProjectTeamPermission.manage]).toBe("Manage");
});
});
describe("TeamRoleMapping", () => {
test("maps TeamUserRole to correct labels", () => {
expect(TeamRoleMapping[TeamUserRole.admin]).toBe("Team Admin");
expect(TeamRoleMapping[TeamUserRole.contributor]).toBe("Contributor");
});
});
describe("getTeamAccessFlags", () => {
test("returns correct flags for admin", () => {
expect(getTeamAccessFlags(TeamUserRole.admin)).toEqual({ isAdmin: true, isContributor: false });
});
test("returns correct flags for contributor", () => {
expect(getTeamAccessFlags(TeamUserRole.contributor)).toEqual({ isAdmin: false, isContributor: true });
});
test("returns false flags for undefined/null", () => {
expect(getTeamAccessFlags()).toEqual({ isAdmin: false, isContributor: false });
expect(getTeamAccessFlags(null)).toEqual({ isAdmin: false, isContributor: false });
});
});
describe("getTeamPermissionFlags", () => {
test("returns correct flags for read", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.read)).toEqual({
hasReadAccess: true,
hasReadWriteAccess: false,
hasManageAccess: false,
});
});
test("returns correct flags for readWrite", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.readWrite)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: true,
hasManageAccess: false,
});
});
test("returns correct flags for manage", () => {
expect(getTeamPermissionFlags(ProjectTeamPermission.manage)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: true,
});
});
test("returns all false for undefined/null", () => {
expect(getTeamPermissionFlags()).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: false,
});
expect(getTeamPermissionFlags(null)).toEqual({
hasReadAccess: false,
hasReadWriteAccess: false,
hasManageAccess: false,
});
});
});

View File

@@ -0,0 +1,409 @@
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 { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyAddressQuestion,
TSurveyLanguage,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { AddressQuestionForm } from "./address-question-form";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({ id, label, value }: { id: string; label: string; value: any }) => (
<input data-testid={id} aria-label={label} value={value?.default ?? value} />
),
}));
vi.mock("@/modules/ui/components/question-toggle-table", () => ({
QuestionToggleTable: ({ fields }: { fields: any[] }) => (
<div data-testid="question-toggle-table">
{fields?.map((field) => (
<div key={field.id} data-testid={`field-${field.id}`}>
{field.label}
</div>
))}
</div>
),
}));
// Mock window.matchMedia - required for useAutoAnimate
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
describe("AddressQuestionForm", () => {
afterEach(() => {
cleanup();
});
test("renders the headline input field with the correct label and value", () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: false, placeholder: { default: "" } },
addressLine2: { show: true, required: false, placeholder: { default: "" } },
city: { show: true, required: false, placeholder: { default: "" } },
state: { show: true, required: false, placeholder: { default: "" } },
zip: { show: true, required: false, placeholder: { default: "" } },
country: { show: true, required: false, placeholder: { default: "" } },
required: false,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
const headlineInput = screen.getByLabelText("environments.surveys.edit.question*");
expect(headlineInput).toBeInTheDocument();
expect((headlineInput as HTMLInputElement).value).toBe("Test Headline");
});
test("renders the QuestionToggleTable with the correct fields", () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: false, placeholder: { default: "" } },
addressLine2: { show: true, required: false, placeholder: { default: "" } },
city: { show: true, required: false, placeholder: { default: "" } },
state: { show: true, required: false, placeholder: { default: "" } },
zip: { show: true, required: false, placeholder: { default: "" } },
country: { show: true, required: false, placeholder: { default: "" } },
required: false,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
const questionToggleTable = screen.getByTestId("question-toggle-table");
expect(questionToggleTable).toBeInTheDocument();
expect(screen.getByTestId("field-addressLine1")).toHaveTextContent(
"environments.surveys.edit.address_line_1"
);
expect(screen.getByTestId("field-addressLine2")).toHaveTextContent(
"environments.surveys.edit.address_line_2"
);
expect(screen.getByTestId("field-city")).toHaveTextContent("environments.surveys.edit.city");
expect(screen.getByTestId("field-state")).toHaveTextContent("environments.surveys.edit.state");
expect(screen.getByTestId("field-zip")).toHaveTextContent("environments.surveys.edit.zip");
expect(screen.getByTestId("field-country")).toHaveTextContent("environments.surveys.edit.country");
});
test("updates the required property of the question object based on address fields visibility and requirement status", () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: false, placeholder: { default: "" } },
addressLine2: { show: true, required: false, placeholder: { default: "" } },
city: { show: true, required: false, placeholder: { default: "" } },
state: { show: true, required: false, placeholder: { default: "" } },
zip: { show: true, required: false, placeholder: { default: "" } },
country: { show: true, required: false, placeholder: { default: "" } },
required: true,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
expect(updateQuestion).toHaveBeenCalledWith(0, { required: false });
});
test("updates required property when questionIdx changes", () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: false, placeholder: { default: "" } },
addressLine2: { show: true, required: false, placeholder: { default: "" } },
city: { show: true, required: false, placeholder: { default: "" } },
state: { show: true, required: false, placeholder: { default: "" } },
zip: { show: true, required: false, placeholder: { default: "" } },
country: { show: true, required: false, placeholder: { default: "" } },
required: false,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
const { rerender } = render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
const updatedQuestion: TSurveyAddressQuestion = {
...question,
addressLine1: { ...question.addressLine1, required: true },
};
rerender(
<AddressQuestionForm
question={updatedQuestion}
questionIdx={1}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
expect(updateQuestion).toHaveBeenCalledTimes(2);
expect(updateQuestion).toHaveBeenNthCalledWith(1, 0, { required: false });
expect(updateQuestion).toHaveBeenNthCalledWith(2, 1, { required: true });
});
test("clicking 'Add Description' button with empty languages array should create a valid i18n string", async () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: false, placeholder: { default: "" } },
addressLine2: { show: true, required: false, placeholder: { default: "" } },
city: { show: true, required: false, placeholder: { default: "" } },
state: { show: true, required: false, placeholder: { default: "" } },
zip: { show: true, required: false, placeholder: { default: "" } },
country: { show: true, required: false, placeholder: { default: "" } },
required: false,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
const addButton = screen.getByText("environments.surveys.edit.add_description");
expect(addButton).toBeInTheDocument();
await userEvent.click(addButton);
expect(updateQuestion).toHaveBeenCalledWith(0, { subheader: { default: "" } });
});
test("should prevent setting the overall question to non-required when all visible address fields are required", () => {
const question: TSurveyAddressQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Test Headline" },
addressLine1: { show: true, required: true, placeholder: { default: "" } },
addressLine2: { show: true, required: true, placeholder: { default: "" } },
city: { show: true, required: true, placeholder: { default: "" } },
state: { show: true, required: true, placeholder: { default: "" } },
zip: { show: true, required: true, placeholder: { default: "" } },
country: { show: true, required: true, placeholder: { default: "" } },
required: false,
};
const localSurvey = {
id: "survey1",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [question],
environmentId: "env1",
welcomeCard: {
headline: {
default: "",
},
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const locale = "en-US";
render(
<AddressQuestionForm
question={question}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={localSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
);
expect(updateQuestion).toHaveBeenCalledWith(0, { required: true });
});
});

View File

@@ -0,0 +1,92 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AnimatedSurveyBg } from "./animated-survey-bg";
describe("AnimatedSurveyBg", () => {
afterEach(() => {
cleanup();
});
test("should initialize animation state with the background prop", () => {
const mockHandleBgChange = vi.fn();
const backgroundValue = "/animated-bgs/4K/1_4k.mp4";
render(<AnimatedSurveyBg handleBgChange={mockHandleBgChange} background={backgroundValue} />);
const checkbox = screen.getByRole("checkbox", {
checked: true,
});
expect(checkbox).toBeInTheDocument();
});
test("should update animation state and call handleBgChange with correct arguments when a thumbnail is clicked", async () => {
const handleBgChange = vi.fn();
const initialBackground = "/animated-bgs/4K/1_4k.mp4";
const { container } = render(
<AnimatedSurveyBg handleBgChange={handleBgChange} background={initialBackground} />
);
// Find the first video element and simulate a click on its parent div
const videoElement = container.querySelector("video");
const parentDiv = videoElement?.closest("div");
if (parentDiv) {
await userEvent.click(parentDiv);
const expectedValue = "/animated-bgs/4K/1_4k.mp4";
expect(handleBgChange).toHaveBeenCalledWith(expectedValue, "animation");
} else {
throw new Error("Could not find the parent div of the video element.");
}
});
test("should update animation state when the checkbox is clicked", () => {
const mockHandleBgChange = vi.fn();
const initialBackground = "/animated-bgs/4K/1_4k.mp4";
render(<AnimatedSurveyBg handleBgChange={mockHandleBgChange} background={initialBackground} />);
const checkbox = screen.getAllByRole("checkbox")[1];
expect(checkbox).toBeInTheDocument();
fireEvent.click(checkbox);
expect(mockHandleBgChange).toHaveBeenCalled();
});
test("handles rejected Promise from video.play()", async () => {
const mockHandleBgChange = vi.fn();
const backgroundValue = "/animated-bgs/4K/1_4k.mp4";
// Mock the video element and its play method to reject the promise
const mockVideo = {
play: vi.fn(() => Promise.reject(new Error("Playback failed"))),
pause: vi.fn(),
load: vi.fn(),
};
vi.spyOn(document, "getElementById").mockImplementation((id) => {
if (id.startsWith("video-")) {
return mockVideo as unknown as HTMLVideoElement;
}
return null;
});
render(<AnimatedSurveyBg handleBgChange={mockHandleBgChange} background={backgroundValue} />);
// Simulate a mouse enter event on the first video thumbnail
const firstThumbnail = screen.getAllByRole("checkbox")[0].closest("div"); // Find the parent div
if (firstThumbnail) {
fireEvent.mouseEnter(firstThumbnail);
}
// Wait for a short period to allow the debounced function to execute
await new Promise((resolve) => setTimeout(resolve, 200));
// Assert that video.play() was called and rejected
expect(mockVideo.play).toHaveBeenCalled();
});
});

View File

@@ -76,7 +76,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
const value = animationFiles[key];
return (
<div
key={index}
key={key}
onMouseEnter={() => debouncedManagePlayback(index, "play")}
onMouseLeave={() => debouncedManagePlayback(index, "pause")}
onClick={() => handleBg(value)}

View File

@@ -0,0 +1,260 @@
import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome-card";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
vi.mock("@/lib/cn");
vi.mock("@/modules/ee/multi-language-surveys/components/localized-editor", () => ({
LocalizedEditor: vi.fn(({ value, id }) => (
<textarea data-testid={`localized-editor-${id}`} defaultValue={value?.default}></textarea>
)),
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ value, id }) => (
<input data-testid={`question-form-input-${id}`} defaultValue={value?.default}></input>
)),
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: vi.fn(({ fileUrl }) => <input data-testid="file-input" defaultValue={fileUrl}></input>),
}));
vi.mock("next/navigation", () => ({
usePathname: vi.fn(() => "/environments/test-env-id/surveys/survey-1/edit"),
}));
vi.mock("@radix-ui/react-collapsible", async () => {
const original = await vi.importActual("@radix-ui/react-collapsible");
return {
...original,
Root: ({ children, open, onOpenChange }: any) => (
<div data-state={open ? "open" : "closed"} onClick={() => onOpenChange(!open)}>
{children}
</div>
),
CollapsibleTrigger: ({ children, asChild }: any) => (asChild ? children : <button>{children}</button>),
CollapsibleContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
};
});
// create a mock survey object
const mockSurvey = {
id: "survey-1",
type: "web",
title: "Test Survey",
description: "This is a test survey.",
languages: ["en"],
questions: [],
} as unknown as TSurvey;
mockSurvey.welcomeCard = {
enabled: true,
headline: { default: "Welcome!" },
html: { default: "<p>Thank you for participating.</p>" },
buttonLabel: { default: "Start Survey" },
timeToFinish: true,
showResponseCount: false,
};
const mockSetLocalSurvey = vi.fn();
const mockSetActiveQuestionId = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
describe("EditWelcomeCard", () => {
afterEach(() => {
cleanup();
});
test("renders correctly when collapsed", () => {
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId={null}
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
expect(screen.getByText("common.welcome_card")).toBeInTheDocument();
expect(screen.getByText("common.shown")).toBeInTheDocument();
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
expect(screen.queryByLabelText("environments.surveys.edit.company_logo")).not.toBeInTheDocument();
});
test("renders correctly when expanded", () => {
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start"
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
expect(screen.getByText("common.welcome_card")).toBeInTheDocument();
expect(screen.queryByText("common.shown")).not.toBeInTheDocument();
expect(screen.getByLabelText("common.on")).toBeInTheDocument();
expect(screen.getByTestId("file-input")).toBeInTheDocument();
expect(screen.getByTestId("question-form-input-headline")).toHaveValue("Welcome!");
expect(screen.getByTestId("localized-editor-html")).toHaveValue("<p>Thank you for participating.</p>");
expect(screen.getByTestId("question-form-input-buttonLabel")).toHaveValue("Start Survey");
expect(screen.getByLabelText("common.time_to_finish")).toBeInTheDocument();
const timeToFinishSwitch = screen.getAllByRole("switch")[1]; // Assuming the second switch is for timeToFinish
expect(timeToFinishSwitch).toBeChecked();
});
test("calls setActiveQuestionId when trigger is clicked", async () => {
const user = userEvent.setup();
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId={null} // Initially collapsed
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
// Click the element containing the text, which will bubble up to the mocked Root div
const triggerTextElement = screen.getByText("common.welcome_card");
await user.click(triggerTextElement);
// The mock's Root onClick calls onOpenChange(true), which calls setOpen(true), which calls setActiveQuestionId("start")
expect(mockSetActiveQuestionId).toHaveBeenCalledWith("start");
});
test("toggles welcome card enabled state", async () => {
const user = userEvent.setup();
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start"
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
const enableToggle = screen.getAllByRole("switch")[0]; // First switch is the main toggle
await user.click(enableToggle);
expect(mockSetLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
welcomeCard: expect.objectContaining({ enabled: false }),
})
);
});
test("toggles timeToFinish state", async () => {
const user = userEvent.setup();
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start"
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
const timeToFinishToggle = screen.getAllByRole("switch")[1]; // Second switch
await user.click(timeToFinishToggle);
expect(mockSetLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
welcomeCard: expect.objectContaining({ timeToFinish: false }),
})
);
});
test("renders and toggles showResponseCount state for link surveys", async () => {
const user = userEvent.setup();
const linkSurvey = { ...mockSurvey, type: "link" as const };
render(
<EditWelcomeCard
localSurvey={linkSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start"
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
expect(screen.getByLabelText("common.show_response_count")).toBeInTheDocument();
const showResponseCountToggle = screen.getAllByRole("switch")[2]; // Third switch for link survey
expect(showResponseCountToggle).not.toBeChecked(); // Initial state from mock data
await user.click(showResponseCountToggle);
expect(mockSetLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
welcomeCard: expect.objectContaining({ showResponseCount: true }),
})
);
});
test("does not render showResponseCount for non-link surveys", () => {
const webSurvey = { ...mockSurvey, type: "web" as const } as unknown as TSurvey;
render(
<EditWelcomeCard
localSurvey={webSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start"
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
expect(screen.queryByLabelText("common.show_response_count")).not.toBeInTheDocument();
});
// Added test case for collapsing the card
test("calls setActiveQuestionId with null when collapsing", async () => {
const user = userEvent.setup();
render(
<EditWelcomeCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
setActiveQuestionId={mockSetActiveQuestionId}
activeQuestionId="start" // Initially expanded
isInvalid={false}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale="en-US"
/>
);
// Click the element containing the text, which will bubble up to the mocked Root div
const triggerTextElement = screen.getByText("common.welcome_card");
await user.click(triggerTextElement);
// The mock's Root onClick calls onOpenChange(false), which calls setOpen(false), which calls setActiveQuestionId(null)
expect(mockSetActiveQuestionId).toHaveBeenCalledWith(null);
});
});

View File

@@ -36,6 +36,7 @@ export const EditWelcomeCard = ({
locale,
}: EditWelcomeCardProps) => {
const { t } = useTranslate();
const [firstRender, setFirstRender] = useState(true);
const path = usePathname();
const environmentId = path?.split("/environments/")[1]?.split("/")[0];

View File

@@ -0,0 +1,189 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { HiddenFieldsCard } from "./hidden-fields-card";
// Mock the Tag component to avoid rendering its internal logic
vi.mock("@/modules/ui/components/tag", () => ({
Tag: ({ tagName }: { tagName: string }) => <div>{tagName}</div>,
}));
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
describe("HiddenFieldsCard", () => {
afterEach(() => {
cleanup();
});
test("should render all hidden fields when localSurvey.hiddenFields.fieldIds is populated", () => {
const hiddenFields = ["field1", "field2", "field3"];
const localSurvey = {
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: hiddenFields,
},
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
} as unknown as TSurvey;
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={vi.fn()}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
/>
);
hiddenFields.forEach((fieldId) => {
expect(screen.getByText(fieldId)).toBeInTheDocument();
});
});
test("should display a message indicating no hidden fields when localSurvey.hiddenFields.fieldIds is empty", () => {
const localSurvey = {
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: [],
},
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
} as unknown as TSurvey;
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={vi.fn()}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
/>
);
expect(
screen.getByText("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")
).toBeInTheDocument();
});
test("should add a new hidden field when the form is submitted with a valid ID", async () => {
const localSurvey = {
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: [],
},
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
/>
);
const inputElement = screen.getByRole("textbox");
const addButton = screen.getByText("environments.surveys.edit.add_hidden_field_id");
await userEvent.type(inputElement, "newFieldId");
await userEvent.click(addButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
hiddenFields: expect.objectContaining({
fieldIds: ["newFieldId"],
}),
})
);
});
test("should display an error toast and prevent adding a hidden field with an existing question ID", async () => {
const existingQuestionId = "question1";
const localSurvey = {
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [{ id: existingQuestionId, headline: { en: "Question 1" }, type: "shortText" }],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: [],
},
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
} as unknown as TSurvey;
const setLocalSurveyMock = vi.fn();
const toastErrorSpy = vi.mocked(toast.error);
toastErrorSpy.mockClear();
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurveyMock}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
/>
);
// Open the collapsible
const collapsibleTrigger = screen.getByText("common.hidden_fields").closest('div[type="button"]');
if (!collapsibleTrigger) throw new Error("Could not find collapsible trigger");
await userEvent.click(collapsibleTrigger);
const inputElement = screen.getByLabelText("common.hidden_field");
fireEvent.change(inputElement, { target: { value: existingQuestionId } });
const addButton = screen.getByRole("button", { name: "environments.surveys.edit.add_hidden_field_id" });
fireEvent.submit(addButton);
expect(toastErrorSpy).toHaveBeenCalled();
expect(setLocalSurveyMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,566 @@
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
import { validateQuestion, validateSurveyQuestionsInBatch } from "@/modules/survey/editor/lib/validation";
import { DndContext } from "@dnd-kit/core";
import { createId } from "@paralleldrive/cuid2";
import { Language, Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Mock, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionsView } from "./questions-view";
// Mock dependencies
vi.mock("@/app/lib/survey-builder", () => ({
getDefaultEndingCard: vi.fn((_, t) => ({
id: createId(),
type: "endScreen",
headline: { default: t("templates.thank_you") },
subheader: { default: t("templates.thank_you_subtitle") },
buttonLabel: { default: t("templates.create_another_response") },
buttonLink: null,
enabled: true,
})),
}));
vi.mock("@/lib/i18n/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/i18n/utils")>();
return {
...actual,
addMultiLanguageLabels: vi.fn((question, languages) => ({
...question,
headline: languages.reduce((acc, lang) => ({ ...acc, [lang]: "" }), { default: "" }),
})),
extractLanguageCodes: vi.fn((languages) => languages.map((l) => l.language.code)),
};
});
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
isConditionGroup: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
checkForEmptyFallBackValue: vi.fn(),
extractRecallInfo: vi.fn(),
}));
vi.mock("@/modules/ee/multi-language-surveys/components/multi-language-card", () => ({
MultiLanguageCard: vi.fn(() => <div>MultiLanguageCard</div>),
}));
vi.mock("@/modules/survey/editor/components/add-ending-card-button", () => ({
AddEndingCardButton: vi.fn(({ addEndingCard }) => (
<button onClick={() => addEndingCard(0)}>AddEndingCardButton</button>
)),
}));
vi.mock("@/modules/survey/editor/components/add-question-button", () => ({
AddQuestionButton: vi.fn(({ addQuestion }) => (
<button
onClick={() =>
addQuestion({
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "New Question" },
required: true,
})
}>
AddQuestionButton
</button>
)),
}));
vi.mock("@/modules/survey/editor/components/edit-ending-card", () => ({
EditEndingCard: vi.fn(({ endingCardIndex }) => <div>EditEndingCard {endingCardIndex}</div>),
}));
vi.mock("@/modules/survey/editor/components/edit-welcome-card", () => ({
EditWelcomeCard: vi.fn(() => <div>EditWelcomeCard</div>),
}));
vi.mock("@/modules/survey/editor/components/hidden-fields-card", () => ({
HiddenFieldsCard: vi.fn(() => <div>HiddenFieldsCard</div>),
}));
vi.mock("@/modules/survey/editor/components/questions-droppable", () => ({
QuestionsDroppable: vi.fn(
({
localSurvey,
moveQuestion,
updateQuestion,
duplicateQuestion,
deleteQuestion,
addQuestion,
activeQuestionId,
setActiveQuestionId,
}) => (
<div>
{localSurvey.questions.map((q, idx) => (
<div key={q.id} data-testid={`question-card-${q.id}`}>
QuestionCard {idx}
<button onClick={() => moveQuestion(idx, true)}>Move Up</button>
<button onClick={() => moveQuestion(idx, false)}>Move Down</button>
<button onClick={() => updateQuestion(idx, { required: !q.required })}>Update</button>
<button onClick={() => duplicateQuestion(idx)}>Duplicate</button>
<button onClick={() => deleteQuestion(idx)}>Delete</button>
<button
onClick={() => addQuestion({ id: "newAdd", type: TSurveyQuestionTypeEnum.OpenText }, idx)}>
Add Specific
</button>
<button onClick={() => setActiveQuestionId(q.id)}>Set Active</button>
{activeQuestionId === q.id && <span>Active</span>}
</div>
))}
</div>
)
),
}));
vi.mock("@/modules/survey/editor/components/survey-variables-card", () => ({
SurveyVariablesCard: vi.fn(() => <div>SurveyVariablesCard</div>),
}));
vi.mock("@/modules/survey/editor/lib/utils", () => ({
findQuestionUsedInLogic: vi.fn(() => -1),
}));
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: vi.fn(({ children, onDragEnd, id }) => (
<div data-testid={`dnd-context-${id}`}>
{children}
<button
onClick={() => {
if (onDragEnd) {
onDragEnd({
active: { id: "q1" },
over: { id: "q2" },
} as any);
}
}}>
Simulate Drag End {id}
</button>
</div>
)),
useSensor: vi.fn(),
useSensors: vi.fn(),
closestCorners: vi.fn(),
};
});
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: vi.fn(({ children }) => <div>{children}</div>),
verticalListSortingStrategy: vi.fn(),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
}));
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "test-id"),
}));
vi.mock("@formbricks/types/surveys/validation", () => ({
findQuestionsWithCyclicLogic: vi.fn(() => []),
}));
vi.mock("../lib/validation", () => ({
isEndingCardValid: vi.fn(() => true),
isWelcomeCardValid: vi.fn(() => true),
validateQuestion: vi.fn(() => true),
// Updated mock: Accumulates invalid questions based on the condition
validateSurveyQuestionsInBatch: vi.fn(
(question, currentInvalidList, _surveyLanguages, _isFirstQuestion) => {
const isInvalid = question.headline.default === "invalid";
const questionExists = currentInvalidList.includes(question.id);
if (isInvalid && !questionExists) {
return [...currentInvalidList, question.id];
} else if (!isInvalid && questionExists) {
return currentInvalidList.filter((id) => id !== question.id);
}
return currentInvalidList;
}
),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "draft",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
} as unknown as TSurveyQuestion,
],
endings: [{ id: "end1", type: "endScreen", headline: { default: "End" } }],
languages: [
{
language: { id: "lang1", code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
pin: null,
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
variables: [],
hiddenFields: { enabled: true, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
runOnDate: null,
closeOnDate: null,
} as unknown as TSurvey;
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 1,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
} as unknown as Project;
const mockProjectLanguages: Language[] = [{ id: "lang1", code: "default" } as unknown as Language];
describe("QuestionsView", () => {
let localSurvey: TSurvey;
let setLocalSurvey: Mock;
let setActiveQuestionId: Mock;
let setInvalidQuestions: Mock;
let setSelectedLanguageCode: Mock;
let setIsCautionDialogOpen: Mock;
beforeEach(() => {
localSurvey = structuredClone(mockSurvey);
setLocalSurvey = vi.fn((update) => {
if (typeof update === "function") {
localSurvey = update(localSurvey);
} else {
localSurvey = update;
}
});
setActiveQuestionId = vi.fn();
setInvalidQuestions = vi.fn();
setSelectedLanguageCode = vi.fn();
setIsCautionDialogOpen = vi.fn();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const renderComponent = (props = {}) => {
return render(
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={null}
setActiveQuestionId={setActiveQuestionId}
project={mockProject}
projectLanguages={mockProjectLanguages}
invalidQuestions={[]}
setInvalidQuestions={setInvalidQuestions}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={true}
isFormbricksCloud={true}
plan={"free" as TOrganizationBillingPlan}
isCxMode={false}
locale={"en" as TUserLocale}
responseCount={0}
setIsCautionDialogOpen={setIsCautionDialogOpen}
{...props}
/>
);
};
test("renders correctly with initial data", () => {
renderComponent();
expect(screen.getByText("EditWelcomeCard")).toBeInTheDocument();
expect(screen.getByTestId("question-card-q1")).toBeInTheDocument();
expect(screen.getByTestId("question-card-q2")).toBeInTheDocument();
expect(screen.getByText("AddQuestionButton")).toBeInTheDocument();
expect(screen.getByText("EditEndingCard 0")).toBeInTheDocument();
expect(screen.getByText("AddEndingCardButton")).toBeInTheDocument();
expect(screen.getByText("HiddenFieldsCard")).toBeInTheDocument();
expect(screen.getByText("SurveyVariablesCard")).toBeInTheDocument();
expect(screen.getByText("MultiLanguageCard")).toBeInTheDocument();
});
test("renders correctly in CX mode", () => {
renderComponent({ isCxMode: true });
expect(screen.queryByText("EditWelcomeCard")).not.toBeInTheDocument();
expect(screen.queryByText("AddEndingCardButton")).not.toBeInTheDocument();
expect(screen.queryByText("HiddenFieldsCard")).not.toBeInTheDocument();
expect(screen.queryByText("SurveyVariablesCard")).not.toBeInTheDocument();
expect(screen.queryByText("MultiLanguageCard")).not.toBeInTheDocument();
expect(screen.getByTestId("question-card-q1")).toBeInTheDocument();
expect(screen.getByTestId("question-card-q2")).toBeInTheDocument();
expect(screen.getByText("AddQuestionButton")).toBeInTheDocument();
expect(screen.getByText("EditEndingCard 0")).toBeInTheDocument(); // Endings still show in CX
});
test("adds a question", async () => {
renderComponent();
const addButton = screen.getByText("AddQuestionButton");
await userEvent.click(addButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions.length).toBe(3);
expect(updatedSurvey.questions[2].headline.default).toBe(""); // Due to addMultiLanguageLabels mock
expect(setActiveQuestionId).toHaveBeenCalledWith("test-id");
});
test("adds a question at a specific index", async () => {
renderComponent();
const addSpecificButton = screen.getAllByText("Add Specific")[0];
await userEvent.click(addSpecificButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions.length).toBe(3);
expect(updatedSurvey.questions[0].id).toBe("newAdd");
expect(setActiveQuestionId).toHaveBeenCalledWith("newAdd");
});
test("deletes a question", async () => {
renderComponent();
const deleteButton = screen.getAllByText("Delete")[0];
await userEvent.click(deleteButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions.length).toBe(1);
expect(updatedSurvey.questions[0].id).toBe("q2");
expect(setActiveQuestionId).toHaveBeenCalledWith("q2"); // Falls back to next question
});
test("duplicates a question", async () => {
renderComponent();
const duplicateButton = screen.getAllByText("Duplicate")[0];
await userEvent.click(duplicateButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions.length).toBe(3);
expect(updatedSurvey.questions[1].id).toBe("test-id"); // New duplicated ID
expect(updatedSurvey.questions[1].headline.default).toBe("Q1");
expect(setActiveQuestionId).toHaveBeenCalledWith("test-id");
});
test("updates a question", async () => {
renderComponent();
const updateButton = screen.getAllByText("Update")[0];
await userEvent.click(updateButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions[0].required).toBe(false);
expect(vi.mocked(validateQuestion)).toHaveBeenCalled();
});
test("moves a question up", async () => {
renderComponent();
const moveUpButton = screen.getAllByText("Move Up")[1]; // Move q2 up
await userEvent.click(moveUpButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions[0].id).toBe("q2");
expect(updatedSurvey.questions[1].id).toBe("q1");
});
test("moves a question down", async () => {
renderComponent();
const moveDownButton = screen.getAllByText("Move Down")[0]; // Move q1 down
await userEvent.click(moveDownButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.questions[0].id).toBe("q2");
expect(updatedSurvey.questions[1].id).toBe("q1");
});
test("adds an ending card", async () => {
renderComponent();
const addEndingButton = screen.getByText("AddEndingCardButton");
await userEvent.click(addEndingButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.endings.length).toBe(2);
expect(updatedSurvey.endings[0].id).toBe("test-id"); // New ending ID
expect(setActiveQuestionId).toHaveBeenCalledWith("test-id");
});
test("handles question card drag end", async () => {
renderComponent();
const dragButton = screen.getByText("Simulate Drag End questions");
await userEvent.click(dragButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
// Based on the hardcoded IDs in the mock DndContext
expect(updatedSurvey.questions[0].id).toBe("q2");
expect(updatedSurvey.questions[1].id).toBe("q1");
});
test("handles ending card drag end", async () => {
// Add a second ending card for the test
localSurvey.endings.push({ id: "end2", type: "endScreen", headline: { default: "End 2" } });
vi.mocked(DndContext).mockImplementation(({ children, onDragEnd, id }) => (
<div>
{children}
<button
onClick={() => {
if (onDragEnd) {
onDragEnd({
active: { id: "end1" },
over: { id: "end2" },
} as any);
}
}}>
Simulate Drag End {id}
</button>
</div>
));
renderComponent();
const dragButton = screen.getByText("Simulate Drag End endings");
await userEvent.click(dragButton);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updatedSurvey = setLocalSurvey.mock.calls[0][0];
expect(updatedSurvey.endings[0].id).toBe("end2");
expect(updatedSurvey.endings[1].id).toBe("end1");
});
test("calls validation useEffect on mount and updates", () => {
const invalidQuestionsProp = ["q-invalid"]; // Prop passed initially
renderComponent({ invalidQuestions: invalidQuestionsProp });
// Initial validation check on mount
expect(vi.mocked(validateSurveyQuestionsInBatch)).toHaveBeenCalledTimes(2); // Called for q1, q2
// In the first render:
// - validateSurveyQuestionsInBatch is called with initial invalidQuestionsProp = ["q-invalid"]
// - For q1 (headline "Q1"): returns ["q-invalid"] (no change)
// - For q2 (headline "Q2"): returns ["q-invalid"] (no change)
// - The final calculated list inside the effect is ["q-invalid"]
// - Comparison: JSON.stringify(["q-invalid"]) !== JSON.stringify(["q-invalid"]) is false
expect(setInvalidQuestions).not.toHaveBeenCalled();
// Simulate adding a new question and re-rendering
const newSurvey = {
...localSurvey,
questions: [
...localSurvey.questions,
{
id: "q3",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q3" },
} as unknown as TSurveyQuestion,
],
};
cleanup();
vi.mocked(validateSurveyQuestionsInBatch).mockClear();
vi.mocked(setInvalidQuestions).mockClear();
// Render again with the new survey but the same initial prop
renderComponent({ localSurvey: newSurvey, invalidQuestions: invalidQuestionsProp });
expect(vi.mocked(validateSurveyQuestionsInBatch)).toHaveBeenCalledTimes(3); // Called for q1, q2, q3
// In the second render:
// - validateSurveyQuestionsInBatch is called with initial invalidQuestionsProp = ["q-invalid"]
// - For q1 (headline "Q1"): returns ["q-invalid"]
// - For q2 (headline "Q2"): returns ["q-invalid"]
// - For q3 (headline "Q3"): returns ["q-invalid"]
// - The final calculated list inside the effect is ["q-invalid"]
// - Comparison: JSON.stringify(["q-invalid"]) !== JSON.stringify(["q-invalid"]) is false
// The previous assertion was incorrect. Let's adjust the test slightly to force a change.
// Let's modify the scenario slightly: Assume the initial prop was [], but validation finds an issue.
cleanup();
vi.mocked(validateSurveyQuestionsInBatch).mockClear();
vi.mocked(setInvalidQuestions).mockClear();
// Add an "invalid" question to the survey for the test
const surveyWithInvalidQ = {
...localSurvey,
questions: [
...localSurvey.questions,
{
id: "q-invalid-real",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "invalid" },
} as unknown as TSurveyQuestion,
],
};
renderComponent({ localSurvey: surveyWithInvalidQ, invalidQuestions: [] }); // Start with empty invalid prop
expect(vi.mocked(validateSurveyQuestionsInBatch)).toHaveBeenCalledTimes(3); // q1, q2, q-invalid-real
// In this render:
// - Initial prop is []
// - For q1: returns []
// - For q2: returns []
// - For q-invalid-real: returns ["q-invalid-real"]
// - Final calculated list is ["q-invalid-real"]
// - Comparison: JSON.stringify(["q-invalid-real"]) !== JSON.stringify([]) is true
expect(setInvalidQuestions).toHaveBeenCalledTimes(1);
expect(setInvalidQuestions).toHaveBeenCalledWith(["q-invalid-real"]);
});
test("calls fallback check useEffect on mount and updates", () => {
renderComponent();
expect(vi.mocked(checkForEmptyFallBackValue)).toHaveBeenCalledTimes(1);
// Simulate activeQuestionId change
cleanup();
renderComponent({ activeQuestionId: "q1" });
expect(vi.mocked(checkForEmptyFallBackValue)).toHaveBeenCalledTimes(2);
});
test("sets active question id", async () => {
renderComponent();
const setActiveButton = screen.getAllByText("Set Active")[0];
await userEvent.click(setActiveButton);
expect(setActiveQuestionId).toHaveBeenCalledWith("q1");
});
});

View File

@@ -193,8 +193,7 @@ export const QuestionsView = ({
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.languages, localSurvey.endings, localSurvey.welcomeCard]);
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidQuestions, setInvalidQuestions]);
// function to validate individual questions
const validateSurveyQuestion = (question: TSurveyQuestion) => {
@@ -327,15 +326,17 @@ export const QuestionsView = ({
const addQuestion = (question: TSurveyQuestion, index?: number) => {
const updatedSurvey = { ...localSurvey };
const newQuestions = [...localSurvey.questions];
const languageSymbols = extractLanguageCodes(localSurvey.languages);
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
if (index) {
updatedSurvey.questions.splice(index, 0, { ...updatedQuestion, isDraft: true });
if (index !== undefined) {
newQuestions.splice(index, 0, { ...updatedQuestion, isDraft: true });
} else {
updatedSurvey.questions.push({ ...updatedQuestion, isDraft: true });
newQuestions.push({ ...updatedQuestion, isDraft: true });
}
updatedSurvey.questions = newQuestions;
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);
@@ -378,8 +379,7 @@ export const QuestionsView = ({
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.languages, localSurvey.questions, localSurvey.endings, localSurvey.welcomeCard]);
}, [localSurvey.questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
useEffect(() => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);

View File

@@ -0,0 +1,216 @@
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { ActionClass, Environment, OrganizationRole } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SettingsView } from "./settings-view";
// Mock child components
vi.mock("@/modules/ee/contacts/segments/components/targeting-card", () => ({
TargetingCard: ({ localSurvey, environmentId }: any) => (
<div data-testid="targeting-card">
TargetingCard - Survey: {localSurvey.id}, Env: {environmentId}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/how-to-send-card", () => ({
HowToSendCard: ({ localSurvey, environment }: any) => (
<div data-testid="how-to-send-card">
HowToSendCard - Survey: {localSurvey.id}, Env: {environment.id}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/recontact-options-card", () => ({
RecontactOptionsCard: ({ localSurvey, environmentId }: any) => (
<div data-testid="recontact-options-card">
RecontactOptionsCard - Survey: {localSurvey.id}, Env: {environmentId}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/response-options-card", () => ({
ResponseOptionsCard: ({ localSurvey, responseCount, isSpamProtectionAllowed }: any) => (
<div data-testid="response-options-card">
ResponseOptionsCard - Survey: {localSurvey.id}, Count: {responseCount}, Spam:{" "}
{isSpamProtectionAllowed.toString()}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/survey-placement-card", () => ({
SurveyPlacementCard: ({ localSurvey, environmentId }: any) => (
<div data-testid="survey-placement-card">
SurveyPlacementCard - Survey: {localSurvey.id}, Env: {environmentId}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/targeting-locked-card", () => ({
TargetingLockedCard: ({ isFormbricksCloud, environmentId }: any) => (
<div data-testid="targeting-locked-card">
TargetingLockedCard - Cloud: {isFormbricksCloud.toString()}, Env: {environmentId}
</div>
),
}));
vi.mock("@/modules/survey/editor/components/when-to-send-card", () => ({
WhenToSendCard: ({ localSurvey, environmentId }: any) => (
<div data-testid="when-to-send-card">
WhenToSendCard - Survey: {localSurvey.id}, Env: {environmentId}
</div>
),
}));
const mockEnvironment: Pick<Environment, "id" | "appSetupCompleted"> = {
id: "env-123",
appSetupCompleted: true,
};
const mockActionClasses: ActionClass[] = [];
const mockContactAttributeKeys: TContactAttributeKey[] = [];
const mockSegments: TSegment[] = [];
const mockProjectPermission: TTeamPermission | null = null;
const baseSurvey = {
id: "survey-123",
name: "Test Survey",
type: "app", // Default to app survey
environmentId: "env-123",
status: "draft",
questions: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
languages: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
pin: null,
resultShareKey: null,
segment: null,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: null,
variables: [],
closeOnDate: null,
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
} as unknown as TSurvey;
describe("SettingsView", () => {
afterEach(() => {
cleanup();
});
test("renders all cards for app survey when targeting is allowed", () => {
const mockSurvey: TSurvey = { ...baseSurvey, type: "app" };
render(
<SettingsView
environment={mockEnvironment}
localSurvey={mockSurvey}
setLocalSurvey={vi.fn()}
actionClasses={mockActionClasses}
contactAttributeKeys={mockContactAttributeKeys}
segments={mockSegments}
responseCount={10}
membershipRole={OrganizationRole.owner}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
projectPermission={mockProjectPermission}
isFormbricksCloud={true}
/>
);
expect(screen.getByTestId("how-to-send-card")).toBeInTheDocument();
expect(screen.getByTestId("targeting-card")).toBeInTheDocument();
expect(screen.queryByTestId("targeting-locked-card")).not.toBeInTheDocument();
expect(screen.getByTestId("when-to-send-card")).toBeInTheDocument();
expect(screen.getByTestId("response-options-card")).toBeInTheDocument();
expect(screen.getByTestId("recontact-options-card")).toBeInTheDocument();
expect(screen.getByTestId("survey-placement-card")).toBeInTheDocument();
// Check props passed
expect(screen.getByTestId("how-to-send-card")).toHaveTextContent("Survey: survey-123");
expect(screen.getByTestId("targeting-card")).toHaveTextContent("Survey: survey-123");
expect(screen.getByTestId("when-to-send-card")).toHaveTextContent("Survey: survey-123");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Survey: survey-123");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Count: 10");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Spam: true");
expect(screen.getByTestId("recontact-options-card")).toHaveTextContent("Survey: survey-123");
expect(screen.getByTestId("survey-placement-card")).toHaveTextContent("Survey: survey-123");
});
test("renders TargetingLockedCard when targeting is not allowed for app survey", () => {
const mockSurvey: TSurvey = { ...baseSurvey, type: "app" };
render(
<SettingsView
environment={mockEnvironment}
localSurvey={mockSurvey}
setLocalSurvey={vi.fn()}
actionClasses={mockActionClasses}
contactAttributeKeys={mockContactAttributeKeys}
segments={mockSegments}
responseCount={5}
membershipRole={OrganizationRole.owner}
isUserTargetingAllowed={false}
isSpamProtectionAllowed={false}
projectPermission={mockProjectPermission}
isFormbricksCloud={false}
/>
);
expect(screen.getByTestId("how-to-send-card")).toBeInTheDocument();
expect(screen.queryByTestId("targeting-card")).not.toBeInTheDocument();
expect(screen.getByTestId("targeting-locked-card")).toBeInTheDocument();
expect(screen.getByTestId("when-to-send-card")).toBeInTheDocument();
expect(screen.getByTestId("response-options-card")).toBeInTheDocument();
expect(screen.getByTestId("recontact-options-card")).toBeInTheDocument();
expect(screen.getByTestId("survey-placement-card")).toBeInTheDocument();
// Check props passed
expect(screen.getByTestId("targeting-locked-card")).toHaveTextContent("Cloud: false");
expect(screen.getByTestId("targeting-locked-card")).toHaveTextContent("Env: env-123");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Count: 5");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Spam: false");
});
test("renders correct cards for link survey", () => {
const mockSurvey: TSurvey = { ...baseSurvey, type: "link" };
render(
<SettingsView
environment={mockEnvironment}
localSurvey={mockSurvey}
setLocalSurvey={vi.fn()}
actionClasses={mockActionClasses}
contactAttributeKeys={mockContactAttributeKeys}
segments={mockSegments}
responseCount={0}
membershipRole={OrganizationRole.owner}
isUserTargetingAllowed={true} // This should be ignored for link surveys
isSpamProtectionAllowed={true}
projectPermission={mockProjectPermission}
isFormbricksCloud={true}
/>
);
expect(screen.getByTestId("how-to-send-card")).toBeInTheDocument();
expect(screen.queryByTestId("targeting-card")).not.toBeInTheDocument();
expect(screen.queryByTestId("targeting-locked-card")).not.toBeInTheDocument();
expect(screen.getByTestId("when-to-send-card")).toBeInTheDocument(); // WhenToSendCard is still relevant for link surveys (e.g., close on date)
expect(screen.getByTestId("response-options-card")).toBeInTheDocument();
expect(screen.getByTestId("recontact-options-card")).toBeInTheDocument();
expect(screen.queryByTestId("survey-placement-card")).not.toBeInTheDocument();
// Check props passed
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Count: 0");
expect(screen.getByTestId("response-options-card")).toHaveTextContent("Spam: true");
});
});

View File

@@ -0,0 +1,329 @@
import { defaultStyling } from "@/lib/styling/constants";
import { Project } from "@prisma/client";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
// Import actual components
import { StylingView } from "./styling-view";
// Mock react-hot-toast so we can assert that a success message is shown
vi.mock("react-hot-toast", () => ({
__esModule: true,
default: {
success: vi.fn(),
},
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a data-testid="mock-link" href={href}>
{children}
</a>
),
}));
// Mocks for child components remain the same
vi.mock("@/modules/survey/editor/components/form-styling-settings", () => ({
FormStylingSettings: ({ open, setOpen, disabled }: any) => (
<div data-testid="form-styling-settings" data-open={open} data-disabled={disabled}>
<span>Form Styling Settings</span>
<button onClick={() => setOpen(!open)}>Toggle Form Styling</button>
</div>
),
}));
vi.mock("@/modules/ui/components/alert-dialog", () => ({
AlertDialog: ({ open, setOpen, onConfirm }: any) =>
open ? (
<div data-testid="alert-dialog">
<span>Alert Dialog</span>
<button data-testid="confirm-reset" onClick={onConfirm}>
common.confirm
</button>
<button onClick={() => setOpen(false)}>Close Alert</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/background-styling-card", () => ({
BackgroundStylingCard: ({ open, setOpen, disabled }: any) => (
<div data-testid="background-styling-card" data-open={open} data-disabled={disabled}>
<span>Background Styling Card</span>
<button onClick={() => setOpen(!open)}>Toggle Background Styling</button>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, type = "button" }: any) => (
<button data-testid={`button-${variant}`} onClick={onClick} type={type}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/card-styling-settings", () => ({
CardStylingSettings: ({ open, setOpen, disabled }: any) => (
<div data-testid="card-styling-settings" data-open={open} data-disabled={disabled}>
<span>Card Styling Settings</span>
<button onClick={() => setOpen(!open)}>Toggle Card Styling</button>
</div>
),
}));
vi.mock("@/modules/ui/components/switch", () => ({
Switch: ({ checked, onCheckedChange }: any) => (
<input
data-testid="overwrite-switch"
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
/>
),
}));
// Global state for mocks (keep prop mocks)
const mockSetStyling = vi.fn();
const mockSetLocalStylingChanges = vi.fn();
const mockSetLocalSurvey = vi.fn();
const mockProject = {
id: "projectId",
name: "Test Project",
styling: { ...defaultStyling, allowStyleOverwrite: true },
} as unknown as Project;
// Adjust mockSurvey styling based on test needs or pass via props
const mockSurvey = {
id: "surveyId",
name: "Test Survey",
type: "link",
styling: { overwriteThemeStyling: false } as unknown as TSurveyStyling, // Initial state for most tests
} as unknown as TSurvey;
const mockAppSurvey = {
...mockSurvey,
type: "app",
} as unknown as TSurvey;
const defaultProps = {
environmentId: "envId",
project: mockProject,
localSurvey: mockSurvey,
setLocalSurvey: mockSetLocalSurvey,
colors: ["#ffffff", "#000000"],
styling: null, // Will be set by the component logic based on overwrite toggle
setStyling: mockSetStyling,
localStylingChanges: null, // Will be set by the component logic
setLocalStylingChanges: mockSetLocalStylingChanges,
isUnsplashConfigured: true,
isCxMode: false,
};
// Helper component to provide REAL Form context
const RenderWithFormProvider = ({
children,
localSurveyOverrides = {},
}: {
children: React.ReactNode;
localSurveyOverrides?: Partial<TSurveyStyling>; // Accept styling overrides
}) => {
// Determine initial form values based on project and potential survey overrides
const initialStyling = {
...defaultStyling,
...mockProject.styling,
...localSurveyOverrides, // Apply overrides passed to the helper
};
const methods = useForm<TSurveyStyling>({
defaultValues: initialStyling,
});
// Pass the real methods down
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe("StylingView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders correctly with default props (overwrite off)", () => {
render(
// Pass initial survey styling state via overrides
<RenderWithFormProvider localSurveyOverrides={mockSurvey.styling ?? {}}>
<StylingView {...defaultProps} localSurvey={mockSurvey} />
</RenderWithFormProvider>
);
expect(screen.getByText("environments.surveys.edit.add_custom_styles")).toBeInTheDocument();
expect(screen.getByTestId("form-styling-settings")).toBeInTheDocument();
expect(screen.getByTestId("card-styling-settings")).toBeInTheDocument();
expect(screen.getByTestId("background-styling-card")).toBeInTheDocument();
expect(screen.getByTestId("overwrite-switch")).not.toBeChecked();
// Check disabled state based on overwriteThemeStyling being false
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-disabled", "true");
expect(screen.queryByTestId("button-ghost")).not.toBeInTheDocument();
});
test("toggles overwrite theme styling switch", async () => {
const user = userEvent.setup();
// Start with overwrite OFF
const surveyWithOverwriteOff = { ...mockSurvey, styling: { overwriteThemeStyling: false } };
const propsWithOverwriteOff = { ...defaultProps, localSurvey: surveyWithOverwriteOff, styling: null }; // styling starts null
render(
<RenderWithFormProvider localSurveyOverrides={surveyWithOverwriteOff.styling ?? {}}>
<StylingView {...propsWithOverwriteOff} />
</RenderWithFormProvider>
);
const switchControl = screen.getByTestId("overwrite-switch");
expect(switchControl).not.toBeChecked();
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-disabled", "true");
// Click to turn ON
await user.click(switchControl);
// Wait for state update and rerender (component internal state + prop calls)
await waitFor(() => {
expect(switchControl).toBeChecked();
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-disabled", "false");
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-disabled", "false");
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-disabled", "false");
expect(screen.getByTestId("button-ghost")).toBeInTheDocument(); // Reset button appears
expect(screen.getByText("environments.surveys.edit.reset_to_theme_styles")).toBeInTheDocument();
// Check if setStyling was called correctly when turning ON
const { allowStyleOverwrite, ...baseStyling } = mockProject.styling;
expect(mockSetStyling).toHaveBeenCalledWith({
...baseStyling,
overwriteThemeStyling: true,
});
});
vi.clearAllMocks(); // Clear mocks before next interaction
// Click to turn OFF
await user.click(switchControl);
await waitFor(() => {
expect(switchControl).not.toBeChecked();
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-disabled", "true");
expect(screen.queryByTestId("button-ghost")).not.toBeInTheDocument(); // Reset button disappears
// Check if setStyling was called correctly when turning OFF
const { allowStyleOverwrite, ...baseStyling } = mockProject.styling;
expect(mockSetStyling).toHaveBeenCalledWith({
...baseStyling,
overwriteThemeStyling: false,
});
// Check if setLocalStylingChanges was called (it stores the state before turning off)
expect(mockSetLocalStylingChanges).toHaveBeenCalled();
});
});
test("handles reset theme styling", async () => {
const user = userEvent.setup();
// Start with overwrite ON and some potentially different styling
const initialSurveyStyling = {
...defaultStyling,
brandColor: { light: "#ff0000" }, // Custom color
overwriteThemeStyling: true,
};
const surveyWithOverwriteOn = { ...mockSurvey, styling: initialSurveyStyling };
const propsWithOverwriteOn = {
...defaultProps,
localSurvey: surveyWithOverwriteOn,
styling: initialSurveyStyling,
};
render(
// Provide initial form values reflecting the overwrite state
<RenderWithFormProvider localSurveyOverrides={surveyWithOverwriteOn.styling ?? {}}>
<StylingView {...propsWithOverwriteOn} />
</RenderWithFormProvider>
);
const resetButton = screen.getByTestId("button-ghost");
expect(resetButton).toBeInTheDocument();
await user.click(resetButton);
await waitFor(() => expect(screen.getByTestId("alert-dialog")).toBeInTheDocument());
const confirmButton = screen.getByTestId("confirm-reset");
await user.click(confirmButton);
await waitFor(() => {
// Check that setStyling was called with the project's base styling + overwrite: true
const { allowStyleOverwrite, ...baseStyling } = mockProject.styling;
expect(mockSetStyling).toHaveBeenCalledWith({
...baseStyling,
overwriteThemeStyling: true,
});
// Ensure the assertion targets the correct mocked function (provided by global mock)
expect(vi.mocked(toast.success)).toHaveBeenCalled();
expect(screen.queryByTestId("alert-dialog")).not.toBeInTheDocument();
});
});
test("does not render BackgroundStylingCard for app surveys", () => {
const propsApp = { ...defaultProps, localSurvey: mockAppSurvey };
render(
<RenderWithFormProvider localSurveyOverrides={mockAppSurvey.styling ?? {}}>
<StylingView {...propsApp} />
</RenderWithFormProvider>
);
expect(screen.getByTestId("form-styling-settings")).toBeInTheDocument();
expect(screen.getByTestId("card-styling-settings")).toBeInTheDocument();
expect(screen.queryByTestId("background-styling-card")).not.toBeInTheDocument();
});
test("opens and closes styling sections (when overwrite is on)", async () => {
const user = userEvent.setup();
// Start with overwrite ON
const surveyWithOverwriteOn = { ...mockSurvey, styling: { overwriteThemeStyling: true } };
const propsWithOverwriteOn = {
...defaultProps,
localSurvey: surveyWithOverwriteOn,
styling: surveyWithOverwriteOn.styling,
};
render(
<RenderWithFormProvider localSurveyOverrides={surveyWithOverwriteOn.styling ?? {}}>
<StylingView {...propsWithOverwriteOn} />
</RenderWithFormProvider>
);
const formStylingToggle = screen.getByText("Toggle Form Styling");
const cardStylingToggle = screen.getByText("Toggle Card Styling");
const backgroundStylingToggle = screen.getByText("Toggle Background Styling");
// Check initial state (mock components default to open=false)
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-open", "false");
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-open", "false");
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-open", "false");
// Check sections are enabled because overwrite is ON
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-disabled", "false");
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-disabled", "false");
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-disabled", "false");
await user.click(formStylingToggle);
expect(screen.getByTestId("form-styling-settings")).toHaveAttribute("data-open", "true");
await user.click(cardStylingToggle);
expect(screen.getByTestId("card-styling-settings")).toHaveAttribute("data-open", "true");
await user.click(backgroundStylingToggle);
expect(screen.getByTestId("background-styling-card")).toHaveAttribute("data-open", "true");
});
});

View File

@@ -0,0 +1,413 @@
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { refetchProjectAction } from "@/modules/survey/editor/actions";
import { Environment, Language, OrganizationRole, Project } from "@prisma/client";
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 { TLanguage } from "@formbricks/types/project";
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { SurveyEditor } from "./survey-editor";
// Mock child components and hooks
vi.mock("@/lib/i18n/utils", () => ({
extractLanguageCodes: vi.fn((langs) => langs.map((l) => l.language.code)),
getEnabledLanguages: vi.fn((langs) => langs.filter((l) => l.enabled)),
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/useDocumentVisibility", () => ({
useDocumentVisibility: vi.fn(),
}));
vi.mock("@/modules/survey/components/edit-public-survey-alert-dialog", () => ({
EditPublicSurveyAlertDialog: vi.fn(({ open }) => (open ? <div>Edit Alert Dialog</div> : null)),
}));
vi.mock("@/modules/survey/editor/components/loading-skeleton", () => ({
LoadingSkeleton: vi.fn(() => <div>Loading...</div>),
}));
vi.mock("@/modules/survey/editor/components/questions-view", () => ({
QuestionsView: vi.fn(() => <div>Questions View</div>),
}));
vi.mock("@/modules/survey/editor/components/settings-view", () => ({
SettingsView: vi.fn(() => <div>Settings View</div>),
}));
vi.mock("@/modules/survey/editor/components/styling-view", () => ({
StylingView: vi.fn(() => <div>Styling View</div>),
}));
vi.mock("@/modules/survey/editor/components/survey-editor-tabs", () => ({
SurveyEditorTabs: vi.fn(({ activeId, setActiveId, isStylingTabVisible, isSurveyFollowUpsAllowed }) => (
<div>
<button onClick={() => setActiveId("questions")}>Questions Tab</button>
{isStylingTabVisible && <button onClick={() => setActiveId("styling")}>Styling Tab</button>}
<button onClick={() => setActiveId("settings")}>Settings Tab</button>
{isSurveyFollowUpsAllowed && <button onClick={() => setActiveId("followUps")}>Follow-ups Tab</button>}
<div>Active Tab: {activeId}</div>
</div>
)),
}));
vi.mock("@/modules/survey/editor/components/survey-menu-bar", () => ({
SurveyMenuBar: vi.fn(({ setIsCautionDialogOpen }) => (
<div>
<span>Survey Menu Bar</span>
<button onClick={() => setIsCautionDialogOpen(true)}>Open Caution Dialog</button>
</div>
)),
}));
vi.mock("@/modules/survey/follow-ups/components/follow-ups-view", () => ({
FollowUpsView: vi.fn(() => <div>Follow Ups View</div>),
}));
vi.mock("@/modules/ui/components/preview-survey", () => ({
PreviewSurvey: vi.fn(() => <div>Preview Survey</div>),
}));
vi.mock("../actions", () => ({
refetchProjectAction: vi.fn(),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "draft",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyOpenTextQuestion,
],
endings: [],
languages: [
{ language: { id: "lang1", code: "default" } as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "en" } as TLanguage, default: false, enabled: true },
],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
displayPercentage: null,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
variables: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
closeOnDate: null,
segment: null,
createdBy: null,
} as unknown as TSurvey;
const mockProject = {
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
} as unknown as Project;
const mockEnvironment: Pick<Environment, "id" | "appSetupCompleted"> = {
id: "env1",
appSetupCompleted: true,
};
const mockLanguages: Language[] = [
{ id: "lang1", code: "default" } as Language,
{ id: "lang2", code: "en" } as Language,
];
describe("SurveyEditor", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset mocks if needed, e.g., refetchProjectAction
vi.mocked(refetchProjectAction).mockResolvedValue({ data: mockProject });
});
test("renders loading skeleton initially if survey is not provided", () => {
render(
<SurveyEditor
// @ts-expect-error - Intentionally passing null for testing loading state
survey={null}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
test("renders default view (Questions) correctly", () => {
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
expect(screen.getByText("Survey Menu Bar")).toBeInTheDocument();
expect(screen.getByText("Questions View")).toBeInTheDocument();
expect(screen.getByText("Preview Survey")).toBeInTheDocument();
expect(screen.getByText("Active Tab: questions")).toBeInTheDocument();
});
test("switches to Styling view when Styling tab is clicked", async () => {
const user = userEvent.setup();
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
const stylingTabButton = screen.getByText("Styling Tab");
await user.click(stylingTabButton);
expect(screen.getByText("Styling View")).toBeInTheDocument();
expect(screen.queryByText("Questions View")).not.toBeInTheDocument();
expect(screen.getByText("Active Tab: styling")).toBeInTheDocument();
});
test("switches to Settings view when Settings tab is clicked", async () => {
const user = userEvent.setup();
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
const settingsTabButton = screen.getByText("Settings Tab");
await user.click(settingsTabButton);
expect(screen.getByText("Settings View")).toBeInTheDocument();
expect(screen.queryByText("Questions View")).not.toBeInTheDocument();
expect(screen.getByText("Active Tab: settings")).toBeInTheDocument();
});
test("switches to Follow-ups view when Follow-ups tab is clicked", async () => {
const user = userEvent.setup();
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
const followUpsTabButton = screen.getByText("Follow-ups Tab");
await user.click(followUpsTabButton);
expect(screen.getByText("Follow Ups View")).toBeInTheDocument();
expect(screen.queryByText("Questions View")).not.toBeInTheDocument();
expect(screen.getByText("Active Tab: followUps")).toBeInTheDocument();
});
test("opens caution dialog when triggered from menu bar", async () => {
const user = userEvent.setup();
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
expect(screen.queryByText("Edit Alert Dialog")).not.toBeInTheDocument();
const openDialogButton = screen.getByText("Open Caution Dialog");
await user.click(openDialogButton);
expect(screen.getByText("Edit Alert Dialog")).toBeInTheDocument();
});
test("does not render Styling tab if allowStyleOverwrite is false", () => {
const projectWithoutStyling = { ...mockProject, styling: { allowStyleOverwrite: false } };
render(
<SurveyEditor
survey={mockSurvey}
project={projectWithoutStyling}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={true}
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
expect(screen.queryByText("Styling Tab")).not.toBeInTheDocument();
});
test("does not render Follow-ups tab if isSurveyFollowUpsAllowed is false", () => {
render(
<SurveyEditor
survey={mockSurvey}
project={mockProject}
projectLanguages={mockLanguages}
environment={mockEnvironment}
actionClasses={[]}
contactAttributeKeys={[]}
segments={[]}
responseCount={0}
membershipRole={OrganizationRole.owner}
colors={[]}
isMultiLanguageAllowed={true}
isUserTargetingAllowed={true}
isSpamProtectionAllowed={true}
isFormbricksCloud={false}
isUnsplashConfigured={false}
plan="free"
isCxMode={false}
locale={"en" as TUserLocale}
projectPermission={null as TTeamPermission | null}
mailFrom="test@example.com"
isSurveyFollowUpsAllowed={false} // Set to false
userEmail="user@example.com"
teamMemberDetails={[]}
/>
);
expect(screen.queryByText("Follow-ups Tab")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,271 @@
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar";
import { isSurveyValid } from "@/modules/survey/editor/lib/validation";
import { Project } from "@prisma/client";
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 { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((e) => e?.message ?? "Unknown error"),
}));
vi.mock("@/modules/ee/contacts/segments/actions", () => ({
createSegmentAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div>{children}</div>,
AlertButton: ({ children, onClick }) => <button onClick={onClick}>{children}</button>,
AlertTitle: ({ children }) => <span>{children}</span>,
}));
vi.mock("@/modules/ui/components/alert-dialog", () => ({
AlertDialog: ({ open, headerText, mainText, confirmBtnLabel, declineBtnLabel, onConfirm, onDecline }) =>
open ? (
<div data-testid="alert-dialog">
<h1>{headerText}</h1>
<p>{mainText}</p>
<button onClick={onConfirm}>{confirmBtnLabel}</button>
<button onClick={onDecline}>{declineBtnLabel}</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, disabled, variant, size, type }) => (
<button
onClick={onClick}
disabled={loading ?? disabled}
data-variant={variant}
data-size={size}
type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ defaultValue, onChange, className }) => (
<input
defaultValue={defaultValue}
onChange={onChange}
className={className}
data-testid="survey-name-input"
/>
),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
updateSurveyAction: vi.fn(),
}));
vi.mock("@/modules/survey/editor/lib/validation", () => ({
isSurveyValid: vi.fn(() => true), // Default to valid
}));
vi.mock("@formbricks/i18n-utils/src/utils", () => ({
getLanguageLabel: vi.fn((code) => `Lang(${code})`),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock Lucide icons
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeft</div>,
SettingsIcon: () => <div data-testid="settings-icon">Settings</div>,
};
});
const mockRouter = {
back: vi.fn(),
push: vi.fn(),
};
const mockSetLocalSurvey = vi.fn();
const mockSetActiveId = vi.fn();
const mockSetInvalidQuestions = vi.fn();
const mockSetIsCautionDialogOpen = vi.fn();
const baseSurvey = {
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "env-1",
status: "draft",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyOpenTextQuestion,
],
endings: [{ id: "end1", type: "endScreen", headline: { default: "End" } }],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
pin: null,
resultShareKey: null,
segment: null,
languages: [],
runOnDate: null,
closeOnDate: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
} as unknown as TSurvey;
const mockProject = {
id: "proj-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
} as unknown as Project;
const defaultProps = {
localSurvey: baseSurvey,
survey: baseSurvey,
setLocalSurvey: mockSetLocalSurvey,
environmentId: "env-1",
activeId: "questions" as const,
setActiveId: mockSetActiveId,
setInvalidQuestions: mockSetInvalidQuestions,
project: mockProject,
responseCount: 0,
selectedLanguageCode: "default",
setSelectedLanguageCode: vi.fn(),
isCxMode: false,
locale: "en",
setIsCautionDialogOpen: mockSetIsCautionDialogOpen,
};
describe("SurveyMenuBar", () => {
beforeEach(() => {
vi.mocked(updateSurveyAction).mockResolvedValue({ data: { ...baseSurvey, updatedAt: new Date() } }); // Mock successful update
vi.mocked(isSurveyValid).mockReturnValue(true);
vi.mocked(createSegmentAction).mockResolvedValue({
data: { id: "seg-1", title: "seg-1", filters: [] },
} as any);
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders correctly with default props", () => {
render(<SurveyMenuBar {...defaultProps} />);
expect(screen.getByText("common.back")).toBeInTheDocument();
expect(screen.getByText("Test Project /")).toBeInTheDocument();
expect(screen.getByTestId("survey-name-input")).toHaveValue("Test Survey");
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.publish")).toBeInTheDocument();
});
test("updates survey name on input change", async () => {
render(<SurveyMenuBar {...defaultProps} />);
const input = screen.getByTestId("survey-name-input");
await userEvent.type(input, " Updated");
expect(mockSetLocalSurvey).toHaveBeenCalledWith({ ...baseSurvey, name: "Test Survey Updated" });
});
test("handles back button click with changes, opens dialog", async () => {
const changedSurvey = { ...baseSurvey, name: "Changed Name" };
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
expect(mockRouter.back).not.toHaveBeenCalled();
expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.confirm_survey_changes")).toBeInTheDocument();
});
test("shows caution alert when responseCount > 0", () => {
render(<SurveyMenuBar {...defaultProps} responseCount={5} />);
expect(screen.getByText("environments.surveys.edit.caution_text")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
});
test("calls setIsCautionDialogOpen when 'Learn More' is clicked", async () => {
render(<SurveyMenuBar {...defaultProps} responseCount={5} />);
const learnMoreButton = screen.getByText("common.learn_more");
await userEvent.click(learnMoreButton);
expect(mockSetIsCautionDialogOpen).toHaveBeenCalledWith(true);
});
test("renders correctly in CX mode", () => {
render(<SurveyMenuBar {...defaultProps} isCxMode={true} />);
expect(screen.queryByText("common.back")).not.toBeInTheDocument();
expect(screen.queryByText("common.save")).not.toBeInTheDocument(); // Save button is hidden in CX mode draft
expect(screen.getByText("environments.surveys.edit.save_and_close")).toBeInTheDocument(); // Publish button text changes
});
test("handles audience prompt for app surveys", async () => {
const appSurvey = { ...baseSurvey, type: "app" as const };
render(<SurveyMenuBar {...defaultProps} localSurvey={appSurvey} />);
expect(screen.getByText("environments.surveys.edit.continue_to_settings")).toBeInTheDocument();
const continueButton = screen
.getByText("environments.surveys.edit.continue_to_settings")
.closest("button");
await userEvent.click(continueButton!);
expect(mockSetActiveId).toHaveBeenCalledWith("settings");
// Button should disappear after click (audiencePrompt becomes false)
expect(screen.queryByText("environments.surveys.edit.continue_to_settings")).not.toBeInTheDocument();
// Publish button should now be visible
expect(screen.getByText("environments.surveys.edit.publish")).toBeInTheDocument();
});
test("hides audience prompt when activeId is settings initially", () => {
const appSurvey = { ...baseSurvey, type: "app" as const };
render(<SurveyMenuBar {...defaultProps} localSurvey={appSurvey} activeId="settings" />);
expect(screen.queryByText("environments.surveys.edit.continue_to_settings")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.publish")).toBeInTheDocument();
});
test("shows 'Save & Close' button for non-draft surveys", () => {
const publishedSurvey = { ...baseSurvey, status: "inProgress" as const };
render(<SurveyMenuBar {...defaultProps} localSurvey={publishedSurvey} />);
expect(screen.getByText("environments.surveys.edit.save_and_close")).toBeInTheDocument();
expect(screen.queryByText("environments.surveys.edit.publish")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.edit.continue_to_settings")).not.toBeInTheDocument();
});
test("enables save buttons if app survey has triggers and is published", () => {
const publishedAppSurveyWithTriggers = {
...baseSurvey,
status: "inProgress" as const,
type: "app" as const,
triggers: ["trigger1"],
} as unknown as TSurvey;
render(<SurveyMenuBar {...defaultProps} localSurvey={publishedAppSurveyWithTriggers} />);
const saveButton = screen.getByText("common.save").closest("button");
const saveCloseButton = screen.getByText("environments.surveys.edit.save_and_close").closest("button");
expect(saveButton).not.toBeDisabled();
expect(saveCloseButton).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,285 @@
import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock the Placement component
vi.mock("@/modules/survey/editor/components/placement", () => ({
Placement: vi.fn(
({
currentPlacement,
setCurrentPlacement,
overlay,
setOverlay,
clickOutsideClose,
setClickOutsideClose,
}) => (
<div data-testid="mock-placement">
<p>Placement: {currentPlacement}</p>
<p>Overlay: {overlay}</p>
<p>ClickOutsideClose: {clickOutsideClose.toString()}</p>
<button onClick={() => setCurrentPlacement("topLeft" as TPlacement)}>Change Placement</button>
<button onClick={() => setOverlay("dark")}>Change Overlay Dark</button>
<button onClick={() => setOverlay("light")}>Change Overlay Light</button>
<button onClick={() => setClickOutsideClose(true)}>Allow Click Outside</button>
<button onClick={() => setClickOutsideClose(false)}>Disallow Click Outside</button>
</div>
)
),
}));
// Mock useAutoAnimate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]), // Return a ref object
}));
const mockEnvironmentId = "env123";
const mockSetLocalSurvey = vi.fn();
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
environmentId: mockEnvironmentId,
status: "draft",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
displayPercentage: null,
languages: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
variables: [],
hiddenFields: { enabled: false },
segment: null,
projectOverwrites: null, // Start with no overwrites
closeOnDate: null,
createdBy: null,
} as unknown as TSurvey;
describe("SurveyPlacementCard", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
mockSetLocalSurvey.mockClear();
});
test("renders correctly initially with no project overwrites", () => {
render(
<SurveyPlacementCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
expect(screen.getByText("environments.surveys.edit.survey_placement")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.overwrite_the_global_placement_of_the_survey")
).toBeInTheDocument();
const switchControl = screen.getByRole("switch");
expect(switchControl).toBeInTheDocument();
expect(switchControl).not.toBeChecked();
expect(screen.queryByTestId("mock-placement")).not.toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.to_keep_the_placement_over_all_surveys_consistent_you_can")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.set_the_global_placement_in_the_look_feel_settings")
).toBeInTheDocument();
});
test("calls setLocalSurvey when placement changes in Placement component", async () => {
const surveyWithOverwrites: TSurvey = {
...mockSurvey,
projectOverwrites: {
placement: "bottomRight",
darkOverlay: false,
clickOutsideClose: false,
},
};
render(
<SurveyPlacementCard
localSurvey={surveyWithOverwrites}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
const changePlacementButton = screen.getByText("Change Placement");
await userEvent.click(changePlacementButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith({
...surveyWithOverwrites,
projectOverwrites: {
...surveyWithOverwrites.projectOverwrites,
placement: "topLeft",
},
});
});
test("calls setLocalSurvey when overlay changes to dark in Placement component", async () => {
const surveyWithOverwrites: TSurvey = {
...mockSurvey,
projectOverwrites: {
placement: "bottomRight",
darkOverlay: false, // Start with light
clickOutsideClose: false,
},
};
render(
<SurveyPlacementCard
localSurvey={surveyWithOverwrites}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
const changeOverlayButton = screen.getByText("Change Overlay Dark");
await userEvent.click(changeOverlayButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith({
...surveyWithOverwrites,
projectOverwrites: {
...surveyWithOverwrites.projectOverwrites,
darkOverlay: true, // Changed to dark
},
});
});
test("calls setLocalSurvey when overlay changes to light in Placement component", async () => {
const surveyWithOverwrites: TSurvey = {
...mockSurvey,
projectOverwrites: {
placement: "bottomRight",
darkOverlay: true, // Start with dark
clickOutsideClose: false,
},
};
render(
<SurveyPlacementCard
localSurvey={surveyWithOverwrites}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
const changeOverlayButton = screen.getByText("Change Overlay Light");
await userEvent.click(changeOverlayButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith({
...surveyWithOverwrites,
projectOverwrites: {
...surveyWithOverwrites.projectOverwrites,
darkOverlay: false, // Changed to light
},
});
});
test("calls setLocalSurvey when clickOutsideClose changes to true in Placement component", async () => {
const surveyWithOverwrites: TSurvey = {
...mockSurvey,
projectOverwrites: {
placement: "bottomRight",
darkOverlay: false,
clickOutsideClose: false, // Start with false
},
};
render(
<SurveyPlacementCard
localSurvey={surveyWithOverwrites}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
const allowClickOutsideButton = screen.getByText("Allow Click Outside");
await userEvent.click(allowClickOutsideButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith({
...surveyWithOverwrites,
projectOverwrites: {
...surveyWithOverwrites.projectOverwrites,
clickOutsideClose: true, // Changed to true
},
});
});
test("calls setLocalSurvey when clickOutsideClose changes to false in Placement component", async () => {
const surveyWithOverwrites: TSurvey = {
...mockSurvey,
projectOverwrites: {
placement: "bottomRight",
darkOverlay: false,
clickOutsideClose: true, // Start with true
},
};
render(
<SurveyPlacementCard
localSurvey={surveyWithOverwrites}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
// Open the collapsible
fireEvent.click(screen.getByText("environments.surveys.edit.survey_placement"));
const disallowClickOutsideButton = screen.getByText("Disallow Click Outside");
await userEvent.click(disallowClickOutsideButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith({
...surveyWithOverwrites,
projectOverwrites: {
...surveyWithOverwrites.projectOverwrites,
clickOutsideClose: false, // Changed to false
},
});
});
test("does not open collapsible if survey type is link", () => {
const linkSurvey: TSurvey = { ...mockSurvey, type: "link" };
render(
<SurveyPlacementCard
localSurvey={linkSurvey}
setLocalSurvey={mockSetLocalSurvey}
environmentId={mockEnvironmentId}
/>
);
const trigger = screen.getByText("environments.surveys.edit.survey_placement");
fireEvent.click(trigger);
// Check if the content that should appear when open is not visible
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});
});

View File

@@ -35,7 +35,7 @@ export const SurveyPlacementCard = ({
const togglePlacement = () => {
if (setProjectOverwrites) {
if (!!placement) {
if (placement) {
setProjectOverwrites(null);
} else {
setProjectOverwrites({

View File

@@ -0,0 +1,247 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { SurveyVariablesCardItem } from "./survey-variables-card-item";
describe("SurveyVariablesCardItem", () => {
afterEach(() => {
cleanup();
});
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const methods = useForm<TSurveyVariable>({
defaultValues: {
id: "newVarId",
name: "",
type: "number",
value: 0,
},
mode: "onChange",
});
return <FormProvider {...methods}>{children}</FormProvider>;
};
test("should create a new survey variable when mode is 'create' and the form is submitted", async () => {
const mockSetLocalSurvey = vi.fn();
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [],
} as unknown as TSurvey;
render(
<TestWrapper>
<SurveyVariablesCardItem
mode="create"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
/>
</TestWrapper>
);
const nameInput = screen.getByPlaceholderText("environments.surveys.edit.field_name_eg_score_price");
const valueInput = screen.getByPlaceholderText("environments.surveys.edit.initial_value");
const addButton = screen.getByRole("button", { name: "environments.surveys.edit.add_variable" });
await userEvent.type(nameInput, "new_variable");
await userEvent.type(valueInput, "10");
await userEvent.click(addButton);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.arrayContaining([
expect.objectContaining({
name: "new_variable",
value: 10,
}),
]),
})
);
});
test("should display an error message when the variable name is invalid", async () => {
const mockSetLocalSurvey = vi.fn();
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [],
} as unknown as TSurvey;
render(
<TestWrapper>
<SurveyVariablesCardItem
mode="create"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
/>
</TestWrapper>
);
const nameInput = screen.getByPlaceholderText("environments.surveys.edit.field_name_eg_score_price");
await userEvent.type(nameInput, "1invalid_name");
const errorMessage = screen.getByText("environments.surveys.edit.variable_name_must_start_with_a_letter");
expect(errorMessage).toBeVisible();
});
test("should handle undefined variable prop in edit mode without crashing", () => {
const mockSetLocalSurvey = vi.fn();
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [],
} as unknown as TSurvey;
const { container } = render(
<SurveyVariablesCardItem
mode="edit"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
variable={undefined}
/>
);
expect(container.firstChild).toBeNull();
});
test("should display an error message when creating a variable with an existing name", async () => {
const mockSetLocalSurvey = vi.fn();
const existingVariableName = "existing_variable";
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [
{
id: "existingVarId",
name: existingVariableName,
type: "number",
value: 0,
},
],
} as unknown as TSurvey;
render(
<TestWrapper>
<SurveyVariablesCardItem
mode="create"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
/>
</TestWrapper>
);
const nameInput = screen.getByPlaceholderText("environments.surveys.edit.field_name_eg_score_price");
const addButton = screen.getByRole("button", { name: "environments.surveys.edit.add_variable" });
await userEvent.type(nameInput, existingVariableName);
await userEvent.click(addButton);
expect(
screen.getByText("environments.surveys.edit.variable_name_is_already_taken_please_choose_another")
).toBeVisible();
});
});

View File

@@ -0,0 +1,170 @@
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
// Mock the child component
vi.mock("@/modules/survey/editor/components/survey-variables-card-item", () => ({
SurveyVariablesCardItem: ({ mode, variable }: { mode: string; variable?: TSurveyVariable }) => (
<div data-testid={`survey-variables-card-item-${mode}`}>
{mode === "edit" && variable ? `Edit: ${variable.name}` : "Create New Variable"}
</div>
),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
}));
const mockSetLocalSurvey = vi.fn();
const mockSetActiveQuestionId = vi.fn();
const mockSurvey = {
id: "survey-123",
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
pin: null,
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
hiddenFields: { enabled: false },
variables: [
{ id: "var1", name: "variable_one", type: "number", value: 1 },
{ id: "var2", name: "variable_two", type: "text", value: "test" },
],
languages: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
createdBy: null,
segment: null,
closeOnDate: null,
runOnDate: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
recaptcha: null,
} as unknown as TSurvey;
const mockSurveyNoVariables: TSurvey = {
...mockSurvey,
variables: [],
};
describe("SurveyVariablesCard", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with existing variables", () => {
render(
<SurveyVariablesCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={null}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
expect(screen.getByText("common.variables")).toBeInTheDocument();
// Check if edit items are not rendered (collapsible is closed initially)
expect(screen.queryByText("Edit: variable_one")).toBeNull();
expect(screen.queryByText("Edit: variable_two")).toBeNull();
// Check if create item is not rendered (collapsible is closed initially)
expect(screen.queryByText("Create New Variable")).toBeNull();
});
test("opens and closes the collapsible content on click", async () => {
render(
<SurveyVariablesCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={null}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
const trigger = screen.getByText("common.variables");
// Initially closed
expect(screen.queryByText("Edit: variable_one")).toBeNull();
expect(screen.queryByText("Create New Variable")).toBeNull();
// Open
await userEvent.click(trigger);
expect(mockSetActiveQuestionId).toHaveBeenCalledWith(expect.stringContaining("fb-variables-"));
// Need to re-render with the new activeQuestionId prop to simulate open state
const activeId = mockSetActiveQuestionId.mock.calls[0][0];
cleanup();
render(
<SurveyVariablesCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={activeId}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
expect(screen.getByText("Edit: variable_one")).toBeVisible();
expect(screen.getByText("Edit: variable_two")).toBeVisible();
expect(screen.getByText("Create New Variable")).toBeVisible();
// Close
await userEvent.click(screen.getByText("common.variables")); // Use the same trigger element
expect(mockSetActiveQuestionId).toHaveBeenCalledWith(null);
// Need to re-render with null activeQuestionId to simulate closed state
cleanup();
render(
<SurveyVariablesCard
localSurvey={mockSurvey}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={null}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
expect(screen.queryByText("Edit: variable_one")).toBeNull();
expect(screen.queryByText("Create New Variable")).toBeNull();
});
test("renders placeholder text when no variables exist", async () => {
render(
<SurveyVariablesCard
localSurvey={mockSurveyNoVariables}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={null}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
const trigger = screen.getByText("common.variables");
await userEvent.click(trigger);
// Re-render with active ID
const activeId = mockSetActiveQuestionId.mock.calls[0][0];
cleanup();
render(
<SurveyVariablesCard
localSurvey={mockSurveyNoVariables}
setLocalSurvey={mockSetLocalSurvey}
activeQuestionId={activeId}
setActiveQuestionId={mockSetActiveQuestionId}
/>
);
expect(screen.getByText("environments.surveys.edit.no_variables_yet_add_first_one_below")).toBeVisible();
expect(screen.getByText("Create New Variable")).toBeVisible(); // Create section should still be visible
expect(screen.queryByTestId("survey-variables-card-item-edit")).not.toBeInTheDocument();
});
});

View File

@@ -29,6 +29,7 @@ export const SurveyVariablesCard = ({
const setOpenState = (state: boolean) => {
if (state) {
// NOSONAR // This is ok for setOpenState
setActiveQuestionId(variablesCardId);
} else {
setActiveQuestionId(null);

View File

@@ -0,0 +1,77 @@
import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
interface UpgradePromptButton {
text: string;
}
interface UpgradePromptProps {
title: string;
description: string;
buttons?: UpgradePromptButton[];
}
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: ({ title, description, buttons }: UpgradePromptProps) => (
<div data-testid="upgrade-prompt-mock">
<div>{title}</div>
<div>{description}</div>
<div>{buttons?.map((button: UpgradePromptButton) => <div key={button.text}>{button.text}</div>)}</div>
</div>
),
}));
describe("TargetingLockedCard", () => {
afterEach(() => {
cleanup();
});
test("renders correctly when isFormbricksCloud is true and environmentId is a valid string", () => {
render(<TargetingLockedCard isFormbricksCloud={true} environmentId="test-env-id" />);
expect(screen.getByText("environments.segments.target_audience")).toBeInTheDocument();
});
test("renders translated text for labels and descriptions", () => {
render(<TargetingLockedCard isFormbricksCloud={true} environmentId="test-env-id" />);
expect(screen.getByText("environments.segments.target_audience")).toBeInTheDocument();
expect(screen.getByText("environments.segments.pre_segment_users")).toBeInTheDocument();
});
test("handles undefined environmentId gracefully without crashing", () => {
render(<TargetingLockedCard isFormbricksCloud={true} environmentId={undefined as any} />);
expect(screen.getByText("environments.segments.target_audience")).toBeInTheDocument();
});
test("toggles collapsible content when the trigger is clicked", async () => {
const user = userEvent.setup();
render(<TargetingLockedCard isFormbricksCloud={true} environmentId="test-env-id" />);
const trigger = screen.getByText("environments.segments.target_audience");
// Initially, the content should NOT be present (closed by default)
expect(screen.queryByTestId("upgrade-prompt-mock")).not.toBeInTheDocument();
// Click the trigger to open the content
await user.click(trigger);
expect(screen.getByTestId("upgrade-prompt-mock")).toBeInTheDocument();
// Click the trigger again to close the content
await user.click(trigger);
expect(screen.queryByTestId("upgrade-prompt-mock")).not.toBeInTheDocument();
});
test("renders UpgradePrompt with correct title, description, and buttons when isFormbricksCloud is true", async () => {
render(<TargetingLockedCard isFormbricksCloud={true} environmentId="test-env-id" />);
// Open the collapsible
const trigger = screen.getByText("environments.segments.target_audience");
await userEvent.click(trigger);
expect(screen.getByText("environments.surveys.edit.unlock_targeting_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.unlock_targeting_description")).toBeInTheDocument();
expect(screen.getByText("common.start_free_trial")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,108 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { triggerDownloadUnsplashImageAction } from "../actions";
import { ImageFromUnsplashSurveyBg } from "./unsplash-images";
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
FORMBRICKS_API_HOST: "mock-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-environment-id",
},
}));
vi.mock("../actions", () => ({
getImagesFromUnsplashAction: vi.fn(),
triggerDownloadUnsplashImageAction: vi.fn(),
}));
vi.mock("react-hot-toast");
describe("ImageFromUnsplashSurveyBg", () => {
afterEach(() => {
cleanup();
});
test("should render default images when no query is provided", () => {
const handleBgChange = vi.fn();
render(<ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />);
const images = screen.getAllByRole("img");
// The number of default images is 13 as defined in the component
expect(images.length).toBe(13);
});
test("should call handleBgChange with the correct parameters when an image is selected", async () => {
const handleBgChange = vi.fn();
render(<ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />);
const image = screen.getAllByRole("img")[0];
// The first default image is dogs.webp
const expectedImageUrl = "/image-backgrounds/dogs.webp";
await userEvent.click(image);
expect(handleBgChange).toHaveBeenCalledTimes(1);
expect(handleBgChange).toHaveBeenCalledWith(expectedImageUrl, "image");
});
test("should focus the search input on render", () => {
const handleBgChange = vi.fn();
render(<ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />);
const input = screen.getByPlaceholderText("environments.surveys.edit.try_lollipop_or_mountain");
expect(input).toHaveFocus();
});
test("handleImageSelected calls handleBgChange with the image URL and does not call triggerDownloadUnsplashImageAction when downloadImageUrl is undefined", async () => {
const handleBgChange = vi.fn();
vi.mock("../actions", () => ({
getImagesFromUnsplashAction: vi.fn(),
triggerDownloadUnsplashImageAction: vi.fn(),
}));
render(<ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />);
const imageUrl = "/image-backgrounds/dogs.webp";
// Find the image element. Using `getAllByRole` and targeting the first image, since we know default images are rendered.
const image = screen.getAllByRole("img")[0];
// Simulate a click on the image.
await userEvent.click(image);
// Assert that handleBgChange is called with the correct URL.
expect(handleBgChange).toHaveBeenCalledWith(imageUrl, "image");
// Assert that triggerDownloadUnsplashImageAction is not called.
expect(triggerDownloadUnsplashImageAction).not.toHaveBeenCalled();
});
test("handles malformed URLs gracefully", async () => {
const handleBgChange = vi.fn();
const malformedURL = "not a valid URL";
const mockImages = [
{
id: "1",
alt_description: "Image 1",
urls: {
regularWithAttribution: malformedURL,
},
},
];
vi.mocked(toast.error).mockImplementation((_: string) => "");
const actions = await import("../actions");
vi.mocked(actions.getImagesFromUnsplashAction).mockResolvedValue({ data: mockImages });
render(<ImageFromUnsplashSurveyBg handleBgChange={handleBgChange} />);
// Wait for the component to finish loading images
await new Promise((resolve) => setTimeout(resolve, 0));
expect(toast.error).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,312 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import * as validationModule from "@formbricks/types/surveys/validation";
import { UpdateQuestionId } from "./update-question-id";
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ id, value, onChange, className, disabled }: any) => (
<input
data-testid={id}
id={id}
value={value || ""}
onChange={onChange}
className={className}
disabled={disabled}
/>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, size }: any) => (
<button data-testid="save-button" onClick={onClick} disabled={disabled} data-size={size}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ htmlFor, children }: any) => <label htmlFor={htmlFor}>{children}</label>,
}));
vi.mock("@formbricks/types/surveys/validation", () => ({
validateId: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
describe("UpdateQuestionId", () => {
afterEach(() => {
cleanup();
});
test("should update the question ID and call updateQuestion when a valid and unique ID is entered and the save button is clicked", async () => {
const user = userEvent.setup();
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
questions: [{ id: "question1" }, { id: "question2" }] as TSurveyQuestion[],
languages: [],
endings: [],
delay: 0,
hiddenFields: { fieldIds: [] },
} as unknown as TSurvey;
const mockQuestion = {
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as unknown as TSurveyQuestion;
const mockQuestionIdx = 0;
const mockUpdateQuestion = vi.fn();
render(
<UpdateQuestionId
localSurvey={mockLocalSurvey}
question={mockQuestion}
questionIdx={mockQuestionIdx}
updateQuestion={mockUpdateQuestion}
/>
);
const inputElement = screen.getByTestId("questionId");
const saveButton = screen.getByTestId("save-button");
// Simulate user entering a new, valid ID
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "newQuestionId");
// Simulate clicking the save button
await user.click(saveButton);
// Assert that updateQuestion is called with the correct arguments
expect(mockUpdateQuestion).toHaveBeenCalledTimes(1);
expect(mockUpdateQuestion).toHaveBeenCalledWith(mockQuestionIdx, { id: "newQuestionId" });
});
test("should disable the input field if the survey is not in draft mode and the question is not a draft", () => {
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "active", // Survey is not in draft mode
questions: [{ id: "question1", isDraft: false }] as TSurveyQuestion[],
languages: [],
endings: [],
delay: 0,
hiddenFields: { fieldIds: [] },
} as unknown as TSurvey;
const mockQuestion = {
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
isDraft: false, // Question is not a draft
} as unknown as TSurveyQuestion;
const mockQuestionIdx = 0;
const mockUpdateQuestion = vi.fn();
render(
<UpdateQuestionId
localSurvey={mockLocalSurvey}
question={mockQuestion}
questionIdx={mockQuestionIdx}
updateQuestion={mockUpdateQuestion}
/>
);
const inputElement = screen.getByTestId("questionId") as HTMLInputElement; // NOSONAR // cast to HTMLInputElement to access disabled property
expect(inputElement.disabled).toBe(true);
});
test("should display an error message and not update the question ID if the entered ID contains special characters and is invalid", async () => {
const user = userEvent.setup();
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
questions: [{ id: "question1" }, { id: "question2" }] as TSurveyQuestion[],
languages: [],
endings: [],
delay: 0,
hiddenFields: { fieldIds: [] },
} as unknown as TSurvey;
const mockQuestion = {
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as unknown as TSurveyQuestion;
const mockQuestionIdx = 0;
const mockUpdateQuestion = vi.fn();
const { validateId } = validationModule;
vi.mocked(validateId).mockReturnValue("Invalid ID");
render(
<UpdateQuestionId
localSurvey={mockLocalSurvey}
question={mockQuestion}
questionIdx={mockQuestionIdx}
updateQuestion={mockUpdateQuestion}
/>
);
const inputElement = screen.getByTestId("questionId") as HTMLInputElement; // NOSONAR // cast to HTMLInputElement to access disabled property
const saveButton = screen.getByTestId("save-button");
// Simulate user entering a new, invalid ID with special characters
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "@#$%^&*");
// Simulate clicking the save button
await user.click(saveButton);
// Assert that validateId is called with the correct arguments
expect(vi.mocked(validateId)).toHaveBeenCalledTimes(1);
expect(vi.mocked(validateId)).toHaveBeenCalledWith(
"Question",
"@#$%^&*",
mockLocalSurvey.questions.map((q) => q.id),
mockLocalSurvey.endings.map((e) => e.id),
mockLocalSurvey.hiddenFields.fieldIds ?? []
);
// Assert that updateQuestion is not called
expect(mockUpdateQuestion).not.toHaveBeenCalled();
// Assert that toast.error is called
expect(toast.error).toHaveBeenCalledWith("Invalid ID");
});
test("should handle case sensitivity when validating question IDs", async () => {
const user = userEvent.setup();
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
questions: [{ id: "question1" }, { id: "question2" }] as TSurveyQuestion[],
languages: [],
endings: [],
delay: 0,
hiddenFields: { fieldIds: [] },
} as unknown as TSurvey;
const mockQuestion = {
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as unknown as TSurveyQuestion;
const mockQuestionIdx = 0;
const mockUpdateQuestion = vi.fn();
// Mock validateId to return an error if the ID is 'Question1' (case-insensitive duplicate)
const { validateId } = validationModule;
vi.mocked(validateId).mockImplementation(
(_type, field, _existingQuestionIds, _existingEndingCardIds, _existingHiddenFieldIds) => {
if (field.toLowerCase() === "question1") {
return "ID already exists";
}
return null; // Return null instead of undefined
}
);
render(
<UpdateQuestionId
localSurvey={mockLocalSurvey}
question={mockQuestion}
questionIdx={mockQuestionIdx}
updateQuestion={mockUpdateQuestion}
/>
);
const inputElement = screen.getByTestId("questionId") as HTMLInputElement; // NOSONAR // cast to HTMLInputElement to access disabled property
const saveButton = screen.getByTestId("save-button");
// Simulate user entering 'Question1'
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "Question1");
// Simulate clicking the save button
await user.click(saveButton);
// Assert that updateQuestion is NOT called because the ID is considered a duplicate
expect(mockUpdateQuestion).not.toHaveBeenCalled();
});
test("should display an error message and not update the question ID if the entered ID is a reserved identifier", async () => {
const user = userEvent.setup();
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
questions: [{ id: "question1" }, { id: "question2" }] as TSurveyQuestion[],
languages: [],
endings: [],
delay: 0,
hiddenFields: { fieldIds: [] },
} as unknown as TSurvey;
const mockQuestion = {
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as unknown as TSurveyQuestion;
const mockQuestionIdx = 0;
const mockUpdateQuestion = vi.fn();
const { validateId } = validationModule;
vi.mocked(validateId).mockReturnValue("This ID is reserved.");
render(
<UpdateQuestionId
localSurvey={mockLocalSurvey}
question={mockQuestion}
questionIdx={mockQuestionIdx}
updateQuestion={mockUpdateQuestion}
/>
);
const inputElement = screen.getByTestId("questionId") as HTMLInputElement; // NOSONAR // cast to HTMLInputElement to access disabled property
const saveButton = screen.getByTestId("save-button");
// Simulate user entering a reserved identifier
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "reservedId");
// Simulate clicking the save button
await user.click(saveButton);
// Assert that updateQuestion is not called
expect(mockUpdateQuestion).not.toHaveBeenCalled();
// Assert that an error message is displayed
expect(toast.error).toHaveBeenCalledWith("This ID is reserved.");
});
});

View File

@@ -0,0 +1,95 @@
import { cleanup, render } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock environment-dependent modules
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
FORMBRICKS_API_HOST: "http://localhost:3000",
FORMBRICKS_ENVIRONMENT_ID: "test-env-id",
}));
vi.mock("@/modules/survey/editor/actions", () => ({}));
describe("WhenToSendCard", () => {
afterEach(() => {
cleanup();
});
test("should initialize open state to true when localSurvey.type is app", () => {
const localSurvey = {
id: "1",
name: "Test Survey",
type: "app",
createdAt: new Date("2024-02-13T11:00:00.000Z"),
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
questions: [],
triggers: [],
} as unknown as TSurvey;
const TestComponent = () => {
const [open] = useState(localSurvey.type === "app");
return <>{open.toString()}</>;
};
const { container } = render(<TestComponent />);
expect(container.textContent).toBe("true");
});
test("should initialize open state to false when localSurvey.type is link", () => {
const localSurvey = {
id: "2",
name: "Test Survey",
type: "link",
createdAt: new Date("2024-02-13T11:00:00.000Z"),
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
questions: [],
triggers: [],
} as unknown as TSurvey;
const TestComponent = () => {
const [open] = useState(localSurvey.type === "app");
return <>{open.toString()}</>;
};
const { container } = render(<TestComponent />);
expect(container.textContent).toBe("false");
});
test("handleTriggerDelay correctly handles invalid inputs", () => {
const localSurvey = {
id: "3",
name: "Test Survey",
type: "app",
createdAt: new Date("2024-02-13T11:00:00.000Z"),
updatedAt: new Date("2024-02-13T11:00:00.000Z"),
questions: [],
triggers: [],
delay: 5,
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
// Recreate the handleTriggerDelay function here to isolate its logic
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
if (value < 1 || Number.isNaN(value)) {
value = 0;
}
const updatedSurvey = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
// Simulate an event with a non-numeric value
handleTriggerDelay({ target: { value: "abc" } });
expect(setLocalSurvey).toHaveBeenCalledWith({ ...localSurvey, delay: 0 });
// Simulate an event with a value less than 1
setLocalSurvey.mockClear(); // Clear mock calls for the next assertion
handleTriggerDelay({ target: { value: "0" } });
expect(setLocalSurvey).toHaveBeenCalledWith({ ...localSurvey, delay: 0 });
});
});

View File

@@ -0,0 +1,262 @@
import { cache } from "@/lib/cache";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TProjectWithLanguages } from "@/modules/survey/list/types/surveys";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { getProjectWithLanguagesByEnvironmentId, getUserProjects } from "./project";
vi.mock("@/lib/cache", () => ({
cache: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
membership: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
tag: {
byEnvironmentId: vi.fn((id) => `environment-${id}`),
byUserId: vi.fn((id) => `user-${id}`),
byOrganizationId: vi.fn((id) => `organization-${id}`),
},
},
}));
vi.mock("react", () => ({
cache: (fn: any) => fn,
}));
describe("Project module", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("getProjectWithLanguagesByEnvironmentId", () => {
test("should return project with languages when successful", async () => {
const mockProject: TProjectWithLanguages = {
id: "project-id",
languages: [
{ alias: "en", code: "English" },
{ alias: "es", code: "Spanish" },
],
};
vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(mockProject);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
const result = await getProjectWithLanguagesByEnvironmentId("env-id");
expect(result).toEqual(mockProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
environments: {
some: {
id: "env-id",
},
},
},
select: {
id: true,
languages: true,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
["survey-list-getProjectByEnvironmentId-env-id"],
{
tags: ["environment-env-id"],
}
);
});
test("should return null when no project is found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(null);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
const result = await getProjectWithLanguagesByEnvironmentId("env-id");
expect(result).toBeNull();
});
test("should handle DatabaseError when Prisma throws known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
clientVersion: "1.0.0",
code: "P2002",
});
vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(prismaError);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
await expect(getProjectWithLanguagesByEnvironmentId("env-id")).rejects.toThrow(DatabaseError);
});
test("should rethrow unknown errors", async () => {
const error = new Error("Unknown error");
vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(error);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
await expect(getProjectWithLanguagesByEnvironmentId("env-id")).rejects.toThrow("Unknown error");
});
});
describe("getUserProjects", () => {
test("should return user projects for manager role", async () => {
const mockOrgMembership = {
userId: "user-id",
organizationId: "org-id",
role: "manager",
};
const mockProjects: TUserProject[] = [
{
id: "project-1",
name: "Project 1",
environments: [
{ id: "env-1", type: "production" },
{ id: "env-2", type: "development" },
],
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
const result = await getUserProjects("user-id", "org-id");
expect(result).toEqual(mockProjects);
expect(prisma.membership.findFirst).toHaveBeenCalledWith({
where: {
userId: "user-id",
organizationId: "org-id",
},
});
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org-id",
},
select: {
id: true,
name: true,
environments: {
select: {
id: true,
type: true,
},
},
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
["survey-list-getUserProjects-user-id-org-id"],
{
tags: ["user-user-id", "organization-org-id"],
}
);
});
test("should return user projects for member role with project team filter", async () => {
const mockOrgMembership = {
userId: "user-id",
organizationId: "org-id",
role: "member",
};
const mockProjects: TUserProject[] = [
{
id: "project-1",
name: "Project 1",
environments: [{ id: "env-1", type: "production" }],
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
const result = await getUserProjects("user-id", "org-id");
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org-id",
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId: "user-id",
},
},
},
},
},
},
select: {
id: true,
name: true,
environments: {
select: {
id: true,
type: true,
},
},
},
});
});
test("should throw ValidationError when user is not a member of the organization", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(null);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
await expect(getUserProjects("user-id", "org-id")).rejects.toThrow(ValidationError);
});
test("should handle DatabaseError when Prisma throws known request error", async () => {
const mockOrgMembership = {
userId: "user-id",
organizationId: "org-id",
role: "admin",
};
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
clientVersion: "1.0.0",
code: "P2002",
});
vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership);
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(prismaError);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
await expect(getUserProjects("user-id", "org-id")).rejects.toThrow(DatabaseError);
});
test("should rethrow unknown errors", async () => {
const mockOrgMembership = {
userId: "user-id",
organizationId: "org-id",
role: "admin",
};
const error = new Error("Unknown error");
vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership);
vi.mocked(prisma.project.findMany).mockRejectedValueOnce(error);
vi.mocked(cache).mockImplementationOnce((fn) => async () => fn());
await expect(getUserProjects("user-id", "org-id")).rejects.toThrow("Unknown error");
});
});
});

View File

@@ -7,7 +7,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
export const getProjectWithLanguagesByEnvironmentId = reactCache(
async (environmentId: string): Promise<TProjectWithLanguages | null> =>
@@ -49,9 +49,39 @@ export const getUserProjects = reactCache(
cache(
async () => {
try {
const orgMembership = await prisma.membership.findFirst({
where: {
userId,
organizationId,
},
});
if (!orgMembership) {
throw new ValidationError("User is not a member of this organization");
}
let projectWhereClause: Prisma.ProjectWhereInput = {};
if (orgMembership.role === "member") {
projectWhereClause = {
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
};
}
const projects = await prisma.project.findMany({
where: {
organizationId,
...projectWhereClause,
},
select: {
id: true,

View File

@@ -51,7 +51,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
const loadScript = async () => {
if (!window.formbricksSurveys) {
try {
if (props.isSpamProtectionEnabled) {
if (props.isSpamProtectionEnabled && props.recaptchaSiteKey) {
await loadRecaptchaScript(props.recaptchaSiteKey);
}
await loadSurveyScript();

View File

@@ -41,14 +41,13 @@
"@lexical/react": "0.31.0",
"@lexical/rich-text": "0.31.0",
"@lexical/table": "0.31.0",
"@opentelemetry/api-logs": "0.56.0",
"@opentelemetry/exporter-prometheus": "0.57.2",
"@opentelemetry/host-metrics": "0.35.5",
"@opentelemetry/instrumentation": "0.57.2",
"@opentelemetry/instrumentation-http": "0.57.2",
"@opentelemetry/instrumentation-runtime-node": "0.12.2",
"@opentelemetry/sdk-logs": "0.56.0",
"@opentelemetry/sdk-metrics": "1.30.1",
"@opentelemetry/exporter-prometheus": "0.200.0",
"@opentelemetry/host-metrics": "0.36.0",
"@opentelemetry/instrumentation": "0.200.0",
"@opentelemetry/instrumentation-http": "0.200.0",
"@opentelemetry/instrumentation-runtime-node": "0.14.0",
"@opentelemetry/sdk-logs": "0.200.0",
"@opentelemetry/sdk-metrics": "2.0.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.7.0",
"@radix-ui/react-accordion": "1.2.9",

View File

@@ -37,9 +37,6 @@ export default defineConfig({
"modules/ui/components/alert/*.tsx",
"modules/ui/components/environmentId-base-layout/*.tsx",
"modules/ui/components/survey/recaptcha.ts",
"app/api/v2/client/**/responses/lib/recaptcha.ts",
"app/api/v2/client/**/responses/lib/organization.ts",
"app/api/v2/client/**/responses/lib/utils.ts",
"modules/ui/components/progress-bar/index.tsx",
"app/(app)/environments/**/layout.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
@@ -83,13 +80,10 @@ export default defineConfig({
"modules/ee/sso/lib/organization.ts",
"app/lib/**/*.ts",
"modules/ee/license-check/lib/utils.ts",
"app/api/(internal)/insights/lib/**/*.ts",
"app/api/(internal)/pipeline/lib/survey-follow-up.ts",
"modules/ee/role-management/*.ts",
"modules/organization/settings/teams/actions.ts",
"modules/organization/settings/api-keys/lib/**/*.ts",
"app/api/v1/**/*.ts",
"app/api/cron/**/*.ts",
"app/api/**/*.ts",
"modules/api/v2/management/auth/*.ts",
"modules/organization/settings/api-keys/components/*.tsx",
"modules/survey/hooks/*.tsx",
@@ -97,6 +91,7 @@ export default defineConfig({
"modules/survey/components/template-list/components/template-tags.tsx",
"modules/survey/lib/client-utils.ts",
"modules/survey/components/edit-public-survey-alert-dialog/index.tsx",
'modules/survey/list/lib/project.ts',
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
"modules/auth/signup/**/*.ts",
@@ -114,41 +109,13 @@ export default defineConfig({
"modules/analysis/**/*.tsx",
"modules/analysis/**/*.ts",
"app/lib/survey-builder.ts",
"modules/survey/editor/components/end-screen-form.tsx",
"modules/survey/editor/components/matrix-question-form.tsx",
"lib/utils/billing.ts",
"modules/survey/list/components/copy-survey-form.tsx",
"lib/crypto.ts",
"lib/surveyLogic/utils.ts",
"lib/utils/billing.ts",
"modules/ui/components/card/index.tsx",
"modules/survey/editor/components/open-question-form.tsx",
"modules/survey/editor/components/picture-selection-form.tsx",
"modules/survey/editor/components/placement.tsx",
"modules/survey/editor/components/question-card.tsx",
"modules/survey/editor/components/questions-droppable.tsx",
"modules/survey/editor/components/ranking-question-form.tsx",
"modules/survey/editor/components/rating-question-form.tsx",
"modules/survey/editor/components/rating-type-dropdown.tsx",
"modules/survey/editor/components/recontact-options-card.tsx",
"modules/survey/editor/components/redirect-url-form.tsx",
"modules/survey/editor/components/response-options-card.tsx",
"modules/survey/editor/components/saved-actions-tab.tsx",
"modules/survey/editor/components/survey-editor-tabs.tsx",
"modules/survey/editor/components/nps-question-form.tsx",
"modules/survey/editor/components/create-new-action-tab.tsx",
"modules/survey/editor/components/logic-editor-actions.tsx",
"modules/survey/editor/components/cal-question-form.tsx",
"modules/survey/editor/components/conditional-logic.tsx",
"modules/survey/editor/components/consent-question-form.tsx",
"modules/survey/editor/components/contact-info-question-form.tsx",
"modules/survey/editor/components/cta-question-form.tsx",
"modules/survey/editor/components/edit-ending-card.tsx",
"modules/survey/editor/components/editor-card-menu.tsx",
"modules/survey/editor/components/loading-skeleton.tsx",
"modules/survey/editor/components/logic-editor-conditions.tsx",
"modules/survey/editor/components/logic-editor.tsx",
"modules/survey/editor/components/multiple-choice-question-form.tsx",
"modules/survey/editor/components/*.tsx",
"lib/fileValidation.ts",
"modules/survey/editor/components/add-action-modal.tsx",
"modules/survey/editor/components/add-ending-card-button.tsx",
@@ -159,6 +126,10 @@ 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",
"modules/ee/teams/**/*.ts",
"modules/ee/teams/**/*.tsx",
"app/(app)/environments/**/*.tsx",
"app/(app)/environments/**/*.ts",
],
exclude: [
"**/.next/**",
@@ -167,8 +138,10 @@ export default defineConfig({
"**/route.ts", // Exclude route files
"**/openapi.ts", // Exclude openapi configuration files
"**/openapi-document.ts", // Exclude openapi document files
"**/types/**", // Exclude types
"modules/**/types/**", // Exclude types
"**/stories.tsx", // Exclude story files
"**/actions.ts", // Exclude action files
"**/stories.tsx" // Exclude story files
],
},
},

View File

@@ -96,7 +96,7 @@ export const renderWidget = async (
return executeRecaptcha(recaptchaSiteKey);
};
if (isSpamProtectionEnabled) {
if (isSpamProtectionEnabled && recaptchaSiteKey) {
await loadRecaptchaScript(recaptchaSiteKey);
}

View File

@@ -41,16 +41,16 @@ export function LanguageSwitch({
});
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center even:fb-pr-1">
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center fb-mr-1">
<button
title="Language switch"
type="button"
className="fb-text-heading fb-relative fb-h-5 fb-w-5 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
className="fb-text-heading fb-relative fb-h-6 fb-w-6 fb-rounded-md hover:fb-bg-black/5 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
onClick={toggleDropdown}
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}>
<GlobeIcon className="fb-text-heading fb-h-5 fb-w-5 fb-p-0.5" />
<GlobeIcon className="fb-text-heading fb-h-6 fb-w-6 fb-p-0.5" />
</button>
{showLanguageDropdown ? (
<div

202
pnpm-lock.yaml generated
View File

@@ -181,30 +181,27 @@ importers:
'@lexical/table':
specifier: 0.31.0
version: 0.31.0
'@opentelemetry/api-logs':
specifier: 0.56.0
version: 0.56.0
'@opentelemetry/exporter-prometheus':
specifier: 0.57.2
version: 0.57.2(@opentelemetry/api@1.9.0)
specifier: 0.200.0
version: 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/host-metrics':
specifier: 0.35.5
version: 0.35.5(@opentelemetry/api@1.9.0)
specifier: 0.36.0
version: 0.36.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation':
specifier: 0.57.2
version: 0.57.2(@opentelemetry/api@1.9.0)
specifier: 0.200.0
version: 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-http':
specifier: 0.57.2
version: 0.57.2(@opentelemetry/api@1.9.0)
specifier: 0.200.0
version: 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-runtime-node':
specifier: 0.12.2
version: 0.12.2(@opentelemetry/api@1.9.0)
specifier: 0.14.0
version: 0.14.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs':
specifier: 0.56.0
version: 0.56.0(@opentelemetry/api@1.9.0)
specifier: 0.200.0
version: 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics':
specifier: 1.30.1
version: 1.30.1(@opentelemetry/api@1.9.0)
specifier: 2.0.0
version: 2.0.0(@opentelemetry/api@1.9.0)
'@paralleldrive/cuid2':
specifier: 2.2.2
version: 2.2.2
@@ -267,7 +264,7 @@ importers:
version: 0.0.38(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@sentry/nextjs':
specifier: 9.15.0
version: 9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.7)
version: 9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.7)
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.1)
@@ -2009,9 +2006,9 @@ packages:
engines: {node: '>=10'}
deprecated: This functionality has been moved to @npmcli/fs
'@opentelemetry/api-logs@0.56.0':
resolution: {integrity: sha512-Wr39+94UNNG3Ei9nv3pHd4AJ63gq5nSemMRpCd8fPwDL9rN3vK26lzxfH27mw16XzOSO+TpyQwBAMaLxaPWG0g==}
engines: {node: '>=14'}
'@opentelemetry/api-logs@0.200.0':
resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.57.1':
resolution: {integrity: sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==}
@@ -2031,15 +2028,15 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@1.29.0':
resolution: {integrity: sha512-gmT7vAreXl0DTHD2rVZcw3+l2g84+5XiHIqdBUxXbExymPCvSsGOpiwMmn8nkiJur28STV31wnhIDrzWDPzjfA==}
'@opentelemetry/core@1.30.1':
resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@1.30.1':
resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==}
engines: {node: '>=14'}
'@opentelemetry/core@2.0.0':
resolution: {integrity: sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
@@ -2055,15 +2052,15 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/exporter-prometheus@0.57.2':
resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==}
engines: {node: '>=14'}
'@opentelemetry/exporter-prometheus@0.200.0':
resolution: {integrity: sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/host-metrics@0.35.5':
resolution: {integrity: sha512-Zf9Cjl7H6JalspnK5KD1+LLKSVecSinouVctNmUxRy+WP+20KwHq+qg4hADllkEmJ99MZByLLmEmzrr7s92V6g==}
engines: {node: '>=14'}
'@opentelemetry/host-metrics@0.36.0':
resolution: {integrity: sha512-14lNY57qa21V3ZOl6xrqLMHR0HGlnPIApR6hr3oCw/Dqs5IzxhTwt2X8Stn82vWJJis7j/ezn11oODsizHj2dQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
@@ -2121,6 +2118,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-http@0.200.0':
resolution: {integrity: sha512-9tqGbCJikhYU68y3k9mi6yWsMyMeCcwoQuHvIXan5VvvPPQ5WIZaV6Mxu/MCVe4swRNoFs8Th+qyj0TZV5ELvw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-http@0.57.2':
resolution: {integrity: sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==}
engines: {node: '>=14'}
@@ -2193,9 +2196,9 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-runtime-node@0.12.2':
resolution: {integrity: sha512-HNBW1rJiHDBTHQlh5oH1IAcV8O5VR7/L5BBOfGAMpGno3Jq9cNqTh96uUp0qBXBuxD8Yl1eoI5N+B5TdmjLteQ==}
engines: {node: '>=17.4.0'}
'@opentelemetry/instrumentation-runtime-node@0.14.0':
resolution: {integrity: sha512-y78dGoFMKwHSz0SD113Gt1dFTcfunpPZXIJh2SzJN27Lyb9FIzuMfjc3Iu3+s/N6qNOLuS9mKnPe3/qVGG4Waw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
@@ -2211,6 +2214,12 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.7.0
'@opentelemetry/instrumentation@0.200.0':
resolution: {integrity: sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.57.2':
resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==}
engines: {node: '>=14'}
@@ -2239,21 +2248,21 @@ packages:
resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==}
engines: {node: '>=14'}
'@opentelemetry/resources@1.29.0':
resolution: {integrity: sha512-s7mLXuHZE7RQr1wwweGcaRp3Q4UJJ0wazeGlc/N5/XSe6UyXfsh1UQGMADYeg7YwD+cEdMtU1yJAUXdnFzYzyQ==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/resources@1.30.1':
resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/sdk-logs@0.56.0':
resolution: {integrity: sha512-OS0WPBJF++R/cSl+terUjQH5PebloidB1Jbbecgg2rnCmQbTST9xsRes23bLfDQVRvmegmHqDh884h0aRdJyLw==}
engines: {node: '>=14'}
'@opentelemetry/resources@2.0.0':
resolution: {integrity: sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.200.0':
resolution: {integrity: sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
@@ -2269,6 +2278,12 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.0.0':
resolution: {integrity: sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@1.30.1':
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
engines: {node: '>=14'}
@@ -11996,7 +12011,7 @@ snapshots:
rimraf: 3.0.2
optional: true
'@opentelemetry/api-logs@0.56.0':
'@opentelemetry/api-logs@0.200.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -12014,16 +12029,16 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core@1.29.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/core@2.0.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.32.0
'@opentelemetry/exporter-metrics-otlp-grpc@0.57.1(@opentelemetry/api@1.9.0)':
dependencies:
'@grpc/grpc-js': 1.12.6
@@ -12045,14 +12060,14 @@ snapshots:
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-prometheus@0.57.2(@opentelemetry/api@1.9.0)':
'@opentelemetry/exporter-prometheus@0.200.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/host-metrics@0.35.5(@opentelemetry/api@1.9.0)':
'@opentelemetry/host-metrics@0.36.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
systeminformation: 5.23.8
@@ -12132,6 +12147,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-http@0.200.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.32.0
forwarded-parse: 2.1.2
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-http@0.57.2(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -12240,10 +12265,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation-runtime-node@0.12.2(@opentelemetry/api@1.9.0)':
'@opentelemetry/instrumentation-runtime-node@0.14.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.200.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -12264,6 +12289,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.200.0
'@types/shimmer': 1.2.0
import-in-the-middle: 1.13.1
require-in-the-middle: 7.5.2
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -12303,24 +12339,24 @@ snapshots:
'@opentelemetry/redis-common@0.36.2': {}
'@opentelemetry/resources@1.29.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 1.29.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
'@opentelemetry/sdk-logs@0.56.0(@opentelemetry/api@1.9.0)':
'@opentelemetry/resources@2.0.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.56.0
'@opentelemetry/core': 1.29.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 1.29.0(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.32.0
'@opentelemetry/sdk-logs@0.200.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.200.0
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs@0.57.1(@opentelemetry/api@1.9.0)':
dependencies:
@@ -12335,6 +12371,12 @@ snapshots:
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics@2.0.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.0.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -13445,7 +13487,7 @@ snapshots:
'@sentry/core@9.15.0': {}
'@sentry/nextjs@9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.7)':
'@sentry/nextjs@9.15.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.7)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.32.0
@@ -13453,7 +13495,7 @@ snapshots:
'@sentry-internal/browser-utils': 9.15.0
'@sentry/core': 9.15.0
'@sentry/node': 9.15.0
'@sentry/opentelemetry': 9.15.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.32.0)
'@sentry/opentelemetry': 9.15.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.32.0)
'@sentry/react': 9.15.0(react@19.1.0)
'@sentry/vercel-edge': 9.15.0
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.7)
@@ -13511,6 +13553,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@sentry/opentelemetry@9.15.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.32.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.200.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.32.0
'@sentry/core': 9.15.0
'@sentry/opentelemetry@9.15.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.32.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -14867,7 +14919,7 @@ snapshots:
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)(typescript@5.8.3)
'@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.8.3)
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.31.0)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.0)
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0)
@@ -16377,9 +16429,9 @@ snapshots:
eslint-plugin-turbo: 2.5.0(eslint@8.57.0)(turbo@2.5.2)
turbo: 2.5.2
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.31.0):
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0)):
dependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -16400,11 +16452,11 @@ snapshots:
tinyglobby: 0.2.13
unrs-resolver: 1.6.2
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.29.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0):
eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -16455,7 +16507,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3