Compare commits

..

18 Commits

Author SHA1 Message Date
Matthias Nannt
2392a436da fix tests with conditional assignment 2025-05-08 14:49:29 +02:00
Matthias Nannt
b917e9a4f8 fix(cloud): add followup permission check 2025-05-08 13:48:35 +02:00
Dhruwang Jariwala
47583b5a32 fix: unauthorized email address change (#5709) 2025-05-08 06:34:04 +00:00
Jakob Schott
03c9a6aaae chore: 576 test coverage: apps/web/modules/survey/list/lib (#5706) 2025-05-07 21:32:52 +00:00
Jakob Schott
4dcf9b093b chore: 576 test coverage components wrappers (#5702) 2025-05-07 21:31:43 +00:00
Jakob Schott
5ba5ebf63d chore: 576 test coverage apps web modules survey list components (#5704) 2025-05-07 19:24:15 +00:00
victorvhs017
115bea2792 chore: add tests to package/surveys/src/components/questions (#5694) 2025-05-07 18:42:25 +00:00
Piyush Gupta
b0495a8a42 chore: adds unit tests in module/projects (#5701) 2025-05-07 16:34:06 +00:00
Johannes
faabd371f5 fix: infinite loop and freeze (#5622)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 14:19:26 +00:00
Johannes
f0be6de0b3 chore: remove unused code (#5697) 2025-05-07 14:16:27 +00:00
Matti Nannt
b338c6d28d chore: remove unused cache files (#5700) 2025-05-07 15:26:13 +02:00
Anshuman Pandey
07e9a7c007 chore: tests for lib/utils and lib/survey (#5676) 2025-05-07 12:27:48 +00:00
victorvhs017
928bb3f8bc chore: updated sonar qube and vite config (#5695) 2025-05-07 11:13:07 +00:00
Piyush Jain
b9d62f6af2 fix: pin version 1 of helmfile actions (#5691) 2025-05-07 09:45:57 +02:00
Matti Nannt
f7ac38953b fix: infinite redirect issue (#5693)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 09:40:45 +02:00
Anshuman Pandey
6441c0aa31 fix: moves storage api management endpoint to use payload instead of … (#5348)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-07 04:55:58 +00:00
victorvhs017
16479eb6cf chore: add tests to environments path - part 2 (#5667) 2025-05-07 02:32:03 +02:00
Matti Nannt
69472c21c2 chore: simplify vite config for better coverage report (#5687) 2025-05-07 01:42:21 +02:00
196 changed files with 24613 additions and 2430 deletions

View File

@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- 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.
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
- 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.
- 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.
@@ -28,4 +28,5 @@ afterEach(() => {
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react
- You don't need to mock @tolgee/react
- Use "import "@testing-library/jest-dom/vitest";"

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release'
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
required: true
type: string
REPOSITORY:
@@ -67,7 +67,7 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
if: inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -75,6 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -84,13 +85,14 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
if: inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets

View File

@@ -30,5 +30,5 @@ jobs:
- docker-build
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

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

View File

@@ -264,7 +264,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"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"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -0,0 +1,456 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AddIntegrationModal } from "./AddIntegrationModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
() => ({
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
<div>
<label htmlFor="base">Base</label>
<select
id="base"
defaultValue={defaultValue}
onChange={(e) => {
control._mockOnChange({ target: { name: "base", value: e.target.value } });
setValue("table", ""); // Reset table when base changes
fetchTable(e.target.value);
}}>
<option value="">Select Base</option>
{airtableArray.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
fetchTables: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value, _locale) => value?.default || value || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey, _locale) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}) => (
<div data-testid="additional-settings">
<input
type="checkbox"
data-testid="include-variables"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-hidden"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-metadata"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-createdat"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
open ? (
<div data-testid="modal">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props) => <img alt="test" {...props} />,
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
}));
// Mock the Select component used for Table and Survey selections
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children }) => (
// Render children, assuming Controller passes props to the Trigger/Value
// The actual select logic will be handled by the mocked Controller/field
// We need to simulate the structure expected by the Controller render prop
<div>{children}</div>
),
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
SelectItem: ({ children, value, ...props }) => (
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
// For Controller, the value change is handled by field.onChange directly
<div data-value={value} {...props}>
{children}
</div>
),
}));
// Mock react-hook-form Controller to render a simple select
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
let fields = {};
const mockReset = vi.fn((values) => {
fields = values || {}; // Reset fields, optionally with new values
});
return {
...actual,
useForm: vi.fn((options) => {
fields = options?.defaultValues || {};
const mockControlOnChange = (event) => {
if (event && event.target) {
fields[event.target.name] = event.target.value;
}
};
return {
handleSubmit: (fn) => (e) => {
e?.preventDefault();
fn(fields);
},
control: {
_mockOnChange: mockControlOnChange,
// Add other necessary control properties if needed
register: vi.fn(),
unregister: vi.fn(),
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
_options: {},
_proxyFormState: {
isDirty: false,
isValidating: false,
dirtyFields: {},
touchedFields: {},
errors: {},
},
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
_updateFormState: vi.fn(),
_updateFieldArray: vi.fn(),
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
_getWatch: vi.fn(),
_subjects: {
watch: { subscribe: vi.fn() },
array: { subscribe: vi.fn() },
state: { subscribe: vi.fn() },
},
_getDirty: vi.fn(),
_reset: vi.fn(),
_removeUnmounted: vi.fn(),
},
watch: (name) => fields[name],
setValue: (name, value) => {
fields[name] = value;
},
reset: mockReset,
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
getValues: (name) => (name ? fields[name] : fields),
};
}),
Controller: ({ name, defaultValue }) => {
// Initialize field value if not already set by reset/defaultValues
if (fields[name] === undefined && defaultValue !== undefined) {
fields[name] = defaultValue;
}
const field = {
onChange: (valueOrEvent) => {
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
fields[name] = value;
// Re-render might be needed here in a real scenario, but testing library handles it
},
onBlur: vi.fn(),
value: fields[name],
name: name,
ref: vi.fn(),
};
// Find the corresponding label to associate with the select
const labelId = name; // Assuming label 'for' matches field name
const labelText =
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
// Render a simple select element instead of the complex component
// This makes interaction straightforward with userEvent.selectOptions
return (
<>
{/* The actual label is rendered outside the Controller in the component */}
<select
id={labelId}
aria-label={labelText} // Use aria-label for accessibility in tests
{...field} // Spread field props
defaultValue={defaultValue} // Pass defaultValue
>
{/* Need to dynamically get options based on context, simplified here */}
{name === "table" &&
mockTables.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
{name === "survey" &&
mockSurveys.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</>
);
},
reset: mockReset,
};
});
const environmentId = "test-env-id";
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
questions: [
{ id: "q1", headline: { default: "Question 1" } },
{ id: "q2", headline: { default: "Question 2" } },
],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
variables: { enabled: true, fieldIds: ["var1"] },
} as any,
{
id: "survey2",
name: "Survey 2",
questions: [{ id: "q3", headline: { default: "Question 3" } }],
hiddenFields: { enabled: false },
variables: { enabled: false },
} as any,
];
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base 1" },
{ id: "base2", name: "Base 2" },
];
const mockAirtableIntegration: TIntegrationAirtable = {
id: "integration1",
type: "airtable",
environmentId,
config: {
key: { access_token: "abc" } as TIntegrationAirtableCredential,
email: "test@test.com",
data: [],
},
};
const mockTables: TIntegrationAirtableTables["tables"] = [
{ id: "table1", name: "Table 1" },
{ id: "table2", name: "Table 2" },
];
const mockSetOpenWithStates = vi.fn();
const mockRouterRefresh = vi.fn();
describe("AddIntegrationModal", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
});
afterEach(() => {
cleanup();
});
test("renders in add mode correctly", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
expect(screen.getByLabelText("Base")).toBeInTheDocument();
// Use getByLabelText for the mocked selects
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
});
test("shows 'No Base Found' error when airtableArray is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={[]}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByTestId("alert-title")).toHaveTextContent(
"environments.integrations.airtable.no_bases_found"
);
});
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={[]}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
});
test("fetches and displays tables when a base is selected", async () => {
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
const baseSelect = screen.getByLabelText("Base");
await userEvent.selectOptions(baseSelect, "base1");
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
await waitFor(() => {
// Use getByLabelText (mocked select)
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
expect(tableSelect).toBeEnabled();
// Check options within the mocked select
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
});
});
test("handles deletion in edit mode", async () => {
const initialData: TIntegrationAirtableConfigData = {
baseId: "base1",
tableId: "table1",
surveyId: "survey1",
questionIds: ["q1"],
questions: "common.selected_questions",
tableName: "Table 1",
surveyName: "Survey 1",
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: true,
};
const integrationWithData = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, data: [initialData] },
};
const defaultData = { ...initialData, index: 0 } as any;
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={integrationWithData}
isEditMode={true}
defaultData={defaultData}
/>
);
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
// Click delete
await userEvent.click(screen.getByText("common.delete"));
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
// Expect data array to be empty after deletion
expect(submittedData.config.data).toHaveLength(0);
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalled();
});
test("handles cancel button click", async () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
await userEvent.click(screen.getByText("common.cancel"));
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,134 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
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 { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "./AirtableWrapper";
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
() => ({
ManageIntegration: ({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
Connect
</button>
</div>
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/airtableLogo.svg", () => ({
default: "airtable-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys = [];
const airtableArray = [];
const locale = "en-US" as const;
const baseProps = {
environmentId,
airtableArray,
surveys,
environment,
webAppUrl,
locale,
};
describe("AirtableWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected (no integration)", () => {
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
});
test("renders ConnectIntegration when not connected (integration without key)", () => {
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration disabled when isEnabled is false", () => {
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://airtable.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
test("renders ManageIntegration when connected", () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
// Initially, ManageIntegration is shown
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Simulate disconnection via ManageIntegration's button
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
await userEvent.click(disconnectButton);
// Now, ConnectIntegration should be shown
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
import { cleanup, render, screen } from "@testing-library/react";
import { useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import { IntegrationModalInputs } from "./AddIntegrationModal";
import { BaseSelectDropdown } from "./BaseSelectDropdown";
// Mock UI components
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
}));
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, disabled, defaultValue }) => (
<select
data-testid="base-select"
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled}
defaultValue={defaultValue}>
{children}
</select>
),
SelectTrigger: ({ children }) => <div>{children}</div>,
SelectValue: () => <span>SelectValueMock</span>,
SelectContent: ({ children }) => <div>{children}</div>,
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
}));
// Mock react-hook-form's Controller specifically
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
// Keep the actual useForm
const originalUseForm = actual.useForm;
// Mock Controller
const MockController = ({ name, _, render, defaultValue }) => {
// Minimal mock: call render with a basic field object
const field = {
onChange: vi.fn(), // Simple spy for field.onChange
onBlur: vi.fn(),
value: defaultValue, // Use defaultValue passed to Controller
name: name,
ref: vi.fn(),
};
// The component passes the render prop result to the actual Select component
return render({ field });
};
return {
...actual,
useForm: originalUseForm, // Use the actual useForm
Controller: MockController, // Use the mocked Controller
};
});
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base One" },
{ id: "base2", name: "Base Two" },
];
const mockFetchTable = vi.fn();
// Use a wrapper component that utilizes the actual useForm
const renderComponent = (
isLoading = false,
defaultValue: string | undefined = undefined,
airtableArray = mockAirtableArray
) => {
const Component = () => {
// Now uses the actual useForm because Controller is mocked separately
const { control, setValue } = useForm<IntegrationModalInputs>({
defaultValues: { base: defaultValue },
});
return (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={mockFetchTable} // The spy
airtableArray={airtableArray}
setValue={setValue} // Actual RHF setValue
defaultValue={defaultValue}
/>
);
};
return render(<Component />);
};
describe("BaseSelectDropdown", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the label and select trigger", () => {
renderComponent();
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
expect(screen.getByTestId("base-select")).toBeInTheDocument();
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
});
test("renders options from airtableArray", () => {
renderComponent();
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
expect(screen.getByText("Base One")).toBeInTheDocument();
expect(screen.getByText("Base Two")).toBeInTheDocument();
});
test("disables the select when isLoading is true", () => {
renderComponent(true);
expect(screen.getByTestId("base-select")).toBeDisabled();
});
test("enables the select when isLoading is false", () => {
renderComponent(false);
expect(screen.getByTestId("base-select")).toBeEnabled();
});
test("renders correctly with empty airtableArray", () => {
renderComponent(false, undefined, []);
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
import { authorize, fetchTables } from "./airtable";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
global.fetch = vi.fn();
const environmentId = "test-env-id";
const baseId = "test-base-id";
const apiHost = "http://localhost:3000";
describe("Airtable Library", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("fetchTables", () => {
test("should fetch tables successfully", async () => {
const mockTables: TIntegrationAirtableTables = {
tables: [
{ id: "tbl1", name: "Table 1" },
{ id: "tbl2", name: "Table 2" },
],
};
const mockResponse = {
ok: true,
json: async () => ({ data: mockTables }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const tables = await fetchTables(environmentId, baseId);
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
cache: "no-store",
});
expect(tables).toEqual(mockTables);
});
});
describe("authorize", () => {
test("should return authUrl successfully", async () => {
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
const mockResponse = {
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const authUrl = await authorize(environmentId, apiHost);
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(authUrl).toBe(mockAuthUrl);
});
test("should throw error and log when fetch fails", async () => {
const errorText = "Failed to fetch";
const mockResponse = {
ok: false,
text: async () => errorText,
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
});
});
});

View File

@@ -0,0 +1,217 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/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 { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
vi.mock("@/lib/airtable/service");
let mockAirtableClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get AIRTABLE_CLIENT_ID() {
return mockAirtableClientId;
},
WEBAPP_URL: "http://localhost:3000",
IS_PRODUCTION: true,
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service");
vi.mock("@/lib/utils/locale");
vi.mock("@/modules/environments/lib/utils");
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation");
const mockEnvironmentId = "test-env-id";
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
const mockAirtableIntegration: TIntegrationAirtable = {
type: "airtable",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
data: [],
email: "test@example.com",
},
environmentId: mockEnvironmentId,
id: "int_airtable_123",
};
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
const mockLocale = "en-US";
const props = {
params: {
environmentId: mockEnvironmentId,
},
};
describe("Airtable Integration Page", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects if user is readOnly", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as unknown as TEnvironmentAuth);
await render(await Page(props));
expect(redirect).toHaveBeenCalledWith("./");
});
test("renders correctly when integration is configured", async () => {
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true,
airtableIntegration: mockAirtableIntegration,
airtableArray: mockAirtableTables,
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined
);
});
test("renders correctly when integration exists but is not configured (no key)", async () => {
const integrationWithoutKey = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, key: undefined },
} as unknown as TIntegrationAirtable;
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
// Update assertion to match the actual call
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
airtableIntegration: integrationWithoutKey,
airtableArray: [], // Should be empty as getAirtableTables is not called
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined // Change second argument to undefined
);
});
test("renders correctly when integration is disabled (no client ID)", async () => {
mockAirtableClientId = undefined; // Simulate disabled integration
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
expect.objectContaining({
isEnabled: false, // Should be false
}),
undefined
);
});
});

View File

@@ -0,0 +1,694 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
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 {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
getSpreadsheetNameByIdAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
<div>
<label>{label}</label>
<select
data-testid="survey-dropdown"
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}>
<option value="">Select a survey</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
return "Please enter a valid Google Sheet URL.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
return "Sync responses with Google Sheets.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const getSpreadsheetNameByIdAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
.getSpreadsheetNameByIdAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
key: {
access_token: "mock_access_token",
expiry_date: Date.now() + 3600000,
refresh_token: "mock_refresh_token",
scope: "mock_scope",
token_type: "Bearer",
},
email: "test@example.com",
data: [], // Initially empty, will be populated in beforeEach
},
} as unknown as TIntegrationGoogleSheets;
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
spreadsheetId: "existing-sheet-id",
spreadsheetName: "Existing Sheet",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddIntegrationModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockGoogleSheetIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toBeInTheDocument();
// Use getByTestId for the dropdown
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={{
...mockGoogleSheetIntegration,
config: { ...mockGoogleSheetIntegration.config, data: [] },
}} // Start with empty data
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
googleSheetIntegration: expect.any(Object),
environmentId,
spreadsheetId: "new-sheet-id",
});
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "googleSheets",
config: expect.objectContaining({
key: mockGoogleSheetIntegration.config.key,
email: mockGoogleSheetIntegration.config.email,
data: expect.arrayContaining([
expect.objectContaining({
spreadsheetId: "new-sheet-id",
spreadsheetName: "Test Sheet Name",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error for invalid URL", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "invalid-url");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (URL should be empty)
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input check after re-render
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("");
});
});

View File

@@ -0,0 +1,175 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
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 {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock child components and functions
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
() => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
</div>
)),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization}>Connect</button>
</div>
)),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
() => ({
AddIntegrationModal: vi.fn(({ open }) =>
open ? <div data-testid="add-integration-modal">Modal</div> : null
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockWebAppUrl = "http://localhost:3000";
const mockLocale = "en-US";
const mockGoogleSheetIntegration = {
id: "test-integration-id",
type: "googleSheets",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
data: [],
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
describe("GoogleSheetWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
// No googleSheetIntegration provided initially
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
});
test("renders ConnectIntegration when integration exists but has no key", () => {
const integrationWithoutKey = {
...mockGoogleSheetIntegration,
config: { data: [], email: "test" },
} as unknown as TIntegrationGoogleSheets;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={integrationWithoutKey}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize when connect button is clicked", async () => {
const user = userEvent.setup();
// Mock window.location.replace
const originalLocation = window.location;
// @ts-expect-error
delete window.location;
window.location = { ...originalLocation, replace: vi.fn() } as any;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
// Need to wait for the promise returned by authorize to resolve
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
});
// Restore window.location
window.location = originalLocation as any;
});
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Modal is rendered but initially hidden
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
const user = userEvent.setup();
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
await user.click(openModalButton);
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./google";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith(
{ errorText },
"authorize: Could not fetch google sheet config"
);
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util";
describe("Google Sheets Util", () => {
describe("extractSpreadsheetIdFromUrl", () => {
test("should extract spreadsheet ID from a valid URL", () => {
const url =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId);
});
test("should throw an error for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL");
});
test("should throw an error for a URL without an ID", () => {
const urlWithoutId = "https://docs.google.com/spreadsheets/d/";
expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL");
});
});
describe("constructGoogleSheetsUrl", () => {
test("should construct a valid Google Sheets URL from a spreadsheet ID", () => {
const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
const expectedUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl);
});
});
describe("isValidGoogleSheetsUrl", () => {
test("should return true for a valid Google Sheets URL", () => {
const validUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
expect(isValidGoogleSheetsUrl(validUrl)).toBe(true);
});
test("should return false for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false);
});
test("should return true for a base Google Sheets URL", () => {
const baseUrl = "https://docs.google.com/spreadsheets/d/";
expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true);
});
});
});

View File

@@ -0,0 +1,40 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock the GoBackButton component
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div>GoBackButton</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders the loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByText("GoBackButton")).toBeInTheDocument();
// Check for the disabled button text
expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button")
).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none");
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument();
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (count based on the loop)
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
// Calculate expected placeholders: 3 rows * 5 placeholders per row = 15
// Plus the button, header divs (4), and the main containers
// It's simpler to check if there are *any* pulse animations
expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true);
});
});

View File

@@ -0,0 +1,228 @@
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrations } from "@/lib/integration/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 { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
() => ({
GoogleSheetWrapper: vi.fn(
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
<div>
<span>Mocked GoogleSheetWrapper</span>
<span data-testid="isEnabled">{isEnabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{googleSheetIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockGoogleSheetClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get GOOGLE_SHEETS_CLIENT_ID() {
return mockGoogleSheetClientId;
},
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
data: [],
key: {
refresh_token: "refresh",
access_token: "access",
expiry_date: Date.now() + 3600000,
} as unknown as TIntegrationGoogleSheetsCredential,
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("GoogleSheetsIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(
screen.getByText("environments.integrations.google_sheets.google_sheets_integration")
).toBeInTheDocument();
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("isEnabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => {
mockGoogleSheetClientId = undefined;
const { default: PageWithMissingConstants } = (await import(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
)) as { default: typeof Page };
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
const PageComponent = await PageWithMissingConstants(mockProps);
render(PageComponent);
expect(screen.getByTestId("isEnabled")).toHaveTextContent("false");
});
test("handles case where no Google Sheet integration exists", async () => {
vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
});
});

View File

@@ -0,0 +1,172 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
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 } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
}));
vi.mock("@/lib/survey/utils");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just return the function
};
});
const environmentId = "test-environment-id";
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
];
const mockTransformedSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
{
id: "survey2",
name: "Survey 2",
status: "draft",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app",
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
];
describe("getSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should fetch and transform surveys successfully", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
if (!found) throw new Error("Survey not found in mock transformed data");
// Ensure the returned object matches the TSurvey structure precisely
return { ...found } as TSurvey;
});
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// Check if the inner cache function was called with the correct arguments
expect(cache).toHaveBeenCalledWith(
expect.any(Function), // The async function passed to cache
[`getSurveys-${environmentId}`], // The cache key
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
}
);
// Remove the assertion for reactCache being called within the test execution
// expect(reactCache).toHaveBeenCalled(); // Removed this line
});
test("should throw DatabaseError on Prisma known request error", async () => {
// No need to mock cache here again as beforeEach handles it
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2025",
clientVersion: "5.0.0",
meta: {}, // Added meta property
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
test("should throw original error on other errors", async () => {
// No need to mock cache here again as beforeEach handles it
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
});

View File

@@ -0,0 +1,114 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
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 { DatabaseError } from "@formbricks/types/errors";
import { getWebhookCountBySource } from "./webhook";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
},
},
}));
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
count: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return webhook count for a specific source", async () => {
const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[sourceZapier, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: sourceZapier,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
}
);
});
test("should return total webhook count when source is undefined", async () => {
const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: undefined,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-undefined`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
}
);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,606 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
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 {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionTypes: () => [
{ id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" },
{ id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" },
{ id: TSurveyQuestionTypeEnum.Date, label: "Date" },
],
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, params?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database";
if (key === "environments.integrations.notion.sync_responses_with_a_notion_database")
return "Sync responses with a Notion database.";
if (key === "environments.integrations.notion.select_a_database") return "Select a database";
if (key === "common.select_survey") return "Select survey";
if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property")
return "Map Formbricks fields to Notion property";
if (key === "environments.integrations.notion.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.notion.please_select_a_database")
return "Please select a database.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.notion.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.notion.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
return "Please complete mapping fields.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.notion.notion_logo") return "Notion logo";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration")
return "Create at least one database.";
if (key === "environments.integrations.notion.duplicate_connection_warning")
return "Duplicate connection warning.";
if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to")
return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`;
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Date,
headline: { default: "Date Question?" },
required: true,
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const databases: TIntegrationNotionDatabase[] = [
{
id: "db1",
name: "Database 1 Title",
properties: {
prop1: { id: "p1", name: "Title Prop", type: "title" },
prop2: { id: "p2", name: "Text Prop", type: "rich_text" },
prop3: { id: "p3", name: "Number Prop", type: "number" },
prop4: { id: "p4", name: "Date Prop", type: "date" },
prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported
},
},
{
id: "db2",
name: "Database 2 Title",
properties: {
propA: { id: "pa", name: "Name", type: "title" },
propB: { id: "pb", name: "Email", type: "email" },
},
},
];
const mockNotionIntegration: TIntegrationNotion = {
id: "integration1",
type: "notion",
environmentId: environmentId,
config: {
key: {
access_token: "token",
bot_id: "bot",
workspace_name: "ws",
workspace_icon: "",
} as unknown as TIntegrationNotionCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = {
databaseId: databases[0].id,
databaseName: databases[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
column: { id: "p1", name: "Title Prop", type: "title" },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
column: { id: "p2", name: "Text Prop", type: "rich_text" },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Notion)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockNotionIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration}
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
expect(columnDropdowns).toHaveLength(2);
// Assert values for the first row
expect(questionDropdowns[0]).toHaveValue("q1");
expect(columnDropdowns[0]).toHaveValue("p1");
// Assert values for the second row
expect(questionDropdowns[1]).toHaveValue("var1");
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
});
test("selects database and survey, shows mapping", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument();
});
test("adds and removes mapping rows", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
const plusButton = screen.getByTestId("plus-icon");
await userEvent.click(plusButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration} // Contains initial data at index 0
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no database selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a database.");
});
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
});
test("shows validation error if no mapping defined", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
// Default mapping row is empty
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping.");
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const cancelButton = screen.getByText("Cancel");
await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset
});
});

View File

@@ -0,0 +1,152 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
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 { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { NotionWrapper } from "./NotionWrapper";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
// Mock child components
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(
(
{ handleAuthorization, isEnabled } // Reverted back to isEnabled
) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
{" "}
{/* Reverted back to isEnabled */}
Connect
</button>
</div>
)
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/notion-logo.svg", () => ({
default: "notion-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys: TSurvey[] = [];
const databases = [];
const locale = "en-US" as const;
const mockNotionIntegration: TIntegrationNotion = {
id: "int-notion-123",
type: "notion",
environmentId: environmentId,
config: {
key: { access_token: "test-token" } as TIntegrationNotionCredential,
data: [],
},
};
const baseProps = {
environment,
surveys,
databasesArray: databases, // Renamed databases to databasesArray to match component prop
webAppUrl,
locale,
};
describe("NotionWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration disabled when enabled is false", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={false} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => {
// Changed description slightly
const integrationWithoutKey = {
...mockNotionIntegration,
config: { data: [] },
} as unknown as TIntegrationNotion;
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={integrationWithoutKey} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://notion.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
});

View File

@@ -0,0 +1,58 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./notion";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config");
});
});

View File

@@ -0,0 +1,50 @@
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/button", () => ({
Button: ({ children, className }: { children: React.ReactNode; className: string }) => (
<button className={className}>{children}</button>
),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div data-testid="go-back-button">Go Back</div>,
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Simple mock translation
}),
}));
describe("Notion Integration Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByTestId("go-back-button")).toBeInTheDocument();
// Check for the disabled button
const linkButton = screen.getByText("environments.integrations.notion.link_database");
expect(linkButton).toBeInTheDocument();
expect(linkButton.closest("button")).toHaveClass(
"pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"
);
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (skeleton loaders)
// There should be 3 rows * 5 pulse divs per row = 15 pulse divs
const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" });
expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered
});
});

View File

@@ -0,0 +1,250 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/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 { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
NotionWrapper: vi.fn(
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked NotionWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{notionIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockNotionClientId: string | undefined = "test-client-id";
let mockNotionClientSecret: string | undefined = "test-client-secret";
let mockNotionAuthUrl: string | undefined = "https://notion.com/auth";
let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect";
vi.mock("@/lib/constants", () => ({
get NOTION_OAUTH_CLIENT_ID() {
return mockNotionClientId;
},
get NOTION_OAUTH_CLIENT_SECRET() {
return mockNotionClientSecret;
},
get NOTION_AUTH_URL() {
return mockNotionAuthUrl;
},
get NOTION_REDIRECT_URI() {
return mockNotionRedirectUri;
},
WEBAPP_URL: "test-webapp-url",
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/notion/service", () => ({
getNotionDatabases: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockNotionIntegration = {
id: "integration1",
type: "notion",
config: {
data: [],
key: { bot_id: "bot-id-123" },
email: "test@example.com",
},
} as unknown as TIntegrationNotion;
const mockDatabases: TIntegrationNotionDatabase[] = [
{ id: "db1", name: "Database 1", properties: {} },
{ id: "db2", name: "Database 2", properties: {} },
];
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("NotionIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration);
vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
mockNotionClientId = "test-client-id";
mockNotionClientSecret = "test-client-secret";
mockNotionAuthUrl = "https://notion.com/auth";
mockNotionRedirectUri = "https://app.formbricks.com/redirect";
});
test("renders the page with NotionWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes enabled=false to NotionWrapper when constants are missing", async () => {
mockNotionClientId = undefined; // Simulate missing constant
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("enabled")).toHaveTextContent("false");
});
test("handles case where no Notion integration exists", async () => {
vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
test("handles case where integration exists but has no key (bot_id)", async () => {
const integrationWithoutKey = {
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, key: undefined },
} as unknown as TIntegrationNotion;
vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id);
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,243 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
import { getIntegrations } from "@/lib/integration/service";
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 { TEnvironment } from "@formbricks/types/environment";
import { TIntegration } from "@formbricks/types/integration";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
getWebhookCountBySource: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/integration-card", () => ({
Card: ({ label, description, statusText, disabled }) => (
<div data-testid={`card-${label}`}>
<h1>{label}</h1>
<p>{description}</p>
<span>{statusText}</span>
{disabled && <span>Disabled</span>}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }) => <img alt={alt} />,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: true,
} as unknown as TEnvironment;
const mockIntegrations: TIntegration[] = [
{
id: "google-sheets-id",
type: "googleSheets",
environmentId: "test-env-id",
config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"],
},
{
id: "slack-id",
type: "slack",
environmentId: "test-env-id",
config: { data: [] } as unknown as TIntegration["config"],
},
];
const mockParams = { environmentId: "test-env-id" };
const mockProps = { params: mockParams };
describe("Integrations Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getWebhookCountBySource).mockResolvedValue(0);
vi.mocked(getIntegrations).mockResolvedValue([]);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
});
test("renders the page header and integration cards", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "zapier") return 1;
if (source === "user") return 2;
return 0;
});
vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.website_or_app_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status
expect(screen.getByTestId("card-Zapier")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument();
expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status
expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument();
expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status
expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheet_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status
expect(screen.getByTestId("card-Airtable")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.airtable_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status
expect(screen.getByTestId("card-Slack")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status
expect(screen.getByTestId("card-n8n")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status
expect(screen.getByTestId("card-Make.com")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status
expect(screen.getByTestId("card-Notion")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status
expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.activepieces_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status
});
test("renders disabled cards when isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
// JS SDK and Webhooks should not be disabled
expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled");
// Other cards should be disabled
expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled");
});
test("redirects when isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: true,
} as unknown as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
`/environments/${mockParams.environmentId}/settings/billing`
);
});
test("renders correct status text for single integration", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 1;
if (source === "make") return 1;
if (source === "activepieces") return 1;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration");
});
test("renders correct status text for multiple integrations", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 3;
if (source === "make") return 4;
if (source === "activepieces") return 5;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations");
});
test("renders not connected status when widgetSetupCompleted is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: { ...mockEnvironment, appSetupCompleted: false },
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected");
});
});

View File

@@ -0,0 +1,750 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
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 { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { AddChannelMappingModal } from "./AddChannelMappingModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, disabled }: any) => (
<div>
<label>{label}</label>
<select
data-testid={label.includes("channel") ? "channel-dropdown" : "survey-dropdown"}
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">Select...</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.slack.select_channel") return "Select channel";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.slack.please_select_a_channel")
return "Please select a channel.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.slack.dont_see_your_channel") return "Don't see your channel?";
if (key === "common.note") return "Note";
if (key === "environments.integrations.slack.already_connected_another_survey")
return "This channel is already connected to another survey.";
if (key === "environments.integrations.slack.create_at_least_one_channel_error")
return "Please create at least one channel in Slack first.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.slack.link_channel") return "Link Channel";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
vi.mock("lucide-react", () => ({
CircleHelpIcon: () => <div data-testid="circle-help-icon" />,
Check: () => <div data-testid="check-icon" />, // Add the Check icon mock
Loader2: () => <div data-testid="loader-icon" />, // Add the Loader2 icon mock
}));
// Mock dependencies
const createOrUpdateIntegrationActionMock = vi.mocked(createOrUpdateIntegrationAction);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const channels: TIntegrationItem[] = [
{ id: "channel1", name: "#general" },
{ id: "channel2", name: "#random" },
];
const mockSlackIntegration: TIntegrationSlack = {
id: "integration1",
type: "slack",
environmentId: environmentId,
config: {
key: {
access_token: "xoxb-test-token",
team_name: "Test Team",
team_id: "T123",
} as unknown as TIntegrationSlackCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationSlackConfigData & { index: number } = {
channelId: channels[0].id,
channelName: channels[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddChannelMappingModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockSlackIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Channel" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
expect(screen.getByText("Don't see your channel?")).toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={{ ...mockSlackIntegration, config: { ...mockSlackIntegration.config, data: [] } }} // Start with empty data
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[1].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "slack",
config: expect.objectContaining({
key: mockSlackIntegration.config.key,
data: expect.arrayContaining([
expect.objectContaining({
channelId: channels[1].id,
channelName: channels[1].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any });
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration} // Contains initial data at index 0
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no channel selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// No channel selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a channel.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
createOrUpdateIntegrationActionMock.mockRejectedValue(new Error(errorMessage));
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (channel should be unselected)
cleanup();
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("channel-dropdown")).toHaveValue("");
});
test("shows warning when selected channel is already connected (add mode)", async () => {
// Add an existing connection for channel1
const integrationWithExisting = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey-other",
surveyName: "Other Survey",
questionIds: ["q-other"],
questions: "All questions",
createdAt: new Date(),
} as TIntegrationSlackConfigData,
],
},
};
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationWithExisting}
channels={channels}
selectedIntegration={null} // Add mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
await userEvent.selectOptions(channelDropdown, "channel1");
expect(screen.getByText("This channel is already connected to another survey.")).toBeInTheDocument();
});
test("does not show warning when selected channel is the one being edited", async () => {
// Edit the existing connection for channel1
const integrationToEdit = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey1",
surveyName: "Survey 1",
questionIds: ["q1"],
questions: "Selected questions",
createdAt: new Date(),
index: 0,
} as TIntegrationSlackConfigData & { index: number },
],
},
};
const selectedIntegrationForEdit = integrationToEdit.config.data[0];
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationToEdit}
channels={channels}
selectedIntegration={selectedIntegrationForEdit} // Edit mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
// Channel is already selected via selectedIntegration prop
expect(channelDropdown).toHaveValue("channel1");
expect(
screen.queryByText("This channel is already connected to another survey.")
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,171 @@
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 { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "../actions";
import { authorize } from "../lib/slack";
import { SlackWrapper } from "./SlackWrapper";
// Mock child components and actions
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/actions", () => ({
getSlackChannelsAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal",
() => ({
AddChannelMappingModal: vi.fn(({ open }) => (open ? <div data-testid="add-modal">Add Modal</div> : null)),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
<button onClick={() => setIsConnected(false)}>Disconnect</button>
<button onClick={handleSlackAuthorization}>Reconnect</button>
</div>
)),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack", () => ({
authorize: vi.fn(),
}));
vi.mock("@/images/slacklogo.png", () => ({
default: "slack-logo-path",
}));
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(({ handleAuthorization, isEnabled }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
Connect
</button>
</div>
)),
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockWebAppUrl = "http://localhost:3000";
const mockLocale: TUserLocale = "en-US";
const mockSlackChannels: TIntegrationItem[] = [{ id: "C123", name: "general" }];
const mockSlackIntegration: TIntegrationSlack = {
id: "slack-int-1",
type: "slack",
environmentId: "test-env-id",
config: {
key: { access_token: "xoxb-valid-token" } as unknown as TIntegrationSlackCredential,
data: [],
},
};
const baseProps = {
environment: mockEnvironment,
surveys: mockSurveys,
webAppUrl: mockWebAppUrl,
locale: mockLocale,
};
describe("SlackWrapper", () => {
beforeEach(() => {
vi.mocked(getSlackChannelsAction).mockResolvedValue({ data: mockSlackChannels });
vi.mocked(authorize).mockResolvedValue("https://slack.com/auth");
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected (no integration)", () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
});
test("renders ConnectIntegration when not connected (integration without key)", () => {
const integrationWithoutKey = { ...mockSlackIntegration, config: { data: [], email: "test" } } as any;
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={integrationWithoutKey} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration disabled when isEnabled is false", () => {
render(<SlackWrapper {...baseProps} isEnabled={false} slackIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
});
});
test("renders ManageIntegration and AddChannelMappingModal (hidden) when connected", () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument(); // Modal is initially hidden
});
test("calls getSlackChannelsAction on mount", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
await waitFor(() => {
expect(getSlackChannelsAction).toHaveBeenCalledWith({ environmentId: mockEnvironment.id });
});
});
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
await userEvent.click(disconnectButton);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("opens AddChannelMappingModal when triggered from ManageIntegration", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument();
const openModalButton = screen.getByRole("button", { name: "Open Modal" });
await userEvent.click(openModalButton);
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("calls handleSlackAuthorization when reconnect button is clicked in ManageIntegration", async () => {
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
const reconnectButton = screen.getByRole("button", { name: "Reconnect" });
await userEvent.click(reconnectButton);
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./slack";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
global.fetch = vi.fn();
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/slack`;
const expectedAuthUrl = "http://slack.com/auth";
test("should return authUrl on successful fetch", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: expectedAuthUrl } }),
} as Response);
const authUrl = await authorize(environmentId, apiHost);
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
});
expect(authUrl).toBe(expectedAuthUrl);
});
test("should throw error and log error on failed fetch", async () => {
const errorText = "Failed to fetch";
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
text: async () => errorText,
} as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: { environmentId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config");
});
});

View File

@@ -0,0 +1,222 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import Page from "@/app/(app)/environments/[environmentId]/integrations/slack/page";
import { getIntegrationByType } from "@/lib/integration/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 { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper", () => ({
SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => (
<div data-testid="slack-wrapper">
Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys=
{surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale}
</div>
)),
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: true,
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SLACK_CLIENT_ID: "test-slack-client-id",
SLACK_CLIENT_SECRET: "test-slack-client-secret",
WEBAPP_URL: "http://test.formbricks.com",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back-button">Go Back: {url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1 data-testid="page-header">{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
// Mock data
const environmentId = "test-env-id";
const mockEnvironment = {
id: environmentId,
createdAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: environmentId,
status: "inProgress",
type: "link",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
languages: [],
styling: null,
segment: null,
resultShareKey: null,
displayPercentage: null,
closeOnDate: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockSlackIntegration = {
id: "slack-int-id",
type: "slack",
config: {
data: [],
key: "test-key" as unknown as TIntegrationSlackCredential,
},
} as unknown as TIntegrationSlack;
const mockLocale = "en-US";
const mockParams = { params: { environmentId } };
describe("SlackIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockSlackIntegration);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
test("renders correctly when user is not read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
const tree = await Page(mockParams);
render(tree);
expect(screen.getByTestId("page-header")).toHaveTextContent(
"environments.integrations.slack.slack_integration"
);
expect(screen.getByTestId("go-back-button")).toHaveTextContent(
`Go Back: http://test.formbricks.com/environments/${environmentId}/integrations`
);
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
// Check props passed to SlackWrapper
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
{
isEnabled: true, // Since SLACK_CLIENT_ID and SLACK_CLIENT_SECRET are mocked
environment: mockEnvironment,
surveys: mockSurveys,
slackIntegration: mockSlackIntegration,
webAppUrl: "http://test.formbricks.com",
locale: mockLocale,
},
undefined
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("redirects when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: true,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
// Need to actually call the component function to trigger the redirect logic
await Page(mockParams);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
expect(vi.mocked(SlackWrapper)).not.toHaveBeenCalled();
});
test("renders correctly when Slack integration is not configured", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
environment: mockEnvironment,
} as unknown as TEnvironmentAuth);
vi.mocked(getIntegrationByType).mockResolvedValue(null); // Simulate no integration found
const tree = await Page(mockParams);
render(tree);
expect(screen.getByTestId("page-header")).toHaveTextContent(
"environments.integrations.slack.slack_integration"
);
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
// Check props passed to SlackWrapper when integration is null
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
{
isEnabled: true,
environment: mockEnvironment,
surveys: mockSurveys,
slackIntegration: null, // Expecting null here
webAppUrl: "http://test.formbricks.com",
locale: mockLocale,
},
undefined
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,14 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import WebhooksPage from "./page";
vi.mock("@/modules/integrations/webhooks/page", () => ({
WebhooksPage: vi.fn(() => <div>WebhooksPageMock</div>),
}));
describe("WebhooksIntegrationPage", () => {
test("renders WebhooksPage component", () => {
render(<WebhooksPage params={{ environmentId: "test-env-id" }} />);
expect(WebhooksPage).toHaveBeenCalled();
});
});

View File

@@ -9,7 +9,7 @@ import { ZId } from "@formbricks/types/common";
import { ZUserUpdateInput } from "@formbricks/types/user";
export const updateUserAction = authenticatedActionClient
.schema(ZUserUpdateInput.partial())
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, parsedInput);
});

View File

@@ -1,6 +1,10 @@
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => {
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
afterEach(() => {
cleanup();
});
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: true,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeDefined();
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
// Call the header function to trigger the t function call with "common.verified_email"
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
verifiedEmailColumn.header();
expect(mockT).toHaveBeenCalledWith("common.verified_email");
}
});
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: false,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Column Implementations", () => {
afterEach(() => {
cleanup();
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
// Call the header function to test it returns the expected value
expect(dateColumn?.header?.()).toBe("common.date");
// Mock a response with a date to test the cell function
const mockRow = {
original: { createdAt: "2023-01-01T12:00:00Z" },
} as any;
// Call the cell function and check the formatted date
dateColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Test header content
const headerResult = personColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with no person
const mockRow = {
original: { person: null },
} as any;
// Mock the t function for this specific call
t.mockReturnValueOnce("Anonymous User");
// Call the cell function and check it returns "Anonymous"
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
expect(t).toHaveBeenCalledWith("common.anonymous");
expect(cellResult?.props?.children).toBe("Anonymous User");
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Mock a response with a person
const mockRow = {
original: {
person: { id: "123", attributes: { email: "test@example.com" } },
contactAttributes: { name: "John Doe" },
},
} as any;
// Call the cell function
personColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
mockRow.original.person,
mockRow.original.contactAttributes
);
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
// Mock a response with no tags
const mockRow = {
original: { tags: null },
} as any;
// Call the cell function
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
expect(var1Column).toBeDefined();
// Test the header
const headerResult = var1Column?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a string variable
const mockRow = {
original: { variables: { var1: "Test Value" } },
} as any;
// Call the cell function
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Test Value");
// Test with a number variable
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
expect(var2Column).toBeDefined();
const mockRowNumber = {
original: { variables: { var2: 42 } },
} as any;
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
expect(cellResultNumber?.props.children).toBe(42);
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeDefined();
// Test the header
const headerResult = hfColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a hidden field value
const mockRow = {
original: { responseData: { hf1: "Hidden Value" } },
} as any;
// Call the cell function
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Hidden Value");
});
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
// Create a survey with no hidden field IDs
const surveyWithNoHiddenFields = {
...mockSurvey,
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeUndefined();
});
});

View File

@@ -0,0 +1,263 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseFilter } from "./ResponseFilter";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getSurveyFilterDataAction: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
generateQuestionAndFilterOptions: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
Select Question
</button>
</div>
),
OptionsType: {
QUESTIONS: "Questions",
ATTRIBUTES: "Attributes",
TAGS: "Tags",
LANGUAGES: "Languages",
},
}));
// Update the mock for QuestionFilterComboBox to always render
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
() => ({
QuestionFilterComboBox: () => (
<div data-testid="filter-combo-box">
<button data-testid="select-filter-btn">Select Filter</button>
<button data-testid="select-filter-type-btn">Select Filter Type</button>
</div>
),
})
);
describe("ResponseFilter", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockSelectedFilter = {
filter: [],
onlyComplete: false,
};
const mockSelectedOptions = {
questionFilterOptions: [
{
type: TSurveyQuestionTypeEnum.OpenText,
filterOptions: ["equals", "does not equal"],
filterComboBoxOptions: [],
id: "q1",
},
],
questionOptions: [
{
label: "Questions",
type: "Questions",
option: [
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
],
},
],
} as any;
const mockSetSelectedFilter = vi.fn();
const mockSetSelectedOptions = vi.fn();
const mockSurvey = {
id: "survey1",
environmentId: "env1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
createdBy: "user1",
questions: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
triggers: [],
displayOption: "displayOnce",
} as unknown as TSurvey;
beforeEach(() => {
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: mockSelectedFilter,
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
questionOptions: mockSelectedOptions.questionOptions,
});
});
test("renders with default state", () => {
render(<ResponseFilter survey={mockSurvey} />);
expect(screen.getByText("Filter")).toBeInTheDocument();
});
test("opens the filter popover when clicked", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
expect(mockSetSelectedOptions).toHaveBeenCalled();
});
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify there's no filter yet
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
// Add a new filter and check that the questions combo box appears
await userEvent.click(screen.getByText("common.add_filter"));
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles only complete checkbox toggle", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
});
test("handles selecting question and filter options", async () => {
// Setup with a pre-populated filter to ensure the filter components are rendered
const setSelectedFilterMock = vi.fn();
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [
{
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
onlyComplete: false,
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify both combo boxes are rendered
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
// Use data-testid to find our buttons instead of text
await userEvent.click(screen.getByText("Select Question"));
await userEvent.click(screen.getByTestId("select-filter-btn"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(setSelectedFilterMock).toHaveBeenCalled();
});
test("handles clear all filters", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
test("uses sharing key action when on sharing page", async () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "env1",
surveyId: "survey1",
sharingKey: "share123",
});
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
sharingKey: "share123",
environmentId: "env1",
});
});
});

View File

@@ -1,4 +1,5 @@
import { sendFollowUpEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
@@ -92,6 +93,14 @@ export const sendSurveyFollowUps = async (
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult[]> => {
// check for permission in Formbricks Cloud
const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organization.billing?.plan);
if (!isSurveyFollowUpsAllowed) {
logger.warn(
`Survey follow-ups are not allowed for the current billing plan "${organization.billing?.plan}". Skipping sending follow-ups.`
);
return [];
}
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;

View File

@@ -9,8 +9,8 @@ import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
@@ -18,28 +18,27 @@ export const POST = async (req: NextRequest): Promise<Response> => {
}
const accessType = "public"; // public files are accessible by anyone
const headersList = await headers();
const fileType = headersList.get("X-File-Type");
const encodedFileName = headersList.get("X-File-Name");
const environmentId = headersList.get("X-Environment-ID");
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const signedSignature = headersList.get("X-Signature");
const signedUuid = headersList.get("X-UUID");
const signedTimestamp = headersList.get("X-Timestamp");
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("fileType is required");
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
@@ -88,8 +87,9 @@ export const POST = async (req: NextRequest): Promise<Response> => {
return responses.unauthorizedResponse();
}
const formData = await req.formData();
const file = formData.get("file") as unknown as File;
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
@@ -105,6 +105,7 @@ export const POST = async (req: NextRequest): Promise<Response> => {
message: "File uploaded successfully",
});
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
}

View File

@@ -2,7 +2,7 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/singleUseSurveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";

View File

@@ -0,0 +1,266 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as fileUploadModule from "./fileUpload";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockAtoB = vi.fn();
global.atob = mockAtoB;
// Mock FileReader
const mockFileReader = {
readAsDataURL: vi.fn(),
result: "data:image/jpeg;base64,test",
onload: null as any,
onerror: null as any,
};
// Mock File object
const createMockFile = (name: string, type: string, size: number) => {
const file = new File([], name, { type });
Object.defineProperty(file, "size", {
value: size,
writable: false,
});
return file;
};
describe("fileUpload", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock FileReader
global.FileReader = vi.fn(() => mockFileReader) as any;
global.atob = (base64) => Buffer.from(base64, "base64").toString("binary");
});
afterEach(() => {
vi.clearAllMocks();
});
test("should return error when no file is provided", async () => {
const result = await fileUploadModule.handleFileUpload(null as any, "test-env");
expect(result.error).toBe(fileUploadModule.FileUploadError.NO_FILE);
expect(result.url).toBe("");
});
test("should return error when file is not an image", async () => {
const file = createMockFile("test.pdf", "application/pdf", 1000);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Please upload an image file.");
expect(result.url).toBe("");
});
test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB
// Mock arrayBuffer to return >10MB buffer
file.arrayBuffer = vi.fn().mockResolvedValueOnce(new ArrayBuffer(11 * 1024 * 1024)); // 11MB
const result = await fileUploadModule.handleFileUpload(file, "env-oversize-buffer");
expect(result.error).toBe(fileUploadModule.FileUploadError.FILE_SIZE_EXCEEDED);
expect(result.url).toBe("");
});
test("should handle API error when getting signed URL", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock failed API response
mockFetch.mockResolvedValueOnce({
ok: false,
});
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should handle successful file upload with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle successful file upload without presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
signingData: {
signature: "test-signature",
timestamp: 1234567890,
uuid: "test-uuid",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
global.atob = vi.fn(() => {
throw new Error("Failed to decode base64 string");
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should handle upload error", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
// Mock failed upload response
mockFetch.mockResolvedValueOnce({
ok: false,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.url).toBe("");
});
test("should catch unexpected errors and return UPLOAD_FAILED", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Force arrayBuffer() to throw
file.arrayBuffer = vi.fn().mockImplementation(() => {
throw new Error("Unexpected crash in arrayBuffer");
});
const result = await fileUploadModule.handleFileUpload(file, "env-crash");
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
expect(result.url).toBe("");
});
});
describe("fileUploadModule.toBase64", () => {
test("resolves with base64 string when FileReader succeeds", async () => {
const dummyFile = new File(["hello"], "hello.txt", { type: "text/plain" });
// Mock FileReader
const mockReadAsDataURL = vi.fn();
const mockFileReaderInstance = {
readAsDataURL: mockReadAsDataURL,
onload: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
onerror: null,
result: "data:text/plain;base64,aGVsbG8=",
};
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
const promise = fileUploadModule.toBase64(dummyFile);
// Trigger the onload manually
mockFileReaderInstance.onload?.call(mockFileReaderInstance as unknown as FileReader, new Error("load"));
const result = await promise;
expect(result).toBe("data:text/plain;base64,aGVsbG8=");
});
test("rejects when FileReader errors", async () => {
const dummyFile = new File(["oops"], "oops.txt", { type: "text/plain" });
const mockReadAsDataURL = vi.fn();
const mockFileReaderInstance = {
readAsDataURL: mockReadAsDataURL,
onload: null,
onerror: null as ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null,
result: null,
};
globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any;
const promise = fileUploadModule.toBase64(dummyFile);
// Simulate error
mockFileReaderInstance.onerror?.call(mockFileReaderInstance as unknown as FileReader, new Error("error"));
await expect(promise).rejects.toThrow();
});
});

View File

@@ -1,90 +1,146 @@
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
}
export const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
export const handleFileUpload = async (
file: File,
environmentId: string
environmentId: string,
allowedFileExtensions?: string[]
): Promise<{
error?: string;
error?: FileUploadError;
url: string;
}> => {
if (!file) return { error: "No file provided", url: "" };
try {
if (!(file instanceof File)) {
return {
error: FileUploadError.NO_FILE,
url: "",
};
}
if (!file.type.startsWith("image/")) {
return { error: "Please upload an image file.", url: "" };
}
if (!file.type.startsWith("image/")) {
return { error: FileUploadError.INVALID_FILE_TYPE, url: "" };
}
if (file.size > 10 * 1024 * 1024) {
return {
error: "File size must be less than 10 MB.",
url: "",
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",
};
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
environmentId,
};
}
const payload = {
fileName: file.name,
fileType: file.type,
environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
// throw new Error(`Upload failed with status: ${response.status}`);
return {
error: "Upload failed. Please try again.",
url: "",
};
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Environment-ID": environmentId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
}
// Add the actual file to be uploaded
formData.append("file", file);
if (!response.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
environmentId,
signature,
timestamp: String(timestamp),
uuid,
};
}
const fileBase64 = (await toBase64(file)) as string;
const formData: Record<string, string> = {};
const formDataForS3 = new FormData();
if (presignedFields) {
Object.entries(presignedFields as Record<string, string>).forEach(([key, value]) => {
formDataForS3.append(key, value);
});
try {
const binaryString = atob(fileBase64.split(",")[1]);
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
const blob = new Blob([uint8Array], { type: file.type });
formDataForS3.append("file", blob);
} catch (err) {
console.error(err);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
}
formData.fileBase64String = fileBase64;
const uploadResponse = await fetch(signedUrl, {
method: "POST",
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
});
if (!uploadResponse.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
if (!uploadResponse.ok) {
return {
error: "Upload failed. Please try again.",
url: fileUrl,
};
} catch (error) {
console.error("Error in uploading file: ", error);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
return {
url: fileUrl,
};
};

View File

@@ -1,34 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { actionClassCache } from "./cache";
import { getActionClass } from "./service";
export const canUserUpdateActionClass = (userId: string, actionClassId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [actionClassId, ZId]);
try {
if (!userId) return false;
const actionClass = await getActionClass(actionClassId);
if (!actionClass) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, actionClass.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserUpdateActionClass-${userId}-${actionClassId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
}
)();

View File

@@ -1,66 +0,0 @@
import { revalidateTag } from "next/cache";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface RevalidateProps {
id?: string;
environmentId?: string | null;
surveyId?: string | null;
responseId?: string | null;
questionId?: string | null;
insightId?: string | null;
}
export const documentCache = {
tag: {
byId(id: string) {
return `documents-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documents`;
},
byResponseId(responseId: string) {
return `responses-${responseId}-documents`;
},
byResponseIdQuestionId(responseId: string, questionId: TSurveyQuestionId) {
return `responses-${responseId}-questions-${questionId}-documents`;
},
bySurveyId(surveyId: string) {
return `surveys-${surveyId}-documents`;
},
bySurveyIdQuestionId(surveyId: string, questionId: TSurveyQuestionId) {
return `surveys-${surveyId}-questions-${questionId}-documents`;
},
byInsightId(insightId: string) {
return `insights-${insightId}-documents`;
},
byInsightIdSurveyIdQuestionId(insightId: string, surveyId: string, questionId: TSurveyQuestionId) {
return `insights-${insightId}-surveys-${surveyId}-questions-${questionId}-documents`;
},
},
revalidate: ({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void => {
if (id) {
revalidateTag(documentCache.tag.byId(id));
}
if (environmentId) {
revalidateTag(documentCache.tag.byEnvironmentId(environmentId));
}
if (responseId) {
revalidateTag(documentCache.tag.byResponseId(responseId));
}
if (surveyId) {
revalidateTag(documentCache.tag.bySurveyId(surveyId));
}
if (responseId && questionId) {
revalidateTag(documentCache.tag.byResponseIdQuestionId(responseId, questionId));
}
if (surveyId && questionId) {
revalidateTag(documentCache.tag.bySurveyIdQuestionId(surveyId, questionId));
}
if (insightId) {
revalidateTag(documentCache.tag.byInsightId(insightId));
}
if (insightId && surveyId && questionId) {
revalidateTag(documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId));
}
},
};

View File

@@ -1,25 +0,0 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
}
export const insightCache = {
tag: {
byId(id: string) {
return `documentGroups-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documentGroups`;
},
},
revalidate: ({ id, environmentId }: RevalidateProps): void => {
if (id) {
revalidateTag(insightCache.tag.byId(id));
}
if (environmentId) {
revalidateTag(insightCache.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -1,39 +0,0 @@
export const fetchRessource = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
// we still try to parse and throw it.
if (!res.ok) {
const error: any = new Error("An error occurred while fetching the data.");
// Attach extra info to the error object.
error.info = await res.json();
error.status = res.status;
throw error;
}
return res.json();
};
export const fetcher = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
// we still try to parse and throw it.
if (!res.ok) {
const error: any = new Error("An error occurred while fetching the data.");
// Attach extra info to the error object.
error.info = await res.json();
error.status = res.status;
throw error;
}
return res.json();
};
export const updateRessource = async (url: string, { arg }: { arg: any }) => {
return fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
};

View File

@@ -1,4 +1,4 @@
import { mockSurveyLanguages } from "@/lib/survey/tests/__mock__/survey.mock";
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
import {
TSurvey,
TSurveyCTAQuestion,

View File

@@ -1,28 +0,0 @@
import "server-only";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { TI18nString } from "@formbricks/types/surveys/types";
import { isI18nObject } from "./utils";
// Helper function to extract a regular string from an i18nString.
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
if (typeof i18nString === "object" && i18nString !== null) {
return i18nString[languageCode] || "";
}
return i18nString;
};
// Assuming I18nString and extraction logic are defined
const reverseTranslateObject = <T extends Record<string, any>>(obj: T, languageCode: string): T => {
const clonedObj = structuredClone(obj);
for (let key in clonedObj) {
const value = clonedObj[key];
if (isI18nObject(value)) {
// Now TypeScript knows `value` is I18nString, treat it accordingly
clonedObj[key] = extractStringFromI18n(value, languageCode) as T[Extract<keyof T, string>];
} else if (typeof value === "object" && value !== null) {
// Recursively handle nested objects
clonedObj[key] = reverseTranslateObject(value, languageCode);
}
}
return clonedObj;
};

View File

@@ -1,31 +0,0 @@
import "server-only";
import { ZId } from "@formbricks/types/common";
import { cache } from "../cache";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { getIntegration } from "./service";
export const canUserAccessIntegration = async (userId: string, integrationId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [integrationId, ZId]);
if (!userId) return false;
try {
const integration = await getIntegration(integrationId);
if (!integration) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, integration.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessIntegration-${userId}-${integrationId}`],
{
tags: [`integrations-${integrationId}`],
}
)();

View File

@@ -1,36 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getSurvey } from "../survey/service";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import { getResponse } from "./service";
export const canUserAccessResponse = (userId: string, responseId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseId, ZId]);
if (!userId) return false;
try {
const response = await getResponse(responseId);
if (!response) return false;
const survey = await getSurvey(response.surveyId);
if (!survey) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessResponse-${userId}-${responseId}`],
{
tags: [responseCache.tag.byId(responseId)],
}
)();

View File

@@ -22,7 +22,7 @@ import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import {

View File

@@ -24,7 +24,7 @@ import {
mockContactAttributeKey,
mockOrganizationOutput,
mockSurveyOutput,
} from "../../survey/tests/__mock__/survey.mock";
} from "../../survey/__mock__/survey.mock";
import {
deleteResponse,
getResponse,

View File

@@ -1,66 +0,0 @@
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { canUserAccessResponse } from "../response/auth";
import { getResponse } from "../response/service";
import { validateInputs } from "../utils/validate";
import { responseNoteCache } from "./cache";
import { getResponseNote } from "./service";
export const canUserModifyResponseNote = async (userId: string, responseNoteId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseNoteId, ZId]);
if (!userId || !responseNoteId) return false;
try {
const responseNote = await getResponseNote(responseNoteId);
if (!responseNote) return false;
return responseNote.user.id === userId;
} catch (error) {
throw error;
}
},
[`canUserModifyResponseNote-${userId}-${responseNoteId}`],
{
tags: [responseNoteCache.tag.byId(responseNoteId)],
}
)();
export const canUserResolveResponseNote = async (
userId: string,
responseId: string,
responseNoteId: string
): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseNoteId, ZId]);
if (!userId || !responseId || !responseNoteId) return false;
try {
const response = await getResponse(responseId);
let noteExistsOnResponse = false;
response?.notes.forEach((note) => {
if (note.id === responseNoteId) {
noteExistsOnResponse = true;
}
});
if (!noteExistsOnResponse) return false;
const canAccessResponse = await canUserAccessResponse(userId, responseId);
return canAccessResponse;
} catch (error) {
throw error;
}
},
[`canUserResolveResponseNote-${userId}-${responseNoteId}`],
{
tags: [responseNoteCache.tag.byId(responseNoteId)],
}
)();

View File

@@ -13,7 +13,7 @@ import {
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { selectSurvey } from "../../service";
import { selectSurvey } from "../service";
const selectContact = {
id: true,

View File

@@ -1,31 +0,0 @@
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { getSurvey } from "./service";
export const canUserAccessSurvey = (userId: string, surveyId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([surveyId, ZId], [userId, ZId]);
if (!userId) return false;
try {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error("Survey not found");
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessSurvey-${userId}-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)();

View File

@@ -0,0 +1,122 @@
import { cleanup } from "@testing-library/react";
import { revalidateTag } from "next/cache";
import { afterEach, describe, expect, test, vi } from "vitest";
import { surveyCache } from "./cache";
// Mock the revalidateTag function from next/cache
vi.mock("next/cache", () => ({
revalidateTag: vi.fn(),
}));
describe("surveyCache", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("tag methods", () => {
test("byId returns the correct tag string", () => {
const id = "survey-123";
expect(surveyCache.tag.byId(id)).toBe(`surveys-${id}`);
});
test("byEnvironmentId returns the correct tag string", () => {
const environmentId = "env-456";
expect(surveyCache.tag.byEnvironmentId(environmentId)).toBe(`environments-${environmentId}-surveys`);
});
test("byAttributeClassId returns the correct tag string", () => {
const attributeClassId = "attr-789";
expect(surveyCache.tag.byAttributeClassId(attributeClassId)).toBe(
`attributeFilters-${attributeClassId}-surveys`
);
});
test("byActionClassId returns the correct tag string", () => {
const actionClassId = "action-012";
expect(surveyCache.tag.byActionClassId(actionClassId)).toBe(`actionClasses-${actionClassId}-surveys`);
});
test("bySegmentId returns the correct tag string", () => {
const segmentId = "segment-345";
expect(surveyCache.tag.bySegmentId(segmentId)).toBe(`segments-${segmentId}-surveys`);
});
test("byResultShareKey returns the correct tag string", () => {
const resultShareKey = "share-678";
expect(surveyCache.tag.byResultShareKey(resultShareKey)).toBe(`surveys-resultShare-${resultShareKey}`);
});
});
describe("revalidate method", () => {
test("calls revalidateTag with correct tag when id is provided", () => {
const id = "survey-123";
surveyCache.revalidate({ id });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${id}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when attributeClassId is provided", () => {
const attributeClassId = "attr-789";
surveyCache.revalidate({ attributeClassId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`attributeFilters-${attributeClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when actionClassId is provided", () => {
const actionClassId = "action-012";
surveyCache.revalidate({ actionClassId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${actionClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when environmentId is provided", () => {
const environmentId = "env-456";
surveyCache.revalidate({ environmentId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${environmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when segmentId is provided", () => {
const segmentId = "segment-345";
surveyCache.revalidate({ segmentId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${segmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when resultShareKey is provided", () => {
const resultShareKey = "share-678";
surveyCache.revalidate({ resultShareKey });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${resultShareKey}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag multiple times when multiple parameters are provided", () => {
const props = {
id: "survey-123",
environmentId: "env-456",
attributeClassId: "attr-789",
actionClassId: "action-012",
segmentId: "segment-345",
resultShareKey: "share-678",
};
surveyCache.revalidate(props);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(6);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${props.id}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${props.environmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(
`attributeFilters-${props.attributeClassId}-surveys`
);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${props.actionClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${props.segmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${props.resultShareKey}`);
});
test("does not call revalidateTag when no parameters are provided", () => {
surveyCache.revalidate({});
expect(vi.mocked(revalidateTag)).not.toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -109,7 +109,7 @@ export const selectSurvey = {
followUps: true,
} satisfies Prisma.SurveySelect;
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
if (!triggers) return;
// check if all the triggers are valid

View File

@@ -1,476 +0,0 @@
import { prisma } from "@/lib/__mocks__/database";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test } from "vitest";
import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey, getSurveyCount, getSurveys, getSurveysByActionClassId, updateSurvey } from "../service";
import {
mockActionClass,
mockId,
mockOrganizationOutput,
mockSurveyOutput,
mockSurveyWithLogic,
mockTransformedSurveyOutput,
updateSurveyInput,
} from "./__mock__/survey.mock";
beforeEach(() => {
prisma.survey.count.mockResolvedValue(1);
});
describe("evaluateLogic with mockSurveyWithLogic", () => {
test("should return true when q1 answer is blue", () => {
const data = { q1: "blue" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
test("should return false when q1 answer is not blue", () => {
const data = { q1: "red" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
test("should return true when q1 is blue and q2 is pizza", () => {
const data = { q1: "blue", q2: "pizza" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
test("should return false when q1 is blue but q2 is not pizza", () => {
const data = { q1: "blue", q2: "burger" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
test("should return true when q2 is pizza or q3 is Inception", () => {
const data = { q2: "pizza", q3: "Inception" };
const variablesData = {};
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[2].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
test("should return true when var1 is equal to single select question value", () => {
const data = { q4: "lmao" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "lmao" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
test("should return false when var1 is not equal to single select question value", () => {
const data = { q4: "lol" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "damn" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
test("should return true when var2 is greater than 30 and less than open text number value", () => {
const data = { q5: "40" };
const variablesData = { km1srr55owtn2r7lkoh5ny1u: 35 };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
test("should return false when var2 is not greater than 30 or greater than open text number value", () => {
const data = { q5: "40" };
const variablesData = { km1srr55owtn2r7lkoh5ny1u: 25 };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
"default"
);
expect(result).toBe(false);
});
test("should return for complex condition", () => {
const data = { q6: ["lmao", "XD"], q1: "green", q2: "pizza", q3: "inspection", name: "pizza" };
const variablesData = { siog1dabtpo3l0a3xoxw2922: "tokyo" };
const result = evaluateLogic(
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[5].logic![0].conditions,
"default"
);
expect(result).toBe(true);
});
});
describe("Tests for getSurvey", () => {
describe("Happy Path", () => {
test("Returns a survey", async () => {
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
const survey = await getSurvey(mockId);
expect(survey).toEqual(mockTransformedSurveyOutput);
});
test("Returns null if survey is not found", async () => {
prisma.survey.findUnique.mockResolvedValueOnce(null);
const survey = await getSurvey(mockId);
expect(survey).toBeNull();
});
});
describe("Sad Path", () => {
testInputValidation(getSurvey, "123#");
test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.survey.findUnique.mockRejectedValue(errToThrow);
await expect(getSurvey(mockId)).rejects.toThrow(DatabaseError);
});
test("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Mock error message";
prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurvey(mockId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getSurveysByActionClassId", () => {
describe("Happy Path", () => {
test("Returns an array of surveys for a given actionClassId", async () => {
prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
const surveys = await getSurveysByActionClassId(mockId);
expect(surveys).toEqual([mockTransformedSurveyOutput]);
});
test("Returns an empty array if no surveys are found", async () => {
prisma.survey.findMany.mockResolvedValueOnce([]);
const surveys = await getSurveysByActionClassId(mockId);
expect(surveys).toEqual([]);
});
});
describe("Sad Path", () => {
testInputValidation(getSurveysByActionClassId, "123#");
test("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurveysByActionClassId(mockId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getSurveys", () => {
describe("Happy Path", () => {
test("Returns an array of surveys for a given environmentId, limit(optional) and offset(optional)", async () => {
prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
const surveys = await getSurveys(mockId);
expect(surveys).toEqual([mockTransformedSurveyOutput]);
});
test("Returns an empty array if no surveys are found", async () => {
prisma.survey.findMany.mockResolvedValueOnce([]);
const surveys = await getSurveys(mockId);
expect(surveys).toEqual([]);
});
});
describe("Sad Path", () => {
testInputValidation(getSurveysByActionClassId, "123#");
test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.survey.findMany.mockRejectedValue(errToThrow);
await expect(getSurveys(mockId)).rejects.toThrow(DatabaseError);
});
test("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurveys(mockId)).rejects.toThrow(Error);
});
});
});
describe("Tests for updateSurvey", () => {
beforeEach(() => {
prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
});
describe("Happy Path", () => {
test("Updates a survey successfully", async () => {
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput);
const updatedSurvey = await updateSurvey(updateSurveyInput);
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
});
});
describe("Sad Path", () => {
testInputValidation(updateSurvey, "123#");
test("Throws ResourceNotFoundError if the survey does not exist", async () => {
prisma.survey.findUnique.mockRejectedValueOnce(
new ResourceNotFoundError("Survey", updateSurveyInput.id)
);
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.update.mockRejectedValue(errToThrow);
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(DatabaseError);
});
test("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage));
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error);
});
});
});
// describe("Tests for createSurvey", () => {
// beforeEach(() => {
// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
// });
// describe("Happy Path", () => {
// test("Creates a survey successfully", async () => {
// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
// prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
// prisma.actionClass.findMany.mockResolvedValue([mockActionClass]);
// prisma.user.findMany.mockResolvedValueOnce([
// {
// ...mockUser,
// twoFactorSecret: null,
// backupCodes: null,
// password: null,
// identityProviderAccountId: null,
// groupId: null,
// role: "engineer",
// },
// ]);
// prisma.user.update.mockResolvedValueOnce({
// ...mockUser,
// twoFactorSecret: null,
// backupCodes: null,
// password: null,
// identityProviderAccountId: null,
// groupId: null,
// role: "engineer",
// });
// const createdSurvey = await createSurvey(mockId, createSurveyInput);
// expect(createdSurvey).toEqual(mockTransformedSurveyOutput);
// });
// });
// describe("Sad Path", () => {
// testInputValidation(createSurvey, "123#", createSurveyInput);
// test("should throw an error if there is an unknown error", async () => {
// const mockErrorMessage = "Unknown error occurred";
// prisma.survey.delete.mockRejectedValue(new Error(mockErrorMessage));
// await expect(createSurvey(mockId, createSurveyInput)).rejects.toThrow(Error);
// });
// });
// });
// describe("Tests for duplicateSurvey", () => {
// beforeEach(() => {
// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
// });
// describe("Happy Path", () => {
// test("Duplicates a survey successfully", async () => {
// prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
// // @ts-expect-error
// prisma.environment.findUnique.mockResolvedValueOnce(mockEnvironment);
// // @ts-expect-error
// prisma.project.findFirst.mockResolvedValueOnce(mockProject);
// prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass);
// prisma.actionClass.create.mockResolvedValueOnce(mockActionClass);
// const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId);
// expect(createdSurvey).toEqual(mockSurveyOutput);
// });
// });
// describe("Sad Path", () => {
// testInputValidation(copySurveyToOtherEnvironment, "123#", "123#", "123#", "123#", "123#");
// test("Throws ResourceNotFoundError if the survey does not exist", async () => {
// prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(
// ResourceNotFoundError
// );
// });
// test("should throw an error if there is an unknown error", async () => {
// const mockErrorMessage = "Unknown error occurred";
// prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(Error);
// });
// });
// });
// describe("Tests for getSyncSurveys", () => {
// describe("Happy Path", () => {
// beforeEach(() => {
// prisma.project.findFirst.mockResolvedValueOnce({
// ...mockProject,
// brandColor: null,
// highlightBorderColor: null,
// logo: null,
// });
// prisma.display.findMany.mockResolvedValueOnce([mockDisplay]);
// prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
// });
// test("Returns synced surveys", async () => {
// prisma.survey.findMany.mockResolvedValueOnce([mockSyncSurveyOutput]);
// prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson);
// prisma.response.findMany.mockResolvedValue([mockResponseWithMockPerson]);
// prisma.responseNote.findMany.mockResolvedValue([mockResponseNote]);
// const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", {
// version: "1.7.0",
// });
// expect(surveys).toEqual([mockTransformedSyncSurveyOutput]);
// });
// test("Returns an empty array if no surveys are found", async () => {
// prisma.survey.findMany.mockResolvedValueOnce([]);
// prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson);
// const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", {
// version: "1.7.0",
// });
// expect(surveys).toEqual([]);
// });
// });
// describe("Sad Path", () => {
// testInputValidation(getSyncSurveys, "123#", {});
// test("does not find a Project", async () => {
// prisma.project.findFirst.mockResolvedValueOnce(null);
// await expect(
// getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" })
// ).rejects.toThrow(Error);
// });
// test("should throw an error if there is an unknown error", async () => {
// const mockErrorMessage = "Unknown error occurred";
// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
// prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
// await expect(
// getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" })
// ).rejects.toThrow(Error);
// });
// });
// });
describe("Tests for getSurveyCount service", () => {
describe("Happy Path", () => {
test("Counts the total number of surveys for a given environment ID", async () => {
const count = await getSurveyCount(mockId);
expect(count).toEqual(1);
});
test("Returns zero count when there are no surveys for a given environment ID", async () => {
prisma.survey.count.mockResolvedValue(0);
const count = await getSurveyCount(mockId);
expect(count).toEqual(0);
});
});
describe("Sad Path", () => {
testInputValidation(getSurveyCount, "123#");
test("Throws a generic Error for other unexpected issues", async () => {
const mockErrorMessage = "Mock error message";
prisma.survey.count.mockRejectedValue(new Error(mockErrorMessage));
await expect(getSurveyCount(mockId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,254 @@
import * as fileValidation from "@/lib/fileValidation";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
describe("transformPrismaSurvey", () => {
test("transforms prisma survey without segment", () => {
const surveyPrisma = {
id: "survey1",
name: "Test Survey",
displayPercentage: "30",
segment: null,
};
const result = transformPrismaSurvey(surveyPrisma);
expect(result).toEqual({
id: "survey1",
name: "Test Survey",
displayPercentage: 30,
segment: null,
});
});
test("transforms prisma survey with segment", () => {
const surveyPrisma = {
id: "survey1",
name: "Test Survey",
displayPercentage: "50",
segment: {
id: "segment1",
name: "Test Segment",
filters: [{ id: "filter1", type: "user" }],
surveys: [{ id: "survey1" }, { id: "survey2" }],
},
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result).toEqual({
id: "survey1",
name: "Test Survey",
displayPercentage: 50,
segment: {
id: "segment1",
name: "Test Segment",
filters: [{ id: "filter1", type: "user" }],
surveys: ["survey1", "survey2"],
},
});
});
test("transforms prisma survey with non-numeric displayPercentage", () => {
const surveyPrisma = {
id: "survey1",
name: "Test Survey",
displayPercentage: "invalid",
};
const result = transformPrismaSurvey<TJsEnvironmentStateSurvey>(surveyPrisma);
expect(result).toEqual({
id: "survey1",
name: "Test Survey",
displayPercentage: null,
segment: null,
});
});
test("transforms prisma survey with undefined displayPercentage", () => {
const surveyPrisma = {
id: "survey1",
name: "Test Survey",
};
const result = transformPrismaSurvey(surveyPrisma);
expect(result).toEqual({
id: "survey1",
name: "Test Survey",
displayPercentage: null,
segment: null,
});
});
});
describe("anySurveyHasFilters", () => {
test("returns false when no surveys have segments", () => {
const surveys = [
{ id: "survey1", name: "Survey 1" },
{ id: "survey2", name: "Survey 2" },
] as TSurvey[];
expect(anySurveyHasFilters(surveys)).toBe(false);
});
test("returns false when surveys have segments but no filters", () => {
const surveys = [
{
id: "survey1",
name: "Survey 1",
segment: {
id: "segment1",
title: "Segment 1",
filters: [],
createdAt: new Date(),
description: "Segment description",
environmentId: "env1",
isPrivate: true,
surveys: ["survey1"],
updatedAt: new Date(),
} as TSegment,
},
{ id: "survey2", name: "Survey 2" },
] as TSurvey[];
expect(anySurveyHasFilters(surveys)).toBe(false);
});
test("returns true when at least one survey has segment with filters", () => {
const surveys = [
{ id: "survey1", name: "Survey 1" },
{
id: "survey2",
name: "Survey 2",
segment: {
id: "segment2",
filters: [
{
id: "filter1",
connector: null,
resource: {
root: { type: "attribute", contactAttributeKey: "attr-1" },
id: "attr-filter-1",
qualifier: { operator: "contains" },
value: "attr",
},
},
],
createdAt: new Date(),
description: "Segment description",
environmentId: "env1",
isPrivate: true,
surveys: ["survey2"],
updatedAt: new Date(),
title: "Segment title",
} as TSegment,
},
] as TSurvey[];
expect(anySurveyHasFilters(surveys)).toBe(true);
});
});
describe("checkForInvalidImagesInQuestions", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("does not throw error when no images are present", () => {
const questions = [
{ id: "q1", type: TSurveyQuestionTypeEnum.OpenText },
{ id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle },
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
});
test("does not throw error when all images are valid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const questions = [
{ id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "valid-image.jpg" },
{ id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle },
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("valid-image.jpg");
});
test("throws error when question image is invalid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
const questions = [
{ id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "invalid-image.txt" },
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
new InvalidInputError("Invalid image file in question 1")
);
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("invalid-image.txt");
});
test("throws error when picture selection question has no choices", () => {
const questions = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
},
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
new InvalidInputError("Choices missing for question 1")
);
});
test("throws error when picture selection choice has invalid image", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid-image.jpg");
const questions = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
choices: [
{ id: "c1", imageUrl: "valid-image.jpg" },
{ id: "c2", imageUrl: "invalid-image.txt" },
],
},
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
new InvalidInputError("Invalid image file for choice 2 in question 1")
);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "valid-image.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "invalid-image.txt");
});
test("validates all choices in picture selection questions", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const questions = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
choices: [
{ id: "c1", imageUrl: "image1.jpg" },
{ id: "c2", imageUrl: "image2.jpg" },
{ id: "c3", imageUrl: "image3.jpg" },
],
},
] as TSurveyQuestion[];
expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
});
});

View File

@@ -1,21 +0,0 @@
import "server-only";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { getTag } from "./service";
export const canUserAccessTag = async (userId: string, tagId: string): Promise<boolean> => {
validateInputs([userId, ZId], [tagId, ZId]);
try {
const tag = await getTag(tagId);
if (!tag) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, tag.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
};

View File

@@ -1,31 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { canUserAccessResponse } from "../response/auth";
import { canUserAccessTag } from "../tag/auth";
import { validateInputs } from "../utils/validate";
import { tagOnResponseCache } from "./cache";
export const canUserAccessTagOnResponse = (
userId: string,
tagId: string,
responseId: string
): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [tagId, ZId], [responseId, ZId]);
try {
const isAuthorizedForTag = await canUserAccessTag(userId, tagId);
const isAuthorizedForResponse = await canUserAccessResponse(userId, responseId);
return isAuthorizedForTag && isAuthorizedForResponse;
} catch (error) {
throw error;
}
},
[`canUserAccessTagOnResponse-${userId}-${tagId}-${responseId}`],
{
tags: [tagOnResponseCache.tag.byResponseIdAndTagId(responseId, tagId)],
}
)();

View File

@@ -0,0 +1,386 @@
import { getMembershipRole } from "@/lib/membership/hooks/actions";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup } from "@testing-library/react";
import { returnValidationErrors } from "next-safe-action";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated, formatErrors } from "./action-client-middleware";
vi.mock("@/lib/membership/hooks/actions", () => ({
getMembershipRole: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
getTeamRoleByTeamIdUserId: vi.fn(),
}));
vi.mock("next-safe-action", () => ({
returnValidationErrors: vi.fn(),
}));
describe("action-client-middleware", () => {
const userId = "user-1";
const organizationId = "org-1";
const projectId = "project-1";
const teamId = "team-1";
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
describe("formatErrors", () => {
// We need to access the private function for testing
// Using any to access the function directly
test("formats simple path ZodIssue", () => {
const issues = [
{
code: "custom",
path: ["name"],
message: "Name is required",
},
] as ZodIssue[];
const result = formatErrors(issues);
expect(result).toEqual({
name: {
_errors: ["Name is required"],
},
});
});
test("formats nested path ZodIssue", () => {
const issues = [
{
code: "custom",
path: ["user", "address", "street"],
message: "Street is required",
},
] as ZodIssue[];
const result = formatErrors(issues);
expect(result).toEqual({
"user.address.street": {
_errors: ["Street is required"],
},
});
});
test("formats multiple ZodIssues", () => {
const issues = [
{
code: "custom",
path: ["name"],
message: "Name is required",
},
{
code: "custom",
path: ["email"],
message: "Invalid email",
},
] as ZodIssue[];
const result = formatErrors(issues);
expect(result).toEqual({
name: {
_errors: ["Name is required"],
},
email: {
_errors: ["Invalid email"],
},
});
});
});
describe("checkAuthorizationUpdated", () => {
test("returns validation errors when schema validation fails", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("owner");
const mockSchema = z.object({
name: z.string(),
});
const mockData = { name: 123 }; // Type error to trigger validation failure
vi.mocked(returnValidationErrors).mockReturnValue("validation-error" as unknown as never);
const access = [
{
type: "organization" as const,
schema: mockSchema,
data: mockData as any,
roles: ["owner" as const],
},
];
const result = await checkAuthorizationUpdated({
userId,
organizationId,
access,
});
expect(returnValidationErrors).toHaveBeenCalledWith(expect.any(Object), expect.any(Object));
expect(result).toBe("validation-error");
});
test("returns true when organization access matches role", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("owner");
const access = [
{
type: "organization" as const,
roles: ["owner" as const],
},
];
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
test("continues checking other access items when organization role doesn't match", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "organization" as const,
roles: ["owner" as const],
},
{
type: "projectTeam" as const,
projectId,
minPermission: "read" as const,
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
});
test("returns true when projectTeam access matches permission", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "projectTeam" as const,
projectId,
minPermission: "read" as const,
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
});
test("continues checking other access items when projectTeam permission is insufficient", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "projectTeam" as const,
projectId,
minPermission: "manage" as const,
},
{
type: "team" as const,
teamId,
minPermission: "contributor" as const,
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
});
test("returns true when team access matches role", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "team" as const,
teamId,
minPermission: "contributor" as const,
},
];
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
});
test("continues checking other access items when team role is insufficient", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "team" as const,
teamId,
minPermission: "admin" as const,
},
{
type: "organization" as const,
roles: ["member" as const],
},
];
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
});
test("throws AuthorizationError when no access matches", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "organization" as const,
roles: ["owner" as const],
},
{
type: "projectTeam" as const,
projectId,
minPermission: "manage" as const,
},
{
type: "team" as const,
teamId,
minPermission: "admin" as const,
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow(
AuthorizationError
);
await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow(
"Not authorized"
);
});
test("continues to check when projectPermission is null", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "projectTeam" as const,
projectId,
minPermission: "read" as const,
},
{
type: "organization" as const,
roles: ["member" as const],
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
test("continues to check when teamRole is null", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "team" as const,
teamId,
minPermission: "contributor" as const,
},
{
type: "organization" as const,
roles: ["member" as const],
},
];
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue(null);
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
test("returns true when schema validation passes", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("owner");
const mockSchema = z.object({
name: z.string(),
});
const mockData = { name: "test" };
const access = [
{
type: "organization" as const,
schema: mockSchema,
data: mockData,
roles: ["owner" as const],
},
];
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
test("handles projectTeam access without minPermission specified", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "projectTeam" as const,
projectId,
},
];
vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
test("handles team access without minPermission specified", async () => {
vi.mocked(getMembershipRole).mockResolvedValue("member");
const access = [
{
type: "team" as const,
teamId,
},
];
vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
const result = await checkAuthorizationUpdated({ userId, organizationId, access });
expect(result).toBe(true);
});
});
});

View File

@@ -7,7 +7,7 @@ import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
export const formatErrors = (issues: ZodIssue[]): Record<string, { _errors: string[] }> => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {

View File

@@ -0,0 +1,70 @@
import { describe, expect, test, vi } from "vitest";
import { hexToRGBA, isLight, mixColor } from "./colors";
describe("Color utilities", () => {
describe("hexToRGBA", () => {
test("should convert hex to rgba", () => {
expect(hexToRGBA("#000000", 1)).toBe("rgba(0, 0, 0, 1)");
expect(hexToRGBA("#FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
expect(hexToRGBA("#FF0000", 0.8)).toBe("rgba(255, 0, 0, 0.8)");
});
test("should convert shorthand hex to rgba", () => {
expect(hexToRGBA("#000", 1)).toBe("rgba(0, 0, 0, 1)");
expect(hexToRGBA("#FFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
expect(hexToRGBA("#F00", 0.8)).toBe("rgba(255, 0, 0, 0.8)");
});
test("should handle hex without # prefix", () => {
expect(hexToRGBA("000000", 1)).toBe("rgba(0, 0, 0, 1)");
expect(hexToRGBA("FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
});
test("should return undefined for undefined or empty input", () => {
expect(hexToRGBA(undefined, 1)).toBeUndefined();
expect(hexToRGBA("", 0.5)).toBeUndefined();
});
test("should return empty string for invalid hex", () => {
expect(hexToRGBA("invalid", 1)).toBe("");
});
});
describe("mixColor", () => {
test("should mix two colors with given weight", () => {
expect(mixColor("#000000", "#FFFFFF", 0.5)).toBe("#808080");
expect(mixColor("#FF0000", "#0000FF", 0.5)).toBe("#800080");
expect(mixColor("#FF0000", "#00FF00", 0.75)).toBe("#40bf00");
});
test("should handle edge cases", () => {
expect(mixColor("#000000", "#FFFFFF", 0)).toBe("#000000");
expect(mixColor("#000000", "#FFFFFF", 1)).toBe("#ffffff");
});
});
describe("isLight", () => {
test("should determine if a color is light", () => {
expect(isLight("#FFFFFF")).toBe(true);
expect(isLight("#EEEEEE")).toBe(true);
expect(isLight("#FFFF00")).toBe(true);
});
test("should determine if a color is dark", () => {
expect(isLight("#000000")).toBe(false);
expect(isLight("#333333")).toBe(false);
expect(isLight("#0000FF")).toBe(false);
});
test("should handle shorthand hex colors", () => {
expect(isLight("#FFF")).toBe(true);
expect(isLight("#000")).toBe(false);
expect(isLight("#F00")).toBe(false);
});
test("should throw error for invalid colors", () => {
expect(() => isLight("invalid-color")).toThrow("Invalid color");
expect(() => isLight("#1")).toThrow("Invalid color");
});
});
});

View File

@@ -1,4 +1,4 @@
const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
// TODO: find a better way to handle this
if (!hex || hex === "") return undefined;

View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TResponseContact } from "@formbricks/types/responses";
import { getContactIdentifier } from "./contact";
describe("getContactIdentifier", () => {
test("should return email from contactAttributes when available", () => {
const contactAttributes: TContactAttributes = {
email: "test@example.com",
};
const contact: TResponseContact = {
id: "contact1",
userId: "user123",
};
const result = getContactIdentifier(contact, contactAttributes);
expect(result).toBe("test@example.com");
});
test("should return userId from contact when email is not available", () => {
const contactAttributes: TContactAttributes = {};
const contact: TResponseContact = {
id: "contact2",
userId: "user123",
};
const result = getContactIdentifier(contact, contactAttributes);
expect(result).toBe("user123");
});
test("should return empty string when both email and userId are not available", () => {
const contactAttributes: TContactAttributes = {};
const contact: TResponseContact = {
id: "contact3",
};
const result = getContactIdentifier(contact, contactAttributes);
expect(result).toBe("");
});
test("should return empty string when both contact and contactAttributes are null", () => {
const result = getContactIdentifier(null, null);
expect(result).toBe("");
});
test("should return userId when contactAttributes is null", () => {
const contact: TResponseContact = {
id: "contact4",
userId: "user123",
};
const result = getContactIdentifier(contact, null);
expect(result).toBe("user123");
});
test("should return email when contact is null", () => {
const contactAttributes: TContactAttributes = {
email: "test@example.com",
};
const result = getContactIdentifier(null, contactAttributes);
expect(result).toBe("test@example.com");
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, test, vi } from "vitest";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
const date1 = new Date("2025-05-01");
const date2 = new Date("2025-05-06");
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
});
test("isValidDateString validates correct date strings", () => {
expect(isValidDateString("2025-05-06")).toBeTruthy();
expect(isValidDateString("06-05-2025")).toBeTruthy();
expect(isValidDateString("2025/05/06")).toBeFalsy();
expect(isValidDateString("invalid-date")).toBeFalsy();
});
test("getFormattedDateTimeString formats a date-time string correctly", () => {
const date = new Date("2025-05-06T14:30:00");
expect(getFormattedDateTimeString(date)).toBe("2025-05-06 14:30:00");
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import { isValidEmail } from "./email";
describe("isValidEmail", () => {
test("validates correct email formats", () => {
// Valid email addresses
expect(isValidEmail("test@example.com")).toBe(true);
expect(isValidEmail("test.user@example.com")).toBe(true);
expect(isValidEmail("test+user@example.com")).toBe(true);
expect(isValidEmail("test_user@example.com")).toBe(true);
expect(isValidEmail("test-user@example.com")).toBe(true);
expect(isValidEmail("test'user@example.com")).toBe(true);
expect(isValidEmail("test@example.co.uk")).toBe(true);
expect(isValidEmail("test@subdomain.example.com")).toBe(true);
});
test("rejects invalid email formats", () => {
// Missing @ symbol
expect(isValidEmail("testexample.com")).toBe(false);
// Multiple @ symbols
expect(isValidEmail("test@example@com")).toBe(false);
// Invalid characters
expect(isValidEmail("test user@example.com")).toBe(false);
expect(isValidEmail("test<>user@example.com")).toBe(false);
// Missing domain
expect(isValidEmail("test@")).toBe(false);
// Missing local part
expect(isValidEmail("@example.com")).toBe(false);
// Starting or ending with dots in local part
expect(isValidEmail(".test@example.com")).toBe(false);
expect(isValidEmail("test.@example.com")).toBe(false);
// Consecutive dots
expect(isValidEmail("test..user@example.com")).toBe(false);
// Empty string
expect(isValidEmail("")).toBe(false);
// Only whitespace
expect(isValidEmail(" ")).toBe(false);
// TLD too short
expect(isValidEmail("test@example.c")).toBe(false);
});
});

View File

@@ -0,0 +1,63 @@
import { AsyncParser } from "@json2csv/node";
import { describe, expect, test, vi } from "vitest";
import * as xlsx from "xlsx";
import { logger } from "@formbricks/logger";
import { convertToCsv, convertToXlsxBuffer } from "./file-conversion";
// Mock the logger to capture error calls
vi.mock("@formbricks/logger", () => ({
logger: { error: vi.fn() },
}));
describe("convertToCsv", () => {
const fields = ["name", "age"];
const data = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
];
test("should convert JSON array to CSV string with header", async () => {
const csv = await convertToCsv(fields, data);
const lines = csv.trim().split("\n");
// json2csv quotes headers by default
expect(lines[0]).toBe('"name","age"');
expect(lines[1]).toBe('"Alice",30');
expect(lines[2]).toBe('"Bob",25');
});
test("should log an error and throw when conversion fails", async () => {
const parseSpy = vi.spyOn(AsyncParser.prototype, "parse").mockImplementation(
() =>
({
promise: () => Promise.reject(new Error("Test parse error")),
}) as any
);
await expect(convertToCsv(fields, data)).rejects.toThrow("Failed to convert to CSV");
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Failed to convert to CSV");
parseSpy.mockRestore();
});
});
describe("convertToXlsxBuffer", () => {
const fields = ["name", "age"];
const data = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
];
test("should convert JSON array to XLSX buffer and preserve data", () => {
const buffer = convertToXlsxBuffer(fields, data);
const wb = xlsx.read(buffer, { type: "buffer" });
const sheet = wb.Sheets["Sheet1"];
// Skip header row (range:1) and remove internal row metadata
const raw = xlsx.utils.sheet_to_json<Record<string, string | number>>(sheet, {
header: fields,
defval: "",
range: 1,
});
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
expect(cleaned).toEqual(data);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { deviceType } from "./headers";
describe("deviceType", () => {
test("should return 'phone' for mobile user agents", () => {
const mobileUserAgents = [
"Mozilla/5.0 (Linux; Android 10; SM-G960F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Mobile Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPad; CPU OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+",
"Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch)",
"Mozilla/5.0 (iPod; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
"Opera/9.80 (Android; Opera Mini/36.2.2254/119.132; U; id) Presto/2.12.423 Version/12.16",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59 (Edition Campaign WPDesktop)",
];
mobileUserAgents.forEach((userAgent) => {
expect(deviceType(userAgent)).toBe("phone");
});
});
test("should return 'desktop' for non-mobile user agents", () => {
const desktopUserAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
"",
];
desktopUserAgents.forEach((userAgent) => {
expect(deviceType(userAgent)).toBe("desktop");
});
});
});

View File

@@ -0,0 +1,795 @@
import * as services from "@/lib/utils/services";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
getEnvironmentIdFromInsightId,
getEnvironmentIdFromResponseId,
getEnvironmentIdFromSegmentId,
getEnvironmentIdFromSurveyId,
getEnvironmentIdFromTagId,
getFormattedErrorMessage,
getOrganizationIdFromActionClassId,
getOrganizationIdFromApiKeyId,
getOrganizationIdFromContactId,
getOrganizationIdFromDocumentId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromInsightId,
getOrganizationIdFromIntegrationId,
getOrganizationIdFromInviteId,
getOrganizationIdFromLanguageId,
getOrganizationIdFromProjectId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getOrganizationIdFromSegmentId,
getOrganizationIdFromSurveyId,
getOrganizationIdFromTagId,
getOrganizationIdFromTeamId,
getOrganizationIdFromWebhookId,
getProductIdFromContactId,
getProjectIdFromActionClassId,
getProjectIdFromContactId,
getProjectIdFromDocumentId,
getProjectIdFromEnvironmentId,
getProjectIdFromInsightId,
getProjectIdFromIntegrationId,
getProjectIdFromLanguageId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
getProjectIdFromSegmentId,
getProjectIdFromSurveyId,
getProjectIdFromTagId,
getProjectIdFromWebhookId,
isStringMatch,
} from "./helper";
// Mock all service functions
vi.mock("@/lib/utils/services", () => ({
getProject: vi.fn(),
getEnvironment: vi.fn(),
getSurvey: vi.fn(),
getResponse: vi.fn(),
getContact: vi.fn(),
getResponseNote: vi.fn(),
getSegment: vi.fn(),
getActionClass: vi.fn(),
getIntegration: vi.fn(),
getWebhook: vi.fn(),
getApiKey: vi.fn(),
getInvite: vi.fn(),
getLanguage: vi.fn(),
getTeam: vi.fn(),
getInsight: vi.fn(),
getDocument: vi.fn(),
getTag: vi.fn(),
}));
describe("Helper Utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getFormattedErrorMessage", () => {
test("returns server error when present", () => {
const result = {
serverError: "Internal server error occurred",
validationErrors: {},
};
expect(getFormattedErrorMessage(result)).toBe("Internal server error occurred");
});
test("formats validation errors correctly with _errors", () => {
const result = {
validationErrors: {
_errors: ["Invalid input", "Missing required field"],
},
};
expect(getFormattedErrorMessage(result)).toBe("Invalid input, Missing required field");
});
test("formats validation errors for specific fields", () => {
const result = {
validationErrors: {
name: { _errors: ["Name is required"] },
email: { _errors: ["Email is invalid"] },
},
};
expect(getFormattedErrorMessage(result)).toBe("nameName is required\nemailEmail is invalid");
});
test("returns empty string for undefined errors", () => {
const result = { validationErrors: undefined };
expect(getFormattedErrorMessage(result)).toBe("");
});
});
describe("Organization ID retrieval functions", () => {
test("getOrganizationIdFromProjectId returns organization ID when project exists", async () => {
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromProjectId("project1");
expect(orgId).toBe("org1");
expect(services.getProject).toHaveBeenCalledWith("project1");
});
test("getOrganizationIdFromProjectId throws error when project not found", async () => {
vi.mocked(services.getProject).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromProjectId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(services.getProject).toHaveBeenCalledWith("nonexistent");
});
test("getOrganizationIdFromEnvironmentId returns organization ID through project", async () => {
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromEnvironmentId("env1");
expect(orgId).toBe("org1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
expect(services.getProject).toHaveBeenCalledWith("project1");
});
test("getOrganizationIdFromEnvironmentId throws error when environment not found", async () => {
vi.mocked(services.getEnvironment).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromSurveyId returns organization ID through environment and project", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromSurveyId("survey1");
expect(orgId).toBe("org1");
expect(services.getSurvey).toHaveBeenCalledWith("survey1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
expect(services.getProject).toHaveBeenCalledWith("project1");
});
test("getOrganizationIdFromSurveyId throws error when survey not found", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromResponseId returns organization ID through the response hierarchy", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromResponseId("response1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromResponseId throws error when response not found", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromContactId returns organization ID correctly", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromContactId("contact1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromContactId throws error when contact not found", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromTagId returns organization ID correctly", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromTagId("tag1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromTagId throws error when tag not found", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromResponseNoteId returns organization ID correctly", async () => {
vi.mocked(services.getResponseNote).mockResolvedValueOnce({
responseId: "response1",
});
vi.mocked(services.getResponse).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromResponseNoteId("note1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromResponseNoteId throws error when note not found", async () => {
vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromSegmentId returns organization ID correctly", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromSegmentId("segment1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromSegmentId throws error when segment not found", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromActionClassId returns organization ID correctly", async () => {
vi.mocked(services.getActionClass).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromActionClassId("action1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromActionClassId throws error when actionClass not found", async () => {
vi.mocked(services.getActionClass).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromIntegrationId returns organization ID correctly", async () => {
vi.mocked(services.getIntegration).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromIntegrationId("integration1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromIntegrationId throws error when integration not found", async () => {
vi.mocked(services.getIntegration).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromWebhookId returns organization ID correctly", async () => {
vi.mocked(services.getWebhook).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromWebhookId("webhook1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromWebhookId throws error when webhook not found", async () => {
vi.mocked(services.getWebhook).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromApiKeyId returns organization ID directly", async () => {
vi.mocked(services.getApiKey).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromApiKeyId("apikey1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromApiKeyId throws error when apiKey not found", async () => {
vi.mocked(services.getApiKey).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromApiKeyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromInviteId returns organization ID directly", async () => {
vi.mocked(services.getInvite).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromInviteId("invite1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromInviteId throws error when invite not found", async () => {
vi.mocked(services.getInvite).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromInviteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromLanguageId returns organization ID correctly", async () => {
vi.mocked(services.getLanguage).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromLanguageId("lang1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromLanguageId throws error when language not found", async () => {
vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any);
await expect(getOrganizationIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromTeamId returns organization ID directly", async () => {
vi.mocked(services.getTeam).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromTeamId("team1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromTeamId throws error when team not found", async () => {
vi.mocked(services.getTeam).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromTeamId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromInsightId returns organization ID correctly", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromInsightId("insight1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromInsightId throws error when insight not found", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getOrganizationIdFromDocumentId returns organization ID correctly", async () => {
vi.mocked(services.getDocument).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
vi.mocked(services.getProject).mockResolvedValueOnce({
organizationId: "org1",
});
const orgId = await getOrganizationIdFromDocumentId("doc1");
expect(orgId).toBe("org1");
});
test("getOrganizationIdFromDocumentId throws error when document not found", async () => {
vi.mocked(services.getDocument).mockResolvedValueOnce(null);
await expect(getOrganizationIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
});
describe("Project ID retrieval functions", () => {
test("getProjectIdFromEnvironmentId returns project ID directly", async () => {
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromEnvironmentId("env1");
expect(projectId).toBe("project1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
});
test("getProjectIdFromEnvironmentId throws error when environment not found", async () => {
vi.mocked(services.getEnvironment).mockResolvedValueOnce(null);
await expect(getProjectIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromSurveyId returns project ID through environment", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromSurveyId("survey1");
expect(projectId).toBe("project1");
expect(services.getSurvey).toHaveBeenCalledWith("survey1");
expect(services.getEnvironment).toHaveBeenCalledWith("env1");
});
test("getProjectIdFromSurveyId throws error when survey not found", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
await expect(getProjectIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromContactId returns project ID correctly", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromContactId("contact1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromContactId throws error when contact not found", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce(null);
await expect(getProjectIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromInsightId returns project ID correctly", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromInsightId("insight1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromInsightId throws error when insight not found", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce(null);
await expect(getProjectIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromSegmentId returns project ID correctly", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromSegmentId("segment1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromSegmentId throws error when segment not found", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce(null);
await expect(getProjectIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromActionClassId returns project ID correctly", async () => {
vi.mocked(services.getActionClass).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromActionClassId("action1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromActionClassId throws error when actionClass not found", async () => {
vi.mocked(services.getActionClass).mockResolvedValueOnce(null);
await expect(getProjectIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromTagId returns project ID correctly", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromTagId("tag1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromTagId throws error when tag not found", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce(null);
await expect(getProjectIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromLanguageId returns project ID directly", async () => {
vi.mocked(services.getLanguage).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromLanguageId("lang1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromLanguageId throws error when language not found", async () => {
vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any);
await expect(getProjectIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromResponseId returns project ID correctly", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromResponseId("response1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromResponseId throws error when response not found", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce(null);
await expect(getProjectIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromResponseNoteId returns project ID correctly", async () => {
vi.mocked(services.getResponseNote).mockResolvedValueOnce({
responseId: "response1",
});
vi.mocked(services.getResponse).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromResponseNoteId("note1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromResponseNoteId throws error when responseNote not found", async () => {
vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
await expect(getProjectIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProductIdFromContactId returns project ID correctly", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProductIdFromContactId("contact1");
expect(projectId).toBe("project1");
});
test("getProductIdFromContactId throws error when contact not found", async () => {
vi.mocked(services.getContact).mockResolvedValueOnce(null);
await expect(getProductIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromDocumentId returns project ID correctly", async () => {
vi.mocked(services.getDocument).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromDocumentId("doc1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromDocumentId throws error when document not found", async () => {
vi.mocked(services.getDocument).mockResolvedValueOnce(null);
await expect(getProjectIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromIntegrationId returns project ID correctly", async () => {
vi.mocked(services.getIntegration).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromIntegrationId("integration1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromIntegrationId throws error when integration not found", async () => {
vi.mocked(services.getIntegration).mockResolvedValueOnce(null);
await expect(getProjectIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getProjectIdFromWebhookId returns project ID correctly", async () => {
vi.mocked(services.getWebhook).mockResolvedValueOnce({
environmentId: "env1",
});
vi.mocked(services.getEnvironment).mockResolvedValueOnce({
projectId: "project1",
});
const projectId = await getProjectIdFromWebhookId("webhook1");
expect(projectId).toBe("project1");
});
test("getProjectIdFromWebhookId throws error when webhook not found", async () => {
vi.mocked(services.getWebhook).mockResolvedValueOnce(null);
await expect(getProjectIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
});
describe("Environment ID retrieval functions", () => {
test("getEnvironmentIdFromSurveyId returns environment ID directly", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
const environmentId = await getEnvironmentIdFromSurveyId("survey1");
expect(environmentId).toBe("env1");
});
test("getEnvironmentIdFromSurveyId throws error when survey not found", async () => {
vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
await expect(getEnvironmentIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getEnvironmentIdFromResponseId returns environment ID correctly", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce({
surveyId: "survey1",
});
vi.mocked(services.getSurvey).mockResolvedValueOnce({
environmentId: "env1",
});
const environmentId = await getEnvironmentIdFromResponseId("response1");
expect(environmentId).toBe("env1");
});
test("getEnvironmentIdFromResponseId throws error when response not found", async () => {
vi.mocked(services.getResponse).mockResolvedValueOnce(null);
await expect(getEnvironmentIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getEnvironmentIdFromInsightId returns environment ID directly", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce({
environmentId: "env1",
});
const environmentId = await getEnvironmentIdFromInsightId("insight1");
expect(environmentId).toBe("env1");
});
test("getEnvironmentIdFromInsightId throws error when insight not found", async () => {
vi.mocked(services.getInsight).mockResolvedValueOnce(null);
await expect(getEnvironmentIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getEnvironmentIdFromSegmentId returns environment ID directly", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce({
environmentId: "env1",
});
const environmentId = await getEnvironmentIdFromSegmentId("segment1");
expect(environmentId).toBe("env1");
});
test("getEnvironmentIdFromSegmentId throws error when segment not found", async () => {
vi.mocked(services.getSegment).mockResolvedValueOnce(null);
await expect(getEnvironmentIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
test("getEnvironmentIdFromTagId returns environment ID directly", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce({
environmentId: "env1",
});
const environmentId = await getEnvironmentIdFromTagId("tag1");
expect(environmentId).toBe("env1");
});
test("getEnvironmentIdFromTagId throws error when tag not found", async () => {
vi.mocked(services.getTag).mockResolvedValueOnce(null);
await expect(getEnvironmentIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
});
});
describe("isStringMatch", () => {
test("returns true for exact matches", () => {
expect(isStringMatch("test", "test")).toBe(true);
});
test("returns true for case-insensitive matches", () => {
expect(isStringMatch("TEST", "test")).toBe(true);
expect(isStringMatch("test", "TEST")).toBe(true);
});
test("returns true for matches with spaces", () => {
expect(isStringMatch("test case", "testcase")).toBe(true);
expect(isStringMatch("testcase", "test case")).toBe(true);
});
test("returns true for matches with underscores", () => {
expect(isStringMatch("test_case", "testcase")).toBe(true);
expect(isStringMatch("testcase", "test_case")).toBe(true);
});
test("returns true for matches with dashes", () => {
expect(isStringMatch("test-case", "testcase")).toBe(true);
expect(isStringMatch("testcase", "test-case")).toBe(true);
});
test("returns true for partial matches", () => {
expect(isStringMatch("test", "testing")).toBe(true);
});
test("returns false for non-matches", () => {
expect(isStringMatch("test", "other")).toBe(false);
});
});
});

View File

@@ -0,0 +1,87 @@
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
import * as nextHeaders from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { findMatchingLocale } from "./locale";
// Mock the Next.js headers function
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
describe("locale", () => {
test("returns DEFAULT_LOCALE when Accept-Language header is missing", async () => {
// Set up the mock to return null for accept-language header
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue(null),
} as any);
const result = await findMatchingLocale();
expect(result).toBe(DEFAULT_LOCALE);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("returns exact match when available", async () => {
// Assuming we have 'en-US' in AVAILABLE_LOCALES
const testLocale = AVAILABLE_LOCALES[0];
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue(`${testLocale},fr-FR,de-DE`),
} as any);
const result = await findMatchingLocale();
expect(result).toBe(testLocale);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("returns normalized match when available", async () => {
// Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
if (!availableLocale) {
// Skip this test if no English locale is available
return;
}
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("en-US,fr-FR,de-DE"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe(availableLocale);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("returns DEFAULT_LOCALE when no match is found", async () => {
// Use a locale that should not exist in AVAILABLE_LOCALES
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("xx-XX,yy-YY"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe(DEFAULT_LOCALE);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("handles multiple potential matches correctly", async () => {
// If we have multiple locales for the same language, it should return the first match
const germanLocale = AVAILABLE_LOCALES.find((locale) => locale.toLowerCase().startsWith("de"));
if (!germanLocale) {
// Skip this test if no German locale is available
return;
}
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("de-DE,en-US,fr-FR"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe(germanLocale);
expect(nextHeaders.headers).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, test, vi } from "vitest";
import { delay, isFulfilled, isRejected } from "./promises";
describe("promises utilities", () => {
test("delay resolves after specified time", async () => {
const delayTime = 100;
vi.useFakeTimers();
const promise = delay(delayTime);
vi.advanceTimersByTime(delayTime);
await promise;
vi.useRealTimers();
});
test("isFulfilled returns true for fulfilled promises", () => {
const fulfilledResult: PromiseSettledResult<string> = {
status: "fulfilled",
value: "success",
};
expect(isFulfilled(fulfilledResult)).toBe(true);
if (isFulfilled(fulfilledResult)) {
expect(fulfilledResult.value).toBe("success");
}
});
test("isFulfilled returns false for rejected promises", () => {
const rejectedResult: PromiseSettledResult<string> = {
status: "rejected",
reason: "error",
};
expect(isFulfilled(rejectedResult)).toBe(false);
});
test("isRejected returns true for rejected promises", () => {
const rejectedResult: PromiseSettledResult<string> = {
status: "rejected",
reason: "error",
};
expect(isRejected(rejectedResult)).toBe(true);
if (isRejected(rejectedResult)) {
expect(rejectedResult.reason).toBe("error");
}
});
test("isRejected returns false for fulfilled promises", () => {
const fulfilledResult: PromiseSettledResult<string> = {
status: "fulfilled",
value: "success",
};
expect(isRejected(fulfilledResult)).toBe(false);
});
test("delay can be used in actual timing scenarios", async () => {
const mockCallback = vi.fn();
setTimeout(mockCallback, 50);
await delay(100);
expect(mockCallback).toHaveBeenCalled();
});
test("type guard functions work correctly with Promise.allSettled", async () => {
const promises = [Promise.resolve("success"), Promise.reject("failure")];
const results = await Promise.allSettled(promises);
const fulfilled = results.filter(isFulfilled);
const rejected = results.filter(isRejected);
expect(fulfilled.length).toBe(1);
expect(fulfilled[0].value).toBe("success");
expect(rejected.length).toBe(1);
expect(rejected[0].reason).toBe("failure");
});
});

View File

@@ -0,0 +1,516 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { describe, expect, test, vi } from "vitest";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import {
checkForEmptyFallBackValue,
extractFallbackValue,
extractId,
extractIds,
extractRecallInfo,
fallbacks,
findRecallInfoById,
getFallbackValues,
getRecallItems,
headlineToRecall,
parseRecallInfo,
recallToHeadline,
replaceHeadlineRecall,
replaceRecallInfoWithUnderline,
} from "./recall";
// Mock dependencies
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
}),
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
}
}),
formatDateWithOrdinal: vi.fn((date) => {
return "January 1st, 2023";
}),
}));
describe("recall utility functions", () => {
describe("extractId", () => {
test("extracts ID correctly from a string with recall pattern", () => {
const text = "This is a #recall:question123 example";
const result = extractId(text);
expect(result).toBe("question123");
});
test("returns null when no ID is found", () => {
const text = "This has no recall pattern";
const result = extractId(text);
expect(result).toBeNull();
});
test("returns null for malformed recall pattern", () => {
const text = "This is a #recall: malformed pattern";
const result = extractId(text);
expect(result).toBeNull();
});
});
describe("extractIds", () => {
test("extracts multiple IDs from a string with multiple recall patterns", () => {
const text = "This has #recall:id1 and #recall:id2 and #recall:id3";
const result = extractIds(text);
expect(result).toEqual(["id1", "id2", "id3"]);
});
test("returns empty array when no IDs are found", () => {
const text = "This has no recall patterns";
const result = extractIds(text);
expect(result).toEqual([]);
});
test("handles mixed content correctly", () => {
const text = "Text #recall:id1 more text #recall:id2";
const result = extractIds(text);
expect(result).toEqual(["id1", "id2"]);
});
});
describe("extractFallbackValue", () => {
test("extracts fallback value correctly", () => {
const text = "Text #recall:id1/fallback:defaultValue# more text";
const result = extractFallbackValue(text);
expect(result).toBe("defaultValue");
});
test("returns empty string when no fallback value is found", () => {
const text = "Text with no fallback";
const result = extractFallbackValue(text);
expect(result).toBe("");
});
test("handles empty fallback value", () => {
const text = "Text #recall:id1/fallback:# more text";
const result = extractFallbackValue(text);
expect(result).toBe("");
});
});
describe("extractRecallInfo", () => {
test("extracts complete recall info from text", () => {
const text = "This is #recall:id1/fallback:default# text";
const result = extractRecallInfo(text);
expect(result).toBe("#recall:id1/fallback:default#");
});
test("returns null when no recall info is found", () => {
const text = "This has no recall info";
const result = extractRecallInfo(text);
expect(result).toBeNull();
});
test("extracts recall info for a specific ID when provided", () => {
const text = "This has #recall:id1/fallback:default1# and #recall:id2/fallback:default2#";
const result = extractRecallInfo(text, "id2");
expect(result).toBe("#recall:id2/fallback:default2#");
});
});
describe("findRecallInfoById", () => {
test("finds recall info by ID", () => {
const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#";
const result = findRecallInfoById(text, "id2");
expect(result).toBe("#recall:id2/fallback:value2#");
});
test("returns null when ID is not found", () => {
const text = "Text #recall:id1/fallback:value1#";
const result = findRecallInfoById(text, "id2");
expect(result).toBeNull();
});
});
describe("recallToHeadline", () => {
test("converts recall pattern to headline format without slash", () => {
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
const survey: TSurvey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("How do you like @Product Question?");
});
test("converts recall pattern to headline format with slash", () => {
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
const survey: TSurvey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, true, "en");
expect(result.en).toBe("Rate /Product Question\\");
});
test("handles hidden fields in recall", () => {
const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
hiddenFields: { fieldIds: ["email"] },
variables: [],
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("Your email is @email");
});
test("handles variables in recall", () => {
const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "plan", name: "Subscription Plan" }],
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("Your plan is @Subscription Plan");
});
test("returns unchanged headline when no recall pattern is found", () => {
const headline = { en: "Regular headline with no recall" };
const survey = {} as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result).toEqual(headline);
});
test("handles nested recall patterns", () => {
const headline = {
en: "This is #recall:outer/fallback:withnbsp#recall:inner/fallback:nested#nbsptext#",
};
const survey: TSurvey = {
id: "test-survey",
questions: [
{ id: "outer", headline: { en: "Outer with @inner" } },
{ id: "inner", headline: { en: "Inner value" } },
] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
const result = recallToHeadline(headline, survey, false, "en");
expect(result.en).toBe("This is @Outer with @inner");
});
});
describe("replaceRecallInfoWithUnderline", () => {
test("replaces recall info with underline", () => {
const text = "This is a #recall:id1/fallback:default# example";
const result = replaceRecallInfoWithUnderline(text);
expect(result).toBe("This is a ___ example");
});
test("replaces multiple recall infos with underlines", () => {
const text = "This #recall:id1/fallback:v1# has #recall:id2/fallback:v2# multiple recalls";
const result = replaceRecallInfoWithUnderline(text);
expect(result).toBe("This ___ has ___ multiple recalls");
});
test("returns unchanged text when no recall info is present", () => {
const text = "This has no recall info";
const result = replaceRecallInfoWithUnderline(text);
expect(result).toBe(text);
});
});
describe("checkForEmptyFallBackValue", () => {
test("identifies question with empty fallback value", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: questionHeadline,
},
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
});
test("identifies question with empty fallback in subheader", () => {
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
},
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
});
test("returns null when no empty fallback values are found", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: questionHeadline,
},
] as unknown as TSurveyQuestion[],
} as unknown as TSurvey;
vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBeNull();
});
});
describe("replaceHeadlineRecall", () => {
test("processes all questions in a survey", () => {
const survey: TSurvey = {
questions: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj)));
const result = replaceHeadlineRecall(survey, "en");
// Verify recallToHeadline was called for each question
expect(result).not.toBe(survey); // Should be a clone
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
});
});
describe("getRecallItems", () => {
test("extracts recall items from text", () => {
const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
const survey: TSurvey = {
questions: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
] as unknown as TSurveyQuestion[],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
const result = getRecallItems(text, survey, "en");
expect(result).toHaveLength(2);
expect(result[0].id).toBe("id1");
expect(result[0].label).toBe("Question One");
expect(result[0].type).toBe("question");
expect(result[1].id).toBe("id2");
expect(result[1].label).toBe("Question Two");
expect(result[1].type).toBe("question");
});
test("handles hidden fields in recall items", () => {
const text = "Text with #recall:hidden1/fallback:val1#";
const survey: TSurvey = {
questions: [],
hiddenFields: { fieldIds: ["hidden1"] },
variables: [],
} as unknown as TSurvey;
const result = getRecallItems(text, survey, "en");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("hidden1");
expect(result[0].type).toBe("hiddenField");
});
test("handles variables in recall items", () => {
const text = "Text with #recall:var1/fallback:val1#";
const survey: TSurvey = {
questions: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "var1", name: "Variable One" }],
} as unknown as TSurvey;
const result = getRecallItems(text, survey, "en");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("var1");
expect(result[0].label).toBe("Variable One");
expect(result[0].type).toBe("variable");
});
test("returns empty array when no recall items are found", () => {
const text = "Text with no recall items";
const survey: TSurvey = {} as TSurvey;
const result = getRecallItems(text, survey, "en");
expect(result).toEqual([]);
});
});
describe("getFallbackValues", () => {
test("extracts fallback values from text", () => {
const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#";
const result = getFallbackValues(text);
expect(result).toEqual({
id1: "value1",
id2: "value2",
});
});
test("returns empty object when no fallback values are found", () => {
const text = "Text with no fallback values";
const result = getFallbackValues(text);
expect(result).toEqual({});
});
});
describe("headlineToRecall", () => {
test("transforms headlines to recall info", () => {
const text = "What do you think of @Product?";
const recallItems: TSurveyRecallItem[] = [{ id: "product", label: "Product", type: "question" }];
const fallbacks: fallbacks = {
product: "our product",
};
const result = headlineToRecall(text, recallItems, fallbacks);
expect(result).toBe("What do you think of #recall:product/fallback:our product#?");
});
test("transforms multiple headlines", () => {
const text = "Rate @Product made by @Company";
const recallItems: TSurveyRecallItem[] = [
{ id: "product", label: "Product", type: "question" },
{ id: "company", label: "Company", type: "question" },
];
const fallbacks: fallbacks = {
product: "our product",
company: "our company",
};
const result = headlineToRecall(text, recallItems, fallbacks);
expect(result).toBe(
"Rate #recall:product/fallback:our product# made by #recall:company/fallback:our company#"
);
});
});
describe("parseRecallInfo", () => {
test("replaces recall info with response data", () => {
const text = "Your answer was #recall:q1/fallback:not-provided#";
const responseData: TResponseData = {
q1: "Yes definitely",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Your answer was Yes definitely");
});
test("uses fallback when response data is missing", () => {
const text = "Your answer was #recall:q1/fallback:notnbspprovided#";
const responseData: TResponseData = {
q2: "Some other answer",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Your answer was not provided");
});
test("formats date values", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "2023-01-01",
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on January 1st, 2023");
});
test("formats array values as comma-separated list", () => {
const text = "Your selections: #recall:preferences/fallback:none#";
const responseData: TResponseData = {
preferences: ["Option A", "Option B", "Option C"],
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("Your selections: Option A, Option B, Option C");
});
test("uses variables when available", () => {
const text = "Welcome back, #recall:username/fallback:user#";
const variables: TResponseVariables = {
username: "John Doe",
};
const result = parseRecallInfo(text, {}, variables);
expect(result).toBe("Welcome back, John Doe");
});
test("prioritizes variables over response data", () => {
const text = "Your email is #recall:email/fallback:no-email#";
const responseData: TResponseData = {
email: "response@example.com",
};
const variables: TResponseVariables = {
email: "variable@example.com",
};
const result = parseRecallInfo(text, responseData, variables);
expect(result).toBe("Your email is variable@example.com");
});
test("handles withSlash parameter", () => {
const text = "Your name is #recall:name/fallback:anonymous#";
const variables: TResponseVariables = {
name: "John Doe",
};
const result = parseRecallInfo(text, {}, variables, true);
expect(result).toBe("Your name is #/John Doe\\#");
});
test("handles 'nbsp' in fallback values", () => {
const text = "Default spacing: #recall:space/fallback:nonnbspbreaking#";
const result = parseRecallInfo(text);
expect(result).toBe("Default spacing: non breaking");
});
});
});

View File

@@ -124,6 +124,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
const recalls = text.match(/#recall:[^ ]+/g);
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
if (
findRecalls(getLocalizedValue(question.headline, language)) ||

View File

@@ -0,0 +1,737 @@
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, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
getActionClass,
getApiKey,
getContact,
getDocument,
getEnvironment,
getInsight,
getIntegration,
getInvite,
getLanguage,
getProject,
getResponse,
getResponseNote,
getSegment,
getSurvey,
getTag,
getTeam,
getWebhook,
isProjectPartOfOrganization,
isTeamPartOfOrganization,
} from "./services";
// Mock all dependencies
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findUnique: vi.fn(),
},
apiKey: {
findUnique: vi.fn(),
},
environment: {
findUnique: vi.fn(),
},
integration: {
findUnique: vi.fn(),
},
invite: {
findUnique: vi.fn(),
},
language: {
findFirst: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
response: {
findUnique: vi.fn(),
},
responseNote: {
findUnique: vi.fn(),
},
survey: {
findUnique: vi.fn(),
},
tag: {
findUnique: vi.fn(),
},
webhook: {
findUnique: vi.fn(),
},
team: {
findUnique: vi.fn(),
},
insight: {
findUnique: vi.fn(),
},
document: {
findUnique: vi.fn(),
},
contact: {
findUnique: vi.fn(),
},
segment: {
findUnique: vi.fn(),
},
},
}));
// Mock cache
vi.mock("@/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
// Mock react cache
vi.mock("react", () => ({
cache: vi.fn((fn) => fn),
}));
// Mock all cache modules
vi.mock("@/lib/actionClass/cache", () => ({
actionClassCache: {
tag: {
byId: vi.fn((id) => `actionClass-${id}`),
},
},
}));
vi.mock("@/lib/cache/api-key", () => ({
apiKeyCache: {
tag: {
byId: vi.fn((id) => `apiKey-${id}`),
},
},
}));
vi.mock("@/lib/environment/cache", () => ({
environmentCache: {
tag: {
byId: vi.fn((id) => `environment-${id}`),
},
},
}));
vi.mock("@/lib/integration/cache", () => ({
integrationCache: {
tag: {
byId: vi.fn((id) => `integration-${id}`),
},
},
}));
vi.mock("@/lib/cache/invite", () => ({
inviteCache: {
tag: {
byId: vi.fn((id) => `invite-${id}`),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
tag: {
byId: vi.fn((id) => `project-${id}`),
},
},
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
tag: {
byId: vi.fn((id) => `response-${id}`),
},
},
}));
vi.mock("@/lib/responseNote/cache", () => ({
responseNoteCache: {
tag: {
byResponseId: vi.fn((id) => `response-${id}-notes`),
byId: vi.fn((id) => `responseNote-${id}`),
},
},
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byId: vi.fn((id) => `survey-${id}`),
},
},
}));
vi.mock("@/lib/tag/cache", () => ({
tagCache: {
tag: {
byId: vi.fn((id) => `tag-${id}`),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byId: vi.fn((id) => `webhook-${id}`),
},
},
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: {
byId: vi.fn((id) => `team-${id}`),
},
},
}));
vi.mock("@/lib/cache/contact", () => ({
contactCache: {
tag: {
byId: vi.fn((id) => `contact-${id}`),
},
},
}));
vi.mock("@/lib/cache/segment", () => ({
segmentCache: {
tag: {
byId: vi.fn((id) => `segment-${id}`),
},
},
}));
describe("Service Functions", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("getActionClass", () => {
const actionClassId = "action123";
test("returns the action class when found", async () => {
const mockActionClass = { environmentId: "env123" };
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
const result = await getActionClass(actionClassId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
where: { id: actionClassId },
select: { environmentId: true },
});
expect(result).toEqual(mockActionClass);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("Database error"));
await expect(getActionClass(actionClassId)).rejects.toThrow(DatabaseError);
});
});
describe("getApiKey", () => {
const apiKeyId = "apiKey123";
test("returns the api key when found", async () => {
const mockApiKey = { organizationId: "org123" };
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKey);
const result = await getApiKey(apiKeyId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { id: apiKeyId },
select: { organizationId: true },
});
expect(result).toEqual(mockApiKey);
});
test("throws InvalidInputError if apiKeyId is empty", async () => {
await expect(getApiKey("")).rejects.toThrow(InvalidInputError);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getApiKey(apiKeyId)).rejects.toThrow(DatabaseError);
});
});
describe("getEnvironment", () => {
const environmentId = "env123";
test("returns the environment when found", async () => {
const mockEnvironment = { projectId: "proj123" };
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
const result = await getEnvironment(environmentId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: { projectId: true },
});
expect(result).toEqual(mockEnvironment);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.environment.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getEnvironment(environmentId)).rejects.toThrow(DatabaseError);
});
});
describe("getIntegration", () => {
const integrationId = "int123";
test("returns the integration when found", async () => {
const mockIntegration = { environmentId: "env123" };
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
const result = await getIntegration(integrationId);
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
where: { id: integrationId },
select: { environmentId: true },
});
expect(result).toEqual(mockIntegration);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.integration.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getIntegration(integrationId)).rejects.toThrow(DatabaseError);
});
});
describe("getInvite", () => {
const inviteId = "invite123";
test("returns the invite when found", async () => {
const mockInvite = { organizationId: "org123" };
vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite);
const result = await getInvite(inviteId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.invite.findUnique).toHaveBeenCalledWith({
where: { id: inviteId },
select: { organizationId: true },
});
expect(result).toEqual(mockInvite);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.invite.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getInvite(inviteId)).rejects.toThrow(DatabaseError);
});
});
describe("getLanguage", () => {
const languageId = "lang123";
test("returns the language when found", async () => {
const mockLanguage = { projectId: "proj123" };
vi.mocked(prisma.language.findFirst).mockResolvedValue(mockLanguage);
const result = await getLanguage(languageId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.language.findFirst).toHaveBeenCalledWith({
where: { id: languageId },
select: { projectId: true },
});
expect(result).toEqual(mockLanguage);
});
test("throws ResourceNotFoundError when language not found", async () => {
vi.mocked(prisma.language.findFirst).mockResolvedValue(null);
await expect(getLanguage(languageId)).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.language.findFirst).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getLanguage(languageId)).rejects.toThrow(DatabaseError);
});
});
describe("getProject", () => {
const projectId = "proj123";
test("returns the project when found", async () => {
const mockProject = { organizationId: "org123" };
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
const result = await getProject(projectId);
expect(prisma.project.findUnique).toHaveBeenCalledWith({
where: { id: projectId },
select: { organizationId: true },
});
expect(result).toEqual(mockProject);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.project.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getProject(projectId)).rejects.toThrow(DatabaseError);
});
});
describe("getResponse", () => {
const responseId = "resp123";
test("returns the response when found", async () => {
const mockResponse = { surveyId: "survey123" };
vi.mocked(prisma.response.findUnique).mockResolvedValue(mockResponse);
const result = await getResponse(responseId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.response.findUnique).toHaveBeenCalledWith({
where: { id: responseId },
select: { surveyId: true },
});
expect(result).toEqual(mockResponse);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.response.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getResponse(responseId)).rejects.toThrow(DatabaseError);
});
});
describe("getResponseNote", () => {
const responseNoteId = "note123";
test("returns the response note when found", async () => {
const mockResponseNote = { responseId: "resp123" };
vi.mocked(prisma.responseNote.findUnique).mockResolvedValue(mockResponseNote);
const result = await getResponseNote(responseNoteId);
expect(prisma.responseNote.findUnique).toHaveBeenCalledWith({
where: { id: responseNoteId },
select: { responseId: true },
});
expect(result).toEqual(mockResponseNote);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.responseNote.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getResponseNote(responseNoteId)).rejects.toThrow(DatabaseError);
});
});
describe("getSurvey", () => {
const surveyId = "survey123";
test("returns the survey when found", async () => {
const mockSurvey = { environmentId: "env123" };
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
const result = await getSurvey(surveyId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId },
select: { environmentId: true },
});
expect(result).toEqual(mockSurvey);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.survey.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError);
});
});
describe("getTag", () => {
const tagId = "tag123";
test("returns the tag when found", async () => {
const mockTag = { environmentId: "env123" };
vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag);
const result = await getTag(tagId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.tag.findUnique).toHaveBeenCalledWith({
where: { id: tagId },
select: { environmentId: true },
});
expect(result).toEqual(mockTag);
});
});
describe("getWebhook", () => {
const webhookId = "webhook123";
test("returns the webhook when found", async () => {
const mockWebhook = { environmentId: "env123" };
vi.mocked(prisma.webhook.findUnique).mockResolvedValue(mockWebhook);
const result = await getWebhook(webhookId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: { id: webhookId },
select: { environmentId: true },
});
expect(result).toEqual(mockWebhook);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.webhook.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getWebhook(webhookId)).rejects.toThrow(DatabaseError);
});
});
describe("getTeam", () => {
const teamId = "team123";
test("returns the team when found", async () => {
const mockTeam = { organizationId: "org123" };
vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam);
const result = await getTeam(teamId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: { id: teamId },
select: { organizationId: true },
});
expect(result).toEqual(mockTeam);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getTeam(teamId)).rejects.toThrow(DatabaseError);
});
});
describe("getInsight", () => {
const insightId = "insight123";
test("returns the insight when found", async () => {
const mockInsight = { environmentId: "env123" };
vi.mocked(prisma.insight.findUnique).mockResolvedValue(mockInsight);
const result = await getInsight(insightId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.insight.findUnique).toHaveBeenCalledWith({
where: { id: insightId },
select: { environmentId: true },
});
expect(result).toEqual(mockInsight);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.insight.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getInsight(insightId)).rejects.toThrow(DatabaseError);
});
});
describe("getDocument", () => {
const documentId = "doc123";
test("returns the document when found", async () => {
const mockDocument = { environmentId: "env123" };
vi.mocked(prisma.document.findUnique).mockResolvedValue(mockDocument);
const result = await getDocument(documentId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.document.findUnique).toHaveBeenCalledWith({
where: { id: documentId },
select: { environmentId: true },
});
expect(result).toEqual(mockDocument);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.document.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getDocument(documentId)).rejects.toThrow(DatabaseError);
});
});
describe("isProjectPartOfOrganization", () => {
const projectId = "proj123";
const organizationId = "org123";
test("returns true when project belongs to organization", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId });
const result = await isProjectPartOfOrganization(organizationId, projectId);
expect(result).toBe(true);
});
test("returns false when project belongs to different organization", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "otherOrg" });
const result = await isProjectPartOfOrganization(organizationId, projectId);
expect(result).toBe(false);
});
test("throws ResourceNotFoundError when project not found", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValue(null);
await expect(isProjectPartOfOrganization(organizationId, projectId)).rejects.toThrow(
ResourceNotFoundError
);
});
});
describe("isTeamPartOfOrganization", () => {
const teamId = "team123";
const organizationId = "org123";
test("returns true when team belongs to organization", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId });
const result = await isTeamPartOfOrganization(organizationId, teamId);
expect(result).toBe(true);
});
test("returns false when team belongs to different organization", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId: "otherOrg" });
const result = await isTeamPartOfOrganization(organizationId, teamId);
expect(result).toBe(false);
});
test("throws ResourceNotFoundError when team not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
await expect(isTeamPartOfOrganization(organizationId, teamId)).rejects.toThrow(ResourceNotFoundError);
});
});
describe("getContact", () => {
const contactId = "contact123";
test("returns the contact when found", async () => {
const mockContact = { environmentId: "env123" };
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(contactId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId },
select: { environmentId: true },
});
expect(result).toEqual(mockContact);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.contact.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getContact(contactId)).rejects.toThrow(DatabaseError);
});
});
describe("getSegment", () => {
const segmentId = "segment123";
test("returns the segment when found", async () => {
const mockSegment = { environmentId: "env123" };
vi.mocked(prisma.segment.findUnique).mockResolvedValue(mockSegment);
const result = await getSegment(segmentId);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.segment.findUnique).toHaveBeenCalledWith({
where: { id: segmentId },
select: { environmentId: true },
});
expect(result).toEqual(mockSegment);
});
test("throws DatabaseError when database operation fails", async () => {
vi.mocked(prisma.segment.findUnique).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Error", {
code: "P2002",
clientVersion: "4.7.0",
})
);
await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -0,0 +1,115 @@
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";
import cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys";
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
}));
vi.mock(
"@paralleldrive/cuid2",
async (importOriginal: () => Promise<typeof import("@paralleldrive/cuid2")>) => {
const original = await importOriginal();
return {
...original,
createId: vi.fn(),
isCuid: vi.fn(),
};
}
);
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "test-encryption-key",
},
}));
describe("Single Use Surveys", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("generateSurveySingleUseId", () => {
test("returns plain cuid when encryption is disabled", () => {
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock.mockReturnValueOnce("test-cuid");
const result = generateSurveySingleUseId(false);
expect(result).toBe("test-cuid");
expect(createIdMock).toHaveBeenCalledTimes(1);
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
});
test("returns encrypted cuid when encryption is enabled", () => {
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock.mockReturnValueOnce("test-cuid");
vi.mocked(crypto.symmetricEncrypt).mockReturnValueOnce("encrypted-test-cuid");
const result = generateSurveySingleUseId(true);
expect(result).toBe("encrypted-test-cuid");
expect(createIdMock).toHaveBeenCalledTimes(1);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith("test-cuid", env.ENCRYPTION_KEY);
});
test("throws error when encryption key is missing", () => {
vi.mocked(env).ENCRYPTION_KEY = "";
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock.mockReturnValueOnce("test-cuid");
expect(() => generateSurveySingleUseId(true)).toThrow("ENCRYPTION_KEY is not set");
// Restore encryption key for subsequent tests
vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
});
});
describe("generateSurveySingleUseIds", () => {
beforeEach(() => {
vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
});
test("generates multiple single use IDs", () => {
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock
.mockReturnValueOnce("test-cuid-1")
.mockReturnValueOnce("test-cuid-2")
.mockReturnValueOnce("test-cuid-3");
const result = generateSurveySingleUseIds(3, false);
expect(result).toEqual(["test-cuid-1", "test-cuid-2", "test-cuid-3"]);
expect(createIdMock).toHaveBeenCalledTimes(3);
});
test("generates encrypted IDs when encryption is enabled", () => {
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock.mockReturnValueOnce("test-cuid-1").mockReturnValueOnce("test-cuid-2");
vi.mocked(crypto.symmetricEncrypt)
.mockReturnValueOnce("encrypted-test-cuid-1")
.mockReturnValueOnce("encrypted-test-cuid-2");
const result = generateSurveySingleUseIds(2, true);
expect(result).toEqual(["encrypted-test-cuid-1", "encrypted-test-cuid-2"]);
expect(createIdMock).toHaveBeenCalledTimes(2);
expect(crypto.symmetricEncrypt).toHaveBeenCalledTimes(2);
});
test("returns empty array when count is zero", () => {
const result = generateSurveySingleUseIds(0, false);
const createIdMock = vi.spyOn(cuid2, "createId");
createIdMock.mockReturnValueOnce("test-cuid");
expect(result).toEqual([]);
expect(createIdMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import cuid2 from "@paralleldrive/cuid2";
@@ -26,24 +26,3 @@ export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean):
return singleUseIds;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
try {
let decryptedCuid: string | null = null;
if (!env.ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
}
} catch (error) {
return undefined;
}
};

View File

@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import {
capitalizeFirstLetter,
isCapitalized,
sanitizeString,
startsWithVowel,
truncate,
truncateText,
} from "./strings";
describe("String Utilities", () => {
describe("capitalizeFirstLetter", () => {
test("capitalizes the first letter of a string", () => {
expect(capitalizeFirstLetter("hello")).toBe("Hello");
});
test("returns empty string if input is null", () => {
expect(capitalizeFirstLetter(null)).toBe("");
});
test("returns empty string if input is empty string", () => {
expect(capitalizeFirstLetter("")).toBe("");
});
test("doesn't change already capitalized string", () => {
expect(capitalizeFirstLetter("Hello")).toBe("Hello");
});
test("handles single character string", () => {
expect(capitalizeFirstLetter("a")).toBe("A");
});
});
describe("truncate", () => {
test("returns the string as is if length is less than the specified length", () => {
expect(truncate("hello", 10)).toBe("hello");
});
test("truncates the string and adds ellipsis if length exceeds the specified length", () => {
expect(truncate("hello world", 5)).toBe("hello...");
});
test("returns empty string if input is falsy", () => {
expect(truncate("", 5)).toBe("");
});
test("handles exact length match correctly", () => {
expect(truncate("hello", 5)).toBe("hello");
});
});
describe("sanitizeString", () => {
test("replaces special characters with delimiter", () => {
expect(sanitizeString("hello@world")).toBe("hello_world");
});
test("keeps alphanumeric and allowed characters", () => {
expect(sanitizeString("hello-world.123")).toBe("hello-world.123");
});
test("truncates string to specified length", () => {
const longString = "a".repeat(300);
expect(sanitizeString(longString).length).toBe(255);
});
test("uses custom delimiter when provided", () => {
expect(sanitizeString("hello@world", "-")).toBe("hello-world");
});
test("uses custom length when provided", () => {
expect(sanitizeString("hello world", "_", 5)).toBe("hello");
});
});
describe("isCapitalized", () => {
test("returns true for capitalized strings", () => {
expect(isCapitalized("Hello")).toBe(true);
});
test("returns false for non-capitalized strings", () => {
expect(isCapitalized("hello")).toBe(false);
});
test("handles single uppercase character", () => {
expect(isCapitalized("A")).toBe(true);
});
test("handles single lowercase character", () => {
expect(isCapitalized("a")).toBe(false);
});
});
describe("startsWithVowel", () => {
test("returns true for strings starting with lowercase vowels", () => {
expect(startsWithVowel("apple")).toBe(true);
expect(startsWithVowel("elephant")).toBe(true);
expect(startsWithVowel("igloo")).toBe(true);
expect(startsWithVowel("octopus")).toBe(true);
expect(startsWithVowel("umbrella")).toBe(true);
});
test("returns true for strings starting with uppercase vowels", () => {
expect(startsWithVowel("Apple")).toBe(true);
expect(startsWithVowel("Elephant")).toBe(true);
expect(startsWithVowel("Igloo")).toBe(true);
expect(startsWithVowel("Octopus")).toBe(true);
expect(startsWithVowel("Umbrella")).toBe(true);
});
test("returns false for strings starting with consonants", () => {
expect(startsWithVowel("banana")).toBe(false);
expect(startsWithVowel("Carrot")).toBe(false);
});
test("returns false for empty strings", () => {
expect(startsWithVowel("")).toBe(false);
});
});
describe("truncateText", () => {
test("returns the string as is if length is less than the specified limit", () => {
expect(truncateText("hello", 10)).toBe("hello");
});
test("truncates the string and adds ellipsis if length exceeds the specified limit", () => {
expect(truncateText("hello world", 5)).toBe("hello...");
});
test("handles exact limit match correctly", () => {
expect(truncateText("hello", 5)).toBe("hello");
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import { TJsEnvironmentStateProject, TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { getStyling } from "./styling";
describe("Styling Utilities", () => {
test("returns project styling when project does not allow style overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: true,
brandColor: "#ffffff",
highlightBorderColor: "#ffffff",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
});
test("returns project styling when project allows style overwrite but survey does not overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: false,
brandColor: "#ffffff",
highlightBorderColor: "#ffffff",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
});
test("returns survey styling when both project and survey allow style overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: true,
brandColor: "#ffffff",
highlightBorderColor: "#ffffff",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(survey.styling);
});
test("returns project styling when project allows style overwrite but survey styling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: undefined,
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
});
test("returns project styling when project allows style overwrite but survey overwriteThemeStyling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
brandColor: "#ffffff",
highlightBorderColor: "#ffffff",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
});
});

View File

@@ -0,0 +1,164 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
// Mock the imported functions
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn(),
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
describe("Template Utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("replaceQuestionPresetPlaceholders", () => {
test("returns original question when project is not provided", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question $[projectName]",
},
} as unknown as TSurveyQuestion;
const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
expect(result).toEqual(question);
expect(structuredClone).not.toHaveBeenCalled();
});
test("replaces projectName placeholder in subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question",
},
subheader: {
default: "Subheader for $[projectName]",
},
} as unknown as TSurveyQuestion;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
} as unknown as TProject;
// Mock for headline and subheader with correct return values
vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
const result = replaceQuestionPresetPlaceholders(question, project);
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
expect(result.subheader?.default).toBe("Subheader for Test Project");
});
test("handles missing headline and subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
} as unknown as TSurveyQuestion;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
} as unknown as TProject;
const result = replaceQuestionPresetPlaceholders(question, project);
expect(structuredClone).toHaveBeenCalledWith(question);
expect(result).toEqual(question);
expect(getLocalizedValue).not.toHaveBeenCalled();
});
});
describe("replacePresetPlaceholders", () => {
test("replaces projectName placeholder in template name and questions", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
preset: {
name: "$[projectName] Feedback",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How do you like $[projectName]?",
},
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Another question",
},
subheader: {
default: "About $[projectName]",
},
},
],
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
// Mock getLocalizedValue to return the original strings with placeholders
vi.mocked(getLocalizedValue)
.mockReturnValueOnce("How do you like $[projectName]?")
.mockReturnValueOnce("Another question")
.mockReturnValueOnce("About $[projectName]");
const result = replacePresetPlaceholders(template, project);
expect(result.preset.name).toBe("Awesome App Feedback");
expect(structuredClone).toHaveBeenCalledWith(template.preset);
// Verify that replaceQuestionPresetPlaceholders was applied to both questions
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3);
expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?");
expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App");
});
test("maintains other template properties", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
preset: {
name: "$[projectName] Feedback",
questions: [],
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
const result = replacePresetPlaceholders(template, project) as unknown as {
name: string;
description: string;
};
expect(result.name).toBe(template.name);
expect(result.description).toBe(template.description);
});
});
});

View File

@@ -0,0 +1,49 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
});
describe("testURLmatch", () => {
const testCases: [string, string, TActionClassPageUrlRule, string][] = [
["https://example.com", "https://example.com", "exactMatch", "yes"],
["https://example.com", "https://example.com/page", "contains", "no"],
["https://example.com/page", "https://example.com", "startsWith", "yes"],
["https://example.com/page", "page", "endsWith", "yes"],
["https://example.com", "https://other.com", "notMatch", "yes"],
["https://example.com", "other", "notContains", "yes"],
];
test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => {
expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule)).toBe(expected);
});
test("throws an error for invalid match type", () => {
expect(() =>
testURLmatch("https://example.com", "https://example.com", "invalidRule" as TActionClassPageUrlRule)
).toThrow("Invalid match type");
});
});
describe("isValidCallbackUrl", () => {
const WEBAPP_URL = "https://webapp.example.com";
test("returns true for valid callback URL", () => {
expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true);
});
test("returns false for invalid scheme", () => {
expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false);
});
test("returns false for invalid domain", () => {
expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false);
});
test("returns false for malformed URL", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "./validate";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
afterEach(() => {
vi.clearAllMocks();
});
describe("validateInputs", () => {
test("validates inputs successfully", () => {
const schema = z.string();
const result = validateInputs(["valid", schema]);
expect(result).toEqual(["valid"]);
});
test("throws ValidationError for invalid inputs", () => {
const schema = z.string();
expect(() => validateInputs([123, schema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("Validation failed")
);
});
test("validates multiple inputs successfully", () => {
const stringSchema = z.string();
const numberSchema = z.number();
const result = validateInputs(["valid", stringSchema], [42, numberSchema]);
expect(result).toEqual(["valid", 42]);
});
test("throws ValidationError for one of multiple invalid inputs", () => {
const stringSchema = z.string();
const numberSchema = z.number();
expect(() => validateInputs(["valid", stringSchema], ["invalid", numberSchema])).toThrow(ValidationError);
expect(logger.error).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("Validation failed")
);
});
});

View File

@@ -0,0 +1,131 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import {
checkForLoomUrl,
checkForVimeoUrl,
checkForYoutubeUrl,
convertToEmbedUrl,
extractLoomId,
extractVimeoId,
extractYoutubeId,
} from "./video-upload";
afterEach(() => {
cleanup();
});
describe("checkForYoutubeUrl", () => {
test("returns true for valid YouTube URLs", () => {
expect(checkForYoutubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
expect(checkForYoutubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true);
expect(checkForYoutubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
expect(checkForYoutubeUrl("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
expect(checkForYoutubeUrl("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
expect(checkForYoutubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true);
});
test("returns false for invalid YouTube URLs", () => {
expect(checkForYoutubeUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBe(false);
expect(checkForYoutubeUrl("invalid-url")).toBe(false);
expect(checkForYoutubeUrl("http://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false); // Non-HTTPS protocol
});
});
describe("extractYoutubeId", () => {
test("extracts video ID from YouTube URLs", () => {
expect(extractYoutubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
expect(extractYoutubeId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
expect(extractYoutubeId("https://youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
expect(extractYoutubeId("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});
test("returns null for invalid YouTube URLs", () => {
expect(extractYoutubeId("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeNull();
expect(extractYoutubeId("invalid-url")).toBeNull();
expect(extractYoutubeId("https://youtube.com/notavalidpath")).toBeNull();
});
});
describe("convertToEmbedUrl", () => {
test("converts YouTube URL to embed URL", () => {
expect(convertToEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
"https://www.youtube.com/embed/dQw4w9WgXcQ"
);
expect(convertToEmbedUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(
"https://www.youtube.com/embed/dQw4w9WgXcQ"
);
});
test("converts Vimeo URL to embed URL", () => {
expect(convertToEmbedUrl("https://vimeo.com/123456789")).toBe("https://player.vimeo.com/video/123456789");
expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
});
test("converts Loom URL to embed URL", () => {
expect(convertToEmbedUrl("https://www.loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
});
test("returns undefined for unsupported URLs", () => {
expect(convertToEmbedUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeUndefined();
expect(convertToEmbedUrl("invalid-url")).toBeUndefined();
});
});
// Testing private functions by importing them through the module system
describe("checkForVimeoUrl", () => {
test("returns true for valid Vimeo URLs", () => {
expect(checkForVimeoUrl("https://vimeo.com/123456789")).toBe(true);
expect(checkForVimeoUrl("https://www.vimeo.com/123456789")).toBe(true);
});
test("returns false for invalid Vimeo URLs", () => {
expect(checkForVimeoUrl("https://www.invalid.com/123456789")).toBe(false);
expect(checkForVimeoUrl("invalid-url")).toBe(false);
expect(checkForVimeoUrl("http://vimeo.com/123456789")).toBe(false); // Non-HTTPS protocol
});
});
describe("checkForLoomUrl", () => {
test("returns true for valid Loom URLs", () => {
expect(checkForLoomUrl("https://loom.com/share/abcdef123456")).toBe(true);
expect(checkForLoomUrl("https://www.loom.com/share/abcdef123456")).toBe(true);
});
test("returns false for invalid Loom URLs", () => {
expect(checkForLoomUrl("https://www.invalid.com/share/abcdef123456")).toBe(false);
expect(checkForLoomUrl("invalid-url")).toBe(false);
expect(checkForLoomUrl("http://loom.com/share/abcdef123456")).toBe(false); // Non-HTTPS protocol
});
});
describe("extractVimeoId", () => {
test("extracts video ID from Vimeo URLs", () => {
expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
});
test("returns null for invalid Vimeo URLs", () => {
expect(extractVimeoId("https://www.invalid.com/123456789")).toBeNull();
expect(extractVimeoId("invalid-url")).toBeNull();
});
});
describe("extractLoomId", () => {
test("extracts video ID from Loom URLs", () => {
expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
});
test("returns null for invalid Loom URLs", async () => {
expect(extractLoomId("https://www.invalid.com/share/abcdef123456")).toBeNull();
expect(extractLoomId("invalid-url")).toBeNull();
expect(extractLoomId("https://loom.com/invalid/abcdef123456")).toBeNull();
});
});

View File

@@ -15,13 +15,12 @@ export const checkForYoutubeUrl = (url: string): boolean => {
const hostname = youtubeUrl.hostname;
return youtubeDomains.includes(hostname);
} catch (err) {
// invalid URL
} catch {
return false;
}
};
const checkForVimeoUrl = (url: string): boolean => {
export const checkForVimeoUrl = (url: string): boolean => {
try {
const vimeoUrl = new URL(url);
@@ -31,13 +30,12 @@ const checkForVimeoUrl = (url: string): boolean => {
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
} catch (err) {
// invalid URL
} catch {
return false;
}
};
const checkForLoomUrl = (url: string): boolean => {
export const checkForLoomUrl = (url: string): boolean => {
try {
const loomUrl = new URL(url);
@@ -47,8 +45,7 @@ const checkForLoomUrl = (url: string): boolean => {
const hostname = loomUrl.hostname;
return loomDomains.includes(hostname);
} catch (err) {
// invalid URL
} catch {
return false;
}
};
@@ -65,8 +62,8 @@ export const extractYoutubeId = (url: string): string | null => {
];
regExpList.some((regExp) => {
const match = url.match(regExp);
if (match && match[1]) {
const match = regExp.exec(url);
if (match?.[1]) {
id = match[1];
return true;
}
@@ -76,23 +73,25 @@ export const extractYoutubeId = (url: string): string | null => {
return id || null;
};
const extractVimeoId = (url: string): string | null => {
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const match = url.match(regExp);
const match = regExp.exec(url);
if (match && match[1]) {
if (match?.[1]) {
return match[1];
}
return null;
};
const extractLoomId = (url: string): string | null => {
export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const match = url.match(regExp);
const match = regExp.exec(url);
if (match && match[1]) {
if (match?.[1]) {
return match[1];
}
return null;
};

View File

@@ -177,17 +177,6 @@ export const authOptions: NextAuthOptions = {
// Conditionally add enterprise SSO providers
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
cookies: {
sessionToken: {
name: "next-auth.session-token",
options: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
},
session: {
maxAge: 3600,
},
@@ -230,6 +219,7 @@ export const authOptions: NextAuthOptions = {
}
if (ENTERPRISE_LICENSE_KEY) {
const result = await handleSsoCallback({ user, account, callbackUrl });
if (result) {
await updateUserLastLoginAt(user.email);
}

View File

@@ -207,7 +207,7 @@ export const TeamSettingsModal = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />

View File

@@ -1,9 +1,9 @@
import { handleFileUpload } from "@/app/lib/fileUpload";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -18,8 +18,8 @@ vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
updateOrganizationEmailLogoUrlAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/file-input/lib/utils", () => ({
uploadFile: vi.fn(),
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const defaultProps = {
@@ -82,8 +82,7 @@ describe("EmailCustomizationSettings", () => {
});
test("calls updateOrganizationEmailLogoUrlAction after uploading and clicking save", async () => {
vi.mocked(uploadFile).mockResolvedValueOnce({
uploaded: true,
vi.mocked(handleFileUpload).mockResolvedValueOnce({
url: "https://example.com/new-uploaded-logo.png",
});
vi.mocked(updateOrganizationEmailLogoUrlAction).mockResolvedValue({
@@ -104,7 +103,7 @@ describe("EmailCustomizationSettings", () => {
await user.click(saveButton[0]);
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
expect(uploadFile).toHaveBeenCalledWith(testFile, ["jpeg", "png", "jpg", "webp"], "env-123");
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",
logoUrl: "https://example.com/new-uploaded-logo.png",

View File

@@ -1,6 +1,7 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
@@ -11,7 +12,6 @@ import {
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
@@ -120,7 +120,13 @@ export const EmailCustomizationSettings = ({
const handleSave = async () => {
if (!logoFile) return;
setIsSaving(true);
const { url } = await uploadFile(logoFile, allowedFileExtensions, environmentId);
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
if (error) {
toast.error(error);
setIsSaving(false);
return;
}
const updateLogoResponse = await updateOrganizationEmailLogoUrlAction({
organizationId: organization.id,
@@ -205,7 +211,7 @@ export const EmailCustomizationSettings = ({
data-testid="replace-logo-button"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={isReadOnly}>
disabled={isReadOnly || isSaving}>
<RepeatIcon className="h-4 w-4" />
{t("environments.settings.general.replace_logo")}
</Button>
@@ -213,7 +219,7 @@ export const EmailCustomizationSettings = ({
data-testid="remove-logo-button"
onClick={removeLogo}
variant="outline"
disabled={isReadOnly}>
disabled={isReadOnly || isSaving}>
<Trash2Icon className="h-4 w-4" />
{t("environments.settings.general.remove_logo")}
</Button>
@@ -241,7 +247,7 @@ export const EmailCustomizationSettings = ({
<Button
data-testid="send-test-email-button"
variant="secondary"
disabled={isReadOnly}
disabled={isReadOnly || isSaving}
onClick={sendTestEmail}>
{t("common.send_test_email")}
</Button>

View File

@@ -0,0 +1,74 @@
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectLimitModal } from "./index";
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, onOpenChange, children }: any) =>
open ? (
<div data-testid="dialog" onClick={() => onOpenChange(false)}>
{children}
</div>
) : null,
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: ({ title, description, buttons }: any) => (
<div data-testid="upgrade-prompt">
<div>{title}</div>
<div>{description}</div>
<button onClick={buttons[0].onClick}>{buttons[0].text}</button>
<button onClick={buttons[1].onClick}>{buttons[1].text}</button>
</div>
),
}));
describe("ProjectLimitModal", () => {
afterEach(() => {
cleanup();
});
const setOpen = vi.fn();
const buttons: [ModalButton, ModalButton] = [
{ text: "Start Trial", onClick: vi.fn() },
{ text: "Upgrade", onClick: vi.fn() },
];
test("renders dialog and upgrade prompt with correct props", () => {
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toHaveClass("bg-white");
expect(screen.getByTestId("dialog-title")).toHaveTextContent("common.projects_limit_reached");
expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
expect(screen.getByText("common.unlock_more_projects_with_a_higher_plan")).toBeInTheDocument();
expect(screen.getByText("common.you_have_reached_your_limit_of_project_limit")).toBeInTheDocument();
expect(screen.getByText("Start Trial")).toBeInTheDocument();
expect(screen.getByText("Upgrade")).toBeInTheDocument();
});
test("calls setOpen(false) when dialog is closed", async () => {
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
await userEvent.click(screen.getByTestId("dialog"));
expect(setOpen).toHaveBeenCalledWith(false);
});
test("calls button onClick handlers", async () => {
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
await userEvent.click(screen.getByText("Start Trial"));
expect(vi.mocked(buttons[0].onClick)).toHaveBeenCalled();
await userEvent.click(screen.getByText("Upgrade"));
expect(vi.mocked(buttons[1].onClick)).toHaveBeenCalled();
});
test("does not render when open is false", () => {
render(<ProjectLimitModal open={false} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,177 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { ProjectSwitcher } from "./index";
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}));
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children }: any) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div data-testid="dropdown-trigger">{children}</div>,
DropdownMenuContent: ({ children }: any) => <div data-testid="dropdown-content">{children}</div>,
DropdownMenuRadioGroup: ({ children, ...props }: any) => (
<div data-testid="dropdown-radio-group" {...props}>
{children}
</div>
),
DropdownMenuRadioItem: ({ children, ...props }: any) => (
<div data-testid="dropdown-radio-item" {...props}>
{children}
</div>
),
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
DropdownMenuItem: ({ children, ...props }: any) => (
<div data-testid="dropdown-item" {...props}>
{children}
</div>
),
}));
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
open ? (
<div data-testid="project-limit-modal">
<button onClick={() => setOpen(false)} data-testid="close-modal">
Close
</button>
<div data-testid="modal-buttons">
{buttons[0].text} {buttons[1].text}
</div>
<div data-testid="modal-project-limit">{projectLimit}</div>
</div>
) : null,
}));
describe("ProjectSwitcher", () => {
afterEach(() => {
cleanup();
});
const organization: TOrganization = {
id: "org1",
name: "Org 1",
billing: { plan: "free" },
} as TOrganization;
const project: TProject = {
id: "proj1",
name: "Project 1",
config: { channel: "website" },
} as TProject;
const projects: TProject[] = [project, { ...project, id: "proj2", name: "Project 2" }];
test("renders dropdown and project name", () => {
render(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
expect(screen.getByTitle("Project 1")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-radio-group")).toBeInTheDocument();
expect(screen.getAllByTestId("dropdown-radio-item").length).toBe(2);
});
test("opens ProjectLimitModal when project limit reached and add project is clicked", async () => {
render(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
});
test("closes ProjectLimitModal when close button is clicked", async () => {
render(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
const closeButton = screen.getByTestId("close-modal");
await userEvent.click(closeButton);
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
});
test("renders correct modal buttons and project limit", async () => {
render(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects}
organizationProjectsLimit={2}
isFormbricksCloud={true}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(screen.getByTestId("modal-buttons")).toHaveTextContent(
"common.start_free_trial common.learn_more"
);
expect(screen.getByTestId("modal-project-limit")).toHaveTextContent("2");
});
test("handleAddProject navigates if under limit", async () => {
render(
<ProjectSwitcher
isCollapsed={false}
isTextVisible={false}
organization={organization}
project={project}
projects={projects.slice(0, 1)}
organizationProjectsLimit={2}
isFormbricksCloud={false}
isLicenseActive={false}
environmentId="env1"
isOwnerOrManager={true}
/>
);
const addButton = screen.getByText("common.add_project");
await userEvent.click(addButton);
expect(mockPush).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/organizations/org1/projects/new/mode");
});
});

View File

@@ -0,0 +1,59 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppConnectionLoading } from "./loading";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId, loading }: any) => (
<div data-testid="project-config-navigation">
{activeId} {loading ? "loading" : "not-loading"}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: any) => (
<div data-testid="page-header">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: (props: any) => (
<div data-testid="loading-card">
{props.title} {props.description}
</div>
),
}));
describe("AppConnectionLoading", () => {
afterEach(() => {
cleanup();
});
test("renders wrapper, header, navigation, and all loading cards with correct tolgee keys", () => {
render(<AppConnectionLoading />);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toHaveTextContent("common.project_configuration");
expect(screen.getByTestId("project-config-navigation")).toHaveTextContent("app-connection loading");
const cards = screen.getAllByTestId("loading-card");
expect(cards.length).toBe(3);
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection");
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection_description");
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup");
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup_description");
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id");
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id_description");
});
test("renders the blue info bar", () => {
render(<AppConnectionLoading />);
expect(screen.getByText((_, element) => element!.className.includes("bg-blue-50"))).toBeInTheDocument();
expect(
screen.getByText((_, element) => element!.className.includes("animate-pulse"))
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,97 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppConnectionPage } from "./page";
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: any) => (
<div data-testid="page-header">
{pageTitle}
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
<div data-testid="project-config-navigation">
{environmentId} {activeId}
</div>
),
}));
vi.mock("@/modules/ui/components/environment-notice", () => ({
EnvironmentNotice: ({ environmentId, subPageUrl }: any) => (
<div data-testid="environment-notice">
{environmentId} {subPageUrl}
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="settings-card">
{title} {description} {children}
</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator", () => ({
WidgetStatusIndicator: ({ environment }: any) => (
<div data-testid="widget-status-indicator">{environment.id}</div>
),
}));
vi.mock("@/modules/projects/settings/(setup)/components/setup-instructions", () => ({
SetupInstructions: ({ environmentId, webAppUrl }: any) => (
<div data-testid="setup-instructions">
{environmentId} {webAppUrl}
</div>
),
}));
vi.mock("@/modules/projects/settings/(setup)/components/environment-id-field", () => ({
EnvironmentIdField: ({ environmentId }: any) => (
<div data-testid="environment-id-field">{environmentId}</div>
),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(async (environmentId: string) => ({ environment: { id: environmentId } })),
}));
let mockWebappUrl = "https://example.com";
vi.mock("@/lib/constants", () => ({
get WEBAPP_URL() {
return mockWebappUrl;
},
}));
describe("AppConnectionPage", () => {
afterEach(() => {
cleanup();
});
test("renders all sections and passes correct props", async () => {
const params = { environmentId: "env-123" };
const props = { params };
const { findByTestId, findAllByTestId } = render(await AppConnectionPage(props));
expect(await findByTestId("page-content-wrapper")).toBeInTheDocument();
expect(await findByTestId("page-header")).toHaveTextContent("common.project_configuration");
expect(await findByTestId("project-config-navigation")).toHaveTextContent("env-123 app-connection");
expect(await findByTestId("environment-notice")).toHaveTextContent("env-123 /project/app-connection");
const cards = await findAllByTestId("settings-card");
expect(cards.length).toBe(3);
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection");
expect(cards[0]).toHaveTextContent("environments.project.app-connection.app_connection_description");
expect(cards[0]).toHaveTextContent("env-123"); // WidgetStatusIndicator
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup");
expect(cards[1]).toHaveTextContent("environments.project.app-connection.how_to_setup_description");
expect(cards[1]).toHaveTextContent("env-123"); // SetupInstructions
expect(cards[1]).toHaveTextContent(mockWebappUrl);
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id");
expect(cards[2]).toHaveTextContent("environments.project.app-connection.environment_id_description");
expect(cards[2]).toHaveTextContent("env-123"); // EnvironmentIdField
});
});

View File

@@ -0,0 +1,38 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnvironmentIdField } from "./environment-id-field";
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: any) => (
<pre data-testid="code-block" data-language={language}>
{children}
</pre>
),
}));
describe("EnvironmentIdField", () => {
afterEach(() => {
cleanup();
});
test("renders the environment id in a code block", () => {
const envId = "env-123";
render(<EnvironmentIdField environmentId={envId} />);
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveAttribute("data-language", "js");
expect(codeBlock).toHaveTextContent(envId);
});
test("applies the correct wrapper class", () => {
render(<EnvironmentIdField environmentId="env-abc" />);
const wrapper = codeBlockParent();
expect(wrapper).toHaveClass("prose");
expect(wrapper).toHaveClass("prose-slate");
expect(wrapper).toHaveClass("-mt-3");
});
});
function codeBlockParent() {
return screen.getByTestId("code-block").parentElement as HTMLElement;
}

View File

@@ -0,0 +1,48 @@
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectConfigNavigation } from "./project-config-navigation";
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
let mockPathname = "/environments/env-1/project/look";
vi.mock("next/navigation", () => ({
usePathname: vi.fn(() => mockPathname),
}));
describe("ProjectConfigNavigation", () => {
afterEach(() => {
cleanup();
});
test("sets current to true for the correct nav item based on pathname", () => {
const cases = [
{ path: "/environments/env-1/project/general", idx: 0 },
{ path: "/environments/env-1/project/look", idx: 1 },
{ path: "/environments/env-1/project/languages", idx: 2 },
{ path: "/environments/env-1/project/tags", idx: 3 },
{ path: "/environments/env-1/project/app-connection", idx: 4 },
{ path: "/environments/env-1/project/teams", idx: 5 },
];
for (const { path, idx } of cases) {
mockPathname = path;
render(<ProjectConfigNavigation activeId="irrelevant" environmentId="env-1" />);
const navArg = SecondaryNavigation.mock.calls[0][0].navigation;
navArg.forEach((item: any, i: number) => {
if (i === idx) {
expect(item.current).toBe(true);
} else {
expect(item.current).toBe(false);
}
});
SecondaryNavigation.mockClear();
}
});
});

View File

@@ -0,0 +1,195 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { DeleteProjectRender } from "./delete-project-render";
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }: any) =>
open ? (
<div data-testid="delete-dialog">
<span>{text}</span>
<button onClick={onDelete} disabled={isDeleting} data-testid="confirm-delete">
Delete
</button>
<button onClick={() => setOpen(false)} data-testid="cancel-delete">
Cancel
</button>
</div>
) : null,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string, params?: any) => (params?.projectName ? `${key} ${params.projectName}` : key),
}),
}));
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "error-message"),
}));
vi.mock("@/lib/utils/strings", () => ({
truncate: (str: string) => str,
}));
const mockDeleteProjectAction = vi.fn();
vi.mock("@/modules/projects/settings/general/actions", () => ({
deleteProjectAction: (...args: any[]) => mockDeleteProjectAction(...args),
}));
const mockLocalStorage = {
removeItem: vi.fn(),
setItem: vi.fn(),
};
global.localStorage = mockLocalStorage as any;
const baseProject: TProject = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [
{
id: "env1",
type: "production",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
appSetupCompleted: false,
},
],
languages: [],
logo: null,
};
describe("DeleteProjectRender", () => {
afterEach(() => {
cleanup();
});
test("shows delete button and dialog when enabled", async () => {
render(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
expect(
screen.getByText(
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more Project 1"
)
).toBeInTheDocument();
expect(screen.getByText("environments.project.general.this_action_cannot_be_undone")).toBeInTheDocument();
const deleteBtn = screen.getByText("common.delete");
expect(deleteBtn).toBeInTheDocument();
await userEvent.click(deleteBtn);
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
});
test("shows alert if delete is disabled and not owner/manager", () => {
render(
<DeleteProjectRender
isDeleteDisabled={true}
isOwnerOrManager={false}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.project.general.only_owners_or_managers_can_delete_projects"
);
});
test("shows alert if delete is disabled and is owner/manager", () => {
render(
<DeleteProjectRender
isDeleteDisabled={true}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.project.general.cannot_delete_only_project"
);
});
test("successful delete with one project removes env id and redirects", async () => {
mockDeleteProjectAction.mockResolvedValue({ data: true });
render(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
await userEvent.click(screen.getByText("common.delete"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockLocalStorage.removeItem).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.project.general.project_deleted_successfully");
expect(mockPush).toHaveBeenCalledWith("/");
});
test("successful delete with multiple projects sets env id and redirects", async () => {
const otherProject: TProject = {
...baseProject,
id: "p2",
environments: [{ ...baseProject.environments[0], id: "env2" }],
};
mockDeleteProjectAction.mockResolvedValue({ data: true });
render(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject, otherProject]}
/>
);
await userEvent.click(screen.getByText("common.delete"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("formbricks-environment-id", "env2");
expect(toast.success).toHaveBeenCalledWith("environments.project.general.project_deleted_successfully");
expect(mockPush).toHaveBeenCalledWith("/");
});
test("delete error shows error toast and closes dialog", async () => {
mockDeleteProjectAction.mockResolvedValue({ data: false });
render(
<DeleteProjectRender
isDeleteDisabled={false}
isOwnerOrManager={true}
currentProject={baseProject}
organizationProjects={[baseProject]}
/>
);
await userEvent.click(screen.getByText("common.delete"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(toast.error).toHaveBeenCalledWith("error-message");
expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,139 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { DeleteProject } from "./delete-project";
vi.mock("@/modules/projects/settings/general/components/delete-project-render", () => ({
DeleteProjectRender: (props: any) => (
<div data-testid="delete-project-render">
<p>isDeleteDisabled: {String(props.isDeleteDisabled)}</p>
<p>isOwnerOrManager: {String(props.isOwnerOrManager)}</p>
</div>
),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
const mockProject = {
id: "proj-1",
name: "Project 1",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-1",
environments: [],
} as any;
const mockOrganization = {
id: "org-1",
name: "Org 1",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free" } as any,
} as any;
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
return (key: string) => key;
}),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getUserProjects: vi.fn(),
}));
describe("/modules/projects/settings/general/components/delete-project.tsx", () => {
beforeEach(() => {
vi.mocked(getServerSession).mockResolvedValue({
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
user: { id: "user1" },
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getUserProjects).mockResolvedValue([mockProject, { ...mockProject, id: "proj-2" }]);
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders DeleteProjectRender with correct props when delete is enabled", async () => {
const result = await DeleteProject({
environmentId: "env-1",
currentProject: mockProject,
organizationProjects: [mockProject, { ...mockProject, id: "proj-2" }],
isOwnerOrManager: true,
});
render(result);
const el = screen.getByTestId("delete-project-render");
expect(el).toBeInTheDocument();
expect(screen.getByText("isDeleteDisabled: false")).toBeInTheDocument();
expect(screen.getByText("isOwnerOrManager: true")).toBeInTheDocument();
});
test("renders DeleteProjectRender with delete disabled if only one project", async () => {
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
const result = await DeleteProject({
environmentId: "env-1",
currentProject: mockProject,
organizationProjects: [mockProject],
isOwnerOrManager: true,
});
render(result);
const el = screen.getByTestId("delete-project-render");
expect(el).toBeInTheDocument();
expect(screen.getByText("isDeleteDisabled: true")).toBeInTheDocument();
});
test("renders DeleteProjectRender with delete disabled if not owner or manager", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getUserProjects).mockResolvedValue([mockProject, { ...mockProject, id: "proj-2" }]);
const result = await DeleteProject({
environmentId: "env-1",
currentProject: mockProject,
organizationProjects: [mockProject, { ...mockProject, id: "proj-2" }],
isOwnerOrManager: false,
});
render(result);
const el = screen.getByTestId("delete-project-render");
expect(el).toBeInTheDocument();
expect(screen.getByText("isDeleteDisabled: true")).toBeInTheDocument();
expect(screen.getByText("isOwnerOrManager: false")).toBeInTheDocument();
});
test("throws error if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
await expect(
DeleteProject({
environmentId: "env-1",
currentProject: mockProject,
organizationProjects: [mockProject],
isOwnerOrManager: true,
})
).rejects.toThrow("common.session_not_found");
});
test("throws error if organization is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } });
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(
DeleteProject({
environmentId: "env-1",
currentProject: mockProject,
organizationProjects: [mockProject],
isOwnerOrManager: true,
})
).rejects.toThrow("common.organization_not_found");
});
});

View File

@@ -0,0 +1,107 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { anyString } from "vitest-mock-extended";
import { TProject } from "@formbricks/types/project";
import { EditProjectNameForm } from "./edit-project-name-form";
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
const mockUpdateProjectAction = vi.fn();
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: (...args: any[]) => mockUpdateProjectAction(...args),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "error-message"),
}));
const baseProject: TProject = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [
{
id: "env1",
type: "production",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
appSetupCompleted: false,
},
],
languages: [],
logo: null,
};
describe("EditProjectNameForm", () => {
afterEach(() => {
cleanup();
});
test("renders form with project name and update button", () => {
render(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
expect(
screen.getByLabelText("environments.project.general.whats_your_project_called")
).toBeInTheDocument();
expect(screen.getByPlaceholderText("common.project_name")).toHaveValue("Project 1");
expect(screen.getByText("common.update")).toBeInTheDocument();
});
test("shows warning alert if isReadOnly", () => {
render(<EditProjectNameForm project={baseProject} isReadOnly={true} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
expect(
screen.getByLabelText("environments.project.general.whats_your_project_called")
).toBeInTheDocument();
expect(screen.getByPlaceholderText("common.project_name")).toBeDisabled();
expect(screen.getByText("common.update")).toBeDisabled();
});
test("calls updateProjectAction and shows success toast on valid submit", async () => {
mockUpdateProjectAction.mockResolvedValue({ data: { name: "New Name" } });
render(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
const input = screen.getByPlaceholderText("common.project_name");
await userEvent.clear(input);
await userEvent.type(input, "New Name");
await userEvent.click(screen.getByText("common.update"));
expect(mockUpdateProjectAction).toHaveBeenCalledWith({ projectId: "p1", data: { name: "New Name" } });
expect(toast.success).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction returns no data", async () => {
mockUpdateProjectAction.mockResolvedValue({ data: null });
render(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
const input = screen.getByPlaceholderText("common.project_name");
await userEvent.clear(input);
await userEvent.type(input, "Another Name");
await userEvent.click(screen.getByText("common.update"));
expect(toast.error).toHaveBeenCalledWith(anyString());
});
test("shows error toast if updateProjectAction throws", async () => {
mockUpdateProjectAction.mockRejectedValue(new Error("fail"));
render(<EditProjectNameForm project={baseProject} isReadOnly={false} />);
const input = screen.getByPlaceholderText("common.project_name");
await userEvent.clear(input);
await userEvent.type(input, "Error Name");
await userEvent.click(screen.getByText("common.update"));
expect(toast.error).toHaveBeenCalledWith("environments.project.general.error_saving_project_information");
});
});

View File

@@ -0,0 +1,114 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { EditWaitingTimeForm } from "./edit-waiting-time-form";
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
const mockUpdateProjectAction = vi.fn();
vi.mock("../../actions", () => ({
updateProjectAction: (...args: any[]) => mockUpdateProjectAction(...args),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "error-message"),
}));
const baseProject: TProject = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 7,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [
{
id: "env1",
type: "production",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
appSetupCompleted: false,
},
],
languages: [],
logo: null,
};
describe("EditWaitingTimeForm", () => {
afterEach(() => {
cleanup();
});
test("renders form with current waiting time and update button", () => {
render(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
expect(
screen.getByLabelText("environments.project.general.wait_x_days_before_showing_next_survey")
).toBeInTheDocument();
expect(screen.getByDisplayValue("7")).toBeInTheDocument();
expect(screen.getByText("common.update")).toBeInTheDocument();
});
test("shows warning alert and disables input/button if isReadOnly", () => {
render(<EditWaitingTimeForm project={baseProject} isReadOnly={true} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
expect(
screen.getByLabelText("environments.project.general.wait_x_days_before_showing_next_survey")
).toBeInTheDocument();
expect(screen.getByDisplayValue("7")).toBeDisabled();
expect(screen.getByText("common.update")).toBeDisabled();
});
test("calls updateProjectAction and shows success toast on valid submit", async () => {
mockUpdateProjectAction.mockResolvedValue({ data: { recontactDays: 10 } });
render(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
const input = screen.getByLabelText(
"environments.project.general.wait_x_days_before_showing_next_survey"
);
await userEvent.clear(input);
await userEvent.type(input, "10");
await userEvent.click(screen.getByText("common.update"));
expect(mockUpdateProjectAction).toHaveBeenCalledWith({ projectId: "p1", data: { recontactDays: 10 } });
expect(toast.success).toHaveBeenCalledWith(
"environments.project.general.waiting_period_updated_successfully"
);
});
test("shows error toast if updateProjectAction returns no data", async () => {
mockUpdateProjectAction.mockResolvedValue({ data: null });
render(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
const input = screen.getByLabelText(
"environments.project.general.wait_x_days_before_showing_next_survey"
);
await userEvent.clear(input);
await userEvent.type(input, "5");
await userEvent.click(screen.getByText("common.update"));
expect(toast.error).toHaveBeenCalledWith("error-message");
});
test("shows error toast if updateProjectAction throws", async () => {
mockUpdateProjectAction.mockRejectedValue(new Error("fail"));
render(<EditWaitingTimeForm project={baseProject} isReadOnly={false} />);
const input = screen.getByLabelText(
"environments.project.general.wait_x_days_before_showing_next_survey"
);
await userEvent.clear(input);
await userEvent.type(input, "3");
await userEvent.click(screen.getByText("common.update"));
expect(toast.error).toHaveBeenCalledWith("Error: fail");
});
});

View File

@@ -0,0 +1,53 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { GeneralSettingsLoading } from "./loading";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: (props: any) => (
<div data-testid="loading-card">
<p>{props.title}</p>
<p>{props.description}</p>
</div>
),
}));
describe("GeneralSettingsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main UI elements", () => {
render(<GeneralSettingsLoading />);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("loading-card").length).toBe(3);
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByText("common.project_name")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.project_name_settings_description")
).toBeInTheDocument();
expect(screen.getByText("environments.project.general.recontact_waiting_time")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.recontact_waiting_time_settings_description")
).toBeInTheDocument();
expect(screen.getByText("environments.project.general.delete_project")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.delete_project_settings_description")
).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More