mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 14:10:45 -06:00
Compare commits
4 Commits
unit-test-
...
cursor/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d8fc6ce6 | ||
|
|
fb019c8c4c | ||
|
|
1d0ce7e5ce | ||
|
|
8b8fcf7539 |
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
- check-latest-release
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
docker-build-cloud:
|
||||
name: Build & push Formbricks Cloud to ECR
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
with:
|
||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
needs:
|
||||
- check-latest-release
|
||||
- docker-build-community
|
||||
@@ -154,4 +154,4 @@ jobs:
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g prisma@6
|
||||
RUN npm install -g prisma
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
|
||||
@@ -96,21 +96,14 @@ export const ResponsePage = ({
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
||||
const hasFilters =
|
||||
selectedFilter?.responseStatus !== "all" ||
|
||||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
|
||||
(dateRange.from && dateRange.to);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFilteredResponses = async () => {
|
||||
try {
|
||||
// skip call for initial mount
|
||||
if (page === null && !hasFilters) {
|
||||
if (page === null) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setIsFetchingFirstPage(true);
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
@@ -133,7 +126,15 @@ export const ResponsePage = ({
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
|
||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
||||
const hasFilters =
|
||||
(selectedFilter && Object.keys(selectedFilter).length > 0) ||
|
||||
(dateRange && (dateRange.from || dateRange.to));
|
||||
|
||||
if (hasFilters) {
|
||||
fetchFilteredResponses();
|
||||
}
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
type QuestionFilterComboBoxProps = {
|
||||
filterOptions: (string | TI18nString)[] | undefined;
|
||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
||||
filterOptions: string[] | undefined;
|
||||
filterComboBoxOptions: string[] | undefined;
|
||||
filterValue: string | undefined;
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
onChangeFilterValue: (o: string) => void;
|
||||
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
@@ -91,15 +91,14 @@ export const QuestionFilterComboBox = ({
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
||||
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
@@ -201,18 +200,14 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions?.map((o, index) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -274,8 +269,7 @@ export const QuestionFilterComboBox = ({
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
@@ -26,17 +25,9 @@ import {
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type:
|
||||
| TSurveyQuestionTypeEnum
|
||||
| "Attributes"
|
||||
| "Tags"
|
||||
| "Languages"
|
||||
| "Quotas"
|
||||
| "Hidden Fields"
|
||||
| "Meta"
|
||||
| OptionsType.OTHERS;
|
||||
filterOptions: (string | TI18nString)[];
|
||||
filterComboBoxOptions: (string | TI18nString)[];
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -78,12 +69,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
||||
|
||||
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
|
||||
if (!option || option.filterOptions.length === 0) return undefined;
|
||||
const firstOption = option.filterOptions[0];
|
||||
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the initial data for the filter and load it into the state
|
||||
const handleInitialData = async () => {
|
||||
@@ -109,18 +94,15 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
);
|
||||
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
|
||||
|
||||
if (filterValue.filter[index].questionType) {
|
||||
// Create a new array and copy existing values from SelectedFilter
|
||||
filterValue.filter[index] = {
|
||||
questionType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: defaultFilterValue,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
},
|
||||
};
|
||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||
@@ -129,7 +111,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
filterValue.filter[index].questionType = value;
|
||||
filterValue.filter[index].filterType = {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: defaultFilterValue,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
}
|
||||
|
||||
@@ -14,22 +14,17 @@ import {
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
// Cleanup React components after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("surveys", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("generateQuestionAndFilterOptions", () => {
|
||||
test("should return question options for basic survey without additional options", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
@@ -40,7 +35,7 @@ describe("surveys", () => {
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -54,23 +49,17 @@ describe("surveys", () => {
|
||||
|
||||
test("should include tags in options when provided", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const tags: TTag[] = [
|
||||
{
|
||||
id: TEST_IDS.team,
|
||||
name: "Tag 1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
|
||||
@@ -83,12 +72,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include attributes in options when provided", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -106,12 +95,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include meta in options when provided", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -129,12 +118,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include hidden fields in options when provided", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -154,12 +143,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should include language options when survey has languages", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
||||
} as unknown as TSurvey;
|
||||
@@ -173,7 +162,7 @@ describe("surveys", () => {
|
||||
|
||||
test("should handle all question types correctly", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
@@ -224,13 +213,13 @@ describe("surveys", () => {
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -245,12 +234,12 @@ describe("surveys", () => {
|
||||
|
||||
test("should provide extended filter options for URL meta field", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
@@ -283,7 +272,7 @@ describe("surveys", () => {
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
const survey = {
|
||||
id: TEST_IDS.survey,
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
@@ -357,7 +346,7 @@ describe("surveys", () => {
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
} => {
|
||||
let questionOptions: QuestionOptions[] = [];
|
||||
let questionFilterOptions: QuestionFilterOptions[] = [];
|
||||
let questionFilterOptions: any = [];
|
||||
|
||||
let questionsOptions: QuestionOption[] = [];
|
||||
let questionsOptions: any = [];
|
||||
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
|
||||
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
# Testing Utilities — Tutorial
|
||||
|
||||
Practical utilities to write cleaner, faster, more consistent unit tests.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
// NOW import modules that depend on mocks
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
// ⚠️ CRITICAL: Setup ALL mocks BEFORE importing modules that use them
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { getContact } from "./contacts";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("should find a contact", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Setup Rules ⚠️
|
||||
|
||||
### Rule 1: Mock Order is Everything
|
||||
|
||||
**Vitest requires all `vi.mock()` calls to happen BEFORE any imports that use the mocked modules.**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - will fail with "prisma is not defined"
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - setup mocks first
|
||||
// THEN import modules that depend on the mock
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
### Rule 2: Mock All External Dependencies
|
||||
|
||||
Don't forget to mock functions that are called by your tested code:
|
||||
|
||||
```typescript
|
||||
// ✅ Mock validateInputs if it's called by the function you're testing
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
// Set up a default behavior
|
||||
vi.mocked(validateInputs).mockImplementation(() => []);
|
||||
```
|
||||
|
||||
### Rule 3: Fixtures Must Match Real Data Structures
|
||||
|
||||
Test fixtures should match the exact structure expected by your code:
|
||||
|
||||
```typescript
|
||||
// ❌ INCOMPLETE - will fail when code tries to access attributes
|
||||
const contact = {
|
||||
id: TEST_IDS.contact,
|
||||
email: "test@example.com",
|
||||
userId: TEST_IDS.user,
|
||||
};
|
||||
|
||||
// ✅ COMPLETE - matches what transformPrismaContact expects
|
||||
const contact = {
|
||||
id: TEST_IDS.contact,
|
||||
environmentId: TEST_IDS.environment,
|
||||
userId: TEST_IDS.user,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
attributes: [
|
||||
{ value: "test@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: TEST_IDS.user, attributeKey: { key: "userId", name: "User ID" } },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Concept 1: TEST_IDs — Use Constants, Not Magic Strings
|
||||
|
||||
### The Problem
|
||||
|
||||
Scattered magic strings make tests hard to maintain:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("getContact", () => {
|
||||
test("should find contact", async () => {
|
||||
const contactId = "contact-123";
|
||||
const userId = "user-456";
|
||||
const environmentId = "env-789";
|
||||
|
||||
const result = await getContact(contactId);
|
||||
expect(result.userId).toBe(userId);
|
||||
});
|
||||
|
||||
test("should handle missing contact", async () => {
|
||||
const contactId = "contact-123"; // Same ID, defined again
|
||||
await expect(getContact(contactId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use TEST_IDs for consistent, reusable identifiers:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
|
||||
describe("getContact", () => {
|
||||
test("should find contact", async () => {
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
expect(result.userId).toBe(TEST_IDS.user);
|
||||
});
|
||||
|
||||
test("should handle missing contact", async () => {
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Available IDs:**
|
||||
|
||||
```
|
||||
TEST_IDS.contact, contactAlt, user, environment, survey, organization, quota,
|
||||
attribute, response, team, project, segment, webhook, apiKey, membership
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Concept 2: FIXTURES — Use Pre-built Test Data
|
||||
|
||||
### The Problem
|
||||
|
||||
Duplicated mock data across tests:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("ContactService", () => {
|
||||
test("should validate contact email", async () => {
|
||||
const contact = {
|
||||
id: "contact-1",
|
||||
email: "test@example.com",
|
||||
userId: "user-1",
|
||||
environmentId: "env-1",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
};
|
||||
expect(isValidEmail(contact.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("should create contact from data", async () => {
|
||||
const contact = {
|
||||
id: "contact-1",
|
||||
email: "test@example.com",
|
||||
userId: "user-1",
|
||||
environmentId: "env-1",
|
||||
createdAt: new Date("2024-01-01"),
|
||||
};
|
||||
const result = await createContact(contact);
|
||||
expect(result).toEqual(contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use FIXTURES for consistent test data:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { FIXTURES } from "@/lib/testing/constants";
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("should validate contact email", async () => {
|
||||
expect(isValidEmail(FIXTURES.contact.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("should create contact from data", async () => {
|
||||
const result = await createContact(FIXTURES.contact);
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Available fixtures:** contact, survey, attributeKey, environment, organization, project, team, user, response
|
||||
|
||||
---
|
||||
|
||||
## Concept 3: setupTestEnvironment — Standard Cleanup
|
||||
|
||||
### The Problem
|
||||
|
||||
Inconsistent beforeEach/afterEach patterns across tests:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this
|
||||
describe("module A", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
// tests...
|
||||
});
|
||||
|
||||
describe("module B", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
// tests...
|
||||
});
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use setupTestEnvironment() for consistent cleanup:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("module", () => {
|
||||
test("should work", () => {
|
||||
// Cleanup is automatic
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
|
||||
- Clears all mocks before and after each test
|
||||
- Provides consistent test isolation
|
||||
- One line replaces repetitive setup code
|
||||
|
||||
---
|
||||
|
||||
## Concept 4: Mock Factories — Reduce Mock Setup from 40+ Lines to 1
|
||||
|
||||
### The Problem
|
||||
|
||||
Massive repetitive mock setup:
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (40+ lines)
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
Use mock factories:
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (1 line)
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
**Available factories:**
|
||||
|
||||
- `createContactsMocks()` — Contact operations (contact, contactAttribute, contactAttributeKey)
|
||||
- `createQuotasMocks()` — Quota operations
|
||||
- `createSurveysMocks()` — Survey and response operations
|
||||
|
||||
### Error Testing with Mock Factories
|
||||
|
||||
**Use COMMON_ERRORS for standardized error tests:**
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (10+ lines per error)
|
||||
const error = new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(error);
|
||||
|
||||
await expect(getContact("invalid")).rejects.toThrow();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (1 line)
|
||||
import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact("invalid")).rejects.toThrow();
|
||||
```
|
||||
|
||||
**Available errors:**
|
||||
|
||||
```
|
||||
COMMON_ERRORS.UNIQUE_CONSTRAINT // P2002
|
||||
COMMON_ERRORS.RECORD_NOT_FOUND // P2025
|
||||
COMMON_ERRORS.FOREIGN_KEY // P2003
|
||||
COMMON_ERRORS.REQUIRED_RELATION // P2014
|
||||
COMMON_ERRORS.DATABASE_ERROR // P5000
|
||||
```
|
||||
|
||||
### Transaction Testing with Mock Factories
|
||||
|
||||
**Use createMockTransaction() for complex database transactions:**
|
||||
|
||||
```typescript
|
||||
// ❌ Don't do this (25+ lines)
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (cb) => {
|
||||
return cb({
|
||||
responseQuotaLink: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Do this (3 lines)
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany"],
|
||||
});
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Example: Efficient Test Suite
|
||||
|
||||
Here's how the utilities work together to write clean, efficient tests:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
|
||||
describe("ContactService", () => {
|
||||
describe("getContact", () => {
|
||||
test("should fetch contact successfully", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.contact },
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContact", () => {
|
||||
test("should create contact with valid data", async () => {
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue(FIXTURES.contact);
|
||||
|
||||
const result = await createContact({
|
||||
email: FIXTURES.contact.email,
|
||||
environmentId: TEST_IDS.environment,
|
||||
});
|
||||
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
});
|
||||
|
||||
test("should reject duplicate email", async () => {
|
||||
vi.mocked(prisma.contact.create).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
|
||||
await expect(
|
||||
createContact({ email: "duplicate@test.com", environmentId: TEST_IDS.environment })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteContact", () => {
|
||||
test("should delete contact and return void", async () => {
|
||||
vi.mocked(prisma.contact.delete).mockResolvedValue(undefined);
|
||||
|
||||
await deleteContact(TEST_IDS.contact);
|
||||
|
||||
expect(prisma.contact.delete).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.contact },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use — Import Options
|
||||
|
||||
### Option 1: From vitestSetup (Recommended)
|
||||
|
||||
```typescript
|
||||
import { COMMON_ERRORS, FIXTURES, TEST_IDS, createContactsMocks, setupTestEnvironment } from "@/vitestSetup";
|
||||
```
|
||||
|
||||
### Option 2: Direct Imports
|
||||
|
||||
```typescript
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/web/lib/testing/
|
||||
├── constants.ts — TEST_IDS & FIXTURES
|
||||
├── setup.ts — setupTestEnvironment()
|
||||
└── mocks/ — Mock factories & error utilities
|
||||
├── database.ts — createContactsMocks(), etc.
|
||||
├── errors.ts — COMMON_ERRORS, error factories
|
||||
├── transactions.ts — Transaction helpers
|
||||
└── index.ts — Exports everything
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary: What Each Concept Solves
|
||||
|
||||
| Concept | Problem | Solution |
|
||||
| -------------------------- | ---------------------------------------- | --------------------------- |
|
||||
| **TEST_IDs** | Magic strings scattered everywhere | One constant per concept |
|
||||
| **FIXTURES** | Duplicate test data in every test | Pre-built, reusable objects |
|
||||
| **setupTestEnvironment()** | Inconsistent cleanup patterns | One standard setup |
|
||||
| **Mock Factories** | 20-40 lines of boilerplate per test file | 1 line mock setup |
|
||||
|
||||
---
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### ✅ Do's
|
||||
|
||||
- Use `TEST_IDS.*` instead of hardcoded strings
|
||||
- Use `FIXTURES.*` for standard test objects
|
||||
- Call `setupTestEnvironment()` at the top of your test file
|
||||
- Use `createContactsMocks()` instead of manually mocking prisma
|
||||
- Use `COMMON_ERRORS.*` for standard error scenarios
|
||||
- Import utilities from `@/vitestSetup` for convenience
|
||||
|
||||
### ❌ Don'ts
|
||||
|
||||
- Don't create magic string IDs in tests
|
||||
- Don't duplicate fixture objects across tests
|
||||
- Don't manually write beforeEach/afterEach cleanup
|
||||
- Don't manually construct Prisma error objects
|
||||
- Don't duplicate long mock setup code
|
||||
- Don't create custom mock structures when factories exist
|
||||
|
||||
---
|
||||
|
||||
## Need More Help?
|
||||
|
||||
- **Mock Factories** → See `mocks/database.ts`, `mocks/errors.ts`, `mocks/transactions.ts`
|
||||
- **All Available Fixtures** → See `constants.ts`
|
||||
- **Error Codes** → See `mocks/errors.ts` for all COMMON_ERRORS
|
||||
- **Mock Setup Pattern** → Review `apps/web/modules/ee/contacts/lib/contacts.test.ts` for a complete example
|
||||
@@ -1,126 +0,0 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Standard test IDs to eliminate magic strings across test files.
|
||||
* Use these constants instead of hardcoded IDs like "contact-1", "env-123", etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { TEST_IDS } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should fetch contact", async () => {
|
||||
* const result = await getContact(TEST_IDS.contact);
|
||||
* expect(result).toBeDefined();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const TEST_IDS = {
|
||||
contact: "contact-123",
|
||||
contactAlt: "contact-456",
|
||||
user: "user-123",
|
||||
environment: "env-123",
|
||||
survey: "survey-123",
|
||||
organization: "org-123",
|
||||
quota: "quota-123",
|
||||
attribute: "attr-123",
|
||||
response: "response-123",
|
||||
team: "team-123",
|
||||
project: "project-123",
|
||||
segment: "segment-123",
|
||||
webhook: "webhook-123",
|
||||
apiKey: "api-key-123",
|
||||
membership: "membership-123",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common test fixtures to reduce duplicate test data definitions.
|
||||
* Extend these as needed for your specific test cases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FIXTURES } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should create contact", async () => {
|
||||
* vi.mocked(getContactAttributeKeys).mockResolvedValue(FIXTURES.attributeKeys);
|
||||
* const result = await createContact(FIXTURES.contact);
|
||||
* expect(result.email).toBe(FIXTURES.contact.email);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const FIXTURES = {
|
||||
contact: {
|
||||
id: TEST_IDS.contact,
|
||||
environmentId: TEST_IDS.environment,
|
||||
userId: TEST_IDS.user,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
attributes: [
|
||||
{ value: "test@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: TEST_IDS.user, attributeKey: { key: "userId", name: "User ID" } },
|
||||
],
|
||||
},
|
||||
|
||||
survey: {
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
environmentId: TEST_IDS.environment,
|
||||
},
|
||||
|
||||
attributeKey: {
|
||||
id: TEST_IDS.attribute,
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default" as const,
|
||||
},
|
||||
|
||||
attributeKeys: [
|
||||
{
|
||||
id: "key-1",
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
key: "name",
|
||||
name: "Name",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
] as TContactAttributeKey[],
|
||||
|
||||
responseData: {
|
||||
q1: "Open text answer",
|
||||
q2: "Option 1",
|
||||
},
|
||||
|
||||
environment: {
|
||||
id: TEST_IDS.environment,
|
||||
name: "Test Environment",
|
||||
type: "development" as const,
|
||||
},
|
||||
|
||||
organization: {
|
||||
id: TEST_IDS.organization,
|
||||
name: "Test Organization",
|
||||
},
|
||||
|
||||
project: {
|
||||
id: TEST_IDS.project,
|
||||
name: "Test Project",
|
||||
},
|
||||
} as const;
|
||||
@@ -1,299 +0,0 @@
|
||||
# Mock Factories & Error Utilities
|
||||
|
||||
Centralized mock factories and error utilities to eliminate 150+ redundant mock setups and standardize error testing across test files.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Database Mocks
|
||||
|
||||
```typescript
|
||||
import { createContactsMocks, COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Setup contacts mocks (replaces 30+ lines)
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
|
||||
describe("ContactService", () => {
|
||||
test("handles not found error", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getContact("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Mocks
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
### Error Testing
|
||||
|
||||
```typescript
|
||||
import { createPrismaError, COMMON_ERRORS, MockValidationError } from "@/lib/testing/mocks";
|
||||
|
||||
// Use pre-built errors
|
||||
vi.mocked(fn).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
|
||||
// Or create custom errors
|
||||
vi.mocked(fn).mockRejectedValue(createPrismaError("P2002", "Email already exists"));
|
||||
|
||||
// Or use Formbricks domain errors
|
||||
vi.mocked(fn).mockRejectedValue(new MockNotFoundError("Contact"));
|
||||
```
|
||||
|
||||
## Available Utilities
|
||||
|
||||
### Database Mocks
|
||||
|
||||
#### `createContactsMocks()`
|
||||
Complete mock setup for contact operations.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
// ... 10+ more methods
|
||||
},
|
||||
contactAttributeKey: {
|
||||
// ... 6+ methods
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
```
|
||||
|
||||
#### `createQuotasMocks()`
|
||||
Complete mock setup for quota operations with transactions.
|
||||
|
||||
#### `createSurveysMocks()`
|
||||
Complete mock setup for survey and response operations.
|
||||
|
||||
#### Individual Mock Methods
|
||||
If you need more control, use individual mock method factories:
|
||||
- `mockContactMethods()`
|
||||
- `mockContactAttributeMethods()`
|
||||
- `mockContactAttributeKeyMethods()`
|
||||
- `mockResponseQuotaLinkMethods()`
|
||||
- `mockSurveyMethods()`
|
||||
- `mockResponseMethods()`
|
||||
|
||||
### Error Utilities
|
||||
|
||||
#### `createPrismaError(code, message?)`
|
||||
Factory to create Prisma errors with specific codes.
|
||||
|
||||
```typescript
|
||||
import { createPrismaError } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.contact.create).mockRejectedValue(
|
||||
createPrismaError("P2002", "Email already exists")
|
||||
);
|
||||
```
|
||||
|
||||
**Common Prisma Error Codes:**
|
||||
- `P2002` - Unique constraint violation
|
||||
- `P2025` - Record not found
|
||||
- `P2003` - Foreign key constraint
|
||||
- `P2014` - Required relation violation
|
||||
|
||||
#### `COMMON_ERRORS`
|
||||
Pre-built common error instances for convenience.
|
||||
|
||||
```typescript
|
||||
import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
|
||||
// Available:
|
||||
// COMMON_ERRORS.UNIQUE_CONSTRAINT
|
||||
// COMMON_ERRORS.RECORD_NOT_FOUND
|
||||
// COMMON_ERRORS.FOREIGN_KEY
|
||||
// COMMON_ERRORS.REQUIRED_RELATION
|
||||
// COMMON_ERRORS.DATABASE_ERROR
|
||||
```
|
||||
|
||||
#### Domain Error Classes
|
||||
Mock implementations of Formbricks domain errors:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MockValidationError,
|
||||
MockDatabaseError,
|
||||
MockNotFoundError,
|
||||
MockAuthorizationError,
|
||||
} from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(validateInputs).mockRejectedValue(new MockValidationError("Invalid email"));
|
||||
vi.mocked(getContact).mockRejectedValue(new MockNotFoundError("Contact"));
|
||||
vi.mocked(updateContact).mockRejectedValue(new MockAuthorizationError());
|
||||
```
|
||||
|
||||
### Transaction Mocks
|
||||
|
||||
#### `createMockTransaction(structure)`
|
||||
Dynamically create transaction mock objects.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany"],
|
||||
contact: ["findMany", "create"],
|
||||
response: ["count"],
|
||||
});
|
||||
|
||||
// Now you have:
|
||||
// mockTx.responseQuotaLink.deleteMany, mockTx.responseQuotaLink.createMany, etc.
|
||||
// mockTx.contact.findMany, mockTx.contact.create, etc.
|
||||
// mockTx.response.count, etc.
|
||||
```
|
||||
|
||||
#### `mockPrismaTransaction(mockTx)`
|
||||
Wrap transaction mock for use with `prisma.$transaction`.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
const mockTx = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany"],
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
```
|
||||
|
||||
#### Pre-configured Mocks
|
||||
Ready-to-use transaction mocks:
|
||||
- `quotaTransactionMock` - For quota operations
|
||||
- `contactTransactionMock` - For contact operations
|
||||
- `responseTransactionMock` - For response operations
|
||||
|
||||
```typescript
|
||||
import { quotaTransactionMock, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
|
||||
vi.mocked(prisma.$transaction) = mockPrismaTransaction(quotaTransactionMock);
|
||||
```
|
||||
|
||||
#### `sequenceTransactionMocks(txMocks[])`
|
||||
Handle multiple sequential transaction calls with different structures.
|
||||
|
||||
```typescript
|
||||
import { createMockTransaction, sequenceTransactionMocks } from "@/lib/testing/mocks";
|
||||
|
||||
const tx1 = createMockTransaction({ contact: ["findMany"] });
|
||||
const tx2 = createMockTransaction({ response: ["count"] });
|
||||
|
||||
vi.mocked(prisma.$transaction) = sequenceTransactionMocks([tx1, tx2]);
|
||||
|
||||
// First $transaction call gets tx1, second call gets tx2
|
||||
```
|
||||
|
||||
## Impact Summary
|
||||
|
||||
- **Duplicate Mock Setups:** 150+ reduced to 1 line
|
||||
- **Error Testing:** 100+ test cases standardized
|
||||
- **Transaction Mocks:** 15+ complex setups simplified
|
||||
- **Test Readability:** 40-50% cleaner test code
|
||||
- **Setup Time:** 90% reduction for database tests
|
||||
|
||||
## Migration Example
|
||||
|
||||
### Before (40+ lines)
|
||||
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
responseQuotaLink: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("QuotaService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("handles quota not found", async () => {
|
||||
const error = new Prisma.PrismaClientKnownRequestError("Not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.responseQuotaLink.count).mockRejectedValue(error);
|
||||
|
||||
await expect(getQuota("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### After (20 lines)
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { createQuotasMocks, COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
import { vi } from "vitest";
|
||||
|
||||
setupTestEnvironment();
|
||||
vi.mock("@formbricks/database", () => createQuotasMocks());
|
||||
|
||||
describe("QuotaService", () => {
|
||||
test("handles quota not found", async () => {
|
||||
vi.mocked(prisma.responseQuotaLink.count).mockRejectedValue(COMMON_ERRORS.RECORD_NOT_FOUND);
|
||||
|
||||
await expect(getQuota("id")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ 50% reduction in mock setup code
|
||||
✅ Standardized error testing across files
|
||||
✅ Easier test maintenance
|
||||
✅ Better test readability
|
||||
✅ Consistent patterns across the codebase
|
||||
✅ Less boilerplate per test file
|
||||
|
||||
## What's Next?
|
||||
|
||||
Phase 3 will introduce:
|
||||
- Custom Vitest matchers for consistent assertions
|
||||
- Comprehensive testing standards documentation
|
||||
- Team training materials
|
||||
|
||||
See the main testing analysis documents in the repository root for the full roadmap.
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock methods for contact operations.
|
||||
* Used to mock prisma.contact in database operations.
|
||||
*/
|
||||
export const mockContactMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for contact attribute operations.
|
||||
* Used to mock prisma.contactAttribute in database operations.
|
||||
*/
|
||||
export const mockContactAttributeMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for contact attribute key operations.
|
||||
* Used to mock prisma.contactAttributeKey in database operations.
|
||||
*/
|
||||
export const mockContactAttributeKeyMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for response quota link operations.
|
||||
* Used to mock prisma.responseQuotaLink in database operations.
|
||||
*/
|
||||
export const mockResponseQuotaLinkMethods = () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete mock setup for contacts module.
|
||||
* Reduces 20-30 lines of mock setup per test file to 1 line.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
* import { vi } from "vitest";
|
||||
*
|
||||
* vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
* ```
|
||||
*/
|
||||
export function createContactsMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
contact: mockContactMethods(),
|
||||
contactAttribute: mockContactAttributeMethods(),
|
||||
contactAttributeKey: mockContactAttributeKeyMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete mock setup for quotas module.
|
||||
* Reduces 30-40 lines of mock setup per test file to 1 line.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createQuotasMocks } from "@/lib/testing/mocks";
|
||||
* import { vi } from "vitest";
|
||||
*
|
||||
* vi.mock("@formbricks/database", () => createQuotasMocks());
|
||||
* ```
|
||||
*/
|
||||
export function createQuotasMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
responseQuotaLink: mockResponseQuotaLinkMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock methods for survey operations.
|
||||
*/
|
||||
export const mockSurveyMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock methods for response operations.
|
||||
*/
|
||||
export const mockResponseMethods = () => ({
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete mock setup for surveys module.
|
||||
*/
|
||||
export function createSurveysMocks() {
|
||||
return {
|
||||
prisma: {
|
||||
survey: mockSurveyMethods(),
|
||||
response: mockResponseMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Factory function to create Prisma errors with a specific error code and message.
|
||||
* Eliminates 100+ lines of repetitive Prisma error setup across test files.
|
||||
*
|
||||
* @param code - The Prisma error code (e.g., "P2002", "P2025")
|
||||
* @param message - Optional error message (defaults to "Database error")
|
||||
* @returns A PrismaClientKnownRequestError instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createPrismaError } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.contact.findMany).mockRejectedValue(
|
||||
* createPrismaError("P2002", "Unique constraint failed")
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function createPrismaError(code: string, message = "Database error") {
|
||||
return new Prisma.PrismaClientKnownRequestError(message, {
|
||||
code,
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-built common Prisma errors for convenience.
|
||||
* Use these instead of creating errors manually every time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.contact.findUnique).mockRejectedValue(
|
||||
* COMMON_ERRORS.RECORD_NOT_FOUND
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const COMMON_ERRORS = {
|
||||
// P2002: Unique constraint failed
|
||||
UNIQUE_CONSTRAINT: createPrismaError("P2002", "Unique constraint violation"),
|
||||
|
||||
// P2025: Record not found
|
||||
RECORD_NOT_FOUND: createPrismaError("P2025", "Record not found"),
|
||||
|
||||
// P2003: Foreign key constraint failed
|
||||
FOREIGN_KEY: createPrismaError("P2003", "Foreign key constraint failed"),
|
||||
|
||||
// P2014: Required relation violation
|
||||
REQUIRED_RELATION: createPrismaError("P2014", "Required relation violation"),
|
||||
|
||||
// Generic database error
|
||||
DATABASE_ERROR: createPrismaError("P5000", "Database connection error"),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Validation error mock for non-database validation failures.
|
||||
* Use this for validation errors in service layers.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ValidationError } from "@formbricks/types/errors";
|
||||
*
|
||||
* vi.mocked(validateInputs).mockImplementation(() => {
|
||||
* throw new ValidationError("Invalid input");
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class MockValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error types that match Formbricks domain errors.
|
||||
*/
|
||||
export class MockDatabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MockNotFoundError extends Error {
|
||||
constructor(entity: string) {
|
||||
super(`${entity} not found`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class MockAuthorizationError extends Error {
|
||||
constructor(message = "Unauthorized") {
|
||||
super(message);
|
||||
this.name = "AuthorizationError";
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Centralized mock exports for all testing utilities.
|
||||
*
|
||||
* Import only what you need:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createContactsMocks } from "@/lib/testing/mocks";
|
||||
* import { COMMON_ERRORS, createPrismaError } from "@/lib/testing/mocks";
|
||||
* import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
* ```
|
||||
*
|
||||
* Or import everything:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import * as mocks from "@/lib/testing/mocks";
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
createContactsMocks,
|
||||
createQuotasMocks,
|
||||
createSurveysMocks,
|
||||
mockContactMethods,
|
||||
mockContactAttributeMethods,
|
||||
mockContactAttributeKeyMethods,
|
||||
mockResponseQuotaLinkMethods,
|
||||
mockSurveyMethods,
|
||||
mockResponseMethods,
|
||||
} from "./database";
|
||||
|
||||
export {
|
||||
createPrismaError,
|
||||
COMMON_ERRORS,
|
||||
MockValidationError,
|
||||
MockDatabaseError,
|
||||
MockNotFoundError,
|
||||
MockAuthorizationError,
|
||||
} from "./errors";
|
||||
|
||||
export {
|
||||
createMockTransaction,
|
||||
mockPrismaTransaction,
|
||||
quotaTransactionMock,
|
||||
contactTransactionMock,
|
||||
responseTransactionMock,
|
||||
sequenceTransactionMocks,
|
||||
} from "./transactions";
|
||||
@@ -1,123 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Factory to dynamically create mock transaction objects with specified methods.
|
||||
* Eliminates complex, repetitive transaction mock setup across test files.
|
||||
*
|
||||
* @param structure - Object mapping namespaces to arrays of method names
|
||||
* @returns Mock transaction object with all specified methods as vi.fn()
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const mockTx = createMockTransaction({
|
||||
* responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
* contact: ["findMany", "create"],
|
||||
* });
|
||||
*
|
||||
* // Now you have:
|
||||
* // mockTx.responseQuotaLink.deleteMany, mockTx.responseQuotaLink.createMany, etc.
|
||||
* // mockTx.contact.findMany, mockTx.contact.create, etc.
|
||||
* ```
|
||||
*/
|
||||
export function createMockTransaction(structure: Record<string, string[]>) {
|
||||
return Object.entries(structure).reduce(
|
||||
(acc, [namespace, methods]) => {
|
||||
acc[namespace] = methods.reduce(
|
||||
(methodAcc, method) => {
|
||||
methodAcc[method] = vi.fn();
|
||||
return methodAcc;
|
||||
},
|
||||
{} as Record<string, ReturnType<typeof vi.fn>>
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, ReturnType<typeof vi.fn>>>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Prisma $transaction wrapper.
|
||||
* Passes the transaction object to the callback function.
|
||||
*
|
||||
* @param mockTx - The mock transaction object
|
||||
* @returns A vi.fn() that mocks prisma.$transaction
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction, mockPrismaTransaction } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const mockTx = createMockTransaction({
|
||||
* responseQuotaLink: ["deleteMany", "createMany"],
|
||||
* });
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = mockPrismaTransaction(mockTx);
|
||||
*
|
||||
* // Now when code calls prisma.$transaction(async (tx) => { ... })
|
||||
* // the tx parameter will be mockTx
|
||||
* ```
|
||||
*/
|
||||
export function mockPrismaTransaction(mockTx: any) {
|
||||
return vi.fn(async (cb: any) => cb(mockTx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for quota operations.
|
||||
* Use this when testing quota-related database transactions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { quotaTransactionMock } from "@/lib/testing/mocks";
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = quotaTransactionMock;
|
||||
* ```
|
||||
*/
|
||||
export const quotaTransactionMock = createMockTransaction({
|
||||
responseQuotaLink: ["deleteMany", "createMany", "updateMany", "count", "groupBy"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for contact operations.
|
||||
*/
|
||||
export const contactTransactionMock = createMockTransaction({
|
||||
contact: ["findMany", "create", "update", "delete"],
|
||||
contactAttribute: ["findMany", "create", "update", "deleteMany"],
|
||||
contactAttributeKey: ["findMany", "create"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Pre-configured transaction mock for response operations.
|
||||
*/
|
||||
export const responseTransactionMock = createMockTransaction({
|
||||
response: ["findMany", "create", "update", "delete", "count"],
|
||||
responseQuotaLink: ["create", "deleteMany", "updateMany"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility to configure multiple transaction return values in sequence.
|
||||
* Useful when code makes multiple calls to $transaction with different structures.
|
||||
*
|
||||
* @param txMocks - Array of transaction mock objects
|
||||
* @returns A vi.fn() that returns each mock in sequence
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockTransaction, sequenceTransactionMocks } from "@/lib/testing/mocks";
|
||||
*
|
||||
* const tx1 = createMockTransaction({ contact: ["findMany"] });
|
||||
* const tx2 = createMockTransaction({ response: ["count"] });
|
||||
*
|
||||
* vi.mocked(prisma.$transaction) = sequenceTransactionMocks([tx1, tx2]);
|
||||
*
|
||||
* // First call gets tx1, second call gets tx2
|
||||
* ```
|
||||
*/
|
||||
export function sequenceTransactionMocks(txMocks: any[]) {
|
||||
let callCount = 0;
|
||||
return vi.fn(async (cb: any) => {
|
||||
const currentMock = txMocks[callCount];
|
||||
callCount++;
|
||||
return cb(currentMock);
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Standard test environment setup with consistent cleanup patterns.
|
||||
* Call this function once at the top of your test file to ensure
|
||||
* mocks are properly cleaned up between tests.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
*
|
||||
* setupTestEnvironment();
|
||||
*
|
||||
* describe("MyModule", () => {
|
||||
* test("should work correctly", () => {
|
||||
* // Your test code here
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Note: This replaces manual beforeEach/afterEach blocks in individual test files.
|
||||
*/
|
||||
export function setupTestEnvironment() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
@@ -1667,14 +1667,34 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "An error occured while deleting survey",
|
||||
"export_survey": "Export survey",
|
||||
"filter": {
|
||||
"complete_and_partial_responses": "Complete and partial responses",
|
||||
"complete_responses": "Complete responses",
|
||||
"partial_responses": "Partial responses"
|
||||
},
|
||||
"import_error_invalid_json": "Invalid JSON file",
|
||||
"import_error_validation": "Survey validation failed",
|
||||
"import_survey": "Import Survey",
|
||||
"import_survey_description": "Import a survey from a JSON file",
|
||||
"import_survey_error": "Failed to import survey",
|
||||
"import_survey_errors": "Errors",
|
||||
"import_survey_file_label": "Select JSON file",
|
||||
"import_survey_import": "Import Survey",
|
||||
"import_survey_name_label": "Survey Name",
|
||||
"import_survey_new_id": "New Survey ID",
|
||||
"import_survey_success": "Survey imported successfully",
|
||||
"import_survey_validate": "Validate Survey",
|
||||
"import_survey_warnings": "Warnings",
|
||||
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
|
||||
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
|
||||
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
|
||||
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
|
||||
"import_warning_segments": "Segment targeting will be removed. Configure targeting after import.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
"or_drag_and_drop_json": "or drag and drop a JSON file here",
|
||||
"preview_survey_in_a_new_tab": "Preview survey in a new tab",
|
||||
"read_only_user_not_allowed_to_create_survey_warning": "As a Read-Only user you are not allowed to create surveys. Please ask a user with write access to create a survey or a manager to upgrade your role.",
|
||||
"relevance": "Relevance",
|
||||
@@ -1918,6 +1938,8 @@
|
||||
"survey_deleted_successfully": "Survey deleted successfully!",
|
||||
"survey_duplicated_successfully": "Survey duplicated successfully.",
|
||||
"survey_duplication_error": "Failed to duplicate the survey.",
|
||||
"survey_export_error": "Failed to export survey",
|
||||
"survey_exported_successfully": "Survey exported successfully",
|
||||
"templates": {
|
||||
"all_channels": "All channels",
|
||||
"all_industries": "All industries",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { Contact, Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
// NOW import modules that depend on mocks
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
// Import utilities that DON'T need to be mocked FIRST
|
||||
import { COMMON_ERRORS, createContactsMocks } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
buildContactWhereClause,
|
||||
createContactsFromCSV,
|
||||
@@ -17,9 +12,7 @@ import {
|
||||
getContactsInSegment,
|
||||
} from "./contacts";
|
||||
|
||||
// Setup ALL mocks BEFORE any other imports
|
||||
vi.mock("@formbricks/database", () => createContactsMocks());
|
||||
|
||||
// Mock additional dependencies for the new functions
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
@@ -38,10 +31,27 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
contactAttributeKey: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ITEMS_PER_PAGE: 2,
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
|
||||
@@ -51,86 +61,124 @@ vi.mock("@/lib/constants", () => ({
|
||||
POSTHOG_API_KEY: "test-posthog-key",
|
||||
}));
|
||||
|
||||
// Setup standard test environment
|
||||
setupTestEnvironment();
|
||||
|
||||
// Mock validateInputs to return no errors by default
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
return [];
|
||||
});
|
||||
const environmentId = "cm123456789012345678901237";
|
||||
const contactId = "cm123456789012345678901238";
|
||||
const userId = "cm123456789012345678901239";
|
||||
const mockContact: Contact & {
|
||||
attributes: { value: string; attributeKey: { key: string; name: string } }[];
|
||||
} = {
|
||||
id: contactId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId,
|
||||
userId,
|
||||
attributes: [
|
||||
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
|
||||
{ value: "John", attributeKey: { key: "name", name: "Name" } },
|
||||
{ value: userId, attributeKey: { key: "userId", name: "User ID" } },
|
||||
],
|
||||
};
|
||||
|
||||
describe("getContacts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns contacts with attributes", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([FIXTURES.contact]);
|
||||
const result = await getContacts(TEST_IDS.environment, 0, "");
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([mockContact]);
|
||||
const result = await getContacts(environmentId, 0, "");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result[0].id).toBe(TEST_IDS.contact);
|
||||
expect(result[0].attributes.email).toBe("test@example.com");
|
||||
expect(result[0].id).toBe(contactId);
|
||||
expect(result[0].attributes.email).toBe("john@example.com");
|
||||
});
|
||||
|
||||
test("returns empty array if no contacts", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
const result = await getContacts(TEST_IDS.environment, 0, "");
|
||||
const result = await getContacts(environmentId, 0, "");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
await expect(getContacts(TEST_IDS.environment, 0, "")).rejects.toThrow(DatabaseError);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(prismaError);
|
||||
await expect(getContacts(environmentId, 0, "")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws original error on unknown error", async () => {
|
||||
const genericError = new Error("Unknown error");
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(genericError);
|
||||
await expect(getContacts(TEST_IDS.environment, 0, "")).rejects.toThrow(genericError);
|
||||
await expect(getContacts(environmentId, 0, "")).rejects.toThrow(genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns contact if found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(FIXTURES.contact);
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
|
||||
const result = await getContact(contactId);
|
||||
expect(result).toEqual(mockContact);
|
||||
});
|
||||
|
||||
test("returns null if not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
const result = await getContact(contactId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow(DatabaseError);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError);
|
||||
await expect(getContact(contactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws original error on unknown error", async () => {
|
||||
const genericError = new Error("Unknown error");
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(genericError);
|
||||
await expect(getContact(TEST_IDS.contact)).rejects.toThrow(genericError);
|
||||
await expect(getContact(contactId)).rejects.toThrow(genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteContact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("deletes contact and revalidates caches", async () => {
|
||||
vi.mocked(prisma.contact.delete).mockResolvedValue(FIXTURES.contact);
|
||||
const result = await deleteContact(TEST_IDS.contact);
|
||||
expect(result).toEqual(FIXTURES.contact);
|
||||
vi.mocked(prisma.contact.delete).mockResolvedValue(mockContact);
|
||||
const result = await deleteContact(contactId);
|
||||
expect(result).toEqual(mockContact);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.contact.delete).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
await expect(deleteContact(TEST_IDS.contact)).rejects.toThrow(DatabaseError);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.delete).mockRejectedValue(prismaError);
|
||||
await expect(deleteContact(contactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws original error on unknown error", async () => {
|
||||
const genericError = new Error("Unknown error");
|
||||
vi.mocked(prisma.contact.delete).mockRejectedValue(genericError);
|
||||
await expect(deleteContact(TEST_IDS.contact)).rejects.toThrow(genericError);
|
||||
await expect(deleteContact(contactId)).rejects.toThrow(genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContactsFromCSV", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("creates new contacts and missing attribute keys", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
@@ -143,7 +191,7 @@ describe("createContactsFromCSV", () => {
|
||||
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [
|
||||
@@ -152,7 +200,7 @@ describe("createContactsFromCSV", () => {
|
||||
],
|
||||
} as any);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, TEST_IDS.environment, "skip", {
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
@@ -170,7 +218,7 @@ describe("createContactsFromCSV", () => {
|
||||
{ key: "name", id: "id-name" },
|
||||
] as any);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, TEST_IDS.environment, "skip", {
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
@@ -194,7 +242,7 @@ describe("createContactsFromCSV", () => {
|
||||
] as any);
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [
|
||||
@@ -203,7 +251,7 @@ describe("createContactsFromCSV", () => {
|
||||
],
|
||||
} as any);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, TEST_IDS.environment, "update", {
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "update", {
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
@@ -228,7 +276,7 @@ describe("createContactsFromCSV", () => {
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
attributes: [
|
||||
@@ -237,7 +285,7 @@ describe("createContactsFromCSV", () => {
|
||||
],
|
||||
} as any);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, TEST_IDS.environment, "overwrite", {
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "overwrite", {
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
@@ -245,21 +293,21 @@ describe("createContactsFromCSV", () => {
|
||||
});
|
||||
|
||||
test("throws ValidationError if email is missing in CSV", async () => {
|
||||
// Override the validateInputs mock to return validation errors for this test
|
||||
vi.mocked(validateInputs).mockImplementationOnce(() => {
|
||||
throw new ValidationError("Validation failed");
|
||||
});
|
||||
const csvData = [{ name: "John" }];
|
||||
await expect(
|
||||
createContactsFromCSV(csvData as any, TEST_IDS.environment, "skip", { name: "name" })
|
||||
createContactsFromCSV(csvData as any, environmentId, "skip", { name: "name" })
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(prismaError);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
await expect(
|
||||
createContactsFromCSV(csvData, TEST_IDS.environment, "skip", { email: "email", name: "name" })
|
||||
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -268,17 +316,22 @@ describe("createContactsFromCSV", () => {
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(genericError);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
await expect(
|
||||
createContactsFromCSV(csvData, TEST_IDS.environment, "skip", { email: "email", name: "name" })
|
||||
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
|
||||
).rejects.toThrow(genericError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildContactWhereClause", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns where clause for email", () => {
|
||||
const environmentId = "env-1";
|
||||
const search = "john";
|
||||
const result = buildContactWhereClause(TEST_IDS.environment, search);
|
||||
const result = buildContactWhereClause(environmentId, search);
|
||||
expect(result).toEqual({
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId,
|
||||
OR: [
|
||||
{
|
||||
attributes: {
|
||||
@@ -301,18 +354,26 @@ describe("buildContactWhereClause", () => {
|
||||
});
|
||||
|
||||
test("returns where clause without search", () => {
|
||||
const result = buildContactWhereClause(TEST_IDS.environment);
|
||||
expect(result).toEqual({ environmentId: TEST_IDS.environment });
|
||||
const environmentId = "cm123456789012345678901240";
|
||||
const result = buildContactWhereClause(environmentId);
|
||||
expect(result).toEqual({ environmentId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactsInSegment", () => {
|
||||
const mockSegmentId = "cm123456789012345678901235";
|
||||
const mockEnvironmentId = "cm123456789012345678901236";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns contacts when segment and filters are valid", async () => {
|
||||
const mockSegment = {
|
||||
id: TEST_IDS.segment,
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
@@ -338,7 +399,7 @@ describe("getContactsInSegment", () => {
|
||||
] as any;
|
||||
|
||||
const mockWhereClause = {
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "email" },
|
||||
@@ -362,7 +423,7 @@ describe("getContactsInSegment", () => {
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
|
||||
|
||||
const result = await getContactsInSegment(TEST_IDS.segment);
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -414,17 +475,17 @@ describe("getContactsInSegment", () => {
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
|
||||
|
||||
const result = await getContactsInSegment(TEST_IDS.segment);
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when segment filter to prisma query fails", async () => {
|
||||
const mockSegment = {
|
||||
id: TEST_IDS.segment,
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
@@ -444,17 +505,17 @@ describe("getContactsInSegment", () => {
|
||||
error: { type: "bad_request" },
|
||||
} as any);
|
||||
|
||||
const result = await getContactsInSegment(TEST_IDS.segment);
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when prisma query fails", async () => {
|
||||
const mockSegment = {
|
||||
id: TEST_IDS.segment,
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: mockEnvironmentId,
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
@@ -476,7 +537,7 @@ describe("getContactsInSegment", () => {
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await getContactsInSegment(TEST_IDS.segment);
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -486,20 +547,28 @@ describe("getContactsInSegment", () => {
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const result = await getContactsInSegment(TEST_IDS.segment);
|
||||
const result = await getContactsInSegment(mockSegmentId);
|
||||
|
||||
expect(result).toBeNull(); // The function catches errors and returns null
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePersonalLinks", () => {
|
||||
const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
|
||||
const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
|
||||
const mockExpirationDays = 7;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns null when getContactsInSegment fails", async () => {
|
||||
// Mock getSegment to fail which will cause getContactsInSegment to return null
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
|
||||
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
|
||||
|
||||
const result = await generatePersonalLinks(TEST_IDS.survey, TEST_IDS.segment);
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -512,10 +581,10 @@ describe("generatePersonalLinks", () => {
|
||||
);
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
id: TEST_IDS.segment,
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env-123",
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
@@ -530,13 +599,12 @@ describe("generatePersonalLinks", () => {
|
||||
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await generatePersonalLinks(TEST_IDS.survey, TEST_IDS.segment);
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates personal links for contacts successfully", async () => {
|
||||
const expirationDays = 7;
|
||||
// Mock all the dependencies that getContactsInSegment needs
|
||||
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
|
||||
const { segmentFilterToPrismaQuery } = await import(
|
||||
@@ -545,10 +613,10 @@ describe("generatePersonalLinks", () => {
|
||||
const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
|
||||
|
||||
vi.mocked(getSegment).mockResolvedValue({
|
||||
id: TEST_IDS.segment,
|
||||
id: mockSegmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: TEST_IDS.environment,
|
||||
environmentId: "env-123",
|
||||
description: "Test segment",
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
@@ -589,7 +657,7 @@ describe("generatePersonalLinks", () => {
|
||||
data: "https://example.com/survey/link2",
|
||||
});
|
||||
|
||||
const result = await generatePersonalLinks(TEST_IDS.survey, TEST_IDS.segment, expirationDays);
|
||||
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -599,7 +667,7 @@ describe("generatePersonalLinks", () => {
|
||||
name: "Test User",
|
||||
},
|
||||
surveyUrl: "https://example.com/survey/link1",
|
||||
expirationDays,
|
||||
expirationDays: mockExpirationDays,
|
||||
},
|
||||
{
|
||||
contactId: "contact-2",
|
||||
@@ -608,11 +676,11 @@ describe("generatePersonalLinks", () => {
|
||||
name: "Another User",
|
||||
},
|
||||
surveyUrl: "https://example.com/survey/link2",
|
||||
expirationDays,
|
||||
expirationDays: mockExpirationDays,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", TEST_IDS.survey, expirationDays);
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", TEST_IDS.survey, expirationDays);
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
|
||||
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
// mocked via vi.mock()
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
@@ -9,14 +8,9 @@ import {
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TSurveyQuota, TSurveyQuotaInput } from "@formbricks/types/quota";
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
import { COMMON_ERRORS } from "@/lib/testing/mocks";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { createQuota, deleteQuota, getQuota, getQuotas, reduceQuotaLimits, updateQuota } from "./quotas";
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -36,11 +30,14 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
}));
|
||||
|
||||
describe("Quota Service", () => {
|
||||
const mockSurveyId = "survey123";
|
||||
const mockQuotaId = "quota123";
|
||||
|
||||
const mockQuota: TSurveyQuota = {
|
||||
id: TEST_IDS.quota,
|
||||
id: mockQuotaId,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
surveyId: TEST_IDS.survey,
|
||||
surveyId: mockSurveyId,
|
||||
name: "Test Quota",
|
||||
limit: 100,
|
||||
logic: {
|
||||
@@ -52,33 +49,42 @@ describe("Quota Service", () => {
|
||||
countPartialSubmissions: false,
|
||||
};
|
||||
|
||||
// Setup validateInputs mock in beforeEach (via setupTestEnvironment)
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
return [];
|
||||
beforeEach(() => {
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getQuota", () => {
|
||||
test("should return quota successfully", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findUnique).mockResolvedValue(mockQuota);
|
||||
const result = await getQuota(TEST_IDS.quota);
|
||||
const result = await getQuota(mockQuotaId);
|
||||
expect(result).toEqual(mockQuota);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if quota not found", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findUnique).mockResolvedValue(null);
|
||||
await expect(getQuota(TEST_IDS.quota)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(getQuota(mockQuotaId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findUnique).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
await expect(getQuota(TEST_IDS.quota)).rejects.toThrow(DatabaseError);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.findUnique).mockRejectedValue(prismaError);
|
||||
await expect(getQuota(mockQuotaId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ValidationError when validateInputs fails", async () => {
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw new ValidationError("Invalid input");
|
||||
});
|
||||
await expect(getQuota(TEST_IDS.quota)).rejects.toThrow(ValidationError);
|
||||
await expect(getQuota(mockQuotaId)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,20 +93,24 @@ describe("Quota Service", () => {
|
||||
const mockQuotas = [mockQuota];
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue(mockQuotas);
|
||||
|
||||
const result = await getQuotas(TEST_IDS.survey);
|
||||
const result = await getQuotas(mockSurveyId);
|
||||
|
||||
expect(result).toEqual(mockQuotas);
|
||||
expect(validateInputs).toHaveBeenCalledWith([TEST_IDS.survey, expect.any(Object)]);
|
||||
expect(validateInputs).toHaveBeenCalledWith([mockSurveyId, expect.any(Object)]);
|
||||
expect(prisma.surveyQuota.findMany).toHaveBeenCalledWith({
|
||||
where: { surveyId: TEST_IDS.survey },
|
||||
where: { surveyId: mockSurveyId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getQuotas(TEST_IDS.survey)).rejects.toThrow(DatabaseError);
|
||||
await expect(getQuotas(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw ValidationError when validateInputs fails", async () => {
|
||||
@@ -108,20 +118,20 @@ describe("Quota Service", () => {
|
||||
throw new ValidationError("Invalid input");
|
||||
});
|
||||
|
||||
await expect(getQuotas(TEST_IDS.survey)).rejects.toThrow(ValidationError);
|
||||
await expect(getQuotas(mockSurveyId)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("should re-throw non-Prisma errors", async () => {
|
||||
const genericError = new Error("Generic error");
|
||||
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getQuotas(TEST_IDS.survey)).rejects.toThrow("Generic error");
|
||||
await expect(getQuotas(mockSurveyId)).rejects.toThrow("Generic error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQuota", () => {
|
||||
const createInput: TSurveyQuotaInput = {
|
||||
surveyId: TEST_IDS.survey,
|
||||
surveyId: mockSurveyId,
|
||||
name: "New Quota",
|
||||
limit: 50,
|
||||
logic: {
|
||||
@@ -145,7 +155,11 @@ describe("Quota Service", () => {
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.create).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createQuota(createInput)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
@@ -161,7 +175,7 @@ describe("Quota Service", () => {
|
||||
describe("updateQuota", () => {
|
||||
const updateInput: TSurveyQuotaInput = {
|
||||
name: "Updated Quota",
|
||||
surveyId: TEST_IDS.survey,
|
||||
surveyId: mockSurveyId,
|
||||
limit: 75,
|
||||
logic: {
|
||||
connector: "or",
|
||||
@@ -176,35 +190,38 @@ describe("Quota Service", () => {
|
||||
const updatedQuota = { ...mockQuota, ...updateInput };
|
||||
vi.mocked(prisma.surveyQuota.update).mockResolvedValue(updatedQuota);
|
||||
|
||||
const result = await updateQuota(updateInput, TEST_IDS.quota);
|
||||
const result = await updateQuota(updateInput, mockQuotaId);
|
||||
|
||||
expect(result).toEqual(updatedQuota);
|
||||
expect(prisma.surveyQuota.update).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.quota },
|
||||
where: { id: mockQuotaId },
|
||||
data: updateInput,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when quota not found", async () => {
|
||||
// P2015 is the "required relation violation" code that maps to ResourceNotFoundError
|
||||
const notFoundError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2015",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.update).mockRejectedValue(notFoundError);
|
||||
vi.mocked(prisma.surveyQuota.update).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(updateQuota(updateInput, TEST_IDS.quota)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(updateQuota(updateInput, mockQuotaId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.surveyQuota.update).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.update).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(updateQuota(updateInput, TEST_IDS.quota)).rejects.toThrow(InvalidInputError);
|
||||
await expect(updateQuota(updateInput, mockQuotaId)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("should throw error on unknown error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.update).mockRejectedValue(new Error("Unknown error"));
|
||||
await expect(updateQuota(updateInput, TEST_IDS.quota)).rejects.toThrow(Error);
|
||||
await expect(updateQuota(updateInput, mockQuotaId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,57 +229,64 @@ describe("Quota Service", () => {
|
||||
test("should delete quota successfully", async () => {
|
||||
vi.mocked(prisma.surveyQuota.delete).mockResolvedValue(mockQuota);
|
||||
|
||||
const result = await deleteQuota(TEST_IDS.quota);
|
||||
const result = await deleteQuota(mockQuotaId);
|
||||
|
||||
expect(result).toEqual(mockQuota);
|
||||
expect(prisma.surveyQuota.delete).toHaveBeenCalledWith({
|
||||
where: { id: TEST_IDS.quota },
|
||||
where: { id: mockQuotaId },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when quota not found", async () => {
|
||||
// P2015 is the "required relation violation" code that maps to ResourceNotFoundError
|
||||
const notFoundError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2015",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(notFoundError);
|
||||
vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteQuota(TEST_IDS.quota)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(deleteQuota(mockQuotaId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteQuota(TEST_IDS.quota)).rejects.toThrow(DatabaseError);
|
||||
await expect(deleteQuota(mockQuotaId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should re-throw non-Prisma errors", async () => {
|
||||
const genericError = new Error("Generic error");
|
||||
vi.mocked(prisma.surveyQuota.delete).mockRejectedValue(genericError);
|
||||
|
||||
await expect(deleteQuota(TEST_IDS.quota)).rejects.toThrow("Generic error");
|
||||
await expect(deleteQuota(mockQuotaId)).rejects.toThrow("Generic error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceQuotaLimits", () => {
|
||||
test("should reduce quota limits successfully", async () => {
|
||||
vi.mocked(prisma.surveyQuota.updateMany).mockResolvedValue({ count: 1 });
|
||||
await reduceQuotaLimits([TEST_IDS.quota]);
|
||||
await reduceQuotaLimits([mockQuotaId]);
|
||||
expect(prisma.surveyQuota.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [TEST_IDS.quota] }, limit: { gt: 1 } },
|
||||
where: { id: { in: [mockQuotaId] }, limit: { gt: 1 } },
|
||||
data: { limit: { decrement: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.updateMany).mockRejectedValue(COMMON_ERRORS.UNIQUE_CONSTRAINT);
|
||||
await expect(reduceQuotaLimits([TEST_IDS.quota])).rejects.toThrow(DatabaseError);
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.surveyQuota.updateMany).mockRejectedValue(prismaError);
|
||||
await expect(reduceQuotaLimits([mockQuotaId])).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw error on unknown error", async () => {
|
||||
vi.mocked(prisma.surveyQuota.updateMany).mockRejectedValue(new Error("Unknown error"));
|
||||
await expect(reduceQuotaLimits([TEST_IDS.quota])).rejects.toThrow(Error);
|
||||
await expect(reduceQuotaLimits([mockQuotaId])).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
surveyClosedMessage: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
|
||||
// Related data
|
||||
languages: {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurveyCreateInput,
|
||||
ZSurvey,
|
||||
ZSurveyCreateInput,
|
||||
ZSurveyFilterCriteria,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -15,7 +20,17 @@ import {
|
||||
} from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getSurvey as getFullSurvey, getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
||||
import {
|
||||
detectImagesInSurvey,
|
||||
getImportWarnings,
|
||||
stripEnterpriseFeatures,
|
||||
} from "@/modules/survey/list/lib/import-validation";
|
||||
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
||||
import {
|
||||
copySurveyToOtherEnvironment,
|
||||
@@ -263,3 +278,200 @@ export const getSurveysAction = authenticatedActionClient
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
});
|
||||
|
||||
const ZExportSurveyAction = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const exportSurveyAction = authenticatedActionClient
|
||||
.schema(ZExportSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getFullSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
const ZValidateSurveyImportAction = z.object({
|
||||
surveyData: z.record(z.any()),
|
||||
environmentId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const validateSurveyImportAction = authenticatedActionClient
|
||||
.schema(ZValidateSurveyImportAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate with Zod
|
||||
const validationResult = ZSurveyCreateInput.safeParse(parsedInput.surveyData);
|
||||
if (!validationResult.success) {
|
||||
errors.push("import_error_validation");
|
||||
}
|
||||
|
||||
// Check for images
|
||||
const hasImages = detectImagesInSurvey(parsedInput.surveyData);
|
||||
|
||||
// Check permissions
|
||||
let permissions = {
|
||||
hasMultiLanguage: true,
|
||||
hasFollowUps: true,
|
||||
hasRecaptcha: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasMultiLanguage = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const organizationBillingData = await getOrganizationBilling(organizationId);
|
||||
if (organizationBillingData) {
|
||||
const isFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBillingData.plan);
|
||||
if (!isFollowUpsEnabled) {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasRecaptcha = false;
|
||||
}
|
||||
|
||||
// Get warnings
|
||||
const importWarnings = getImportWarnings(parsedInput.surveyData, hasImages, permissions);
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings: importWarnings,
|
||||
surveyName: parsedInput.surveyData?.name || "Imported Survey",
|
||||
hasImages,
|
||||
willStripFeatures: {
|
||||
multiLanguage:
|
||||
!permissions.hasMultiLanguage && (parsedInput.surveyData?.languages?.length > 1 || false),
|
||||
followUps: !permissions.hasFollowUps && (parsedInput.surveyData?.followUps?.length > 0 || false),
|
||||
recaptcha: !permissions.hasRecaptcha && (parsedInput.surveyData?.recaptcha?.enabled || false),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ZImportSurveyAction = z.object({
|
||||
surveyData: z.record(z.any()),
|
||||
environmentId: z.string().cuid2(),
|
||||
newName: z.string(),
|
||||
});
|
||||
|
||||
export const importSurveyAction = authenticatedActionClient
|
||||
.schema(ZImportSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Re-validate
|
||||
const validationResult = ZSurveyCreateInput.safeParse(parsedInput.surveyData);
|
||||
if (!validationResult.success) {
|
||||
throw new Error("Survey validation failed");
|
||||
}
|
||||
|
||||
// Check permissions and strip features
|
||||
let permissions = {
|
||||
hasMultiLanguage: true,
|
||||
hasFollowUps: true,
|
||||
hasRecaptcha: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasMultiLanguage = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const organizationBillingData = await getOrganizationBilling(organizationId);
|
||||
if (organizationBillingData) {
|
||||
const isFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBillingData.plan);
|
||||
if (!isFollowUpsEnabled) {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasRecaptcha = false;
|
||||
}
|
||||
|
||||
// Prepare survey for import - strip fields that should be auto-generated
|
||||
const { id, createdAt, updatedAt, ...surveyWithoutMetadata } = stripEnterpriseFeatures(
|
||||
validationResult.data,
|
||||
permissions
|
||||
);
|
||||
|
||||
const importedSurvey: TSurveyCreateInput = {
|
||||
...surveyWithoutMetadata,
|
||||
name: parsedInput.newName,
|
||||
segment: null,
|
||||
environmentId: parsedInput.environmentId,
|
||||
createdBy: ctx.user.id,
|
||||
};
|
||||
|
||||
// Create the survey
|
||||
const result = await createSurvey(parsedInput.environmentId, importedSurvey);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
264
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
264
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { importSurveyAction, validateSurveyImportAction } from "@/modules/survey/list/actions";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface ImportSurveyModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSurveyImported?: () => void;
|
||||
}
|
||||
|
||||
export const ImportSurveyModal = ({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
onSurveyImported,
|
||||
}: ImportSurveyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<"upload" | "preview">("upload");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [surveyData, setSurveyData] = useState<any>(null);
|
||||
const [surveyName, setSurveyName] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSurveyId, setNewSurveyId] = useState("");
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
const resetState = () => {
|
||||
setStep("upload");
|
||||
setSurveyData(null);
|
||||
setSurveyName("");
|
||||
setNewName("");
|
||||
setNewSurveyId("");
|
||||
setErrors([]);
|
||||
setWarnings([]);
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
resetState();
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file type
|
||||
if (file.type !== "application/json" && !file.name.endsWith(".json")) {
|
||||
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
setSurveyData(parsed);
|
||||
setSurveyName(parsed?.name || "Imported Survey");
|
||||
setNewName(`${parsed?.name || "Imported Survey"} (imported)`);
|
||||
|
||||
// Validate the survey data
|
||||
const validationResult = await validateSurveyImportAction({
|
||||
surveyData: parsed,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (validationResult?.data) {
|
||||
setErrors(validationResult.data.errors || []);
|
||||
setWarnings(validationResult.data.warnings || []);
|
||||
|
||||
if (validationResult.data.errors.length === 0) {
|
||||
// Generate a preview ID
|
||||
const previewId = createId();
|
||||
setNewSurveyId(previewId);
|
||||
setStep("preview");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!surveyData || errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await importSurveyAction({
|
||||
surveyData,
|
||||
environmentId,
|
||||
newName,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.import_survey_success"));
|
||||
onSurveyImported?.();
|
||||
router.refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage || t("environments.surveys.import_survey_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.import_survey_error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="json-file">{t("environments.surveys.import_survey_file_label")}</Label>
|
||||
<div className="mt-2 flex items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-white p-8">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="mx-auto mb-4 h-12 w-12 text-slate-400" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="json-file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={loading}
|
||||
loading={loading}>
|
||||
{t("common.upload")}
|
||||
</Button>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.or_drag_and_drop_json")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.import_survey_validate")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
{errors.length > 0 && (
|
||||
<Alert variant="destructive" title={t("environments.surveys.import_survey_errors")}>
|
||||
<ul className="space-y-1">
|
||||
{errors.map((error) => (
|
||||
<li key={error} className="text-sm">
|
||||
• {t(`environments.surveys.${error}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<Alert variant="default" title={t("environments.surveys.import_survey_warnings")}>
|
||||
<ul className="space-y-1">
|
||||
{warnings.map((warning) => (
|
||||
<li key={warning} className="text-sm">
|
||||
• {t(`environments.surveys.${warning}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div>
|
||||
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
|
||||
<Input
|
||||
id="survey-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={surveyName}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("environments.surveys.import_survey_new_id")}</Label>
|
||||
<div className="mt-2">
|
||||
<IdBadge id={newSurveyId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setStep("upload")} disabled={loading}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={errors.length > 0 || loading || !newName}
|
||||
loading={loading}>
|
||||
{t("environments.surveys.import_survey_import")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
MoreVertical,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
deleteSurveyAction,
|
||||
getSurveyAction,
|
||||
} from "@/modules/survey/list/actions";
|
||||
import { exportSurveyAction } from "@/modules/survey/list/actions";
|
||||
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
@@ -125,6 +128,29 @@ export const SurveyDropDownMenu = ({
|
||||
setIsCautionDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExportSurvey = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setLoading(true);
|
||||
|
||||
const result = await exportSurveyAction({ surveyId: survey.id });
|
||||
|
||||
if (result?.data) {
|
||||
const jsonString = JSON.stringify(result.data, null, 2);
|
||||
downloadSurveyJson(survey.name, jsonString);
|
||||
toast.success(t("environments.surveys.survey_exported_successfully"));
|
||||
} else {
|
||||
toast.error(t("environments.surveys.survey_export_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.survey_export_error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||
@@ -170,20 +196,33 @@ export const SurveyDropDownMenu = ({
|
||||
</>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={handleExportSurvey}>
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.surveys.export_survey")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, UploadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ImportSurveyModal } from "./import-survey-modal";
|
||||
|
||||
interface SurveysHeaderActionsProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const SurveysHeaderActions = ({ environmentId }: SurveysHeaderActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setIsImportModalOpen(true)}>
|
||||
<UploadIcon className="h-4 w-4" />
|
||||
{t("environments.surveys.import_survey")}
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImportSurveyModal
|
||||
environmentId={environmentId}
|
||||
open={isImportModalOpen}
|
||||
setOpen={setIsImportModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
28
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
28
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const downloadSurveyJson = (surveyName: string, jsonContent: string): void => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
throw new Error("downloadSurveyJson can only be used in a browser environment");
|
||||
}
|
||||
|
||||
const trimmedName = (surveyName ?? "").trim();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let normalizedFileName = trimmedName || `survey-${today}`;
|
||||
|
||||
if (!normalizedFileName.toLowerCase().endsWith(".json")) {
|
||||
normalizedFileName = `${normalizedFileName}-export-${today}.json`;
|
||||
}
|
||||
|
||||
const file = new File([jsonContent], normalizedFileName, {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
|
||||
const link = document.createElement("a");
|
||||
let url: string | undefined;
|
||||
|
||||
url = URL.createObjectURL(file);
|
||||
link.href = url;
|
||||
link.download = normalizedFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
108
apps/web/modules/survey/list/lib/import-validation.ts
Normal file
108
apps/web/modules/survey/list/lib/import-validation.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const detectImagesInSurvey = (survey: any): boolean => {
|
||||
if (survey?.questions) {
|
||||
for (const question of survey.questions) {
|
||||
// Check for image fields in various question types
|
||||
if (question.imageUrl || question.videoUrl || question.fileUrl) {
|
||||
return true;
|
||||
}
|
||||
// Check for images in options
|
||||
if (question.options) {
|
||||
for (const option of question.options) {
|
||||
if (option.imageUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check welcome card
|
||||
if (survey?.welcomeCard?.fileUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check endings
|
||||
if (survey?.endings) {
|
||||
for (const ending of survey.endings) {
|
||||
if (ending.imageUrl || ending.videoUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const stripEnterpriseFeatures = (
|
||||
survey: any,
|
||||
permissions: {
|
||||
hasMultiLanguage: boolean;
|
||||
hasFollowUps: boolean;
|
||||
hasRecaptcha: boolean;
|
||||
}
|
||||
): any => {
|
||||
const cleanedSurvey = { ...survey };
|
||||
|
||||
// Strip multi-language if not permitted
|
||||
if (!permissions.hasMultiLanguage) {
|
||||
cleanedSurvey.languages = [
|
||||
{
|
||||
language: {
|
||||
code: "en",
|
||||
alias: "English",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
cleanedSurvey.showLanguageSwitch = false;
|
||||
}
|
||||
|
||||
// Strip follow-ups if not permitted
|
||||
if (!permissions.hasFollowUps) {
|
||||
cleanedSurvey.followUps = [];
|
||||
}
|
||||
|
||||
// Strip recaptcha if not permitted
|
||||
if (!permissions.hasRecaptcha) {
|
||||
cleanedSurvey.recaptcha = null;
|
||||
}
|
||||
|
||||
return cleanedSurvey;
|
||||
};
|
||||
|
||||
export const getImportWarnings = (
|
||||
survey: any,
|
||||
hasImages: boolean,
|
||||
permissions: {
|
||||
hasMultiLanguage: boolean;
|
||||
hasFollowUps: boolean;
|
||||
hasRecaptcha: boolean;
|
||||
}
|
||||
): string[] => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!permissions.hasMultiLanguage && survey?.languages?.length > 1) {
|
||||
warnings.push("import_warning_multi_language");
|
||||
}
|
||||
|
||||
if (!permissions.hasFollowUps && survey?.followUps?.length) {
|
||||
warnings.push("import_warning_follow_ups");
|
||||
}
|
||||
|
||||
if (!permissions.hasRecaptcha && survey?.recaptcha?.enabled) {
|
||||
warnings.push("import_warning_recaptcha");
|
||||
}
|
||||
|
||||
if (hasImages) {
|
||||
warnings.push("import_warning_images");
|
||||
}
|
||||
|
||||
if (survey?.segment) {
|
||||
warnings.push("import_warning_segments");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
||||
import { SurveysHeaderActions } from "@/modules/survey/list/components/surveys-header-actions";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -46,16 +47,6 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
|
||||
const currentProjectChannel = project.config.channel ?? null;
|
||||
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
|
||||
const CreateSurveyButton = () => {
|
||||
return (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const projectWithRequiredProps = {
|
||||
...project,
|
||||
@@ -77,7 +68,10 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
if (surveyCount > 0) {
|
||||
content = (
|
||||
<>
|
||||
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
|
||||
<PageHeader
|
||||
pageTitle={t("common.surveys")}
|
||||
cta={isReadOnly ? <></> : <SurveysHeaderActions environmentId={environment.id} />}
|
||||
/>
|
||||
<SurveysList
|
||||
environmentId={environment.id}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -14,7 +14,6 @@ declare global {
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
|
||||
setNonce: (nonce: string | undefined) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -81,7 +80,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
||||
|
||||
loadScript();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScriptLoaded) {
|
||||
|
||||
@@ -184,11 +184,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
|
||||
});
|
||||
};
|
||||
|
||||
// Export new testing utilities for easy access
|
||||
export { setupTestEnvironment } from "./lib/testing/setup";
|
||||
export { TEST_IDS, FIXTURES } from "./lib/testing/constants";
|
||||
export * from "./lib/testing/mocks";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
"pages": [
|
||||
"xm-and-surveys/surveys/website-app-surveys/quickstart",
|
||||
"xm-and-surveys/surveys/website-app-surveys/framework-guides",
|
||||
"xm-and-surveys/surveys/website-app-surveys/google-tag-manager",
|
||||
{
|
||||
"group": "Features",
|
||||
"icon": "wrench",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -297,47 +297,6 @@ Example least-privileged S3 bucket policy:
|
||||
Replace `your-bucket-name` with your actual bucket name and `arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public read access only to specific paths while restricting write access to your Formbricks service user.
|
||||
</Note>
|
||||
|
||||
### S3 CORS Configuration
|
||||
|
||||
CORS (Cross-Origin Resource Sharing) must be configured on your S3 bucket to allow Formbricks to upload files using presigned POST URLs. Without proper CORS configuration, file uploads from the browser will fail.
|
||||
|
||||
Configure CORS on your S3 bucket with the following settings:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedHeaders": [
|
||||
"*"
|
||||
],
|
||||
"AllowedMethods": [
|
||||
"POST",
|
||||
"GET",
|
||||
"HEAD",
|
||||
"DELETE",
|
||||
"PUT"
|
||||
],
|
||||
"AllowedOrigins": [
|
||||
"*"
|
||||
],
|
||||
"ExposeHeaders": [
|
||||
"ETag",
|
||||
"x-amz-meta-custom-header"
|
||||
],
|
||||
"MaxAgeSeconds": 3000
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
<Note>
|
||||
For production environments, consider restricting `AllowedOrigins` to your specific Formbricks domain(s) instead of using `"*"` for better security. For example: `["https://app.yourdomain.com", "https://yourdomain.com"]`.
|
||||
</Note>
|
||||
|
||||
**How to configure CORS:**
|
||||
|
||||
- **AWS S3**: Navigate to your bucket → Permissions → Cross-origin resource sharing (CORS) → Edit → Paste the JSON configuration
|
||||
- **DigitalOcean Spaces**: Navigate to your Space → Settings → CORS Configurations → Add CORS configuration → Paste the JSON
|
||||
- **Other S3-compatible providers**: Refer to your provider's documentation for CORS configuration
|
||||
|
||||
### MinIO Security
|
||||
|
||||
When using bundled MinIO:
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
---
|
||||
title: "Google Tag Manager"
|
||||
description: "Deploy Formbricks surveys through GTM without modifying your website code."
|
||||
icon: "tags"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Google Tag Manager](https://tagmanager.google.com/) installed on your website
|
||||
- Your Formbricks **Environment ID** (Settings > Configuration > Website & App Connection)
|
||||
- Your **App URL**: `https://app.formbricks.com` (or your self-hosted URL)
|
||||
|
||||
<Note>
|
||||
Use PUBLIC_URL for multi-domain setups, WEBAPP_URL for single-domain setups.
|
||||
</Note>
|
||||
|
||||
## Basic Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Custom HTML tag in GTM">
|
||||
1. Create a new tag with preferred name e.g. "Formbricks Intercept Surveys"
|
||||
2. Tag Type: Custom HTML
|
||||
3. Paste the code from Step 2. Make sure to replace `<your-environment-id>` and if you self-host, replace `<your-app-url>`
|
||||
</Step>
|
||||
|
||||
<Step title="Add initialization script">
|
||||
|
||||
```html
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "https://app.formbricks.com"; // REPLACE ONLY IF YOUR SELF-HOST
|
||||
var environmentId = "<your-environment-id>"; // REPLACE
|
||||
var t=document.createElement("script");
|
||||
t.type="text/javascript";
|
||||
t.async=!0;
|
||||
t.src=appUrl+"/js/formbricks.umd.cjs";
|
||||
t.onload=function(){
|
||||
window.formbricks && window.formbricks.setup({
|
||||
environmentId: environmentId,
|
||||
appUrl: appUrl
|
||||
});
|
||||
};
|
||||
var e=document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t,e);
|
||||
}();
|
||||
</script>
|
||||
```
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set trigger">
|
||||
1. Trigger: **All Pages** - Page View (default) or use case specific event
|
||||
2. Save and publish
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Test">
|
||||
1. Use GTM Preview mode
|
||||
2. Verify the tag fires
|
||||
3. Add `?formbricksDebug=true` to the URL to see test logs in browser console (see [Debugging Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#debugging-formbricks-integration) for more details)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## User Identification
|
||||
|
||||
Identify users to enable targeting and attributes. Learn more about [user identification](/xm-and-surveys/surveys/website-app-surveys/user-identification).
|
||||
|
||||
<Note>
|
||||
User identification is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Create GTM variables">
|
||||
1. Go to Variables on GTM dashboard
|
||||
2. Create new User-defined variable
|
||||
3. Name it (e.g., "User ID")
|
||||
4. Variable Type: Data Layer Variable
|
||||
5. Data Layer Variable: "userId"
|
||||
6. Save and publish
|
||||
7. Repeat for attributes you want to track e.g. "userEmail" and "userPlan" (optional)
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Create identification tag">
|
||||
New Custom HTML tag named "Formbricks - User":
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function() {
|
||||
var check = setInterval(function() {
|
||||
if (window.formbricks && window.formbricks.setUserId) {
|
||||
clearInterval(check);
|
||||
var userId = {{User ID}};
|
||||
if (userId) {
|
||||
window.formbricks.setUserId(userId);
|
||||
var attrs = {};
|
||||
if ({{User Email}}) attrs.email = {{User Email}};
|
||||
if ({{User Plan}}) attrs.plan = {{User Plan}};
|
||||
if (Object.keys(attrs).length) {
|
||||
window.formbricks.setAttributes(attrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(function() { clearInterval(check); }, 10000);
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set trigger and push data">
|
||||
1. Create a custom event trigger in GTM
|
||||
2. Trigger Type: Custom Event
|
||||
3. Event name: `user-login` (or your preferred event name)
|
||||
4. Attach this trigger to your "Formbricks - User" tag
|
||||
5. Save and publish
|
||||
|
||||

|
||||
|
||||
6. In your code, push data with the same event name:
|
||||
|
||||
```javascript
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({
|
||||
'event': 'user-login',
|
||||
'userId': 'user-123',
|
||||
'userEmail': 'user@example.com',
|
||||
'userPlan': 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Track Custom Events
|
||||
|
||||
<Steps>
|
||||
<Step title="Create code action in Formbricks">
|
||||
Add code action via Formbricks UI
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Create GTM variable for Event Name">
|
||||
1. Go to Variables on GTM dashboard
|
||||
2. Create new User-defined variable
|
||||
3. Name it "Event Name"
|
||||
4. Variable Type: Data Layer Variable
|
||||
5. Data Layer Variable: "eventName"
|
||||
6. Save and publish
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create event tracking tag">
|
||||
New Custom HTML tag:
|
||||
|
||||
```html
|
||||
<script>
|
||||
if (window.formbricks && window.formbricks.track) {
|
||||
window.formbricks.track({{Event Name}});
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create custom trigger">
|
||||
1. Create a custom event trigger in GTM
|
||||
2. Trigger Type: Custom Event
|
||||
3. Event name: `eventName` or name that matches with your event in code.
|
||||
4. Attach this trigger to your event tracking tag
|
||||
5. Save and publish
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Fire events from your site">
|
||||
|
||||
```javascript
|
||||
// Track button click
|
||||
window.dataLayer.push({
|
||||
'event': 'eventName',
|
||||
'eventName': 'code-action'
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Surveys not showing?**
|
||||
- Use GTM Preview mode to check tag firing
|
||||
- Add `?formbricksDebug=true` to your URL
|
||||
- Check browser console for errors
|
||||
- Wait 1 minute for the Server Cache to refresh
|
||||
|
||||
**User ID not working?**
|
||||
- Verify Data Layer push syntax
|
||||
- Check GTM variables are reading correct values
|
||||
- Ensure user tag fires after initialization
|
||||
|
||||
**Events not tracking?**
|
||||
- Confirm `window.formbricks` exists before calling track
|
||||
- Match event names exactly with Formbricks action names
|
||||
- Check timing - Formbricks must be initialized first
|
||||
|
||||
## Need Help?
|
||||
|
||||
- [GitHub Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
- [Framework Guides](/xm-and-surveys/surveys/website-app-surveys/framework-guides)
|
||||
- [Actions](/xm-and-surveys/surveys/website-app-surveys/actions)
|
||||
- [User Identification](/xm-and-surveys/surveys/website-app-surveys/user-identification)
|
||||
|
||||
@@ -76,19 +76,6 @@ const registerRouteChange = async (): Promise<void> => {
|
||||
await queue.add(checkPageUrl, CommandType.GeneralAction);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the CSP nonce for inline styles
|
||||
* @param nonce - The CSP nonce value (without 'nonce-' prefix), or undefined to clear
|
||||
*/
|
||||
const setNonce = (nonce: string | undefined): void => {
|
||||
// Store nonce on window for access when surveys package loads
|
||||
globalThis.window.__formbricksNonce = nonce;
|
||||
|
||||
// Set nonce in surveys package if it's already loaded
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
globalThis.window.formbricksSurveys?.setNonce?.(nonce);
|
||||
};
|
||||
|
||||
const formbricks = {
|
||||
/** @deprecated Use setup() instead. This method will be removed in a future version */
|
||||
init: (initConfig: TLegacyConfigInput) => setup(initConfig as unknown as TConfigInput),
|
||||
@@ -101,7 +88,6 @@ const formbricks = {
|
||||
track,
|
||||
logout,
|
||||
registerRouteChange,
|
||||
setNonce,
|
||||
};
|
||||
|
||||
type TFormbricks = typeof formbricks;
|
||||
|
||||
@@ -201,24 +201,19 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
|
||||
const config = Config.getInstance();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
if (window.formbricksSurveys) {
|
||||
resolve(window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// Apply stored nonce if it was set before surveys package loaded
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||
}
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
resolve(window.formbricksSurveys);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
|
||||
import { isValidHTML } from "@/lib/html-utils";
|
||||
|
||||
interface HeadlineProps {
|
||||
headline: string;
|
||||
@@ -12,16 +12,8 @@ interface HeadlineProps {
|
||||
|
||||
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
|
||||
const { t } = useTranslation();
|
||||
// Strip inline styles BEFORE parsing to avoid CSP violations
|
||||
const strippedHeadline = stripInlineStyles(headline);
|
||||
const isHeadlineHtml = isValidHTML(strippedHeadline);
|
||||
const safeHtml =
|
||||
isHeadlineHtml && strippedHeadline
|
||||
? DOMPurify.sanitize(strippedHeadline, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
|
||||
})
|
||||
: "";
|
||||
const isHeadlineHtml = isValidHTML(headline);
|
||||
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
|
||||
|
||||
return (
|
||||
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
|
||||
import { isValidHTML } from "@/lib/html-utils";
|
||||
|
||||
interface SubheaderProps {
|
||||
subheader?: string;
|
||||
@@ -8,16 +8,8 @@ interface SubheaderProps {
|
||||
}
|
||||
|
||||
export function Subheader({ subheader, questionId }: SubheaderProps) {
|
||||
// Strip inline styles BEFORE parsing to avoid CSP violations
|
||||
const strippedSubheader = subheader ? stripInlineStyles(subheader) : "";
|
||||
const isHtml = strippedSubheader ? isValidHTML(strippedSubheader) : false;
|
||||
const safeHtml =
|
||||
isHtml && strippedSubheader
|
||||
? DOMPurify.sanitize(strippedSubheader, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
|
||||
})
|
||||
: "";
|
||||
const isHtml = subheader ? isValidHTML(subheader) : false;
|
||||
const safeHtml = isHtml && subheader ? DOMPurify.sanitize(subheader, { ADD_ATTR: ["target"] }) : "";
|
||||
|
||||
if (!subheader) return null;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RenderSurvey } from "@/components/general/render-survey";
|
||||
import { I18nProvider } from "@/components/i18n/provider";
|
||||
import { FILE_PICK_EVENT } from "@/lib/constants";
|
||||
import { getI18nLanguage } from "@/lib/i18n-utils";
|
||||
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
|
||||
|
||||
export const renderSurveyInline = (props: SurveyContainerProps) => {
|
||||
const inlineProps: SurveyContainerProps = {
|
||||
@@ -70,17 +70,15 @@ export const renderSurveyModal = renderSurvey;
|
||||
|
||||
export const onFilePick = (files: { name: string; type: string; base64: string }[]) => {
|
||||
const fileUploadEvent = new CustomEvent(FILE_PICK_EVENT, { detail: files });
|
||||
globalThis.dispatchEvent(fileUploadEvent);
|
||||
window.dispatchEvent(fileUploadEvent);
|
||||
};
|
||||
|
||||
// Initialize the global formbricksSurveys object if it doesn't exist
|
||||
if (globalThis.window !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type definition is in @formbricks/types package
|
||||
(globalThis.window as any).formbricksSurveys = {
|
||||
if (typeof window !== "undefined") {
|
||||
window.formbricksSurveys = {
|
||||
renderSurveyInline,
|
||||
renderSurveyModal,
|
||||
renderSurvey,
|
||||
onFilePick,
|
||||
setNonce: setStyleNonce,
|
||||
} as typeof globalThis.window.formbricksSurveys;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isValidHTML, stripInlineStyles } from "./html-utils";
|
||||
import { isValidHTML } from "./html-utils";
|
||||
|
||||
describe("html-utils", () => {
|
||||
describe("stripInlineStyles", () => {
|
||||
test("should remove inline styles with double quotes", () => {
|
||||
const input = '<div style="color: red;">Test</div>';
|
||||
const expected = "<div>Test</div>";
|
||||
expect(stripInlineStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove inline styles with single quotes", () => {
|
||||
const input = "<div style='color: red;'>Test</div>";
|
||||
const expected = "<div>Test</div>";
|
||||
expect(stripInlineStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should remove multiple inline styles", () => {
|
||||
const input = '<div style="color: red;"><span style="font-size: 14px;">Test</span></div>';
|
||||
const expected = "<div><span>Test</span></div>";
|
||||
expect(stripInlineStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should handle complex inline styles", () => {
|
||||
const input = '<p style="margin: 10px; padding: 5px; background-color: blue;">Content</p>';
|
||||
const expected = "<p>Content</p>";
|
||||
expect(stripInlineStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should not affect other attributes", () => {
|
||||
const input = '<div class="test" id="myDiv" style="color: red;">Test</div>';
|
||||
const expected = '<div class="test" id="myDiv">Test</div>';
|
||||
expect(stripInlineStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("should return unchanged string if no inline styles", () => {
|
||||
const input = '<div class="test">Test</div>';
|
||||
expect(stripInlineStyles(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
expect(stripInlineStyles("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHTML", () => {
|
||||
test("should return false for empty string", () => {
|
||||
expect(isValidHTML("")).toBe(false);
|
||||
@@ -63,9 +22,5 @@ describe("html-utils", () => {
|
||||
test("should return true for complex HTML", () => {
|
||||
expect(isValidHTML('<div class="test"><p>Test</p></div>')).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle HTML with inline styles (they should be stripped)", () => {
|
||||
expect(isValidHTML('<p style="color: red;">Test</p>')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
/**
|
||||
* Strip inline style attributes from HTML string to avoid CSP violations
|
||||
* @param html - The HTML string to process
|
||||
* @returns HTML string with all style attributes removed
|
||||
* @note This is a security measure to prevent CSP violations during HTML parsing
|
||||
*/
|
||||
export const stripInlineStyles = (html: string): string => {
|
||||
// Remove style="..." or style='...' attributes
|
||||
// Use separate patterns for each quote type to avoid ReDoS vulnerability
|
||||
// The pattern [^"]* and [^']* are safe as they don't cause backtracking
|
||||
return html.replace(/\s+style\s*=\s*["'][^"']*["']/gi, ""); //NOSONAR
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight HTML detection for browser environments
|
||||
* Uses native DOMParser (built-in, 0 KB bundle size)
|
||||
* @param str - The input string to test
|
||||
* @returns true if the string contains valid HTML elements, false otherwise
|
||||
* @note Returns false in non-browser environments (SSR, Node.js) where window is undefined
|
||||
* @note Strips inline styles before parsing to avoid CSP violations
|
||||
*/
|
||||
export const isValidHTML = (str: string): boolean => {
|
||||
// This should ideally never happen because the surveys package should be used in an environment where DOM is available
|
||||
@@ -26,10 +12,7 @@ export const isValidHTML = (str: string): boolean => {
|
||||
if (!str) return false;
|
||||
|
||||
try {
|
||||
// Strip inline style attributes to avoid CSP violations during parsing
|
||||
const strippedStr = stripInlineStyles(str);
|
||||
|
||||
const doc = new DOMParser().parseFromString(strippedStr, "text/html");
|
||||
const doc = new DOMParser().parseFromString(str, "text/html");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode) return false;
|
||||
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { type TProjectStyling } from "@formbricks/types/project";
|
||||
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { addCustomThemeToDom, addStylesToDom, getStyleNonce, setStyleNonce } from "./styles";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "./styles";
|
||||
|
||||
// Mock CSS module imports
|
||||
vi.mock("@/styles/global.css?inline", () => ({ default: ".global {}" }));
|
||||
@@ -40,85 +40,11 @@ const getBaseProjectStyling = (overrides: Partial<TProjectStyling> = {}): TProje
|
||||
};
|
||||
};
|
||||
|
||||
describe("setStyleNonce and getStyleNonce", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the DOM and nonce before each test
|
||||
document.head.innerHTML = "";
|
||||
document.body.innerHTML = "";
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
test("should set and get the nonce value", () => {
|
||||
const nonce = "test-nonce-123";
|
||||
setStyleNonce(nonce);
|
||||
expect(getStyleNonce()).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should allow clearing the nonce with undefined", () => {
|
||||
setStyleNonce("initial-nonce");
|
||||
expect(getStyleNonce()).toBe("initial-nonce");
|
||||
setStyleNonce(undefined);
|
||||
expect(getStyleNonce()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should update existing formbricks__css element with nonce", () => {
|
||||
// Create an existing style element
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css";
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
const nonce = "test-nonce-456";
|
||||
setStyleNonce(nonce);
|
||||
|
||||
expect(existingElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should update existing formbricks__css__custom element with nonce", () => {
|
||||
// Create an existing custom style element
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css__custom";
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
const nonce = "test-nonce-789";
|
||||
setStyleNonce(nonce);
|
||||
|
||||
expect(existingElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should not update nonce on existing elements when nonce is undefined", () => {
|
||||
// Create existing style elements
|
||||
const mainElement = document.createElement("style");
|
||||
mainElement.id = "formbricks__css";
|
||||
mainElement.setAttribute("nonce", "existing-nonce");
|
||||
document.head.appendChild(mainElement);
|
||||
|
||||
const customElement = document.createElement("style");
|
||||
customElement.id = "formbricks__css__custom";
|
||||
customElement.setAttribute("nonce", "existing-nonce");
|
||||
document.head.appendChild(customElement);
|
||||
|
||||
setStyleNonce(undefined);
|
||||
|
||||
// Elements should retain their existing nonce (or be cleared if implementation removes it)
|
||||
// The current implementation doesn't remove nonce when undefined, so we check it's not changed
|
||||
expect(mainElement.getAttribute("nonce")).toBe("existing-nonce");
|
||||
expect(customElement.getAttribute("nonce")).toBe("existing-nonce");
|
||||
});
|
||||
|
||||
test("should handle setting nonce when elements don't exist", () => {
|
||||
const nonce = "test-nonce-no-elements";
|
||||
setStyleNonce(nonce);
|
||||
expect(getStyleNonce()).toBe(nonce);
|
||||
// Should not throw and should store the nonce for future use
|
||||
});
|
||||
});
|
||||
|
||||
describe("addStylesToDom", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the DOM before each test
|
||||
document.head.innerHTML = "";
|
||||
document.body.innerHTML = "";
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -126,7 +52,6 @@ describe("addStylesToDom", () => {
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
test("should add a style element to the head with combined CSS", () => {
|
||||
@@ -153,68 +78,12 @@ describe("addStylesToDom", () => {
|
||||
expect(secondStyleElement).toBe(firstStyleElement);
|
||||
expect(secondStyleElement?.innerHTML).toBe(initialInnerHTML);
|
||||
});
|
||||
|
||||
test("should apply nonce to new style element when nonce is set", () => {
|
||||
const nonce = "test-nonce-styles";
|
||||
setStyleNonce(nonce);
|
||||
addStylesToDom();
|
||||
|
||||
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
|
||||
expect(styleElement).not.toBeNull();
|
||||
expect(styleElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should not apply nonce when nonce is not set", () => {
|
||||
addStylesToDom();
|
||||
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
|
||||
expect(styleElement).not.toBeNull();
|
||||
expect(styleElement.getAttribute("nonce")).toBeNull();
|
||||
});
|
||||
|
||||
test("should update nonce on existing style element if nonce is set after creation", () => {
|
||||
addStylesToDom(); // Create element without nonce
|
||||
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
|
||||
expect(styleElement.getAttribute("nonce")).toBeNull();
|
||||
|
||||
const nonce = "test-nonce-update";
|
||||
setStyleNonce(nonce);
|
||||
addStylesToDom(); // Call again to trigger update logic
|
||||
|
||||
expect(styleElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should not overwrite existing nonce when updating via addStylesToDom", () => {
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css";
|
||||
existingElement.setAttribute("nonce", "existing-nonce");
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
// Don't call setStyleNonce - just verify addStylesToDom doesn't overwrite
|
||||
addStylesToDom(); // Should not overwrite since nonce already exists
|
||||
|
||||
// The update logic in addStylesToDom only sets nonce if it doesn't exist
|
||||
expect(existingElement.getAttribute("nonce")).toBe("existing-nonce");
|
||||
});
|
||||
|
||||
test("should overwrite existing nonce when setStyleNonce is called directly", () => {
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css";
|
||||
existingElement.setAttribute("nonce", "existing-nonce");
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
const newNonce = "new-nonce";
|
||||
setStyleNonce(newNonce); // setStyleNonce always updates existing elements
|
||||
|
||||
// setStyleNonce directly updates the nonce attribute
|
||||
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addCustomThemeToDom", () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = "";
|
||||
document.body.innerHTML = "";
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -222,7 +91,6 @@ describe("addCustomThemeToDom", () => {
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
const getCssVariables = (styleElement: HTMLStyleElement | null): Record<string, string> => {
|
||||
@@ -403,66 +271,6 @@ describe("addCustomThemeToDom", () => {
|
||||
expect(variables["--fb-survey-background-color"]).toBeUndefined();
|
||||
expect(variables["--fb-input-background-color"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should apply nonce to new custom theme style element when nonce is set", () => {
|
||||
const nonce = "test-nonce-custom";
|
||||
setStyleNonce(nonce);
|
||||
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
|
||||
addCustomThemeToDom({ styling });
|
||||
|
||||
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
|
||||
expect(styleElement).not.toBeNull();
|
||||
expect(styleElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should not apply nonce when nonce is not set", () => {
|
||||
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
|
||||
addCustomThemeToDom({ styling });
|
||||
|
||||
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
|
||||
expect(styleElement).not.toBeNull();
|
||||
expect(styleElement.getAttribute("nonce")).toBeNull();
|
||||
});
|
||||
|
||||
test("should update nonce on existing custom style element if nonce is set after creation", () => {
|
||||
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
|
||||
addCustomThemeToDom({ styling }); // Create element without nonce
|
||||
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
|
||||
expect(styleElement.getAttribute("nonce")).toBeNull();
|
||||
|
||||
const nonce = "test-nonce-custom-update";
|
||||
setStyleNonce(nonce);
|
||||
addCustomThemeToDom({ styling }); // Call again to trigger update logic
|
||||
|
||||
expect(styleElement.getAttribute("nonce")).toBe(nonce);
|
||||
});
|
||||
|
||||
test("should not overwrite existing nonce when updating custom theme via addCustomThemeToDom", () => {
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css__custom";
|
||||
existingElement.setAttribute("nonce", "existing-custom-nonce");
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
// Don't call setStyleNonce - just verify addCustomThemeToDom doesn't overwrite
|
||||
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
|
||||
addCustomThemeToDom({ styling }); // Should not overwrite since nonce already exists
|
||||
|
||||
// The update logic in addCustomThemeToDom only sets nonce if it doesn't exist
|
||||
expect(existingElement.getAttribute("nonce")).toBe("existing-custom-nonce");
|
||||
});
|
||||
|
||||
test("should overwrite existing nonce when setStyleNonce is called directly on custom theme", () => {
|
||||
const existingElement = document.createElement("style");
|
||||
existingElement.id = "formbricks__css__custom";
|
||||
existingElement.setAttribute("nonce", "existing-custom-nonce");
|
||||
document.head.appendChild(existingElement);
|
||||
|
||||
const newNonce = "new-custom-nonce";
|
||||
setStyleNonce(newNonce); // setStyleNonce directly updates the nonce attribute
|
||||
|
||||
// setStyleNonce directly updates the nonce attribute
|
||||
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBaseProjectStyling_Helper", () => {
|
||||
|
||||
@@ -8,74 +8,24 @@ import preflight from "@/styles/preflight.css?inline";
|
||||
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
|
||||
import datePickerCustomCss from "../styles/date-picker.css?inline";
|
||||
|
||||
// Store the nonce globally for style elements
|
||||
let styleNonce: string | undefined;
|
||||
|
||||
/**
|
||||
* Set the CSP nonce to be applied to all style elements
|
||||
* @param nonce - The CSP nonce value (without 'nonce-' prefix)
|
||||
*/
|
||||
export const setStyleNonce = (nonce: string | undefined): void => {
|
||||
styleNonce = nonce;
|
||||
|
||||
// Update existing style elements if they exist
|
||||
const existingStyleElement = document.getElementById("formbricks__css");
|
||||
if (existingStyleElement && nonce) {
|
||||
existingStyleElement.setAttribute("nonce", nonce);
|
||||
}
|
||||
|
||||
const existingCustomStyleElement = document.getElementById("formbricks__css__custom");
|
||||
if (existingCustomStyleElement && nonce) {
|
||||
existingCustomStyleElement.setAttribute("nonce", nonce);
|
||||
}
|
||||
};
|
||||
|
||||
export const getStyleNonce = (): string | undefined => {
|
||||
return styleNonce;
|
||||
};
|
||||
|
||||
export const addStylesToDom = () => {
|
||||
if (document.getElementById("formbricks__css") === null) {
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "formbricks__css";
|
||||
|
||||
// Apply nonce if available
|
||||
if (styleNonce) {
|
||||
styleElement.setAttribute("nonce", styleNonce);
|
||||
}
|
||||
|
||||
styleElement.innerHTML =
|
||||
preflight + global + editorCss + datePickerCss + calendarCss + datePickerCustomCss;
|
||||
document.head.appendChild(styleElement);
|
||||
} else {
|
||||
// If style element already exists, update its nonce if needed
|
||||
const existingStyleElement = document.getElementById("formbricks__css");
|
||||
if (existingStyleElement && styleNonce && !existingStyleElement.getAttribute("nonce")) {
|
||||
existingStyleElement.setAttribute("nonce", styleNonce);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TSurveyStyling }): void => {
|
||||
// Check if the style element already exists
|
||||
let styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement | null;
|
||||
let styleElement = document.getElementById("formbricks__css__custom");
|
||||
|
||||
// If the style element exists, update nonce if needed
|
||||
if (styleElement) {
|
||||
// Update nonce if it wasn't set before
|
||||
if (styleNonce && !styleElement.getAttribute("nonce")) {
|
||||
styleElement.setAttribute("nonce", styleNonce);
|
||||
}
|
||||
} else {
|
||||
// Create it and append to the head
|
||||
// If the style element doesn't exist, create it and append to the head
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style");
|
||||
styleElement.id = "formbricks__css__custom";
|
||||
|
||||
// Apply nonce if available
|
||||
if (styleNonce) {
|
||||
styleElement.setAttribute("nonce", styleNonce);
|
||||
}
|
||||
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
|
||||
2
packages/types/surveys.d.ts
vendored
2
packages/types/surveys.d.ts
vendored
@@ -7,8 +7,6 @@ declare global {
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
|
||||
setNonce: (nonce: string | undefined) => void;
|
||||
};
|
||||
__formbricksNonce?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user