mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 14:10:45 -06:00
Compare commits
9 Commits
feature/bu
...
increase-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7056466ce9 | ||
|
|
5d45de6bc4 | ||
|
|
cf5bc51e94 | ||
|
|
9a7d24ea4e | ||
|
|
649f28ff8d | ||
|
|
bc5a81d146 | ||
|
|
7dce35bde4 | ||
|
|
f30ebc32ec | ||
|
|
027bc20975 |
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -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.
|
- 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
|
- 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 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:
|
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Apply labels from linked issue to PR
|
- 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:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
|
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,7 +33,7 @@ const Loading = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -264,7 +264,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
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 ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,14 +8,14 @@ import {
|
|||||||
mockResponseWithContactQuestion,
|
mockResponseWithContactQuestion,
|
||||||
mockSurvey,
|
mockSurvey,
|
||||||
mockSurveyWithContactQuestion,
|
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 { sendFollowUpEmail } from "@/modules/email";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
|
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/modules/email", () => ({
|
vi.mock("@/modules/email", () => ({
|
||||||
@@ -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)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,11 @@ import { registerInstrumentations } from "@opentelemetry/instrumentation";
|
|||||||
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
|
||||||
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
||||||
import {
|
import {
|
||||||
Resource,
|
detectResources,
|
||||||
detectResourcesSync,
|
|
||||||
envDetector,
|
envDetector,
|
||||||
hostDetector,
|
hostDetector,
|
||||||
processDetector,
|
processDetector,
|
||||||
|
resourceFromAttributes,
|
||||||
} from "@opentelemetry/resources";
|
} from "@opentelemetry/resources";
|
||||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
@@ -21,11 +21,11 @@ const exporter = new PrometheusExporter({
|
|||||||
host: "0.0.0.0", // Listen on all network interfaces
|
host: "0.0.0.0", // Listen on all network interfaces
|
||||||
});
|
});
|
||||||
|
|
||||||
const detectedResources = detectResourcesSync({
|
const detectedResources = detectResources({
|
||||||
detectors: [envDetector, processDetector, hostDetector],
|
detectors: [envDetector, processDetector, hostDetector],
|
||||||
});
|
});
|
||||||
|
|
||||||
const customResources = new Resource({});
|
const customResources = resourceFromAttributes({});
|
||||||
|
|
||||||
const resources = detectedResources.merge(customResources);
|
const resources = detectedResources.merge(customResources);
|
||||||
|
|
||||||
|
|||||||
113
apps/web/modules/ee/teams/lib/roles.test.ts
Normal file
113
apps/web/modules/ee/teams/lib/roles.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
apps/web/modules/ee/teams/project-teams/lib/team.test.ts
Normal file
68
apps/web/modules/ee/teams/project-teams/lib/team.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
41
apps/web/modules/ee/teams/project-teams/loading.test.tsx
Normal file
41
apps/web/modules/ee/teams/project-teams/loading.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/web/modules/ee/teams/project-teams/page.test.tsx
Normal file
73
apps/web/modules/ee/teams/project-teams/page.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
86
apps/web/modules/ee/teams/team-list/actions.test.ts
Normal file
86
apps/web/modules/ee/teams/team-list/actions.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
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 { Button } from "@/modules/ui/components/button";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { TTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { cn } from "@/lib/cn";
|
|||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
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 { DeleteTeam } from "@/modules/ee/teams/team-list/components/team-settings/delete-team";
|
||||||
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
|
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
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 { CreateTeamButton } from "@/modules/ee/teams/team-list/components/create-team-button";
|
||||||
import { ManageTeamButton } from "@/modules/ee/teams/team-list/components/manage-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";
|
import { TeamSettingsModal } from "@/modules/ee/teams/team-list/components/team-settings/team-settings-modal";
|
||||||
|
|||||||
50
apps/web/modules/ee/teams/team-list/lib/project.test.ts
Normal file
50
apps/web/modules/ee/teams/team-list/lib/project.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
343
apps/web/modules/ee/teams/team-list/lib/team.test.ts
Normal file
343
apps/web/modules/ee/teams/team-list/lib/team.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,7 +57,7 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
const getUserTeams = reactCache(
|
export const getUserTeams = reactCache(
|
||||||
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
|
async (userId: string, organizationId: string): Promise<TUserTeam[]> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
67
apps/web/modules/ee/teams/utils/teams.test.ts
Normal file
67
apps/web/modules/ee/teams/utils/teams.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,7 +76,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
|
|||||||
const value = animationFiles[key];
|
const value = animationFiles[key];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={key}
|
||||||
onMouseEnter={() => debouncedManagePlayback(index, "play")}
|
onMouseEnter={() => debouncedManagePlayback(index, "play")}
|
||||||
onMouseLeave={() => debouncedManagePlayback(index, "pause")}
|
onMouseLeave={() => debouncedManagePlayback(index, "pause")}
|
||||||
onClick={() => handleBg(value)}
|
onClick={() => handleBg(value)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ export const EditWelcomeCard = ({
|
|||||||
locale,
|
locale,
|
||||||
}: EditWelcomeCardProps) => {
|
}: EditWelcomeCardProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const [firstRender, setFirstRender] = useState(true);
|
const [firstRender, setFirstRender] = useState(true);
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,8 +193,7 @@ export const QuestionsView = ({
|
|||||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||||
setInvalidQuestions(updatedInvalidQuestions);
|
setInvalidQuestions(updatedInvalidQuestions);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidQuestions, setInvalidQuestions]);
|
||||||
}, [localSurvey.languages, localSurvey.endings, localSurvey.welcomeCard]);
|
|
||||||
|
|
||||||
// function to validate individual questions
|
// function to validate individual questions
|
||||||
const validateSurveyQuestion = (question: TSurveyQuestion) => {
|
const validateSurveyQuestion = (question: TSurveyQuestion) => {
|
||||||
@@ -327,15 +326,17 @@ export const QuestionsView = ({
|
|||||||
|
|
||||||
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
||||||
const updatedSurvey = { ...localSurvey };
|
const updatedSurvey = { ...localSurvey };
|
||||||
|
const newQuestions = [...localSurvey.questions];
|
||||||
|
|
||||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||||
|
|
||||||
if (index) {
|
if (index !== undefined) {
|
||||||
updatedSurvey.questions.splice(index, 0, { ...updatedQuestion, isDraft: true });
|
newQuestions.splice(index, 0, { ...updatedQuestion, isDraft: true });
|
||||||
} else {
|
} else {
|
||||||
updatedSurvey.questions.push({ ...updatedQuestion, isDraft: true });
|
newQuestions.push({ ...updatedQuestion, isDraft: true });
|
||||||
}
|
}
|
||||||
|
updatedSurvey.questions = newQuestions;
|
||||||
|
|
||||||
setLocalSurvey(updatedSurvey);
|
setLocalSurvey(updatedSurvey);
|
||||||
setActiveQuestionId(question.id);
|
setActiveQuestionId(question.id);
|
||||||
@@ -378,8 +379,7 @@ export const QuestionsView = ({
|
|||||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||||
setInvalidQuestions(updatedInvalidQuestions);
|
setInvalidQuestions(updatedInvalidQuestions);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [localSurvey.questions, surveyLanguages, invalidQuestions, setInvalidQuestions]);
|
||||||
}, [localSurvey.languages, localSurvey.questions, localSurvey.endings, localSurvey.welcomeCard]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||||
|
|||||||
216
apps/web/modules/survey/editor/components/settings-view.test.tsx
Normal file
216
apps/web/modules/survey/editor/components/settings-view.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
329
apps/web/modules/survey/editor/components/styling-view.test.tsx
Normal file
329
apps/web/modules/survey/editor/components/styling-view.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
413
apps/web/modules/survey/editor/components/survey-editor.test.tsx
Normal file
413
apps/web/modules/survey/editor/components/survey-editor.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export const SurveyPlacementCard = ({
|
|||||||
|
|
||||||
const togglePlacement = () => {
|
const togglePlacement = () => {
|
||||||
if (setProjectOverwrites) {
|
if (setProjectOverwrites) {
|
||||||
if (!!placement) {
|
if (placement) {
|
||||||
setProjectOverwrites(null);
|
setProjectOverwrites(null);
|
||||||
} else {
|
} else {
|
||||||
setProjectOverwrites({
|
setProjectOverwrites({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ export const SurveyVariablesCard = ({
|
|||||||
|
|
||||||
const setOpenState = (state: boolean) => {
|
const setOpenState = (state: boolean) => {
|
||||||
if (state) {
|
if (state) {
|
||||||
|
// NOSONAR // This is ok for setOpenState
|
||||||
setActiveQuestionId(variablesCardId);
|
setActiveQuestionId(variablesCardId);
|
||||||
} else {
|
} else {
|
||||||
setActiveQuestionId(null);
|
setActiveQuestionId(null);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
262
apps/web/modules/survey/list/lib/project.test.ts
Normal file
262
apps/web/modules/survey/list/lib/project.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import { Prisma } from "@prisma/client";
|
|||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
export const getProjectWithLanguagesByEnvironmentId = reactCache(
|
export const getProjectWithLanguagesByEnvironmentId = reactCache(
|
||||||
async (environmentId: string): Promise<TProjectWithLanguages | null> =>
|
async (environmentId: string): Promise<TProjectWithLanguages | null> =>
|
||||||
@@ -49,9 +49,39 @@ export const getUserProjects = reactCache(
|
|||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
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({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
...projectWhereClause,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
|||||||
const loadScript = async () => {
|
const loadScript = async () => {
|
||||||
if (!window.formbricksSurveys) {
|
if (!window.formbricksSurveys) {
|
||||||
try {
|
try {
|
||||||
if (props.isSpamProtectionEnabled) {
|
if (props.isSpamProtectionEnabled && props.recaptchaSiteKey) {
|
||||||
await loadRecaptchaScript(props.recaptchaSiteKey);
|
await loadRecaptchaScript(props.recaptchaSiteKey);
|
||||||
}
|
}
|
||||||
await loadSurveyScript();
|
await loadSurveyScript();
|
||||||
|
|||||||
@@ -41,14 +41,13 @@
|
|||||||
"@lexical/react": "0.31.0",
|
"@lexical/react": "0.31.0",
|
||||||
"@lexical/rich-text": "0.31.0",
|
"@lexical/rich-text": "0.31.0",
|
||||||
"@lexical/table": "0.31.0",
|
"@lexical/table": "0.31.0",
|
||||||
"@opentelemetry/api-logs": "0.56.0",
|
"@opentelemetry/exporter-prometheus": "0.200.0",
|
||||||
"@opentelemetry/exporter-prometheus": "0.57.2",
|
"@opentelemetry/host-metrics": "0.36.0",
|
||||||
"@opentelemetry/host-metrics": "0.35.5",
|
"@opentelemetry/instrumentation": "0.200.0",
|
||||||
"@opentelemetry/instrumentation": "0.57.2",
|
"@opentelemetry/instrumentation-http": "0.200.0",
|
||||||
"@opentelemetry/instrumentation-http": "0.57.2",
|
"@opentelemetry/instrumentation-runtime-node": "0.14.0",
|
||||||
"@opentelemetry/instrumentation-runtime-node": "0.12.2",
|
"@opentelemetry/sdk-logs": "0.200.0",
|
||||||
"@opentelemetry/sdk-logs": "0.56.0",
|
"@opentelemetry/sdk-metrics": "2.0.0",
|
||||||
"@opentelemetry/sdk-metrics": "1.30.1",
|
|
||||||
"@paralleldrive/cuid2": "2.2.2",
|
"@paralleldrive/cuid2": "2.2.2",
|
||||||
"@prisma/client": "6.7.0",
|
"@prisma/client": "6.7.0",
|
||||||
"@radix-ui/react-accordion": "1.2.9",
|
"@radix-ui/react-accordion": "1.2.9",
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ export default defineConfig({
|
|||||||
"modules/ui/components/alert/*.tsx",
|
"modules/ui/components/alert/*.tsx",
|
||||||
"modules/ui/components/environmentId-base-layout/*.tsx",
|
"modules/ui/components/environmentId-base-layout/*.tsx",
|
||||||
"modules/ui/components/survey/recaptcha.ts",
|
"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",
|
"modules/ui/components/progress-bar/index.tsx",
|
||||||
"app/(app)/environments/**/layout.tsx",
|
"app/(app)/environments/**/layout.tsx",
|
||||||
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
||||||
@@ -83,13 +80,10 @@ export default defineConfig({
|
|||||||
"modules/ee/sso/lib/organization.ts",
|
"modules/ee/sso/lib/organization.ts",
|
||||||
"app/lib/**/*.ts",
|
"app/lib/**/*.ts",
|
||||||
"modules/ee/license-check/lib/utils.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/ee/role-management/*.ts",
|
||||||
"modules/organization/settings/teams/actions.ts",
|
"modules/organization/settings/teams/actions.ts",
|
||||||
"modules/organization/settings/api-keys/lib/**/*.ts",
|
"modules/organization/settings/api-keys/lib/**/*.ts",
|
||||||
"app/api/v1/**/*.ts",
|
"app/api/**/*.ts",
|
||||||
"app/api/cron/**/*.ts",
|
|
||||||
"modules/api/v2/management/auth/*.ts",
|
"modules/api/v2/management/auth/*.ts",
|
||||||
"modules/organization/settings/api-keys/components/*.tsx",
|
"modules/organization/settings/api-keys/components/*.tsx",
|
||||||
"modules/survey/hooks/*.tsx",
|
"modules/survey/hooks/*.tsx",
|
||||||
@@ -97,6 +91,7 @@ export default defineConfig({
|
|||||||
"modules/survey/components/template-list/components/template-tags.tsx",
|
"modules/survey/components/template-list/components/template-tags.tsx",
|
||||||
"modules/survey/lib/client-utils.ts",
|
"modules/survey/lib/client-utils.ts",
|
||||||
"modules/survey/components/edit-public-survey-alert-dialog/index.tsx",
|
"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-card.tsx",
|
||||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||||
"modules/auth/signup/**/*.ts",
|
"modules/auth/signup/**/*.ts",
|
||||||
@@ -114,41 +109,13 @@ export default defineConfig({
|
|||||||
"modules/analysis/**/*.tsx",
|
"modules/analysis/**/*.tsx",
|
||||||
"modules/analysis/**/*.ts",
|
"modules/analysis/**/*.ts",
|
||||||
"app/lib/survey-builder.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",
|
"lib/utils/billing.ts",
|
||||||
"modules/survey/list/components/copy-survey-form.tsx",
|
"modules/survey/list/components/copy-survey-form.tsx",
|
||||||
"lib/crypto.ts",
|
"lib/crypto.ts",
|
||||||
"lib/surveyLogic/utils.ts",
|
"lib/surveyLogic/utils.ts",
|
||||||
"lib/utils/billing.ts",
|
"lib/utils/billing.ts",
|
||||||
"modules/ui/components/card/index.tsx",
|
"modules/ui/components/card/index.tsx",
|
||||||
"modules/survey/editor/components/open-question-form.tsx",
|
"modules/survey/editor/components/*.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",
|
|
||||||
"lib/fileValidation.ts",
|
"lib/fileValidation.ts",
|
||||||
"modules/survey/editor/components/add-action-modal.tsx",
|
"modules/survey/editor/components/add-action-modal.tsx",
|
||||||
"modules/survey/editor/components/add-ending-card-button.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/file-upload-question-form.tsx",
|
||||||
"modules/survey/editor/components/how-to-send-card.tsx",
|
"modules/survey/editor/components/how-to-send-card.tsx",
|
||||||
"modules/survey/editor/components/image-survey-bg.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: [
|
exclude: [
|
||||||
"**/.next/**",
|
"**/.next/**",
|
||||||
@@ -167,8 +138,10 @@ export default defineConfig({
|
|||||||
"**/route.ts", // Exclude route files
|
"**/route.ts", // Exclude route files
|
||||||
"**/openapi.ts", // Exclude openapi configuration files
|
"**/openapi.ts", // Exclude openapi configuration files
|
||||||
"**/openapi-document.ts", // Exclude openapi document files
|
"**/openapi-document.ts", // Exclude openapi document files
|
||||||
|
"**/types/**", // Exclude types
|
||||||
"modules/**/types/**", // Exclude types
|
"modules/**/types/**", // Exclude types
|
||||||
"**/stories.tsx", // Exclude story files
|
"**/actions.ts", // Exclude action files
|
||||||
|
"**/stories.tsx" // Exclude story files
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const renderWidget = async (
|
|||||||
return executeRecaptcha(recaptchaSiteKey);
|
return executeRecaptcha(recaptchaSiteKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSpamProtectionEnabled) {
|
if (isSpamProtectionEnabled && recaptchaSiteKey) {
|
||||||
await loadRecaptchaScript(recaptchaSiteKey);
|
await loadRecaptchaScript(recaptchaSiteKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,16 +41,16 @@ export function LanguageSwitch({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
title="Language switch"
|
title="Language switch"
|
||||||
type="button"
|
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}
|
onClick={toggleDropdown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={showLanguageDropdown}>
|
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>
|
</button>
|
||||||
{showLanguageDropdown ? (
|
{showLanguageDropdown ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
202
pnpm-lock.yaml
generated
202
pnpm-lock.yaml
generated
@@ -181,30 +181,27 @@ importers:
|
|||||||
'@lexical/table':
|
'@lexical/table':
|
||||||
specifier: 0.31.0
|
specifier: 0.31.0
|
||||||
version: 0.31.0
|
version: 0.31.0
|
||||||
'@opentelemetry/api-logs':
|
|
||||||
specifier: 0.56.0
|
|
||||||
version: 0.56.0
|
|
||||||
'@opentelemetry/exporter-prometheus':
|
'@opentelemetry/exporter-prometheus':
|
||||||
specifier: 0.57.2
|
specifier: 0.200.0
|
||||||
version: 0.57.2(@opentelemetry/api@1.9.0)
|
version: 0.200.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/host-metrics':
|
'@opentelemetry/host-metrics':
|
||||||
specifier: 0.35.5
|
specifier: 0.36.0
|
||||||
version: 0.35.5(@opentelemetry/api@1.9.0)
|
version: 0.36.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/instrumentation':
|
'@opentelemetry/instrumentation':
|
||||||
specifier: 0.57.2
|
specifier: 0.200.0
|
||||||
version: 0.57.2(@opentelemetry/api@1.9.0)
|
version: 0.200.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/instrumentation-http':
|
'@opentelemetry/instrumentation-http':
|
||||||
specifier: 0.57.2
|
specifier: 0.200.0
|
||||||
version: 0.57.2(@opentelemetry/api@1.9.0)
|
version: 0.200.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/instrumentation-runtime-node':
|
'@opentelemetry/instrumentation-runtime-node':
|
||||||
specifier: 0.12.2
|
specifier: 0.14.0
|
||||||
version: 0.12.2(@opentelemetry/api@1.9.0)
|
version: 0.14.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/sdk-logs':
|
'@opentelemetry/sdk-logs':
|
||||||
specifier: 0.56.0
|
specifier: 0.200.0
|
||||||
version: 0.56.0(@opentelemetry/api@1.9.0)
|
version: 0.200.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/sdk-metrics':
|
'@opentelemetry/sdk-metrics':
|
||||||
specifier: 1.30.1
|
specifier: 2.0.0
|
||||||
version: 1.30.1(@opentelemetry/api@1.9.0)
|
version: 2.0.0(@opentelemetry/api@1.9.0)
|
||||||
'@paralleldrive/cuid2':
|
'@paralleldrive/cuid2':
|
||||||
specifier: 2.2.2
|
specifier: 2.2.2
|
||||||
version: 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)
|
version: 0.0.38(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: 9.15.0
|
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':
|
'@t3-oss/env-nextjs':
|
||||||
specifier: 0.13.4
|
specifier: 0.13.4
|
||||||
version: 0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.1)
|
version: 0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.1)
|
||||||
@@ -2009,9 +2006,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
deprecated: This functionality has been moved to @npmcli/fs
|
deprecated: This functionality has been moved to @npmcli/fs
|
||||||
|
|
||||||
'@opentelemetry/api-logs@0.56.0':
|
'@opentelemetry/api-logs@0.200.0':
|
||||||
resolution: {integrity: sha512-Wr39+94UNNG3Ei9nv3pHd4AJ63gq5nSemMRpCd8fPwDL9rN3vK26lzxfH27mw16XzOSO+TpyQwBAMaLxaPWG0g==}
|
resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
'@opentelemetry/api-logs@0.57.1':
|
'@opentelemetry/api-logs@0.57.1':
|
||||||
resolution: {integrity: sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==}
|
resolution: {integrity: sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==}
|
||||||
@@ -2031,15 +2028,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||||
|
|
||||||
'@opentelemetry/core@1.29.0':
|
'@opentelemetry/core@1.30.1':
|
||||||
resolution: {integrity: sha512-gmT7vAreXl0DTHD2rVZcw3+l2g84+5XiHIqdBUxXbExymPCvSsGOpiwMmn8nkiJur28STV31wnhIDrzWDPzjfA==}
|
resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||||
|
|
||||||
'@opentelemetry/core@1.30.1':
|
'@opentelemetry/core@2.0.0':
|
||||||
resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==}
|
resolution: {integrity: sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: ^18.19.0 || >=20.6.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||||
|
|
||||||
@@ -2055,15 +2052,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@opentelemetry/api': ^1.3.0
|
||||||
|
|
||||||
'@opentelemetry/exporter-prometheus@0.57.2':
|
'@opentelemetry/exporter-prometheus@0.200.0':
|
||||||
resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==}
|
resolution: {integrity: sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: ^18.19.0 || >=20.6.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@opentelemetry/api': ^1.3.0
|
||||||
|
|
||||||
'@opentelemetry/host-metrics@0.35.5':
|
'@opentelemetry/host-metrics@0.36.0':
|
||||||
resolution: {integrity: sha512-Zf9Cjl7H6JalspnK5KD1+LLKSVecSinouVctNmUxRy+WP+20KwHq+qg4hADllkEmJ99MZByLLmEmzrr7s92V6g==}
|
resolution: {integrity: sha512-14lNY57qa21V3ZOl6xrqLMHR0HGlnPIApR6hr3oCw/Dqs5IzxhTwt2X8Stn82vWJJis7j/ezn11oODsizHj2dQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: ^18.19.0 || >=20.6.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@opentelemetry/api': ^1.3.0
|
||||||
|
|
||||||
@@ -2121,6 +2118,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@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':
|
'@opentelemetry/instrumentation-http@0.57.2':
|
||||||
resolution: {integrity: sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==}
|
resolution: {integrity: sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2193,9 +2196,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@opentelemetry/api': ^1.3.0
|
||||||
|
|
||||||
'@opentelemetry/instrumentation-runtime-node@0.12.2':
|
'@opentelemetry/instrumentation-runtime-node@0.14.0':
|
||||||
resolution: {integrity: sha512-HNBW1rJiHDBTHQlh5oH1IAcV8O5VR7/L5BBOfGAMpGno3Jq9cNqTh96uUp0qBXBuxD8Yl1eoI5N+B5TdmjLteQ==}
|
resolution: {integrity: sha512-y78dGoFMKwHSz0SD113Gt1dFTcfunpPZXIJh2SzJN27Lyb9FIzuMfjc3Iu3+s/N6qNOLuS9mKnPe3/qVGG4Waw==}
|
||||||
engines: {node: '>=17.4.0'}
|
engines: {node: ^18.19.0 || >=20.6.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.3.0
|
'@opentelemetry/api': ^1.3.0
|
||||||
|
|
||||||
@@ -2211,6 +2214,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.7.0
|
'@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':
|
'@opentelemetry/instrumentation@0.57.2':
|
||||||
resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==}
|
resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2239,21 +2248,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==}
|
resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==}
|
||||||
engines: {node: '>=14'}
|
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':
|
'@opentelemetry/resources@1.30.1':
|
||||||
resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==}
|
resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||||
|
|
||||||
'@opentelemetry/sdk-logs@0.56.0':
|
'@opentelemetry/resources@2.0.0':
|
||||||
resolution: {integrity: sha512-OS0WPBJF++R/cSl+terUjQH5PebloidB1Jbbecgg2rnCmQbTST9xsRes23bLfDQVRvmegmHqDh884h0aRdJyLw==}
|
resolution: {integrity: sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg==}
|
||||||
engines: {node: '>=14'}
|
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:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
||||||
|
|
||||||
@@ -2269,6 +2278,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
'@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':
|
'@opentelemetry/sdk-trace-base@1.30.1':
|
||||||
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
|
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -11996,7 +12011,7 @@ snapshots:
|
|||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@opentelemetry/api-logs@0.56.0':
|
'@opentelemetry/api-logs@0.200.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
|
|
||||||
@@ -12014,16 +12029,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@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)':
|
'@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/semantic-conventions': 1.28.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)':
|
'@opentelemetry/exporter-metrics-otlp-grpc@0.57.1(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@grpc/grpc-js': 1.12.6
|
'@grpc/grpc-js': 1.12.6
|
||||||
@@ -12045,14 +12060,14 @@ snapshots:
|
|||||||
'@opentelemetry/resources': 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/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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
|
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
|
'@opentelemetry/resources': 2.0.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/sdk-metrics': 1.30.1(@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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
systeminformation: 5.23.8
|
systeminformation: 5.23.8
|
||||||
@@ -12132,6 +12147,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@opentelemetry/instrumentation-http@0.57.2(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12240,10 +12265,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -12264,6 +12289,17 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -12303,24 +12339,24 @@ snapshots:
|
|||||||
|
|
||||||
'@opentelemetry/redis-common@0.36.2': {}
|
'@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)':
|
'@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
|
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/semantic-conventions': 1.28.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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/api-logs': 0.56.0
|
'@opentelemetry/core': 2.0.0(@opentelemetry/api@1.9.0)
|
||||||
'@opentelemetry/core': 1.29.0(@opentelemetry/api@1.9.0)
|
'@opentelemetry/semantic-conventions': 1.32.0
|
||||||
'@opentelemetry/resources': 1.29.0(@opentelemetry/api@1.9.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)':
|
'@opentelemetry/sdk-logs@0.57.1(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12335,6 +12371,12 @@ snapshots:
|
|||||||
'@opentelemetry/core': 1.30.1(@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/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)':
|
'@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -13445,7 +13487,7 @@ snapshots:
|
|||||||
|
|
||||||
'@sentry/core@9.15.0': {}
|
'@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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
'@opentelemetry/semantic-conventions': 1.32.0
|
'@opentelemetry/semantic-conventions': 1.32.0
|
||||||
@@ -13453,7 +13495,7 @@ snapshots:
|
|||||||
'@sentry-internal/browser-utils': 9.15.0
|
'@sentry-internal/browser-utils': 9.15.0
|
||||||
'@sentry/core': 9.15.0
|
'@sentry/core': 9.15.0
|
||||||
'@sentry/node': 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/react': 9.15.0(react@19.1.0)
|
||||||
'@sentry/vercel-edge': 9.15.0
|
'@sentry/vercel-edge': 9.15.0
|
||||||
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.7)
|
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.7)
|
||||||
@@ -13511,6 +13553,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@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:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@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/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)
|
'@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-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-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-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)
|
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)
|
eslint-plugin-turbo: 2.5.0(eslint@8.57.0)(turbo@2.5.2)
|
||||||
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:
|
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:
|
eslint-import-resolver-node@0.3.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16400,11 +16452,11 @@ snapshots:
|
|||||||
tinyglobby: 0.2.13
|
tinyglobby: 0.2.13
|
||||||
unrs-resolver: 1.6.2
|
unrs-resolver: 1.6.2
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -16455,7 +16507,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
eslint-import-resolver-node: 0.3.9
|
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
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|||||||
Reference in New Issue
Block a user