mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
Compare commits
13 Commits
v3.9.2
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2392a436da | ||
|
|
b917e9a4f8 | ||
|
|
47583b5a32 | ||
|
|
03c9a6aaae | ||
|
|
4dcf9b093b | ||
|
|
5ba5ebf63d | ||
|
|
115bea2792 | ||
|
|
b0495a8a42 | ||
|
|
faabd371f5 | ||
|
|
f0be6de0b3 | ||
|
|
b338c6d28d | ||
|
|
07e9a7c007 | ||
|
|
928bb3f8bc |
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -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";"
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
66
apps/web/lib/cache/document.ts
vendored
66
apps/web/lib/cache/document.ts
vendored
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
25
apps/web/lib/cache/insight.ts
vendored
25
apps/web/lib/cache/insight.ts
vendored
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mockSurveyLanguages } from "@/lib/survey/tests/__mock__/survey.mock";
|
||||
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}`],
|
||||
}
|
||||
)();
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
mockContactAttributeKey,
|
||||
mockOrganizationOutput,
|
||||
mockSurveyOutput,
|
||||
} from "../../survey/tests/__mock__/survey.mock";
|
||||
} from "../../survey/__mock__/survey.mock";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
@@ -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,
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
122
apps/web/lib/survey/cache.test.ts
Normal file
122
apps/web/lib/survey/cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1037
apps/web/lib/survey/service.test.ts
Normal file
1037
apps/web/lib/survey/service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
apps/web/lib/survey/utils.test.ts
Normal file
254
apps/web/lib/survey/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
386
apps/web/lib/utils/action-client-middleware.test.ts
Normal file
386
apps/web/lib/utils/action-client-middleware.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(".")] = {
|
||||
|
||||
70
apps/web/lib/utils/colors.test.ts
Normal file
70
apps/web/lib/utils/colors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
64
apps/web/lib/utils/contact.test.ts
Normal file
64
apps/web/lib/utils/contact.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
31
apps/web/lib/utils/datetime.test.ts
Normal file
31
apps/web/lib/utils/datetime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
50
apps/web/lib/utils/email.test.ts
Normal file
50
apps/web/lib/utils/email.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
apps/web/lib/utils/file-conversion.test.ts
Normal file
63
apps/web/lib/utils/file-conversion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
apps/web/lib/utils/headers.test.ts
Normal file
36
apps/web/lib/utils/headers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
795
apps/web/lib/utils/helper.test.ts
Normal file
795
apps/web/lib/utils/helper.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
apps/web/lib/utils/locale.test.ts
Normal file
87
apps/web/lib/utils/locale.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
84
apps/web/lib/utils/promises.test.ts
Normal file
84
apps/web/lib/utils/promises.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
516
apps/web/lib/utils/recall.test.ts
Normal file
516
apps/web/lib/utils/recall.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)) ||
|
||||
|
||||
737
apps/web/lib/utils/services.test.ts
Normal file
737
apps/web/lib/utils/services.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
115
apps/web/lib/utils/single-use-surveys.test.ts
Normal file
115
apps/web/lib/utils/single-use-surveys.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
133
apps/web/lib/utils/strings.test.ts
Normal file
133
apps/web/lib/utils/strings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
100
apps/web/lib/utils/styling.test.ts
Normal file
100
apps/web/lib/utils/styling.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
164
apps/web/lib/utils/templates.test.ts
Normal file
164
apps/web/lib/utils/templates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
apps/web/lib/utils/url.test.ts
Normal file
49
apps/web/lib/utils/url.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
apps/web/lib/utils/validate.test.ts
Normal file
54
apps/web/lib/utils/validate.test.ts
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
131
apps/web/lib/utils/video-upload.test.ts
Normal file
131
apps/web/lib/utils/video-upload.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
53
apps/web/modules/projects/settings/general/loading.test.tsx
Normal file
53
apps/web/modules/projects/settings/general/loading.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
128
apps/web/modules/projects/settings/general/page.test.tsx
Normal file
128
apps/web/modules/projects/settings/general/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
41
apps/web/modules/projects/settings/layout.test.tsx
Normal file
41
apps/web/modules/projects/settings/layout.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
226
apps/web/modules/projects/settings/lib/project.test.ts
Normal file
226
apps/web/modules/projects/settings/lib/project.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
143
apps/web/modules/projects/settings/lib/tag.test.ts
Normal file
143
apps/web/modules/projects/settings/lib/tag.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/projects/settings/look/lib/project.test.ts
Normal file
65
apps/web/modules/projects/settings/look/lib/project.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
66
apps/web/modules/projects/settings/look/loading.test.tsx
Normal file
66
apps/web/modules/projects/settings/look/loading.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
121
apps/web/modules/projects/settings/look/page.test.tsx
Normal file
121
apps/web/modules/projects/settings/look/page.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
20
apps/web/modules/projects/settings/page.test.tsx
Normal file
20
apps/web/modules/projects/settings/page.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
76
apps/web/modules/projects/settings/tags/actions.test.ts
Normal file
76
apps/web/modules/projects/settings/tags/actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
51
apps/web/modules/projects/settings/tags/loading.test.tsx
Normal file
51
apps/web/modules/projects/settings/tags/loading.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
80
apps/web/modules/projects/settings/tags/page.test.tsx
Normal file
80
apps/web/modules/projects/settings/tags/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => ({
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
75
apps/web/modules/survey/list/components/sort-option.test.tsx
Normal file
75
apps/web/modules/survey/list/components/sort-option.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
378
apps/web/modules/survey/list/components/survey-filters.test.tsx
Normal file
378
apps/web/modules/survey/list/components/survey-filters.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
371
apps/web/modules/survey/list/components/survey-list.test.tsx
Normal file
371
apps/web/modules/survey/list/components/survey-list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
201
apps/web/modules/survey/list/lib/environment.test.ts
Normal file
201
apps/web/modules/survey/list/lib/environment.test.ts
Normal 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.
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
817
apps/web/modules/survey/list/lib/survey.test.ts
Normal file
817
apps/web/modules/survey/list/lib/survey.test.ts
Normal 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
Reference in New Issue
Block a user