Compare commits

...

13 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
147 changed files with 18898 additions and 2045 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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,128 @@
import { getProjects } from "@/lib/project/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { GeneralSettingsPage } from "./page";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: any) => (
<div data-testid="settings-id">
<p>{title}</p>:<p>{id}</p>
</div>
),
}));
vi.mock("./components/edit-project-name-form", () => ({
EditProjectNameForm: (props: any) => <div data-testid="edit-project-name-form">{props.project.id}</div>,
}));
vi.mock("./components/edit-waiting-time-form", () => ({
EditWaitingTimeForm: (props: any) => <div data-testid="edit-waiting-time-form">{props.project.id}</div>,
}));
vi.mock("./components/delete-project", () => ({
DeleteProject: (props: any) => <div data-testid="delete-project">{props.environmentId}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
return (key: string) => key;
}),
}));
const mockProject = {
id: "proj-1",
name: "Project 1",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-1",
environments: [],
} as any;
const mockOrganization: TOrganization = {
id: "org-1",
name: "Org 1",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
limits: { monthly: { miu: 10, responses: 10 }, projects: 4 },
period: "monthly",
periodStart: new Date(),
stripeCustomerId: null,
},
isAIEnabled: false,
};
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getProjects: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_DEVELOPMENT: false,
}));
vi.mock("@/package.json", () => ({
default: {
version: "1.2.3",
},
}));
describe("GeneralSettingsPage", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main UI elements", async () => {
const props = { params: { environmentId: "env1" } } as any;
vi.mocked(getProjects).mockResolvedValue([mockProject]);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
isOwner: true,
isManager: false,
project: mockProject,
organization: mockOrganization,
} as any);
const Page = await GeneralSettingsPage(props);
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("settings-id").length).toBe(2);
expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
expect(screen.getByTestId("delete-project")).toBeInTheDocument();
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByText("common.project_name")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.project_name_settings_description")
).toBeInTheDocument();
expect(screen.getByText("environments.project.general.recontact_waiting_time")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.recontact_waiting_time_settings_description")
).toBeInTheDocument();
expect(screen.getByText("environments.project.general.delete_project")).toBeInTheDocument();
expect(
screen.getByText("environments.project.general.delete_project_settings_description")
).toBeInTheDocument();
expect(screen.getByText("common.project_id")).toBeInTheDocument();
expect(screen.getByText("common.formbricks_version")).toBeInTheDocument();
expect(screen.getByText("1.2.3")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,41 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectSettingsLayout } from "./layout";
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
describe("ProjectSettingsLayout", () => {
afterEach(() => {
cleanup();
});
test("redirects to billing if isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isBilling: true } as TEnvironmentAuth);
const props = { params: { environmentId: "env-1" }, children: <div>child</div> };
await ProjectSettingsLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-1/settings/billing");
});
test("renders children if isBilling is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isBilling: false } as TEnvironmentAuth);
const props = { params: { environmentId: "env-2" }, children: <div>child</div> };
const result = await ProjectSettingsLayout(props);
expect(result).toEqual(<div>child</div>);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("throws error if getEnvironmentAuth throws", async () => {
const error = new Error("fail");
vi.mocked(getEnvironmentAuth).mockRejectedValue(error);
const props = { params: { environmentId: "env-3" }, children: <div>child</div> };
await expect(ProjectSettingsLayout(props)).rejects.toThrow(error);
});
});

View File

@@ -0,0 +1,226 @@
import { environmentCache } from "@/lib/environment/cache";
import { createEnvironment } from "@/lib/environment/service";
import { projectCache } from "@/lib/project/cache";
import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TEnvironment } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { ZProject } from "@formbricks/types/project";
import { createProject, deleteProject, updateProject } from "./project";
const baseProject = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
languages: [],
recontactDays: 0,
linkSurveyBranding: false,
inAppSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [
{
id: "prodenv",
createdAt: new Date(),
updatedAt: new Date(),
type: "production" as TEnvironment["type"],
projectId: "p1",
appSetupCompleted: false,
},
{
id: "devenv",
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as TEnvironment["type"],
projectId: "p1",
appSetupCompleted: false,
},
],
styling: { allowStyleOverwrite: true },
logo: null,
};
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
update: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
projectTeam: {
createMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/environment/cache", () => ({
environmentCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/storage/service", () => ({
deleteLocalFilesByEnvironmentId: vi.fn(),
deleteS3FilesByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
createEnvironment: vi.fn(),
}));
let mockIsS3Configured = true;
vi.mock("@/lib/constants", () => ({
isS3Configured: () => {
return mockIsS3Configured;
},
}));
describe("project lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("updateProject", () => {
test("updates project and revalidates cache", async () => {
vi.mocked(prisma.project.update).mockResolvedValueOnce(baseProject as any);
vi.mocked(projectCache.revalidate).mockImplementation(() => {});
const result = await updateProject("p1", { name: "Project 1", environments: baseProject.environments });
expect(result).toEqual(ZProject.parse(baseProject));
expect(prisma.project.update).toHaveBeenCalled();
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.project.update).mockRejectedValueOnce(
new (class extends Error {
constructor() {
super();
this.message = "fail";
}
})()
);
await expect(updateProject("p1", { name: "Project 1" })).rejects.toThrow();
});
test("throws ValidationError on Zod error", async () => {
vi.mocked(prisma.project.update).mockResolvedValueOnce({ ...baseProject, id: 123 } as any);
await expect(
updateProject("p1", { name: "Project 1", environments: baseProject.environments })
).rejects.toThrow(ValidationError);
});
});
describe("createProject", () => {
test("creates project, environments, and revalidates cache", async () => {
vi.mocked(prisma.project.create).mockResolvedValueOnce({ ...baseProject, id: "p2" } as any);
vi.mocked(prisma.projectTeam.createMany).mockResolvedValueOnce({} as any);
vi.mocked(createEnvironment).mockResolvedValueOnce(baseProject.environments[0] as any);
vi.mocked(createEnvironment).mockResolvedValueOnce(baseProject.environments[1] as any);
vi.mocked(prisma.project.update).mockResolvedValueOnce(baseProject as any);
vi.mocked(projectCache.revalidate).mockImplementation(() => {});
const result = await createProject("org1", { name: "Project 1", teamIds: ["t1"] });
expect(result).toEqual(baseProject);
expect(prisma.project.create).toHaveBeenCalled();
expect(prisma.projectTeam.createMany).toHaveBeenCalled();
expect(createEnvironment).toHaveBeenCalled();
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p2", organizationId: "org1" });
});
test("throws ValidationError if name is missing", async () => {
await expect(createProject("org1", {})).rejects.toThrow(ValidationError);
});
test("throws InvalidInputError on unique constraint", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.create).mockRejectedValueOnce(prismaError);
await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow(InvalidInputError);
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2001",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.create).mockRejectedValueOnce(prismaError);
await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow(DatabaseError);
});
test("throws unknown error", async () => {
vi.mocked(prisma.project.create).mockRejectedValueOnce(new Error("fail"));
await expect(createProject("org1", { name: "Project 1" })).rejects.toThrow("fail");
});
});
describe("deleteProject", () => {
test("deletes project, deletes files, and revalidates cache (S3)", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
vi.mocked(deleteS3FilesByEnvironmentId).mockResolvedValue(undefined);
vi.mocked(projectCache.revalidate).mockImplementation(() => {});
vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
const result = await deleteProject("p1");
expect(result).toEqual(baseProject);
expect(deleteS3FilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
expect(environmentCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
});
test("deletes project, deletes files, and revalidates cache (local)", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
mockIsS3Configured = false;
vi.mocked(deleteLocalFilesByEnvironmentId).mockResolvedValue(undefined);
vi.mocked(projectCache.revalidate).mockImplementation(() => {});
vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
const result = await deleteProject("p1");
expect(result).toEqual(baseProject);
expect(deleteLocalFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: "p1", organizationId: "org1" });
expect(environmentCache.revalidate).toHaveBeenCalledWith({ projectId: "p1" });
});
test("logs error if file deletion fails", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
mockIsS3Configured = true;
vi.mocked(deleteS3FilesByEnvironmentId).mockRejectedValueOnce(new Error("fail"));
vi.mocked(logger.error).mockImplementation(() => {});
vi.mocked(projectCache.revalidate).mockImplementation(() => {});
vi.mocked(environmentCache.revalidate).mockImplementation(() => {});
await deleteProject("p1");
expect(logger.error).toHaveBeenCalled();
});
test("throws DatabaseError on Prisma error", async () => {
const err = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2001",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.delete).mockRejectedValueOnce(err as any);
await expect(deleteProject("p1")).rejects.toThrow(DatabaseError);
});
test("throws unknown error", async () => {
vi.mocked(prisma.project.delete).mockRejectedValueOnce(new Error("fail"));
await expect(deleteProject("p1")).rejects.toThrow("fail");
});
});
});

View File

@@ -0,0 +1,143 @@
import { tagCache } from "@/lib/tag/cache";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TTag } from "@formbricks/types/tags";
import { deleteTag, mergeTags, updateTagName } from "./tag";
const baseTag: TTag = {
id: "cltag1234567890",
createdAt: new Date(),
updatedAt: new Date(),
name: "Tag1",
environmentId: "clenv1234567890",
};
const newTag: TTag = {
...baseTag,
id: "cltag0987654321",
name: "Tag2",
};
vi.mock("@formbricks/database", () => ({
prisma: {
tag: {
delete: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
response: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
tagsOnResponses: {
deleteMany: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/tag/cache", () => ({
tagCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("tag lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("deleteTag", () => {
test("deletes tag and revalidates cache", async () => {
vi.mocked(prisma.tag.delete).mockResolvedValueOnce(baseTag);
vi.mocked(tagCache.revalidate).mockImplementation(() => {});
const result = await deleteTag(baseTag.id);
expect(result).toEqual(baseTag);
expect(prisma.tag.delete).toHaveBeenCalledWith({ where: { id: baseTag.id } });
expect(tagCache.revalidate).toHaveBeenCalledWith({
id: baseTag.id,
environmentId: baseTag.environmentId,
});
});
test("throws error on prisma error", async () => {
vi.mocked(prisma.tag.delete).mockRejectedValueOnce(new Error("fail"));
await expect(deleteTag(baseTag.id)).rejects.toThrow("fail");
});
});
describe("updateTagName", () => {
test("updates tag name and revalidates cache", async () => {
vi.mocked(prisma.tag.update).mockResolvedValueOnce(baseTag);
vi.mocked(tagCache.revalidate).mockImplementation(() => {});
const result = await updateTagName(baseTag.id, "Tag1");
expect(result).toEqual(baseTag);
expect(prisma.tag.update).toHaveBeenCalledWith({ where: { id: baseTag.id }, data: { name: "Tag1" } });
expect(tagCache.revalidate).toHaveBeenCalledWith({
id: baseTag.id,
environmentId: baseTag.environmentId,
});
});
test("throws error on prisma error", async () => {
vi.mocked(prisma.tag.update).mockRejectedValueOnce(new Error("fail"));
await expect(updateTagName(baseTag.id, "Tag1")).rejects.toThrow("fail");
});
});
describe("mergeTags", () => {
test("merges tags with responses with both tags", async () => {
vi.mocked(prisma.tag.findUnique)
.mockResolvedValueOnce(baseTag as any)
.mockResolvedValueOnce(newTag as any);
vi.mocked(prisma.response.findMany).mockResolvedValueOnce([{ id: "resp1" }] as any);
vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
vi.mocked(tagCache.revalidate).mockImplementation(() => {});
const result = await mergeTags(baseTag.id, newTag.id);
expect(result).toEqual(newTag);
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: baseTag.id } });
expect(prisma.tag.findUnique).toHaveBeenCalledWith({ where: { id: newTag.id } });
expect(prisma.response.findMany).toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
});
test("merges tags with no responses with both tags", async () => {
vi.mocked(prisma.tag.findUnique)
.mockResolvedValueOnce(baseTag as any)
.mockResolvedValueOnce(newTag as any);
vi.mocked(prisma.response.findMany).mockResolvedValueOnce([] as any);
vi.mocked(prisma.$transaction).mockResolvedValueOnce(undefined);
vi.mocked(tagCache.revalidate).mockImplementation(() => {});
const result = await mergeTags(baseTag.id, newTag.id);
expect(result).toEqual(newTag);
expect(tagCache.revalidate).toHaveBeenCalledWith({
id: baseTag.id,
environmentId: baseTag.environmentId,
});
expect(tagCache.revalidate).toHaveBeenCalledWith({ id: newTag.id });
});
test("throws if original tag not found", async () => {
vi.mocked(prisma.tag.findUnique).mockResolvedValueOnce(null);
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
});
test("throws if new tag not found", async () => {
vi.mocked(prisma.tag.findUnique)
.mockResolvedValueOnce(baseTag as any)
.mockResolvedValueOnce(null);
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("Tag not found");
});
test("throws on prisma error", async () => {
vi.mocked(prisma.tag.findUnique).mockRejectedValueOnce(new Error("fail"));
await expect(mergeTags(baseTag.id, newTag.id)).rejects.toThrow("fail");
});
});
});

View File

@@ -0,0 +1,202 @@
import { Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EditLogo } from "./edit-logo";
const baseProject: Project = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [],
languages: [],
logo: { url: "https://logo.com/logo.png", bgColor: "#fff" },
} as any;
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: any) => <img alt="test" {...props} />,
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children }: any) => <div data-testid="advanced-option-toggle">{children}</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color }: any) => <div data-testid="color-picker">{color}</div>,
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button data-testid="confirm-delete" onClick={onDelete}>
Delete
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: () => <div data-testid="file-input" />,
}));
vi.mock("@/modules/ui/components/input", () => ({ Input: (props: any) => <input {...props} /> }));
const mockUpdateProjectAction = vi.fn(async () => ({ data: true }));
const mockGetFormattedErrorMessage = vi.fn(() => "error-message");
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: () => mockUpdateProjectAction(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: () => mockGetFormattedErrorMessage(),
}));
describe("EditLogo", () => {
afterEach(() => {
cleanup();
});
test("renders logo and edit button", () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
expect(screen.getByAltText("Logo")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument();
});
test("renders file input if no logo", () => {
render(<EditLogo project={{ ...baseProject, logo: null }} environmentId="env1" isReadOnly={false} />);
expect(screen.getByTestId("file-input")).toBeInTheDocument();
});
test("shows alert if isReadOnly", () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={true} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
});
test("clicking edit enables editing and shows save button", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
const editBtn = screen.getByText("common.edit");
await userEvent.click(editBtn);
expect(screen.getByText("common.save")).toBeInTheDocument();
});
test("clicking save calls updateProjectAction and shows success toast", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
expect(mockUpdateProjectAction).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction returns no data", async () => {
mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction throws", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
// error toast is called
});
test("clicking remove logo opens dialog and confirms removal", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockUpdateProjectAction).toHaveBeenCalled();
});
test("shows error toast if removeLogo returns no data", async () => {
mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
test("shows error toast if removeLogo throws", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
});
test("toggle background color enables/disables color picker", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
expect(screen.getByTestId("color-picker")).toBeInTheDocument();
});
test("saveChanges with isEditing false enables editing", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
// Save button should now be visible
expect(screen.getByText("common.save")).toBeInTheDocument();
});
test("saveChanges error toast on update failure", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
// error toast is called
});
test("removeLogo with isEditing false enables editing", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
});
test("removeLogo error toast on update failure", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
// error toast is called
});
test("toggleBackgroundColor disables and resets color", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
const toggle = screen.getByTestId("advanced-option-toggle");
await userEvent.click(toggle);
expect(screen.getByTestId("color-picker")).toBeInTheDocument();
});
test("DeleteDialog closes after confirming removal", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EditPlacementForm } from "./edit-placement-form";
const baseProject: Project = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [],
languages: [],
logo: null,
} as any;
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
describe("EditPlacementForm", () => {
afterEach(() => {
cleanup();
});
test("renders all placement radio buttons and save button", () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByLabelText("common.bottom_right")).toBeInTheDocument();
expect(screen.getByLabelText("common.top_right")).toBeInTheDocument();
expect(screen.getByLabelText("common.top_left")).toBeInTheDocument();
expect(screen.getByLabelText("common.bottom_left")).toBeInTheDocument();
expect(screen.getByLabelText("common.centered_modal")).toBeInTheDocument();
});
test("submits form and shows success toast", async () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.save"));
expect(updateProjectAction).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction returns no data", async () => {
vi.mocked(updateProjectAction).mockResolvedValueOnce({ data: false } as any);
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.save"));
expect(getFormattedErrorMessage).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction throws", async () => {
vi.mocked(updateProjectAction).mockResolvedValueOnce({ data: false } as any);
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("error");
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.save"));
expect(toast.error).toHaveBeenCalledWith("error");
});
test("renders overlay and disables save when isReadOnly", () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={true} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
expect(screen.getByText("common.save")).toBeDisabled();
});
test("shows darkOverlay and clickOutsideClose options for centered modal", async () => {
render(
<EditPlacementForm
project={{ ...baseProject, placement: "center", darkOverlay: true, clickOutsideClose: true }}
environmentId="env1"
isReadOnly={false}
/>
);
expect(screen.getByLabelText("common.light_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.dark_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.disallow")).toBeInTheDocument();
expect(screen.getByLabelText("common.allow")).toBeInTheDocument();
});
test("changing placement to center shows overlay and clickOutsideClose options", async () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByLabelText("common.centered_modal"));
expect(screen.getByLabelText("common.light_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.dark_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.disallow")).toBeInTheDocument();
expect(screen.getByLabelText("common.allow")).toBeInTheDocument();
});
test("radio buttons are disabled when isReadOnly", () => {
render(<EditPlacementForm project={baseProject} environmentId="env1" isReadOnly={true} />);
expect(screen.getByLabelText("common.bottom_right")).toBeDisabled();
expect(screen.getByLabelText("common.top_right")).toBeDisabled();
expect(screen.getByLabelText("common.top_left")).toBeDisabled();
expect(screen.getByLabelText("common.bottom_left")).toBeDisabled();
expect(screen.getByLabelText("common.centered_modal")).toBeDisabled();
});
});

View File

@@ -0,0 +1,209 @@
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ThemeStyling } from "./theme-styling";
const baseProject: Project = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [],
languages: [],
logo: null,
} as any;
const colors = ["#fff", "#000"];
const mockGetFormattedErrorMessage = vi.fn(() => "error-message");
const mockRouter = { refresh: vi.fn() };
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: () => mockGetFormattedErrorMessage(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => mockRouter }));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/switch", () => ({
Switch: ({ checked, onCheckedChange }: any) => (
<input type="checkbox" checked={checked} onChange={(e) => onCheckedChange(e.target.checked)} />
),
}));
vi.mock("@/modules/ui/components/alert-dialog", () => ({
AlertDialog: ({ open, onConfirm, onDecline, headerText, mainText, confirmBtnLabel }: any) =>
open ? (
<div data-testid="alert-dialog">
<div>{headerText}</div>
<div>{mainText}</div>
<button onClick={onConfirm}>{confirmBtnLabel}</button>
<button onClick={onDecline}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/background-styling-card", () => ({
BackgroundStylingCard: () => <div data-testid="background-styling-card" />,
}));
vi.mock("@/modules/ui/components/card-styling-settings", () => ({
CardStylingSettings: () => <div data-testid="card-styling-settings" />,
}));
vi.mock("@/modules/survey/editor/components/form-styling-settings", () => ({
FormStylingSettings: () => <div data-testid="form-styling-settings" />,
}));
vi.mock("@/modules/ui/components/theme-styling-preview-survey", () => ({
ThemeStylingPreviewSurvey: () => <div data-testid="theme-styling-preview-survey" />,
}));
vi.mock("@/app/lib/templates", () => ({ previewSurvey: () => ({}) }));
vi.mock("@/lib/styling/constants", () => ({ defaultStyling: { allowStyleOverwrite: false } }));
describe("ThemeStyling", () => {
afterEach(() => {
cleanup();
});
test("renders all main sections and save/reset buttons", () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
expect(screen.getByTestId("form-styling-settings")).toBeInTheDocument();
expect(screen.getByTestId("card-styling-settings")).toBeInTheDocument();
expect(screen.getByTestId("background-styling-card")).toBeInTheDocument();
expect(screen.getByTestId("theme-styling-preview-survey")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByText("common.reset_to_default")).toBeInTheDocument();
});
test("submits form and shows success toast", async () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.save"));
expect(updateProjectAction).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction returns no data on submit", async () => {
vi.mocked(updateProjectAction).mockResolvedValueOnce({});
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.save"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
test("shows error toast if updateProjectAction throws on submit", async () => {
vi.mocked(updateProjectAction).mockResolvedValueOnce({});
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.save"));
expect(toast.error).toHaveBeenCalled();
});
test("opens and confirms reset styling modal", async () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.reset_to_default"));
expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("common.confirm"));
expect(updateProjectAction).toHaveBeenCalled();
});
test("opens and cancels reset styling modal", async () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.reset_to_default"));
expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("Cancel"));
expect(screen.queryByTestId("alert-dialog")).not.toBeInTheDocument();
});
test("shows error toast if updateProjectAction returns no data on reset", async () => {
vi.mocked(updateProjectAction).mockResolvedValueOnce({});
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={false}
/>
);
await userEvent.click(screen.getByText("common.reset_to_default"));
await userEvent.click(screen.getByText("common.confirm"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
test("renders alert if isReadOnly", () => {
render(
<ThemeStyling
project={baseProject}
environmentId="env1"
colors={colors}
isUnsplashConfigured={true}
isReadOnly={true}
/>
);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
});
});

View File

@@ -0,0 +1,65 @@
import { Prisma, Project } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getProjectByEnvironmentId } from "./project";
vi.mock("@/lib/cache", () => ({ cache: (fn: any) => fn }));
vi.mock("@/lib/project/cache", () => ({
projectCache: { tag: { byEnvironmentId: vi.fn(() => "env-tag") } },
}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("react", () => ({ cache: (fn: any) => fn }));
vi.mock("@formbricks/database", () => ({ prisma: { project: { findFirst: vi.fn() } } }));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
const baseProject: Project = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true } as any,
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null } as any,
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
logo: null,
brandColor: null,
highlightBorderColor: null,
};
describe("getProjectByEnvironmentId", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("returns project when found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(baseProject);
const result = await getProjectByEnvironmentId("env1");
expect(result).toEqual(baseProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: { environments: { some: { id: "env1" } } },
});
});
test("returns null when not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValueOnce(null);
const result = await getProjectByEnvironmentId("env1");
expect(result).toBeNull();
});
test("throws DatabaseError on Prisma error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(error);
await expect(getProjectByEnvironmentId("env1")).rejects.toThrow(DatabaseError);
});
test("throws unknown error", async () => {
vi.mocked(prisma.project.findFirst).mockRejectedValueOnce(new Error("fail"));
await expect(getProjectByEnvironmentId("env1")).rejects.toThrow("fail");
});
});

View File

@@ -0,0 +1,66 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectLookSettingsLoading } from "./loading";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ children, ...props }: any) => (
<div data-testid="settings-card" {...props}>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
// Badge, Button, Label, RadioGroup, RadioGroupItem, Switch are simple enough, no need to mock
describe("ProjectLookSettingsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main UI elements", () => {
render(<ProjectLookSettingsLoading />);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("settings-card").length).toBe(4);
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByText("environments.project.look.enable_custom_styling")).toBeInTheDocument();
expect(
screen.getByText("environments.project.look.enable_custom_styling_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.form_styling")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.card_styling")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.style_the_survey_card")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.background_styling")).toBeInTheDocument();
expect(screen.getByText("common.link_surveys")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")
).toBeInTheDocument();
expect(screen.getAllByText("common.loading").length).toBeGreaterThanOrEqual(3);
expect(screen.getByText("common.preview")).toBeInTheDocument();
expect(screen.getByText("common.restart")).toBeInTheDocument();
expect(screen.getByText("environments.project.look.show_powered_by_formbricks")).toBeInTheDocument();
expect(screen.getByText("common.bottom_right")).toBeInTheDocument();
expect(screen.getByText("common.top_right")).toBeInTheDocument();
expect(screen.getByText("common.top_left")).toBeInTheDocument();
expect(screen.getByText("common.bottom_left")).toBeInTheDocument();
expect(screen.getByText("common.centered_modal")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,121 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/project";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { ProjectLookSettingsPage } from "./page";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ children, ...props }: any) => (
<div data-testid="settings-card" {...props}>
{children}
</div>
),
}));
vi.mock("@/lib/constants", () => ({
SURVEY_BG_COLORS: ["#fff", "#000"],
IS_FORMBRICKS_CLOUD: 1,
UNSPLASH_ACCESS_KEY: "unsplash-key",
}));
vi.mock("@/lib/cn", () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ee/license-check/lib/utils", async () => ({
getWhiteLabelPermission: vi.fn(),
}));
vi.mock("@/modules/ee/whitelabel/remove-branding/components/branding-settings-card", () => ({
BrandingSettingsCard: () => <div data-testid="branding-settings-card" />,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: (props: any) => <div data-testid="project-config-navigation" {...props} />,
}));
vi.mock("./components/edit-logo", () => ({
EditLogo: () => <div data-testid="edit-logo" />,
}));
vi.mock("@/modules/projects/settings/look/lib/project", async () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
return (key: string) => key;
}),
}));
vi.mock("./components/edit-placement-form", () => ({
EditPlacementForm: () => <div data-testid="edit-placement-form" />,
}));
vi.mock("./components/theme-styling", () => ({
ThemeStyling: () => <div data-testid="theme-styling" />,
}));
describe("ProjectLookSettingsPage", () => {
const props = { params: Promise.resolve({ environmentId: "env1" }) };
const mockOrg = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "pro" } as any,
} as TOrganization;
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
organization: mockOrg,
} as any);
});
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main UI elements", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
environments: [],
} as any);
const Page = await ProjectLookSettingsPage(props);
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("settings-card").length).toBe(3);
expect(screen.getByTestId("theme-styling")).toBeInTheDocument();
expect(screen.getByTestId("edit-logo")).toBeInTheDocument();
expect(screen.getByTestId("edit-placement-form")).toBeInTheDocument();
expect(screen.getByTestId("branding-settings-card")).toBeInTheDocument();
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
});
test("throws error if project is not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
const props = { params: Promise.resolve({ environmentId: "env1" }) };
await expect(ProjectLookSettingsPage(props)).rejects.toThrow("Project not found");
});
});

View File

@@ -0,0 +1,20 @@
import { cleanup } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectSettingsPage } from "./page";
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("ProjectSettingsPage", () => {
afterEach(() => {
cleanup();
});
test("redirects to the general project settings page", async () => {
const params = { environmentId: "env-123" };
await ProjectSettingsPage({ params: Promise.resolve(params) });
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/project/general");
});
});

View File

@@ -0,0 +1,76 @@
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getEnvironmentIdFromTagId } from "@/lib/utils/helper";
import { deleteTag, mergeTags, updateTagName } from "@/modules/projects/settings/lib/tag";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { deleteTagAction, mergeTagsAction, updateTagNameAction } from "./actions";
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => fn,
}),
},
}));
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getEnvironmentIdFromTagId: vi.fn(async (tagId: string) => tagId + "-env"),
getOrganizationIdFromEnvironmentId: vi.fn(async (envId: string) => envId + "-org"),
getOrganizationIdFromTagId: vi.fn(async (tagId: string) => tagId + "-org"),
getProjectIdFromEnvironmentId: vi.fn(async (envId: string) => envId + "-proj"),
getProjectIdFromTagId: vi.fn(async (tagId: string) => tagId + "-proj"),
}));
vi.mock("@/modules/projects/settings/lib/tag", () => ({
deleteTag: vi.fn(async (tagId: string) => ({ deleted: tagId })),
updateTagName: vi.fn(async (tagId: string, name: string) => ({ updated: tagId, name })),
mergeTags: vi.fn(async (originalTagId: string, newTagId: string) => ({
merged: [originalTagId, newTagId],
})),
}));
const ctx = { user: { id: "user1" } };
const validTagId = "tag_123";
const validTagId2 = "tag_456";
describe("/modules/projects/settings/tags/actions.ts", () => {
afterEach(() => {
vi.clearAllMocks();
cleanup();
});
test("deleteTagAction calls authorization and deleteTag", async () => {
const result = await deleteTagAction({ ctx, parsedInput: { tagId: validTagId } } as any);
expect(result).toEqual({ deleted: validTagId });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTag).toHaveBeenCalledWith(validTagId);
});
test("updateTagNameAction calls authorization and updateTagName", async () => {
const name = "New Name";
const result = await updateTagNameAction({ ctx, parsedInput: { tagId: validTagId, name } } as any);
expect(result).toEqual({ updated: validTagId, name });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(updateTagName).toHaveBeenCalledWith(validTagId, name);
});
test("mergeTagsAction throws if tags are in different environments", async () => {
vi.mocked(getEnvironmentIdFromTagId).mockImplementationOnce(async (id) => id + "-env1");
vi.mocked(getEnvironmentIdFromTagId).mockImplementationOnce(async (id) => id + "-env2");
await expect(
mergeTagsAction({ ctx, parsedInput: { originalTagId: validTagId, newTagId: validTagId2 } } as any)
).rejects.toThrow("Tags must be in the same environment");
});
test("mergeTagsAction calls authorization and mergeTags if environments match", async () => {
vi.mocked(getEnvironmentIdFromTagId).mockResolvedValue("env1");
const result = await mergeTagsAction({
ctx,
parsedInput: { originalTagId: validTagId, newTagId: validTagId },
} as any);
expect(result).toEqual({ merged: [validTagId, validTagId] });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(mergeTags).toHaveBeenCalledWith(validTagId, validTagId);
});
});

View File

@@ -0,0 +1,88 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TTag, TTagsCount } from "@formbricks/types/tags";
import { EditTagsWrapper } from "./edit-tags-wrapper";
vi.mock("@/modules/projects/settings/tags/components/single-tag", () => ({
SingleTag: (props: any) => <div data-testid={`single-tag-${props.tagId}`}>{props.tagName}</div>,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: () => <div data-testid="empty-space-filler" />,
}));
describe("EditTagsWrapper", () => {
afterEach(() => {
cleanup();
});
const environment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "p1",
appSetupCompleted: true,
};
const tags: TTag[] = [
{ id: "tag1", createdAt: new Date(), updatedAt: new Date(), name: "Tag 1", environmentId: "env1" },
{ id: "tag2", createdAt: new Date(), updatedAt: new Date(), name: "Tag 2", environmentId: "env1" },
];
const tagsCount: TTagsCount = [
{ tagId: "tag1", count: 5 },
{ tagId: "tag2", count: 0 },
];
test("renders table headers and actions column if not readOnly", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={false}
/>
);
expect(screen.getByText("environments.project.tags.tag")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.count")).toBeInTheDocument();
expect(screen.getByText("common.actions")).toBeInTheDocument();
});
test("does not render actions column if readOnly", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={true}
/>
);
expect(screen.queryByText("common.actions")).not.toBeInTheDocument();
});
test("renders EmptySpaceFiller if no tags", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={[]}
environmentTagsCount={[]}
isReadOnly={false}
/>
);
expect(screen.getByTestId("empty-space-filler")).toBeInTheDocument();
});
test("renders SingleTag for each tag", () => {
render(
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={tagsCount}
isReadOnly={false}
/>
);
expect(screen.getByTestId("single-tag-tag1")).toHaveTextContent("Tag 1");
expect(screen.getByTestId("single-tag-tag2")).toHaveTextContent("Tag 2");
});
});

View File

@@ -0,0 +1,70 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MergeTagsCombobox } from "./merge-tags-combobox";
vi.mock("@/modules/ui/components/command", () => ({
Command: ({ children }: any) => <div data-testid="command">{children}</div>,
CommandEmpty: ({ children }: any) => <div data-testid="command-empty">{children}</div>,
CommandGroup: ({ children }: any) => <div data-testid="command-group">{children}</div>,
CommandInput: (props: any) => <input data-testid="command-input" {...props} />,
CommandItem: ({ children, onSelect, ...props }: any) => (
<div data-testid="command-item" tabIndex={0} onClick={() => onSelect && onSelect(children)} {...props}>
{children}
</div>
),
CommandList: ({ children }: any) => <div data-testid="command-list">{children}</div>,
}));
vi.mock("@/modules/ui/components/popover", () => ({
Popover: ({ children }: any) => <div data-testid="popover">{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid="popover-content">{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid="popover-trigger">{children}</div>,
}));
describe("MergeTagsCombobox", () => {
afterEach(() => {
cleanup();
});
const tags = [
{ label: "Tag 1", value: "tag1" },
{ label: "Tag 2", value: "tag2" },
];
test("renders button with tolgee string", () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
expect(screen.getByText("environments.project.tags.merge")).toBeInTheDocument();
});
test("shows popover and all tag items when button is clicked", async () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
await userEvent.click(screen.getByText("environments.project.tags.merge"));
expect(screen.getByTestId("popover-content")).toBeInTheDocument();
expect(screen.getAllByTestId("command-item").length).toBe(2);
expect(screen.getByText("Tag 1")).toBeInTheDocument();
expect(screen.getByText("Tag 2")).toBeInTheDocument();
});
test("calls onSelect with tag value and closes popover", async () => {
const onSelect = vi.fn();
render(<MergeTagsCombobox tags={tags} onSelect={onSelect} />);
await userEvent.click(screen.getByText("environments.project.tags.merge"));
await userEvent.click(screen.getByText("Tag 1"));
expect(onSelect).toHaveBeenCalledWith("tag1");
});
test("shows no tag found if tags is empty", async () => {
render(<MergeTagsCombobox tags={[]} onSelect={vi.fn()} />);
await userEvent.click(screen.getByText("environments.project.tags.merge"));
expect(screen.getByTestId("command-empty")).toBeInTheDocument();
});
test("filters tags using input", async () => {
render(<MergeTagsCombobox tags={tags} onSelect={vi.fn()} />);
await userEvent.click(screen.getByText("environments.project.tags.merge"));
const input = screen.getByTestId("command-input");
await userEvent.type(input, "Tag 2");
expect(screen.getByText("Tag 2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,150 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteTagAction,
mergeTagsAction,
updateTagNameAction,
} from "@/modules/projects/settings/tags/actions";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TTag } from "@formbricks/types/tags";
import { SingleTag } from "./single-tag";
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button data-testid="confirm-delete" onClick={onDelete}>
Delete
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div data-testid="loading-spinner" />,
}));
vi.mock("@/modules/projects/settings/tags/components/merge-tags-combobox", () => ({
MergeTagsCombobox: ({ tags, onSelect }: any) => (
<div data-testid="merge-tags-combobox">
{tags.map((t: any) => (
<button key={t.value} onClick={() => onSelect(t.value)}>
{t.label}
</button>
))}
</div>
),
}));
const mockRouter = { refresh: vi.fn() };
vi.mock("@/modules/projects/settings/tags/actions", () => ({
updateTagNameAction: vi.fn(() => Promise.resolve({ data: {} })),
deleteTagAction: vi.fn(() => Promise.resolve({ data: {} })),
mergeTagsAction: vi.fn(() => Promise.resolve({ data: {} })),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => mockRouter }));
const baseTag: TTag = {
id: "tag1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Tag 1",
environmentId: "env1",
};
const environmentTags: TTag[] = [
baseTag,
{ id: "tag2", createdAt: new Date(), updatedAt: new Date(), name: "Tag 2", environmentId: "env1" },
];
describe("SingleTag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders tag name and count", () => {
render(
<SingleTag tagId={baseTag.id} tagName={baseTag.name} tagCount={5} environmentTags={environmentTags} />
);
expect(screen.getByDisplayValue("Tag 1")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
});
test("shows loading spinner if tagCountLoading", () => {
render(
<SingleTag
tagId={baseTag.id}
tagName={baseTag.name}
tagCountLoading={true}
environmentTags={environmentTags}
/>
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
test("calls updateTagNameAction and shows success toast on blur", async () => {
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
const input = screen.getByDisplayValue("Tag 1");
await userEvent.clear(input);
await userEvent.type(input, "Tag 1 Updated");
fireEvent.blur(input);
expect(updateTagNameAction).toHaveBeenCalledWith({ tagId: baseTag.id, name: "Tag 1 Updated" });
});
test("shows error toast and sets error state if updateTagNameAction fails", async () => {
vi.mocked(updateTagNameAction).mockResolvedValueOnce({ serverError: "Error occurred" });
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
const input = screen.getByDisplayValue("Tag 1");
fireEvent.blur(input);
});
test("shows merge tags combobox and calls mergeTagsAction", async () => {
vi.mocked(mergeTagsAction).mockImplementationOnce(() => Promise.resolve({ data: undefined }));
vi.mocked(getFormattedErrorMessage).mockReturnValue("Error occurred");
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
const mergeBtn = screen.getByText("Tag 2");
await userEvent.click(mergeBtn);
expect(mergeTagsAction).toHaveBeenCalledWith({ originalTagId: baseTag.id, newTagId: "tag2" });
expect(getFormattedErrorMessage).toHaveBeenCalled();
});
test("shows error toast if mergeTagsAction fails", async () => {
vi.mocked(mergeTagsAction).mockResolvedValueOnce({});
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
const mergeBtn = screen.getByText("Tag 2");
await userEvent.click(mergeBtn);
expect(getFormattedErrorMessage).toHaveBeenCalled();
});
test("shows delete dialog and calls deleteTagAction on confirm", async () => {
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
await userEvent.click(screen.getByText("common.delete"));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(deleteTagAction).toHaveBeenCalledWith({ tagId: baseTag.id });
});
test("shows error toast if deleteTagAction fails", async () => {
vi.mocked(deleteTagAction).mockResolvedValueOnce({});
render(<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} />);
await userEvent.click(screen.getByText("common.delete"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(getFormattedErrorMessage).toHaveBeenCalled();
});
test("does not render actions if isReadOnly", () => {
render(
<SingleTag tagId={baseTag.id} tagName={baseTag.name} environmentTags={environmentTags} isReadOnly />
);
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
expect(screen.queryByTestId("merge-tags-combobox")).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,51 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TagsLoading } from "./loading";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ children, title, description }: any) => (
<div data-testid="settings-card">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId }: any) => (
<div data-testid="project-config-navigation">{activeId}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
describe("TagsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and skeletons", () => {
render(<TagsLoading />);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("settings-card")).toBeInTheDocument();
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.manage_tags")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.manage_tags_description")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.tag")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.count")).toBeInTheDocument();
expect(screen.getByText("common.actions")).toBeInTheDocument();
expect(
screen.getAllByText((_, node) => node!.className?.includes("animate-pulse")).length
).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,80 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TagsPage } from "./page";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ children, title, description }: any) => (
<div data-testid="settings-card">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ environmentId, activeId }: any) => (
<div data-testid="project-config-navigation">
{environmentId}-{activeId}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="page-content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="page-header">
<div>{pageTitle}</div>
{children}
</div>
),
}));
vi.mock("./components/edit-tags-wrapper", () => ({
EditTagsWrapper: () => <div data-testid="edit-tags-wrapper">edit-tags-wrapper</div>,
}));
const mockGetTranslate = vi.fn(async () => (key: string) => key);
vi.mock("@/tolgee/server", () => ({ getTranslate: () => mockGetTranslate() }));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/lib/tag/service", () => ({
getTagsByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/tagOnResponse/service", () => ({
getTagsOnResponsesCount: vi.fn(),
}));
describe("TagsPage", () => {
afterEach(() => {
cleanup();
});
test("renders all tolgee strings and main components", async () => {
const props = { params: { environmentId: "env1" } };
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
environment: {
id: "env1",
appSetupCompleted: true,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project1",
type: "development",
},
} as any);
const Page = await TagsPage(props);
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("settings-card")).toBeInTheDocument();
expect(screen.getByTestId("edit-tags-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toHaveTextContent("env1-tags");
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.manage_tags")).toBeInTheDocument();
expect(screen.getByText("environments.project.tags.manage_tags_description")).toBeInTheDocument();
});
});

View File

@@ -1,14 +1,37 @@
import * as utils from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { SurveyVariablesCardItem } from "./survey-variables-card-item";
vi.mock("@/modules/survey/editor/lib/utils", () => {
return {
findVariableUsedInLogic: vi.fn(),
getVariableTypeFromValue: vi.fn().mockImplementation((value) => {
if (typeof value === "number") return "number";
if (typeof value === "boolean") return "boolean";
return "text";
}),
translateOptions: vi.fn().mockReturnValue([]),
validateLogic: vi.fn(),
};
});
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
describe("SurveyVariablesCardItem", () => {
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -88,6 +111,62 @@ describe("SurveyVariablesCardItem", () => {
);
});
test("should not create a new survey variable when mode is 'create' and the form input is invalid", async () => {
const mockSetLocalSurvey = vi.fn();
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [],
} as unknown as TSurvey;
render(
<TestWrapper>
<SurveyVariablesCardItem
mode="create"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
/>
</TestWrapper>
);
const nameInput = screen.getByPlaceholderText("environments.surveys.edit.field_name_eg_score_price");
const valueInput = screen.getByPlaceholderText("environments.surveys.edit.initial_value");
const addButton = screen.getByRole("button", { name: "environments.surveys.edit.add_variable" });
await userEvent.type(nameInput, "1invalidvariablename");
await userEvent.type(valueInput, "10");
await userEvent.click(addButton);
const errorMessage = screen.getByText("environments.surveys.edit.variable_name_must_start_with_a_letter");
expect(errorMessage).toBeVisible();
expect(mockSetLocalSurvey).not.toHaveBeenCalled();
});
test("should display an error message when the variable name is invalid", async () => {
const mockSetLocalSurvey = vi.fn();
const initialSurvey = {
@@ -244,4 +323,142 @@ describe("SurveyVariablesCardItem", () => {
screen.getByText("environments.surveys.edit.variable_name_is_already_taken_please_choose_another")
).toBeVisible();
});
test("should show error toast if trying to delete a variable used in logic and not call setLocalSurvey", async () => {
const variableUsedInLogic = {
id: "logicVarId",
name: "logic_variable",
type: "text",
value: "test_value",
} as TSurveyVariable;
const mockSetLocalSurvey = vi.fn();
// Mock findVariableUsedInLogic to return 2, indicating the variable is used in logic
const findVariableUsedInLogicMock = vi.fn().mockReturnValue(2);
vi.spyOn(utils, "findVariableUsedInLogic").mockImplementation(findVariableUsedInLogicMock);
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [
{
id: "q1WithLogic",
type: "openText",
headline: { default: "Question with logic" },
required: false,
logic: [{ condition: "equals", value: "logicVarId", destination: "q2" }],
},
{ id: "q2", type: "openText", headline: { default: "Q2" }, required: false },
],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [variableUsedInLogic],
} as unknown as TSurvey;
render(
<SurveyVariablesCardItem
mode="edit"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
variable={variableUsedInLogic}
/>
);
const deleteButton = screen.getByRole("button");
await userEvent.click(deleteButton);
expect(utils.findVariableUsedInLogic).toHaveBeenCalledWith(initialSurvey, variableUsedInLogic.id);
expect(mockSetLocalSurvey).not.toHaveBeenCalled();
});
test("should delete variable when it's not used in logic", async () => {
const variableToDelete = {
id: "recallVarId",
name: "recall_variable",
type: "text",
value: "recall_value",
} as TSurveyVariable;
const mockSetLocalSurvey = vi.fn();
const findVariableUsedInLogicMock = vi.fn().mockReturnValue(-1);
vi.spyOn(utils, "findVariableUsedInLogic").mockImplementation(findVariableUsedInLogicMock);
const initialSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "env123",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [
{
id: "q1",
type: "openText",
headline: { default: "Question with recall:recallVarId in it" },
required: false,
},
],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: ["field1", "field2"],
},
variables: [variableToDelete],
} as unknown as TSurvey;
render(
<SurveyVariablesCardItem
mode="edit"
localSurvey={initialSurvey}
setLocalSurvey={mockSetLocalSurvey}
variable={variableToDelete}
/>
);
const deleteButton = screen.getByRole("button");
await userEvent.click(deleteButton);
expect(utils.findVariableUsedInLogic).toHaveBeenCalledWith(initialSurvey, variableToDelete.id);
expect(mockSetLocalSurvey).toHaveBeenCalledTimes(1);
expect(mockSetLocalSurvey).toHaveBeenCalledWith(expect.any(Function));
});
});

View File

@@ -16,7 +16,7 @@ import {
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
import React, { useCallback, useEffect } from "react";
import React, { useCallback } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
@@ -64,7 +64,6 @@ export const SurveyVariablesCardItem = ({
...localSurvey,
variables: [...localSurvey.variables, data],
});
form.reset({
id: createId(),
name: "",
@@ -73,26 +72,18 @@ export const SurveyVariablesCardItem = ({
});
};
useEffect(() => {
if (mode === "create") {
return;
}
// Removed auto-submit effect
const subscription = form.watch(() => form.handleSubmit(editSurveyVariable)());
return () => subscription.unsubscribe();
}, [form, mode, editSurveyVariable]);
const onVariableDelete = (variable: TSurveyVariable) => {
const onVariableDelete = (variableToDelete: TSurveyVariable) => {
const questions = [...localSurvey.questions];
const quesIdx = findVariableUsedInLogic(localSurvey, variable.id);
const quesIdx = findVariableUsedInLogic(localSurvey, variableToDelete.id);
if (quesIdx !== -1) {
toast.error(
t(
"environments.surveys.edit.variable_is_used_in_logic_of_question_please_remove_it_from_logic_first",
{
variable: variable.name,
variable: variableToDelete.name,
questionIndex: quesIdx + 1,
}
)
@@ -100,10 +91,10 @@ export const SurveyVariablesCardItem = ({
return;
}
// find if this variable is used in any question's recall and remove it for every language
// remove recall references
questions.forEach((question) => {
for (const [languageCode, headline] of Object.entries(question.headline)) {
if (headline.includes(`recall:${variable.id}`)) {
if (headline.includes(`recall:${variableToDelete.id}`)) {
const recallInfo = extractRecallInfo(headline);
if (recallInfo) {
question.headline[languageCode] = headline.replace(recallInfo, "");
@@ -113,7 +104,7 @@ export const SurveyVariablesCardItem = ({
});
setLocalSurvey((prevSurvey) => {
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variable.id);
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variableToDelete.id);
return { ...prevSurvey, variables: updatedVariables, questions };
});
};
@@ -139,6 +130,7 @@ export const SurveyVariablesCardItem = ({
)}
<div className="mt-2 flex w-full items-center gap-2">
{/* Name field: update on blur */}
<FormField
control={form.control}
name="name"
@@ -150,25 +142,18 @@ export const SurveyVariablesCardItem = ({
),
},
validate: (value) => {
// if the variable name is already taken
if (
mode === "create" &&
localSurvey.variables.find((variable) => variable.name === value)
) {
if (mode === "create" && localSurvey.variables.find((v) => v.name === value)) {
return t(
"environments.surveys.edit.variable_name_is_already_taken_please_choose_another"
);
}
if (mode === "edit" && variable && variable.name !== value) {
if (localSurvey.variables.find((variable) => variable.name === value)) {
if (localSurvey.variables.find((v) => v.name === value)) {
return t(
"environments.surveys.edit.variable_name_is_already_taken_please_choose_another"
);
}
}
// if it does not start with a letter
if (!/^[a-z]/.test(value)) {
return t("environments.surveys.edit.variable_name_must_start_with_a_letter");
}
@@ -180,8 +165,8 @@ export const SurveyVariablesCardItem = ({
<Input
{...field}
isInvalid={isNameError}
type="text"
placeholder={t("environments.surveys.edit.field_name_eg_score_price")}
onBlur={mode === "edit" ? () => form.handleSubmit(editSurveyVariable)() : undefined}
/>
</FormControl>
</FormItem>
@@ -192,28 +177,29 @@ export const SurveyVariablesCardItem = ({
control={form.control}
name="type"
render={({ field }) => (
<Select
{...field}
onValueChange={(value) => {
form.setValue("value", value === "number" ? 0 : "");
field.onChange(value);
}}>
<SelectTrigger className="w-24">
<SelectValue
placeholder={t("environments.surveys.edit.select_type")}
className="text-sm"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={"number"}>{t("common.number")}</SelectItem>
<SelectItem value={"text"}>{t("common.text")}</SelectItem>
</SelectContent>
</Select>
<FormItem
onBlur={mode === "edit" ? () => form.handleSubmit(editSurveyVariable)() : undefined}>
<Select
{...field}
onValueChange={(value) => {
form.setValue("value", value === "number" ? 0 : "");
field.onChange(value);
}}>
<SelectTrigger className="w-24">
<SelectValue placeholder={t("environments.surveys.edit.select_type")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="number">{t("common.number")}</SelectItem>
<SelectItem value="text">{t("common.text")}</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<p className="text-slate-600">=</p>
{/* Value field: update on blur */}
<FormField
control={form.control}
name="value"
@@ -222,23 +208,24 @@ export const SurveyVariablesCardItem = ({
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value);
}}
onChange={(e) =>
field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value)
}
placeholder={t("environments.surveys.edit.initial_value")}
type={variableType === "number" ? "number" : "text"}
onBlur={mode === "edit" ? () => form.handleSubmit(editSurveyVariable)() : undefined}
/>
</FormControl>
</FormItem>
)}
/>
{/* Create / Delete buttons */}
{mode === "create" && (
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
{t("environments.surveys.edit.add_variable")}
</Button>
)}
{mode === "edit" && variable && (
<Button
variant="ghost"

View File

@@ -1,43 +0,0 @@
import { cache } from "@/lib/cache";
import { environmentCache } from "@/lib/environment/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Environment, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
export const getEnvironment = reactCache(
async (environmentId: string): Promise<Pick<Environment, "id" | "appSetupCompleted"> | null> =>
cache(
async () => {
validateInputs([environmentId, z.string().cuid2()]);
try {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
id: true,
appSetupCompleted: true,
},
});
return environment;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error fetching environment");
throw new DatabaseError(error.message);
}
throw error;
}
},
[`survey-lib-getEnvironment-${environmentId}`],
{
tags: [environmentCache.tag.byId(environmentId)],
}
)()
);

View File

@@ -1,46 +0,0 @@
import { cache } from "@/lib/cache";
import { membershipCache } from "@/lib/membership/cache";
import { validateInputs } from "@/lib/utils/validate";
import { OrganizationRole, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { AuthorizationError, DatabaseError, UnknownError } from "@formbricks/types/errors";
export const getMembershipRoleByUserIdOrganizationId = reactCache(
async (userId: string, organizationId: string): Promise<OrganizationRole> =>
cache(
async () => {
validateInputs([userId, z.string()], [organizationId, z.string().cuid2()]);
try {
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
if (!membership) {
throw new AuthorizationError("You are not a member of this organization");
}
return membership.role;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error fetching membership role by user id and organization id");
throw new DatabaseError(error.message);
}
throw new UnknownError("Error while fetching membership");
}
},
[`survey-getMembershipRoleByUserIdOrganizationId-${userId}-${organizationId}`],
{
tags: [membershipCache.tag.byUserId(userId), membershipCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -8,7 +8,7 @@ import {
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { generateSurveySingleUseId } from "@/lib/utils/singleUseSurveys";
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {

View File

@@ -4,7 +4,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CopySurveyForm } from "../copy-survey-form";
import { CopySurveyForm } from "./copy-survey-form";
// Mock dependencies
vi.mock("@/modules/survey/list/actions", () => ({

View File

@@ -0,0 +1,110 @@
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CopySurveyModal } from "./copy-survey-modal";
// Mock dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, noPadding, restrictOverflow }) =>
open ? (
<div data-testid="mock-modal" data-no-padding={noPadding} data-restrict-overflow={restrictOverflow}>
<button data-testid="modal-close-button" onClick={() => setOpen(false)}>
Close Modal
</button>
{children}
</div>
) : null,
}));
// Mock SurveyCopyOptions component
vi.mock("./survey-copy-options", () => ({
default: ({ survey, environmentId, onCancel, setOpen }) => (
<div data-testid="mock-survey-copy-options">
<div>Survey ID: {survey.id}</div>
<div>Environment ID: {environmentId}</div>
<button data-testid="cancel-button" onClick={onCancel}>
Cancel
</button>
<button data-testid="close-button" onClick={() => setOpen(false)}>
Close
</button>
</div>
),
}));
describe("CopySurveyModal", () => {
const mockSurvey = {
id: "survey-123",
name: "Test Survey",
environmentId: "env-456",
type: "link",
status: "draft",
} as unknown as TSurvey;
const mockSetOpen = vi.fn();
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal when open is true", () => {
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
// Check if the modal is rendered with correct props
const modal = screen.getByTestId("mock-modal");
expect(modal).toBeInTheDocument();
expect(modal).toHaveAttribute("data-no-padding", "true");
expect(modal).toHaveAttribute("data-restrict-overflow", "true");
// Check if the header content is rendered
expect(screen.getByText("environments.surveys.copy_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.copy_survey_description")).toBeInTheDocument();
});
test("doesn't render modal when open is false", () => {
render(<CopySurveyModal open={false} setOpen={mockSetOpen} survey={mockSurvey} />);
expect(screen.queryByTestId("mock-modal")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.copy_survey")).not.toBeInTheDocument();
});
test("renders SurveyCopyOptions with correct props", () => {
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
// Check if SurveyCopyOptions is rendered with correct props
const surveyCopyOptions = screen.getByTestId("mock-survey-copy-options");
expect(surveyCopyOptions).toBeInTheDocument();
expect(screen.getByText(`Survey ID: ${mockSurvey.id}`)).toBeInTheDocument();
expect(screen.getByText(`Environment ID: ${mockSurvey.environmentId}`)).toBeInTheDocument();
});
test("passes setOpen to SurveyCopyOptions", async () => {
const user = userEvent.setup();
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
// Click the close button in SurveyCopyOptions
await user.click(screen.getByTestId("close-button"));
// Verify setOpen was called with false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("passes onCancel function that closes the modal", async () => {
const user = userEvent.setup();
render(<CopySurveyModal open={true} setOpen={mockSetOpen} survey={mockSurvey} />);
// Click the cancel button in SurveyCopyOptions
await user.click(screen.getByTestId("cancel-button"));
// Verify setOpen was called with false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,75 @@
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { SortOption } from "./sort-option";
// Mock dependencies
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenuItem: ({ children, className, onClick }: any) => (
<div data-testid="dropdown-menu-item" className={className} onClick={onClick}>
{children}
</div>
),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
describe("SortOption", () => {
const mockOption: TSortOption = {
label: "test.sort.option",
value: "testValue",
};
const mockHandleSortChange = vi.fn();
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with the option label", () => {
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
expect(screen.getByText("test.sort.option")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-menu-item")).toBeInTheDocument();
});
test("applies correct styling when option is selected", () => {
render(<SortOption option={mockOption} sortBy="testValue" handleSortChange={mockHandleSortChange} />);
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
expect(circleIndicator).toHaveClass("bg-brand-dark");
expect(circleIndicator).toHaveClass("outline-brand-dark");
});
test("applies correct styling when option is not selected", () => {
render(
<SortOption option={mockOption} sortBy="differentValue" handleSortChange={mockHandleSortChange} />
);
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
expect(circleIndicator).not.toHaveClass("bg-brand-dark");
expect(circleIndicator).not.toHaveClass("outline-brand-dark");
});
test("calls handleSortChange when clicked", async () => {
const user = userEvent.setup();
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
await user.click(screen.getByTestId("dropdown-menu-item"));
expect(mockHandleSortChange).toHaveBeenCalledTimes(1);
expect(mockHandleSortChange).toHaveBeenCalledWith(mockOption);
});
test("translates the option label", () => {
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
// The mock for useTranslate returns the key itself, so we're checking if translation was attempted
expect(screen.getByText(mockOption.label)).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyCard } from "../survey-card";
import { SurveyCard } from "./survey-card";
// Mock constants
vi.mock("@/lib/constants", () => ({

View File

@@ -0,0 +1,200 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getProjectsByEnvironmentIdAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import SurveyCopyOptions from "./survey-copy-options";
// Mock dependencies
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error) => error?.message || "An error occurred"),
}));
vi.mock("@/modules/survey/list/actions", () => ({
getProjectsByEnvironmentIdAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("lucide-react", () => ({
Loader2: () => <div data-testid="loading-spinner">Loading...</div>,
}));
// Mock CopySurveyForm component
vi.mock("./copy-survey-form", () => ({
CopySurveyForm: ({ defaultProjects, survey, onCancel, setOpen }) => (
<div data-testid="copy-survey-form">
<div>Projects count: {defaultProjects.length}</div>
<div>Survey ID: {survey.id}</div>
<button data-testid="cancel-button" onClick={onCancel}>
Cancel
</button>
<button data-testid="close-button" onClick={() => setOpen(false)}>
Close
</button>
</div>
),
}));
describe("SurveyCopyOptions", () => {
const mockSurvey = {
id: "survey-1",
name: "Test Survey",
environmentId: "env-1",
} as unknown as TSurvey;
const mockOnCancel = vi.fn();
const mockSetOpen = vi.fn();
const mockProjects: TUserProject[] = [
{
id: "project-1",
name: "Project 1",
environments: [
{ id: "env-1", type: "development" },
{ id: "env-2", type: "production" },
],
},
{
id: "project-2",
name: "Project 2",
environments: [
{ id: "env-3", type: "development" },
{ id: "env-4", type: "production" },
],
},
];
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders loading spinner when projects are being fetched", () => {
// Mock the action to not resolve so component stays in loading state
vi.mocked(getProjectsByEnvironmentIdAction).mockReturnValue(new Promise(() => {}));
render(
<SurveyCopyOptions
survey={mockSurvey}
environmentId="env-1"
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByTestId("copy-survey-form")).not.toBeInTheDocument();
});
test("renders CopySurveyForm when projects are loaded successfully", async () => {
// Mock successful response
vi.mocked(getProjectsByEnvironmentIdAction).mockResolvedValue({
data: mockProjects,
});
render(
<SurveyCopyOptions
survey={mockSurvey}
environmentId="env-1"
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
// Initially should show loading spinner
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
// After data loading, should show the form
await waitFor(() => {
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
expect(screen.getByTestId("copy-survey-form")).toBeInTheDocument();
});
// Check if props are passed correctly
expect(screen.getByText(`Projects count: ${mockProjects.length}`)).toBeInTheDocument();
expect(screen.getByText(`Survey ID: ${mockSurvey.id}`)).toBeInTheDocument();
});
test("shows error toast when project fetch fails", async () => {
// Mock error response
const mockError = new Error("Failed to fetch projects");
vi.mocked(getProjectsByEnvironmentIdAction).mockResolvedValue({
error: mockError,
});
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to fetch projects");
render(
<SurveyCopyOptions
survey={mockSurvey}
environmentId="env-1"
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to fetch projects");
// Form should still render but with empty projects
expect(screen.getByTestId("copy-survey-form")).toBeInTheDocument();
expect(screen.getByText("Projects count: 0")).toBeInTheDocument();
});
});
test("passes onCancel function to CopySurveyForm", async () => {
// Mock successful response
vi.mocked(getProjectsByEnvironmentIdAction).mockResolvedValue({
data: mockProjects,
});
render(
<SurveyCopyOptions
survey={mockSurvey}
environmentId="env-1"
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
await waitFor(() => {
expect(screen.getByTestId("copy-survey-form")).toBeInTheDocument();
});
// Click the cancel button in CopySurveyForm
screen.getByTestId("cancel-button").click();
// Verify onCancel was called
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
test("passes setOpen function to CopySurveyForm", async () => {
// Mock successful response
vi.mocked(getProjectsByEnvironmentIdAction).mockResolvedValue({
data: mockProjects,
});
render(
<SurveyCopyOptions
survey={mockSurvey}
environmentId="env-1"
onCancel={mockOnCancel}
setOpen={mockSetOpen}
/>
);
await waitFor(() => {
expect(screen.getByTestId("copy-survey-form")).toBeInTheDocument();
});
// Click the close button in CopySurveyForm
screen.getByTestId("close-button").click();
// Verify setOpen was called with false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -2,7 +2,7 @@ import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyDropDownMenu } from "../survey-dropdown-menu";
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
// Mock translation
vi.mock("@tolgee/react", () => ({

View File

@@ -0,0 +1,155 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TFilterOption } from "@formbricks/types/surveys/types";
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
// Mock dependencies
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Mock UI components
vi.mock("@/modules/ui/components/checkbox", () => ({
Checkbox: ({ checked, className }) => (
<div data-testid="mock-checkbox" data-checked={checked} className={className} />
),
}));
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children, open, onOpenChange }) => (
<div data-testid="dropdown-menu" data-open={open} onClick={onOpenChange}>
{children}
</div>
),
DropdownMenuTrigger: ({ children, asChild, className }) => (
<div data-testid="dropdown-trigger" className={className}>
{asChild ? children : null}
</div>
),
DropdownMenuContent: ({ children, align, className }) => (
<div data-testid="dropdown-content" data-align={align} className={className}>
{children}
</div>
),
DropdownMenuItem: ({ children, className, onClick }) => (
<div data-testid="dropdown-item" className={className} onClick={onClick}>
{children}
</div>
),
}));
vi.mock("lucide-react", () => ({
ChevronDownIcon: () => <div data-testid="chevron-icon">ChevronDownIcon</div>,
}));
describe("SurveyFilterDropdown", () => {
const mockOptions: TFilterOption[] = [
{ label: "option1.label", value: "option1" },
{ label: "option2.label", value: "option2" },
{ label: "option3.label", value: "option3" },
];
const mockProps = {
title: "Test Filter",
id: "status" as const,
options: mockOptions,
selectedOptions: ["option2"],
setSelectedOptions: vi.fn(),
isOpen: false,
toggleDropdown: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders with correct title", () => {
render(<SurveyFilterDropdown {...mockProps} />);
expect(screen.getByText("Test Filter")).toBeInTheDocument();
});
test("applies correct styling when options are selected", () => {
render(<SurveyFilterDropdown {...mockProps} />);
const trigger = screen.getByTestId("dropdown-trigger");
expect(trigger.className).toContain("bg-slate-900 text-white");
});
test("applies correct styling when no options are selected", () => {
render(<SurveyFilterDropdown {...mockProps} selectedOptions={[]} />);
const trigger = screen.getByTestId("dropdown-trigger");
expect(trigger.className).toContain("hover:bg-slate-900");
expect(trigger.className).not.toContain("bg-slate-900 text-white");
});
test("calls toggleDropdown when dropdown opens or closes", async () => {
const user = userEvent.setup();
render(<SurveyFilterDropdown {...mockProps} />);
const dropdown = screen.getByTestId("dropdown-menu");
await user.click(dropdown);
expect(mockProps.toggleDropdown).toHaveBeenCalledWith("status");
});
test("renders all options in dropdown", () => {
render(<SurveyFilterDropdown {...mockProps} isOpen={true} />);
const dropdownContent = screen.getByTestId("dropdown-content");
expect(dropdownContent).toBeInTheDocument();
const items = screen.getAllByTestId("dropdown-item");
expect(items).toHaveLength(mockOptions.length);
// Check that all option labels are displayed
expect(screen.getByText("option1.label")).toBeInTheDocument();
expect(screen.getByText("option2.label")).toBeInTheDocument();
expect(screen.getByText("option3.label")).toBeInTheDocument();
});
test("renders checkboxes with correct checked state", () => {
render(<SurveyFilterDropdown {...mockProps} isOpen={true} />);
const checkboxes = screen.getAllByTestId("mock-checkbox");
expect(checkboxes).toHaveLength(mockOptions.length);
// The option2 is selected, others are not
checkboxes.forEach((checkbox, index) => {
if (mockOptions[index].value === "option2") {
expect(checkbox).toHaveAttribute("data-checked", "true");
expect(checkbox.className).toContain("bg-brand-dark border-none");
} else {
expect(checkbox).toHaveAttribute("data-checked", "false");
expect(checkbox.className).not.toContain("bg-brand-dark border-none");
}
});
});
test("calls setSelectedOptions when an option is clicked", async () => {
const user = userEvent.setup();
render(<SurveyFilterDropdown {...mockProps} isOpen={true} />);
const items = screen.getAllByTestId("dropdown-item");
await user.click(items[0]); // Click on the first option
expect(mockProps.setSelectedOptions).toHaveBeenCalledWith("option1");
});
test("renders dropdown content with correct align property", () => {
render(<SurveyFilterDropdown {...mockProps} isOpen={true} />);
const dropdownContent = screen.getByTestId("dropdown-content");
expect(dropdownContent).toHaveAttribute("data-align", "start");
});
test("renders ChevronDownIcon", () => {
render(<SurveyFilterDropdown {...mockProps} />);
const chevronIcon = screen.getByTestId("chevron-icon");
expect(chevronIcon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,378 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { SurveyFilters } from "./survey-filters";
import { initialFilters } from "./survey-list";
// Mock environment to prevent server-side env variable access
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
NODE_ENV: "test",
},
}));
// Mock constants that depend on env
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azuread-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SMTP_FROM_ADDRESS: "mock-from-address",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
SMTP_SECURE_ENABLED: "mock-smtp-secure",
WEBAPP_URL: "https://example.com",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-license-key",
}));
// Track the callback for useDebounce to better control when it fires
let debouncedCallback: (() => void) | null = null;
// Mock dependencies
vi.mock("react-use", () => ({
useDebounce: (callback: () => void, ms: number, deps: any[]) => {
debouncedCallback = callback;
return undefined;
},
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Mock the DropdownMenu components
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children }: any) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuTrigger: ({ children, className }: any) => (
<div data-testid="dropdown-menu-trigger" className={className}>
{children}
</div>
),
DropdownMenuContent: ({ children, align, className }: any) => (
<div data-testid="dropdown-menu-content" data-align={align} className={className}>
{children}
</div>
),
}));
// Mock the Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className, size }: any) => (
<button data-testid="clear-filters-button" className={className} onClick={onClick} data-size={size}>
{children}
</button>
),
}));
// Mock the SearchBar component
vi.mock("@/modules/ui/components/search-bar", () => ({
SearchBar: ({ value, onChange, placeholder, className }: any) => (
<input
data-testid="search-bar"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={className}
/>
),
}));
// Mock the SortOption component
vi.mock("./sort-option", () => ({
SortOption: ({ option, sortBy, handleSortChange }: any) => (
<div
data-testid={`sort-option-${option.value}`}
data-selected={option.value === sortBy}
onClick={() => handleSortChange(option)}>
{option.label}
</div>
),
}));
// Mock the SurveyFilterDropdown component with direct call implementation
vi.mock("./survey-filter-dropdown", () => ({
SurveyFilterDropdown: ({
title,
id,
options,
selectedOptions,
setSelectedOptions,
isOpen,
toggleDropdown,
}: any) => (
<div
data-testid={`filter-dropdown-${id}`}
data-title={title}
data-is-open={isOpen}
onClick={() => toggleDropdown(id)}>
<span>Filter: {title}</span>
<ul>
{options.map((option: any) => (
<li
key={option.value}
data-testid={`filter-option-${id}-${option.value}`}
data-selected={selectedOptions.includes(option.value)}
onClick={(e) => {
e.stopPropagation();
setSelectedOptions(option.value);
}}>
{option.label}
</li>
))}
</ul>
</div>
),
}));
describe("SurveyFilters", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
debouncedCallback = null;
});
test("renders all filter components correctly", () => {
const mockSetSurveyFilters = vi.fn();
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
expect(screen.getByTestId("filter-dropdown-createdBy")).toBeInTheDocument();
expect(screen.getByTestId("filter-dropdown-status")).toBeInTheDocument();
expect(screen.getByTestId("filter-dropdown-type")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
});
test("handles search input and debouncing", async () => {
const mockSetSurveyFilters = vi.fn((x) => x({ ...initialFilters }));
const user = userEvent.setup();
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const searchBar = screen.getByTestId("search-bar");
await user.type(searchBar, "test");
// Manually trigger the debounced callback
if (debouncedCallback) {
debouncedCallback();
}
// Check that setSurveyFilters was called with a function
expect(mockSetSurveyFilters).toHaveBeenCalled();
});
test("handles toggling created by filter", async () => {
const mockSetSurveyFilters = vi.fn((cb) => {
const newFilters = cb({ ...initialFilters });
return newFilters;
});
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const createdByFilter = screen.getByTestId("filter-dropdown-createdBy");
await userEvent.click(createdByFilter);
const youOption = screen.getByTestId("filter-option-createdBy-you");
await userEvent.click(youOption);
expect(mockSetSurveyFilters).toHaveBeenCalled();
// Check the result by calling the callback with initialFilters
const result = mockSetSurveyFilters.mock.calls[0][0]({ ...initialFilters });
expect(result.createdBy).toContain("you");
});
test("handles toggling status filter", async () => {
const mockSetSurveyFilters = vi.fn((cb) => {
const newFilters = cb({ ...initialFilters });
return newFilters;
});
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const statusFilter = screen.getByTestId("filter-dropdown-status");
await userEvent.click(statusFilter);
const draftOption = screen.getByTestId("filter-option-status-draft");
await userEvent.click(draftOption);
expect(mockSetSurveyFilters).toHaveBeenCalled();
// Check the result by calling the callback with initialFilters
const result = mockSetSurveyFilters.mock.calls[0][0]({ ...initialFilters });
expect(result.status).toContain("draft");
});
test("handles toggling type filter", async () => {
const mockSetSurveyFilters = vi.fn((cb) => {
const newFilters = cb({ ...initialFilters });
return newFilters;
});
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const typeFilter = screen.getByTestId("filter-dropdown-type");
await userEvent.click(typeFilter);
const linkOption = screen.getByTestId("filter-option-type-link");
await userEvent.click(linkOption);
expect(mockSetSurveyFilters).toHaveBeenCalled();
// Check the result by calling the callback with initialFilters
const result = mockSetSurveyFilters.mock.calls[0][0]({ ...initialFilters });
expect(result.type).toContain("link");
});
test("doesn't render type filter when currentProjectChannel is link", () => {
const mockSetSurveyFilters = vi.fn();
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="link"
/>
);
expect(screen.queryByTestId("filter-dropdown-type")).not.toBeInTheDocument();
});
test("shows clear filters button when filters are applied", () => {
const mockSetSurveyFilters = vi.fn();
const filtersWithValues: TSurveyFilters = {
...initialFilters,
createdBy: ["you"],
status: ["draft"],
type: ["link"],
};
render(
<SurveyFilters
surveyFilters={filtersWithValues}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const clearButton = screen.getByTestId("clear-filters-button");
expect(clearButton).toBeInTheDocument();
});
test("doesn't show clear filters button when no filters are applied", () => {
const mockSetSurveyFilters = vi.fn();
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
expect(screen.queryByTestId("clear-filters-button")).not.toBeInTheDocument();
});
test("clears filters when clear button is clicked", async () => {
const mockSetSurveyFilters = vi.fn();
const mockLocalStorageRemove = vi.spyOn(Storage.prototype, "removeItem");
const filtersWithValues: TSurveyFilters = {
...initialFilters,
createdBy: ["you"],
status: ["draft"],
type: ["link"],
};
render(
<SurveyFilters
surveyFilters={filtersWithValues}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const clearButton = screen.getByTestId("clear-filters-button");
await userEvent.click(clearButton);
expect(mockSetSurveyFilters).toHaveBeenCalledWith(initialFilters);
expect(mockLocalStorageRemove).toHaveBeenCalledWith("surveyFilters");
});
test("changes sort option when a sort option is selected", async () => {
const mockSetSurveyFilters = vi.fn((cb) => {
const newFilters = cb({ ...initialFilters });
return newFilters;
});
render(
<SurveyFilters
surveyFilters={{ ...initialFilters }}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
const updatedAtOption = screen.getByTestId("sort-option-updatedAt");
await userEvent.click(updatedAtOption);
expect(mockSetSurveyFilters).toHaveBeenCalled();
// Check the result by calling the callback with initialFilters
const result = mockSetSurveyFilters.mock.calls[0][0]({ ...initialFilters });
expect(result.sortBy).toBe("updatedAt");
});
test("handles sortBy option that is not in the options list", () => {
const mockSetSurveyFilters = vi.fn();
const customFilters: TSurveyFilters = {
...initialFilters,
sortBy: "nonExistentOption" as any,
};
render(
<SurveyFilters
surveyFilters={customFilters}
setSurveyFilters={mockSetSurveyFilters}
currentProjectChannel="app"
/>
);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,371 @@
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { useAutoAnimate } from "@formkit/auto-animate/react";
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 { TProjectConfigChannel } from "@formbricks/types/project";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { SurveyCard } from "./survey-card";
import { SurveyFilters } from "./survey-filters";
import { SurveysList, initialFilters as surveyFiltersInitialFiltersFromModule } from "./survey-list";
import { SurveyLoading } from "./survey-loading";
// Mock definitions
vi.mock("@/modules/survey/list/actions", () => ({
getSurveysAction: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/utils", () => ({
getFormattedFilters: vi.fn((filters) => filters), // Simple pass-through mock
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, loading, disabled, ...rest }) => (
<button onClick={onClick} disabled={loading || disabled} {...rest}>
{loading ? "Loading..." : children}
</button>
)),
}));
const mockUseAutoAnimateRef = vi.fn();
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [mockUseAutoAnimateRef]),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(() => ({
t: (key: string) => key,
})),
}));
vi.mock("./survey-card", () => ({
SurveyCard: vi.fn(
({ survey, deleteSurvey, duplicateSurvey, isReadOnly, locale, environmentId, surveyDomain }) => (
<div
data-testid={`survey-card-${survey.id}`}
data-readonly={isReadOnly}
data-locale={locale}
data-env-id={environmentId}
data-survey-domain={surveyDomain}>
<span>{survey.name}</span>
<button data-testid={`delete-${survey.id}`} onClick={() => deleteSurvey(survey.id)}>
Delete
</button>
<button data-testid={`duplicate-${survey.id}`} onClick={() => duplicateSurvey(survey)}>
Duplicate
</button>
</div>
)
),
}));
vi.mock("./survey-filters", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
initialFilters: actual.initialFilters, // Preserve initialFilters export
SurveyFilters: vi.fn(({ setSurveyFilters, surveyFilters, currentProjectChannel }) => (
<div data-testid="survey-filters" data-channel={currentProjectChannel}>
<button
data-testid="update-filter-button"
onClick={() => setSurveyFilters({ ...surveyFilters, name: "filtered name" })}>
Mock Update Filter
</button>
</div>
)),
};
});
vi.mock("./survey-loading", () => ({
SurveyLoading: vi.fn(() => <div data-testid="survey-loading">Loading...</div>),
}));
let mockLocalStorageStore: { [key: string]: string } = {};
const mockLocalStorage = {
getItem: vi.fn((key: string) => mockLocalStorageStore[key] || null),
setItem: vi.fn((key: string, value: string) => {
mockLocalStorageStore[key] = value.toString();
}),
removeItem: vi.fn((key: string) => {
delete mockLocalStorageStore[key];
}),
clear: vi.fn(() => {
mockLocalStorageStore = {};
}),
length: 0,
key: vi.fn(),
};
const defaultProps = {
environmentId: "test-env-id",
isReadOnly: false,
surveyDomain: "test.formbricks.com",
userId: "test-user-id",
surveysPerPage: 3,
currentProjectChannel: "link" as TProjectConfigChannel,
locale: "en" as TUserLocale,
};
const surveyMock: TSurvey = {
id: "1",
name: "Survey 1",
status: "inProgress",
type: "link",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
environmentId: "test-env-id",
singleUse: null,
creator: null,
};
describe("SurveysList", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLocalStorageStore = {};
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
// Reset surveyFiltersInitialFiltersFromModule to its actual initial state from the module for each test
vi.resetModules(); // This will ensure modules are re-imported with fresh state if needed
// Re-import or re-set specific mocks if resetModules is too broad or causes issues
vi.mock("@/modules/survey/list/actions", () => ({
getSurveysAction: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/utils", () => ({
getFormattedFilters: vi.fn((filters) => filters),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, loading, disabled, ...rest }) => (
<button onClick={onClick} disabled={loading || disabled} {...rest}>
{loading ? "Loading..." : children}
</button>
)),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [mockUseAutoAnimateRef]),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(() => ({
t: (key: string) => key,
})),
}));
vi.mock("./survey-card", () => ({
SurveyCard: vi.fn(
({ survey, deleteSurvey, duplicateSurvey, isReadOnly, locale, environmentId, surveyDomain }) => (
<div
data-testid={`survey-card-${survey.id}`}
data-readonly={isReadOnly}
data-locale={locale}
data-env-id={environmentId}
data-survey-domain={surveyDomain}>
<span>{survey.name}</span>
<button data-testid={`delete-${survey.id}`} onClick={() => deleteSurvey(survey.id)}>
Delete
</button>
<button data-testid={`duplicate-${survey.id}`} onClick={() => duplicateSurvey(survey)}>
Duplicate
</button>
</div>
)
),
}));
vi.mock("./survey-filters", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
initialFilters: actual.initialFilters,
SurveyFilters: vi.fn(({ setSurveyFilters, surveyFilters, currentProjectChannel }) => (
<div data-testid="survey-filters" data-channel={currentProjectChannel}>
<button
data-testid="update-filter-button"
onClick={() => setSurveyFilters({ ...surveyFilters, name: "filtered name" })}>
Mock Update Filter
</button>
</div>
)),
};
});
vi.mock("./survey-loading", () => ({
SurveyLoading: vi.fn(() => <div data-testid="survey-loading">Loading...</div>),
}));
});
afterEach(() => {
cleanup();
});
test("renders SurveyLoading initially and fetches surveys using initial filters", async () => {
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [] });
render(<SurveysList {...defaultProps} />);
expect(screen.getByTestId("survey-loading")).toBeInTheDocument();
// Check initial call, subsequent calls might happen due to state updates after async ops
expect(SurveyLoading).toHaveBeenCalled();
await waitFor(() => {
expect(getSurveysAction).toHaveBeenCalledWith({
environmentId: defaultProps.environmentId,
limit: defaultProps.surveysPerPage,
filterCriteria: surveyFiltersInitialFiltersFromModule,
});
});
await waitFor(() => {
expect(screen.queryByTestId("survey-loading")).not.toBeInTheDocument();
});
});
test("loads filters from localStorage if valid and fetches surveys", async () => {
const storedFilters: TSurveyFilters = { ...surveyFiltersInitialFiltersFromModule, name: "Stored Filter" };
mockLocalStorageStore[FORMBRICKS_SURVEYS_FILTERS_KEY_LS] = JSON.stringify(storedFilters);
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [] });
render(<SurveysList {...defaultProps} />);
await waitFor(() => {
expect(mockLocalStorage.getItem).toHaveBeenCalledWith(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
expect(getFormattedFilters).toHaveBeenCalledWith(storedFilters, defaultProps.userId);
expect(getSurveysAction).toHaveBeenCalledWith(
expect.objectContaining({
filterCriteria: storedFilters,
})
);
});
});
test("uses initialFilters if localStorage has invalid JSON", async () => {
mockLocalStorageStore[FORMBRICKS_SURVEYS_FILTERS_KEY_LS] = "invalid json";
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [] });
render(<SurveysList {...defaultProps} />);
await waitFor(() => {
expect(mockLocalStorage.getItem).toHaveBeenCalledWith(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
expect(getFormattedFilters).toHaveBeenCalledWith(
surveyFiltersInitialFiltersFromModule,
defaultProps.userId
);
expect(getSurveysAction).toHaveBeenCalledWith(
expect.objectContaining({
filterCriteria: surveyFiltersInitialFiltersFromModule,
})
);
});
});
test("fetches and displays surveys, sets hasMore to true if equal to limit, shows load more", async () => {
const surveysData = [
{ ...surveyMock, id: "s1", name: "Survey One" },
{ ...surveyMock, id: "s2", name: "Survey Two" },
{ ...surveyMock, id: "s3", name: "Survey Three" },
];
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
render(<SurveysList {...defaultProps} surveysPerPage={3} />);
await waitFor(() => {
expect(screen.getByText("Survey One")).toBeInTheDocument();
expect(screen.getByText("Survey Three")).toBeInTheDocument();
expect(SurveyCard).toHaveBeenCalledTimes(3);
});
expect(screen.getByText("common.load_more")).toBeInTheDocument();
});
test("displays 'No surveys found' message when no surveys are fetched", async () => {
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [] });
render(<SurveysList {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("common.no_surveys_found")).toBeInTheDocument();
});
expect(screen.queryByTestId("survey-loading")).not.toBeInTheDocument();
});
test("hides 'Load more' button when no more surveys to fetch on pagination", async () => {
const initialSurveys = [{ ...surveyMock, id: "s1", name: "S1" }];
vi.mocked(getSurveysAction)
.mockResolvedValueOnce({ data: initialSurveys })
.mockResolvedValueOnce({ data: [] }); // No more surveys
const user = userEvent.setup();
render(<SurveysList {...defaultProps} surveysPerPage={1} />);
await waitFor(() => expect(screen.getByText("S1")).toBeInTheDocument());
const loadMoreButton = screen.getByText("common.load_more");
await user.click(loadMoreButton);
await waitFor(() => {
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
});
test("handleDeleteSurvey removes the survey from the list", async () => {
const surveysData = [
{ ...surveyMock, id: "s1", name: "Survey One" },
{ ...surveyMock, id: "s2", name: "Survey Two" },
];
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
const user = userEvent.setup();
render(<SurveysList {...defaultProps} />);
await waitFor(() => expect(screen.getByText("Survey One")).toBeInTheDocument());
expect(screen.getByText("Survey Two")).toBeInTheDocument();
const deleteButtonS1 = screen.getByTestId("delete-s1");
await user.click(deleteButtonS1);
await waitFor(() => {
expect(screen.queryByText("Survey One")).not.toBeInTheDocument();
});
expect(screen.getByText("Survey Two")).toBeInTheDocument();
});
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });
const user = userEvent.setup();
render(<SurveysList {...defaultProps} />);
await waitFor(() => expect(screen.getByText("Original Survey")).toBeInTheDocument());
const duplicateButtonS1 = screen.getByTestId("duplicate-s1");
// The mock SurveyCard calls duplicateSurvey(survey) with the original survey object.
await user.click(duplicateButtonS1);
await waitFor(() => {
const surveyCards = screen.getAllByTestId(/survey-card-/);
expect(surveyCards).toHaveLength(2);
// Both cards will show "Original Survey" as the object is prepended.
expect(surveyCards[0]).toHaveTextContent("Original Survey");
expect(surveyCards[1]).toHaveTextContent("Original Survey");
});
});
test("applies useAutoAnimate ref to the survey list container", async () => {
const surveysData = [{ ...surveyMock, id: "s1" }];
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
render(<SurveysList {...defaultProps} />);
await waitFor(() => expect(screen.getByTestId(`survey-card-${surveysData[0].id}`)).toBeInTheDocument());
expect(useAutoAnimate).toHaveBeenCalled();
expect(mockUseAutoAnimateRef).toHaveBeenCalled();
});
test("handles getSurveysAction returning { data: null } by remaining in loading state", async () => {
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: null } as any);
render(<SurveysList {...defaultProps} />);
expect(screen.getByTestId("survey-loading")).toBeInTheDocument(); // Initial loading
await waitFor(() => {
expect(getSurveysAction).toHaveBeenCalled();
});
// isFetching remains true because setIsFetching(false) is in `if (res?.data)`
expect(screen.getByTestId("survey-loading")).toBeInTheDocument();
expect(screen.queryByText("common.no_surveys_found")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SurveyLoading } from "./survey-loading";
describe("SurveyLoading", () => {
afterEach(() => {
cleanup();
});
test("renders the loading skeleton with correct structure and styles", () => {
const { container } = render(<SurveyLoading />);
// Check for the main container
const mainDiv = container.firstChild;
expect(mainDiv).toBeInTheDocument();
expect(mainDiv).toHaveClass("grid h-full w-full animate-pulse place-content-stretch gap-4");
// Check for the 5 loading items
if (!mainDiv) throw new Error("Main div not found");
const loadingItems = mainDiv.childNodes;
expect(loadingItems.length).toBe(5);
loadingItems.forEach((item) => {
expect(item).toHaveClass(
"relative flex h-16 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out"
);
// Check inner structure of each item
const innerFlexDiv = item.firstChild;
expect(innerFlexDiv).toBeInTheDocument();
expect(innerFlexDiv).toHaveClass("flex w-full items-center justify-between");
if (!innerFlexDiv) throw new Error("Inner div not found");
const placeholders = innerFlexDiv.childNodes;
expect(placeholders.length).toBe(7);
// Check classes for each placeholder
expect(placeholders[0]).toHaveClass("h-4 w-32 rounded-xl bg-slate-400");
expect(placeholders[1]).toHaveClass("h-4 w-20 rounded-xl bg-slate-200");
expect(placeholders[2]).toHaveClass("h-4 w-20 rounded-xl bg-slate-200");
expect(placeholders[3]).toHaveClass("h-4 w-20 rounded-xl bg-slate-200");
expect(placeholders[4]).toHaveClass("h-4 w-20 rounded-xl bg-slate-200");
expect(placeholders[5]).toHaveClass("h-4 w-20 rounded-xl bg-slate-200");
expect(placeholders[6]).toHaveClass("h-8 w-8 rounded-md bg-slate-300");
});
});
});

View File

@@ -0,0 +1,201 @@
// Retain only vitest import here
// Import modules after mocks
import { cache as libCacheImport } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { doesEnvironmentExist, getEnvironment, getProjectIdIfEnvironmentExists } from "./environment";
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: vi.fn((workFn: () => Promise<any>, _cacheKey?: string, _options?: any) =>
vi.fn(async () => await workFn())
),
}));
vi.mock("@/lib/environment/cache", () => ({
environmentCache: {
tag: {
byId: vi.fn((id) => `environment-${id}`),
},
},
}));
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("react", async () => {
const actualReact = await vi.importActual("react");
return {
...actualReact,
cache: vi.fn((fnToMemoize: (...args: any[]) => any) => fnToMemoize),
};
});
const mockEnvironmentId = "clxko31qs000008jya8v4ah0a";
const mockProjectId = "clxko31qt000108jyd64v5688";
describe("doesEnvironmentExist", () => {
beforeEach(() => {
vi.resetAllMocks();
// No need to call mockImplementation for libCacheImport or reactCacheImport here anymore
});
test("should return environmentId if environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({ id: mockEnvironmentId });
const result = await doesEnvironmentExist(mockEnvironmentId);
expect(result).toBe(mockEnvironmentId);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { id: true },
});
// Check if mocks were called as expected by the new setup
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
test("should throw ResourceNotFoundError if environment does not exist", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(doesEnvironmentExist(mockEnvironmentId)).rejects.toThrow(ResourceNotFoundError);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { id: true },
});
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
});
describe("getProjectIdIfEnvironmentExists", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should return projectId if environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({ projectId: mockProjectId }); // Ensure correct mock value
const result = await getProjectIdIfEnvironmentExists(mockEnvironmentId);
expect(result).toBe(mockProjectId);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { projectId: true },
});
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
test("should throw ResourceNotFoundError if environment does not exist", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getProjectIdIfEnvironmentExists(mockEnvironmentId)).rejects.toThrow(ResourceNotFoundError);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { projectId: true },
});
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
});
describe("getEnvironment", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should return environment if it exists", async () => {
const mockEnvData = { id: mockEnvironmentId, type: "production" as const };
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvData);
const result = await getEnvironment(mockEnvironmentId);
expect(result).toEqual(mockEnvData);
expect(validateInputs).toHaveBeenCalledWith([mockEnvironmentId, expect.any(Object)]);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { id: true, type: true },
});
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
test("should return null if environment does not exist (as per select, though findUnique would return null directly)", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
const result = await getEnvironment(mockEnvironmentId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith([mockEnvironmentId, expect.any(Object)]);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: mockEnvironmentId },
select: { id: true, type: true },
});
// Additional checks for cache mocks
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
test("should throw DatabaseError if PrismaClientKnownRequestError occurs", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2001",
clientVersion: "2.0.0", // Ensure clientVersion is a string
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironment(mockEnvironmentId)).rejects.toThrow(DatabaseError);
expect(validateInputs).toHaveBeenCalledWith([mockEnvironmentId, expect.any(Object)]);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error fetching environment");
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
test("should re-throw error if a generic error occurs", async () => {
const genericError = new Error("Test Generic Error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(genericError);
await expect(getEnvironment(mockEnvironmentId)).rejects.toThrow(genericError);
expect(validateInputs).toHaveBeenCalledWith([mockEnvironmentId, expect.any(Object)]);
expect(logger.error).not.toHaveBeenCalled();
expect(libCacheImport).toHaveBeenCalledTimes(1);
// Check that the function returned by libCacheImport was called
const libCacheReturnedFn = vi.mocked(libCacheImport).mock.results[0].value;
expect(libCacheReturnedFn).toHaveBeenCalledTimes(1);
});
});
// Remove the global afterEach if it was only for vi.useRealTimers() and no fake timers are used.
// vi.resetAllMocks() in beforeEach is generally the preferred way to ensure test isolation for mocks.
// The specific afterEach(() => { vi.clearAllMocks(); }) inside each describe block can also be removed.
// For consistency, I'll remove the afterEach blocks from the describe suites.

View File

@@ -1,40 +0,0 @@
import { cache } from "@/lib/cache";
import { environmentCache } from "@/lib/environment/cache";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const getOrganizationIdByEnvironmentId = reactCache(
async (environmentId: string): Promise<string | null> =>
cache(
async () => {
const organization = await prisma.organization.findFirst({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
id: true,
},
});
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
return organization.id;
},
[`survey-list-getOrganizationIdByEnvironmentId-${environmentId}`],
{
tags: [environmentCache.tag.byId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,817 @@
import { actionClassCache } from "@/lib/actionClass/cache";
import { cache } from "@/lib/cache";
import { segmentCache } from "@/lib/cache/segment";
import { projectCache } from "@/lib/project/cache";
import { responseCache } from "@/lib/response/cache";
import { surveyCache } from "@/lib/survey/cache";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TActionClassType } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectWithLanguages, TSurvey } from "../types/surveys";
// Import the module to be tested
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveyCount,
getSurveys,
getSurveysSortedByRelevance,
surveySelect,
} from "./survey";
// Mocked modules
vi.mock("@/lib/cache", () => ({
cache: vi.fn((fn, _options) => fn), // Return the function itself, not its execution result
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
cache: vi.fn((fn) => fn), // Return the function itself, as reactCache is a HOF
};
});
vi.mock("@/lib/actionClass/cache", () => ({
actionClassCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/cache/segment", () => ({
segmentCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
tag: {
byEnvironmentId: vi.fn((id) => `response-env-${id}`),
bySurveyId: vi.fn((id) => `response-survey-${id}`),
},
},
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
tag: {
byEnvironmentId: vi.fn((id) => `survey-env-${id}`),
byId: vi.fn((id) => `survey-${id}`),
byActionClassId: vi.fn((id) => `survey-actionclass-${id}`),
byResultShareKey: vi.fn((key) => `survey-resultsharekey-${key}`),
},
},
}));
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/survey/lib/utils", () => ({
buildOrderByClause: vi.fn((sortBy) => (sortBy ? [{ [sortBy]: "desc" }] : [])),
buildWhereClause: vi.fn((filterCriteria) => (filterCriteria ? { name: filterCriteria.name } : {})),
}));
vi.mock("@/modules/survey/list/lib/environment", () => ({
doesEnvironmentExist: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/project", () => ({
getProjectWithLanguagesByEnvironmentId: vi.fn(),
}));
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "new_cuid2_id"),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
findUnique: vi.fn(),
count: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
segment: {
delete: vi.fn(),
findFirst: vi.fn(),
},
language: {
// Added for language connectOrCreate in copySurvey
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Helper to reset mocks
const resetMocks = () => {
vi.mocked(cache).mockClear();
vi.mocked(reactCache).mockClear();
vi.mocked(actionClassCache.revalidate).mockClear();
vi.mocked(segmentCache.revalidate).mockClear();
vi.mocked(projectCache.revalidate).mockClear();
vi.mocked(responseCache.revalidate).mockClear();
vi.mocked(surveyCache.revalidate).mockClear();
vi.mocked(checkForInvalidImagesInQuestions).mockClear();
vi.mocked(validateInputs).mockClear();
vi.mocked(buildOrderByClause).mockClear();
vi.mocked(buildWhereClause).mockClear();
vi.mocked(doesEnvironmentExist).mockClear();
vi.mocked(getProjectWithLanguagesByEnvironmentId).mockClear();
vi.mocked(createId).mockClear();
vi.mocked(prisma.survey.findMany).mockReset();
vi.mocked(prisma.survey.findUnique).mockReset();
vi.mocked(prisma.survey.count).mockReset();
vi.mocked(prisma.survey.delete).mockReset();
vi.mocked(prisma.survey.create).mockReset();
vi.mocked(prisma.segment.delete).mockReset();
vi.mocked(prisma.segment.findFirst).mockReset();
vi.mocked(logger.error).mockClear();
};
const makePrismaKnownError = () =>
new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2001",
clientVersion: "test",
meta: {},
});
// Sample data
const environmentId = "env_1";
const surveyId = "survey_1";
const userId = "user_1";
const mockSurveyPrisma = {
id: surveyId,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "web" as any,
creator: { name: "Test User" },
status: "draft" as any,
singleUse: null,
environmentId,
_count: { responses: 10 },
};
describe("getSurveyCount", () => {
beforeEach(() => {
resetMocks();
});
test("should return survey count successfully", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(5);
const count = await getSurveyCount(environmentId);
expect(count).toBe(5);
expect(prisma.survey.count).toHaveBeenCalledWith({
where: { environmentId },
});
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.count).mockRejectedValue(prismaError);
await expect(getSurveyCount(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey count");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.count).mockRejectedValue(unknownError);
await expect(getSurveyCount(environmentId)).rejects.toThrow(unknownError);
});
});
describe("getSurvey", () => {
beforeEach(() => {
resetMocks();
});
test("should return a survey if found", async () => {
const prismaSurvey = { ...mockSurveyPrisma, _count: { responses: 5 } };
vi.mocked(prisma.survey.findUnique).mockResolvedValue(prismaSurvey);
const survey = await getSurvey(surveyId);
expect(survey).toEqual({ ...prismaSurvey, responseCount: 5 });
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId },
select: surveySelect,
});
expect(surveyCache.tag.byId).toHaveBeenCalledWith(surveyId);
expect(responseCache.tag.bySurveyId).toHaveBeenCalledWith(surveyId);
});
test("should return null if survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
const survey = await getSurvey(surveyId);
expect(survey).toBeNull();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.findUnique).mockRejectedValue(prismaError);
await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.findUnique).mockRejectedValue(unknownError);
await expect(getSurvey(surveyId)).rejects.toThrow(unknownError);
});
});
describe("getSurveys", () => {
beforeEach(() => {
resetMocks();
});
const mockPrismaSurveys = [
{ ...mockSurveyPrisma, id: "s1", name: "Survey 1", _count: { responses: 1 } },
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
];
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
...s,
responseCount: s._count.responses,
}));
test("should return surveys with default parameters", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(expectedSurveys);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, ...buildWhereClause() },
select: surveySelect,
orderBy: buildOrderByClause(),
take: undefined,
skip: undefined,
});
expect(surveyCache.tag.byEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(responseCache.tag.byEnvironmentId).toHaveBeenCalledWith(environmentId);
});
test("should return surveys with limit and offset", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurveys[0]]);
const surveys = await getSurveys(environmentId, 1, 1);
expect(surveys).toEqual([expectedSurveys[0]]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, ...buildWhereClause() },
select: surveySelect,
orderBy: buildOrderByClause(),
take: 1,
skip: 1,
});
});
test("should return surveys with filterCriteria", async () => {
const filterCriteria: any = { name: "Test", sortBy: "createdAt" };
vi.mocked(buildWhereClause).mockReturnValue({ AND: [{ name: { contains: "Test" } }] }); // Mock correct return type
vi.mocked(buildOrderByClause).mockReturnValue([{ createdAt: "desc" }]); // Mock specific return
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
const surveys = await getSurveys(environmentId, undefined, undefined, filterCriteria);
expect(surveys).toEqual(expectedSurveys);
expect(buildWhereClause).toHaveBeenCalledWith(filterCriteria);
expect(buildOrderByClause).toHaveBeenCalledWith("createdAt");
expect(prisma.survey.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { environmentId, AND: [{ name: { contains: "Test" } }] }, // Check with correct structure
orderBy: [{ createdAt: "desc" }], // Check the mocked order by
})
);
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError);
await expect(getSurveys(environmentId)).rejects.toThrow(unknownError);
});
});
describe("getSurveysSortedByRelevance", () => {
beforeEach(() => {
resetMocks();
});
const mockInProgressPrisma = {
...mockSurveyPrisma,
id: "s_inprog",
status: "inProgress" as any,
_count: { responses: 3 },
};
const mockOtherPrisma = {
...mockSurveyPrisma,
id: "s_other",
status: "completed" as any,
_count: { responses: 5 },
};
const expectedInProgressSurvey: TSurvey = { ...mockInProgressPrisma, responseCount: 3 };
const expectedOtherSurvey: TSurvey = { ...mockOtherPrisma, responseCount: 5 };
test("should fetch inProgress surveys first, then others if limit not met", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([mockInProgressPrisma]) // In-progress surveys
.mockResolvedValueOnce([mockOtherPrisma]); // Additional surveys
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]);
expect(prisma.survey.count).toHaveBeenCalledWith({
where: { environmentId, status: "inProgress", ...buildWhereClause() },
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, {
where: { environmentId, status: "inProgress", ...buildWhereClause() },
select: surveySelect,
orderBy: buildOrderByClause("updatedAt"),
take: 2,
skip: 0,
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, {
where: { environmentId, status: { not: "inProgress" }, ...buildWhereClause() },
select: surveySelect,
orderBy: buildOrderByClause("updatedAt"),
take: 1,
skip: 0,
});
});
test("should only fetch inProgress surveys if limit is met", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(1);
vi.mocked(prisma.survey.findMany).mockResolvedValueOnce([mockInProgressPrisma]);
const surveys = await getSurveysSortedByRelevance(environmentId, 1, 0);
expect(surveys).toEqual([expectedInProgressSurvey]);
expect(prisma.survey.findMany).toHaveBeenCalledTimes(1);
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.count).mockRejectedValue(prismaError);
await expect(getSurveysSortedByRelevance(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys sorted by relevance");
resetMocks(); // Reset for the next part of the test
vi.mocked(prisma.survey.count).mockResolvedValue(0); // Make count succeed
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); // Error on findMany
await expect(getSurveysSortedByRelevance(environmentId)).rejects.toThrow(DatabaseError);
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.count).mockRejectedValue(unknownError);
await expect(getSurveysSortedByRelevance(environmentId)).rejects.toThrow(unknownError);
});
});
describe("deleteSurvey", () => {
beforeEach(() => {
resetMocks();
});
const mockDeletedSurveyData = {
id: surveyId,
environmentId,
segment: null,
type: "web" as any,
resultShareKey: "sharekey1",
triggers: [{ actionClass: { id: "action_1" } }],
};
test("should delete a survey and revalidate caches (no private segment)", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyData as any);
const result = await deleteSurvey(surveyId);
expect(result).toBe(true);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
});
expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId });
expect(surveyCache.revalidate).toHaveBeenCalledWith({
id: surveyId,
environmentId,
resultShareKey: "sharekey1",
});
expect(surveyCache.revalidate).toHaveBeenCalledWith({ actionClassId: "action_1" });
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should revalidate segment cache for non-private segment if segment exists", async () => {
const surveyWithPublicSegment = {
...mockDeletedSurveyData,
segment: { id: "segment_public_1", isPrivate: false },
};
vi.mocked(prisma.survey.delete).mockResolvedValue(surveyWithPublicSegment as any);
await deleteSurvey(surveyId);
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).toHaveBeenCalledWith({ id: "segment_public_1", environmentId });
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error deleting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.delete).mockRejectedValue(unknownError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(unknownError);
});
});
const mockExistingSurveyDetails = {
name: "Original Survey",
type: "web" as any,
languages: [{ default: true, enabled: true, language: { code: "en", alias: "English" } }],
welcomeCard: { enabled: true, headline: { default: "Welcome!" } },
questions: [{ id: "q1", type: "openText", headline: { default: "Question 1" } }],
endings: [{ type: "default", headline: { default: "Thanks!" } }],
variables: [{ id: "var1", name: "Var One" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
surveyClosedMessage: { enabled: false },
singleUse: { enabled: false },
projectOverwrites: null,
styling: { theme: {} },
segment: null,
followUps: [{ name: "Follow Up 1", trigger: {}, action: {} }],
triggers: [
{
actionClass: {
id: "ac1",
name: "Code Action",
environmentId,
description: "",
type: "code" as TActionClassType,
key: "code_action_key",
noCodeConfig: null,
},
},
{
actionClass: {
id: "ac2",
name: "No-Code Action",
environmentId,
description: "",
type: "noCode" as TActionClassType,
key: null,
noCodeConfig: { type: "url" },
},
},
],
};
describe("copySurveyToOtherEnvironment", () => {
const targetEnvironmentId = "env_target";
const sourceProjectId = "proj_source";
const targetProjectId = "proj_target";
const mockSourceProject: TProjectWithLanguages = {
id: sourceProjectId,
languages: [{ code: "en", alias: "English" }],
};
const mockTargetProject: TProjectWithLanguages = {
id: targetProjectId,
languages: [{ code: "en", alias: "English" }],
};
const mockNewSurveyResult = {
id: "new_cuid2_id",
environmentId: targetEnvironmentId,
segment: null,
triggers: [
{ actionClass: { id: "new_ac1", name: "Code Action", environmentId: targetEnvironmentId } },
{ actionClass: { id: "new_ac2", name: "No-Code Action", environmentId: targetEnvironmentId } },
],
languages: [{ language: { code: "en" } }],
resultShareKey: null,
};
beforeEach(() => {
resetMocks();
vi.mocked(createId).mockReturnValue("new_cuid2_id");
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockExistingSurveyDetails as any);
vi.mocked(doesEnvironmentExist).mockResolvedValue(environmentId);
vi.mocked(getProjectWithLanguagesByEnvironmentId)
.mockResolvedValueOnce(mockSourceProject)
.mockResolvedValueOnce(mockTargetProject);
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null);
});
test("should copy survey to a different environment successfully", async () => {
const newSurvey = await copySurveyToOtherEnvironment(
environmentId,
surveyId,
targetEnvironmentId,
userId
);
expect(newSurvey).toBeDefined();
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
id: "new_cuid2_id",
name: `${mockExistingSurveyDetails.name} (copy)`,
environment: { connect: { id: targetEnvironmentId } },
creator: { connect: { id: userId } },
status: "draft",
triggers: {
create: [
expect.objectContaining({
actionClass: {
connectOrCreate: {
where: {
key_environmentId: { key: "code_action_key", environmentId: targetEnvironmentId },
},
create: expect.objectContaining({ name: "Code Action", key: "code_action_key" }),
},
},
}),
expect.objectContaining({
actionClass: {
connectOrCreate: {
where: {
name_environmentId: { name: "No-Code Action", environmentId: targetEnvironmentId },
},
create: expect.objectContaining({
name: "No-Code Action",
noCodeConfig: { type: "url" },
}),
},
},
}),
],
},
}),
})
);
expect(checkForInvalidImagesInQuestions).toHaveBeenCalledWith(mockExistingSurveyDetails.questions);
expect(actionClassCache.revalidate).toHaveBeenCalledTimes(2);
expect(surveyCache.revalidate).toHaveBeenCalledWith(expect.objectContaining({ id: "new_cuid2_id" }));
expect(surveyCache.revalidate).toHaveBeenCalledWith({ actionClassId: "ac1" });
expect(surveyCache.revalidate).toHaveBeenCalledWith({ actionClassId: "ac2" });
});
test("should copy survey to the same environment successfully", async () => {
vi.mocked(getProjectWithLanguagesByEnvironmentId).mockReset();
vi.mocked(getProjectWithLanguagesByEnvironmentId).mockResolvedValue(mockSourceProject);
await copySurveyToOtherEnvironment(environmentId, surveyId, environmentId, userId);
expect(getProjectWithLanguagesByEnvironmentId).toHaveBeenCalledTimes(1);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
environment: { connect: { id: environmentId } },
triggers: {
create: [
{ actionClass: { connect: { id: "ac1" } } },
{ actionClass: { connect: { id: "ac2" } } },
],
},
}),
})
);
});
test("should handle private segment: create new private segment in target", async () => {
const surveyWithPrivateSegment = {
...mockExistingSurveyDetails,
segment: { id: "seg_private", isPrivate: true, filters: [{ type: "user", value: "test" }] },
};
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithPrivateSegment as any);
const mockNewSurveyWithSegment = { ...mockNewSurveyResult, segment: { id: "new_seg_private" } };
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyWithSegment as any);
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
create: {
title: "new_cuid2_id",
isPrivate: true,
filters: surveyWithPrivateSegment.segment.filters,
environment: { connect: { id: targetEnvironmentId } },
},
},
}),
})
);
expect(segmentCache.revalidate).toHaveBeenCalledWith({
id: "new_seg_private",
environmentId: targetEnvironmentId,
});
});
test("should handle public segment: connect if same env, create new if different env (no existing in target)", async () => {
const surveyWithPublicSegment = {
...mockExistingSurveyDetails,
segment: { id: "seg_public", title: "Public Segment", isPrivate: false, filters: [] },
};
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithPublicSegment as any);
vi.mocked(getProjectWithLanguagesByEnvironmentId)
.mockReset() // for same env part
.mockResolvedValueOnce(mockSourceProject);
// Case 1: Same environment
await copySurveyToOtherEnvironment(environmentId, surveyId, environmentId, userId); // target is same
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: { connect: { id: "seg_public" } },
}),
})
);
// Reset for different env part
resetMocks();
vi.mocked(createId).mockReturnValue("new_cuid2_id");
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithPublicSegment as any);
vi.mocked(doesEnvironmentExist).mockResolvedValue(environmentId);
vi.mocked(getProjectWithLanguagesByEnvironmentId)
.mockResolvedValueOnce(mockSourceProject)
.mockResolvedValueOnce(mockTargetProject);
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue(null); // No existing public segment with same title in target
// Case 2: Different environment, segment with same title does not exist in target
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
create: {
title: "Public Segment",
isPrivate: false,
filters: [],
environment: { connect: { id: targetEnvironmentId } },
},
},
}),
})
);
});
test("should handle public segment: create new with appended timestamp if different env and segment with same title exists in target", async () => {
const surveyWithPublicSegment = {
...mockExistingSurveyDetails,
segment: { id: "seg_public", title: "Public Segment", isPrivate: false, filters: [] },
};
resetMocks();
vi.mocked(createId).mockReturnValue("new_cuid2_id");
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithPublicSegment as any);
vi.mocked(doesEnvironmentExist).mockResolvedValue(environmentId);
vi.mocked(getProjectWithLanguagesByEnvironmentId)
.mockResolvedValueOnce(mockSourceProject)
.mockResolvedValueOnce(mockTargetProject);
vi.mocked(prisma.survey.create).mockResolvedValue(mockNewSurveyResult as any);
vi.mocked(prisma.segment.findFirst).mockResolvedValue({ id: "existing_target_seg" } as any); // Segment with same title EXISTS
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(1234567890);
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
segment: {
create: {
title: `Public Segment-1234567890`,
isPrivate: false,
filters: [],
environment: { connect: { id: targetEnvironmentId } },
},
},
}),
})
);
dateNowSpy.mockRestore();
});
test("should throw ResourceNotFoundError if source environment not found", async () => {
vi.mocked(doesEnvironmentExist).mockResolvedValueOnce(null);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(new ResourceNotFoundError("Environment", environmentId));
});
test("should throw ResourceNotFoundError if source project not found", async () => {
vi.mocked(getProjectWithLanguagesByEnvironmentId).mockReset().mockResolvedValueOnce(null);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(new ResourceNotFoundError("Project", environmentId));
});
test("should throw ResourceNotFoundError if existing survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
});
test("should throw ResourceNotFoundError if target environment not found (different env copy)", async () => {
vi.mocked(doesEnvironmentExist).mockResolvedValueOnce(environmentId).mockResolvedValueOnce(null);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(new ResourceNotFoundError("Environment", targetEnvironmentId));
});
test("should throw DatabaseError on Prisma create error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.create).mockRejectedValue(prismaError);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error copying survey to other environment");
});
test("should rethrow unknown error during copy", async () => {
const unknownError = new Error("Some unknown error during copy");
vi.mocked(prisma.survey.create).mockRejectedValue(unknownError);
await expect(
copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId)
).rejects.toThrow(unknownError);
});
test("should handle survey with no languages", async () => {
const surveyWithoutLanguages = { ...mockExistingSurveyDetails, languages: [] };
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithoutLanguages as any);
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
languages: undefined,
}),
})
);
expect(projectCache.revalidate).not.toHaveBeenCalled();
});
test("should handle survey with no triggers", async () => {
const surveyWithoutTriggers = { ...mockExistingSurveyDetails, triggers: [] };
vi.mocked(prisma.survey.findUnique).mockResolvedValue(surveyWithoutTriggers as any);
await copySurveyToOtherEnvironment(environmentId, surveyId, targetEnvironmentId, userId);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
triggers: { create: [] },
}),
})
);
expect(surveyCache.revalidate).not.toHaveBeenCalledWith(
expect.objectContaining({ actionClassId: expect.any(String) })
);
});
});

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