diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
index 1c855c062d..8397827475 100644
--- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
+++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts
@@ -2,7 +2,7 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getSurvey } from "@/lib/survey/service";
-import { generateSurveySingleUseIds } from "@/lib/utils/singleUseSurveys";
+import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
diff --git a/apps/web/lib/i18n/i18n.mock.ts b/apps/web/lib/i18n/i18n.mock.ts
index d377ead34a..638886377a 100644
--- a/apps/web/lib/i18n/i18n.mock.ts
+++ b/apps/web/lib/i18n/i18n.mock.ts
@@ -1,4 +1,4 @@
-import { mockSurveyLanguages } from "@/lib/survey/tests/__mock__/survey.mock";
+import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
import {
TSurvey,
TSurveyCTAQuestion,
diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts
index 21bb6ee7e4..55db15025f 100644
--- a/apps/web/lib/response/service.ts
+++ b/apps/web/lib/response/service.ts
@@ -22,7 +22,7 @@ import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
-import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
+import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import {
diff --git a/apps/web/lib/response/tests/response.test.ts b/apps/web/lib/response/tests/response.test.ts
index 7701b29e4c..dab3cb97d7 100644
--- a/apps/web/lib/response/tests/response.test.ts
+++ b/apps/web/lib/response/tests/response.test.ts
@@ -24,7 +24,7 @@ import {
mockContactAttributeKey,
mockOrganizationOutput,
mockSurveyOutput,
-} from "../../survey/tests/__mock__/survey.mock";
+} from "../../survey/__mock__/survey.mock";
import {
deleteResponse,
getResponse,
diff --git a/apps/web/lib/survey/tests/__mock__/survey.mock.ts b/apps/web/lib/survey/__mock__/survey.mock.ts
similarity index 99%
rename from apps/web/lib/survey/tests/__mock__/survey.mock.ts
rename to apps/web/lib/survey/__mock__/survey.mock.ts
index 7b1ec38e36..719ca4a7b9 100644
--- a/apps/web/lib/survey/tests/__mock__/survey.mock.ts
+++ b/apps/web/lib/survey/__mock__/survey.mock.ts
@@ -13,7 +13,7 @@ import {
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
-import { selectSurvey } from "../../service";
+import { selectSurvey } from "../service";
const selectContact = {
id: true,
diff --git a/apps/web/lib/survey/auth.test.ts b/apps/web/lib/survey/auth.test.ts
new file mode 100644
index 0000000000..b24453756d
--- /dev/null
+++ b/apps/web/lib/survey/auth.test.ts
@@ -0,0 +1,113 @@
+import { cache } from "@/lib/cache";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ZId } from "@formbricks/types/common";
+import { TSurvey } from "@formbricks/types/surveys/types";
+import { hasUserEnvironmentAccess } from "../environment/auth";
+import { validateInputs } from "../utils/validate";
+import { canUserAccessSurvey } from "./auth";
+import { surveyCache } from "./cache";
+import { getSurvey } from "./service";
+
+// Mock dependencies
+vi.mock("@/lib/cache", () => ({
+ cache: vi.fn((fn) => fn),
+}));
+
+vi.mock("@/lib/utils/validate", () => ({
+ validateInputs: vi.fn(),
+}));
+
+vi.mock("./service", () => ({
+ getSurvey: vi.fn(),
+}));
+
+vi.mock("../environment/auth", () => ({
+ hasUserEnvironmentAccess: vi.fn(),
+}));
+
+vi.mock("./cache", () => ({
+ surveyCache: {
+ tag: {
+ byId: vi.fn().mockReturnValue("survey-tag-id"),
+ },
+ },
+}));
+
+describe("canUserAccessSurvey", () => {
+ const userId = "user-123";
+ const surveyId = "survey-456";
+ const environmentId = "env-789";
+
+ const mockSurvey = {
+ id: surveyId,
+ environmentId: environmentId,
+ } as TSurvey;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(cache).mockImplementation((fn) => () => fn());
+ vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
+ vi.mocked(surveyCache.tag.byId).mockReturnValue(`survey-${surveyId}`);
+ });
+
+ test("validates input parameters", async () => {
+ await canUserAccessSurvey(userId, surveyId);
+ expect(validateInputs).toHaveBeenCalledWith([surveyId, ZId], [userId, ZId]);
+ });
+
+ test("returns false if userId is falsy", async () => {
+ const result = await canUserAccessSurvey("", surveyId);
+ expect(result).toBe(false);
+ expect(getSurvey).not.toHaveBeenCalled();
+ });
+
+ test("returns false if survey is not found", async () => {
+ vi.mocked(getSurvey).mockResolvedValueOnce(null);
+
+ await expect(canUserAccessSurvey(userId, surveyId)).rejects.toThrowError("Survey not found");
+ expect(getSurvey).toHaveBeenCalledWith(surveyId);
+ expect(hasUserEnvironmentAccess).not.toHaveBeenCalled();
+ });
+
+ test("calls hasUserEnvironmentAccess with userId and survey's environmentId", async () => {
+ await canUserAccessSurvey(userId, surveyId);
+
+ expect(getSurvey).toHaveBeenCalledWith(surveyId);
+ expect(hasUserEnvironmentAccess).toHaveBeenCalledWith(userId, environmentId);
+ });
+
+ test("returns false if user doesn't have access to the environment", async () => {
+ vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
+
+ const result = await canUserAccessSurvey(userId, surveyId);
+
+ expect(result).toBe(false);
+ expect(getSurvey).toHaveBeenCalledWith(surveyId);
+ expect(hasUserEnvironmentAccess).toHaveBeenCalledWith(userId, environmentId);
+ });
+
+ test("returns true if user has access to the environment", async () => {
+ const result = await canUserAccessSurvey(userId, surveyId);
+
+ expect(result).toBe(true);
+ expect(getSurvey).toHaveBeenCalledWith(surveyId);
+ expect(hasUserEnvironmentAccess).toHaveBeenCalledWith(userId, environmentId);
+ });
+
+ test("rethrows errors that occur during execution", async () => {
+ const error = new Error("Test error");
+ vi.mocked(getSurvey).mockRejectedValueOnce(error);
+
+ await expect(canUserAccessSurvey(userId, surveyId)).rejects.toThrow(error);
+ });
+
+ test("uses cache with correct cache key and tags", async () => {
+ await canUserAccessSurvey(userId, surveyId);
+
+ expect(cache).toHaveBeenCalledWith(expect.any(Function), [`canUserAccessSurvey-${userId}-${surveyId}`], {
+ tags: [`survey-${surveyId}`],
+ });
+ expect(surveyCache.tag.byId).toHaveBeenCalledWith(surveyId);
+ });
+});
diff --git a/apps/web/lib/survey/cache.test.ts b/apps/web/lib/survey/cache.test.ts
new file mode 100644
index 0000000000..0c7b69b3d2
--- /dev/null
+++ b/apps/web/lib/survey/cache.test.ts
@@ -0,0 +1,122 @@
+import { cleanup } from "@testing-library/react";
+import { revalidateTag } from "next/cache";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { surveyCache } from "./cache";
+
+// Mock the revalidateTag function from next/cache
+vi.mock("next/cache", () => ({
+ revalidateTag: vi.fn(),
+}));
+
+describe("surveyCache", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ describe("tag methods", () => {
+ test("byId returns the correct tag string", () => {
+ const id = "survey-123";
+ expect(surveyCache.tag.byId(id)).toBe(`surveys-${id}`);
+ });
+
+ test("byEnvironmentId returns the correct tag string", () => {
+ const environmentId = "env-456";
+ expect(surveyCache.tag.byEnvironmentId(environmentId)).toBe(`environments-${environmentId}-surveys`);
+ });
+
+ test("byAttributeClassId returns the correct tag string", () => {
+ const attributeClassId = "attr-789";
+ expect(surveyCache.tag.byAttributeClassId(attributeClassId)).toBe(
+ `attributeFilters-${attributeClassId}-surveys`
+ );
+ });
+
+ test("byActionClassId returns the correct tag string", () => {
+ const actionClassId = "action-012";
+ expect(surveyCache.tag.byActionClassId(actionClassId)).toBe(`actionClasses-${actionClassId}-surveys`);
+ });
+
+ test("bySegmentId returns the correct tag string", () => {
+ const segmentId = "segment-345";
+ expect(surveyCache.tag.bySegmentId(segmentId)).toBe(`segments-${segmentId}-surveys`);
+ });
+
+ test("byResultShareKey returns the correct tag string", () => {
+ const resultShareKey = "share-678";
+ expect(surveyCache.tag.byResultShareKey(resultShareKey)).toBe(`surveys-resultShare-${resultShareKey}`);
+ });
+ });
+
+ describe("revalidate method", () => {
+ test("calls revalidateTag with correct tag when id is provided", () => {
+ const id = "survey-123";
+ surveyCache.revalidate({ id });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${id}`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag with correct tag when attributeClassId is provided", () => {
+ const attributeClassId = "attr-789";
+ surveyCache.revalidate({ attributeClassId });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`attributeFilters-${attributeClassId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag with correct tag when actionClassId is provided", () => {
+ const actionClassId = "action-012";
+ surveyCache.revalidate({ actionClassId });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${actionClassId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag with correct tag when environmentId is provided", () => {
+ const environmentId = "env-456";
+ surveyCache.revalidate({ environmentId });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${environmentId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag with correct tag when segmentId is provided", () => {
+ const segmentId = "segment-345";
+ surveyCache.revalidate({ segmentId });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${segmentId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag with correct tag when resultShareKey is provided", () => {
+ const resultShareKey = "share-678";
+ surveyCache.revalidate({ resultShareKey });
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${resultShareKey}`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
+ });
+
+ test("calls revalidateTag multiple times when multiple parameters are provided", () => {
+ const props = {
+ id: "survey-123",
+ environmentId: "env-456",
+ attributeClassId: "attr-789",
+ actionClassId: "action-012",
+ segmentId: "segment-345",
+ resultShareKey: "share-678",
+ };
+
+ surveyCache.revalidate(props);
+
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(6);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${props.id}`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${props.environmentId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(
+ `attributeFilters-${props.attributeClassId}-surveys`
+ );
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${props.actionClassId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${props.segmentId}-surveys`);
+ expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${props.resultShareKey}`);
+ });
+
+ test("does not call revalidateTag when no parameters are provided", () => {
+ surveyCache.revalidate({});
+ expect(vi.mocked(revalidateTag)).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts
new file mode 100644
index 0000000000..437a74d5b6
--- /dev/null
+++ b/apps/web/lib/survey/service.test.ts
@@ -0,0 +1,1037 @@
+import { prisma } from "@/lib/__mocks__/database";
+import { getActionClasses } from "@/lib/actionClass/service";
+import { segmentCache } from "@/lib/cache/segment";
+import {
+ getOrganizationByEnvironmentId,
+ subscribeOrganizationMembersToSurveyResponses,
+} from "@/lib/organization/service";
+import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
+import { surveyCache } from "@/lib/survey/cache";
+import { evaluateLogic } from "@/lib/surveyLogic/utils";
+import { ActionClass, Prisma, Survey } from "@prisma/client";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { testInputValidation } from "vitestSetup";
+import { PrismaErrorType } from "@formbricks/database/types/error";
+import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
+import { TActionClass } from "@formbricks/types/action-classes";
+import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
+import { TSegment } from "@formbricks/types/segment";
+import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
+import {
+ mockActionClass,
+ mockId,
+ mockOrganizationOutput,
+ mockSurveyOutput,
+ mockSurveyWithLogic,
+ mockTransformedSurveyOutput,
+ updateSurveyInput,
+} from "./__mock__/survey.mock";
+import {
+ createSurvey,
+ getSurvey,
+ getSurveyCount,
+ getSurveyIdByResultShareKey,
+ getSurveys,
+ getSurveysByActionClassId,
+ getSurveysBySegmentId,
+ handleTriggerUpdates,
+ loadNewSegmentInSurvey,
+ updateSurvey,
+} from "./service";
+
+vi.mock("./cache", () => ({
+ surveyCache: {
+ revalidate: vi.fn(),
+ tag: {
+ byId: vi.fn().mockImplementation((id) => `survey-${id}`),
+ byEnvironmentId: vi.fn().mockImplementation((id) => `survey-env-${id}`),
+ byActionClassId: vi.fn().mockImplementation((id) => `survey-action-${id}`),
+ bySegmentId: vi.fn().mockImplementation((id) => `survey-segment-${id}`),
+ byResultShareKey: vi.fn().mockImplementation((key) => `survey-share-${key}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/segment", () => ({
+ segmentCache: {
+ revalidate: vi.fn(),
+ tag: {
+ byId: vi.fn().mockImplementation((id) => `segment-${id}`),
+ byEnvironmentId: vi.fn().mockImplementation((id) => `segment-env-${id}`),
+ },
+ },
+}));
+
+// Mock organization service
+vi.mock("@/lib/organization/service", () => ({
+ getOrganizationByEnvironmentId: vi.fn().mockResolvedValue({
+ id: "org123",
+ }),
+ subscribeOrganizationMembersToSurveyResponses: vi.fn(),
+}));
+
+// Mock posthogServer
+vi.mock("@/lib/posthogServer", () => ({
+ capturePosthogEnvironmentEvent: vi.fn(),
+}));
+
+// Mock actionClass service
+vi.mock("@/lib/actionClass/service", () => ({
+ getActionClasses: vi.fn(),
+}));
+
+beforeEach(() => {
+ prisma.survey.count.mockResolvedValue(1);
+});
+
+describe("evaluateLogic with mockSurveyWithLogic", () => {
+ test("should return true when q1 answer is blue", () => {
+ const data = { q1: "blue" };
+ const variablesData = {};
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[0].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+
+ test("should return false when q1 answer is not blue", () => {
+ const data = { q1: "red" };
+ const variablesData = {};
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[0].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(false);
+ });
+
+ test("should return true when q1 is blue and q2 is pizza", () => {
+ const data = { q1: "blue", q2: "pizza" };
+ const variablesData = {};
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[1].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+
+ test("should return false when q1 is blue but q2 is not pizza", () => {
+ const data = { q1: "blue", q2: "burger" };
+ const variablesData = {};
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[1].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(false);
+ });
+
+ test("should return true when q2 is pizza or q3 is Inception", () => {
+ const data = { q2: "pizza", q3: "Inception" };
+ const variablesData = {};
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[2].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+
+ test("should return true when var1 is equal to single select question value", () => {
+ const data = { q4: "lmao" };
+ const variablesData = { siog1dabtpo3l0a3xoxw2922: "lmao" };
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[3].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+
+ test("should return false when var1 is not equal to single select question value", () => {
+ const data = { q4: "lol" };
+ const variablesData = { siog1dabtpo3l0a3xoxw2922: "damn" };
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[3].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(false);
+ });
+
+ test("should return true when var2 is greater than 30 and less than open text number value", () => {
+ const data = { q5: "40" };
+ const variablesData = { km1srr55owtn2r7lkoh5ny1u: 35 };
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[4].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+
+ test("should return false when var2 is not greater than 30 or greater than open text number value", () => {
+ const data = { q5: "40" };
+ const variablesData = { km1srr55owtn2r7lkoh5ny1u: 25 };
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[4].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(false);
+ });
+
+ test("should return for complex condition", () => {
+ const data = { q6: ["lmao", "XD"], q1: "green", q2: "pizza", q3: "inspection", name: "pizza" };
+ const variablesData = { siog1dabtpo3l0a3xoxw2922: "tokyo" };
+
+ const result = evaluateLogic(
+ mockSurveyWithLogic,
+ data,
+ variablesData,
+ mockSurveyWithLogic.questions[5].logic![0].conditions,
+ "default"
+ );
+ expect(result).toBe(true);
+ });
+});
+
+describe("Tests for getSurvey", () => {
+ describe("Happy Path", () => {
+ test("Returns a survey", async () => {
+ prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
+ const survey = await getSurvey(mockId);
+ expect(survey).toEqual(mockTransformedSurveyOutput);
+ });
+
+ test("Returns null if survey is not found", async () => {
+ prisma.survey.findUnique.mockResolvedValueOnce(null);
+ const survey = await getSurvey(mockId);
+ expect(survey).toBeNull();
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(getSurvey, "123#");
+
+ test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
+ const mockErrorMessage = "Mock error message";
+ const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "0.0.1",
+ });
+ prisma.survey.findUnique.mockRejectedValue(errToThrow);
+ await expect(getSurvey(mockId)).rejects.toThrow(DatabaseError);
+ });
+
+ test("should throw an error if there is an unknown error", async () => {
+ const mockErrorMessage = "Mock error message";
+ prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage));
+ await expect(getSurvey(mockId)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for getSurveysByActionClassId", () => {
+ describe("Happy Path", () => {
+ test("Returns an array of surveys for a given actionClassId", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
+ const surveys = await getSurveysByActionClassId(mockId);
+ expect(surveys).toEqual([mockTransformedSurveyOutput]);
+ });
+
+ test("Returns an empty array if no surveys are found", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([]);
+ const surveys = await getSurveysByActionClassId(mockId);
+ expect(surveys).toEqual([]);
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(getSurveysByActionClassId, "123#");
+
+ test("should throw an error if there is an unknown error", async () => {
+ const mockErrorMessage = "Unknown error occurred";
+ prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
+ await expect(getSurveysByActionClassId(mockId)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for getSurveys", () => {
+ describe("Happy Path", () => {
+ test("Returns an array of surveys for a given environmentId, limit(optional) and offset(optional)", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
+ const surveys = await getSurveys(mockId);
+ expect(surveys).toEqual([mockTransformedSurveyOutput]);
+ });
+
+ test("Returns an empty array if no surveys are found", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([]);
+
+ const surveys = await getSurveys(mockId);
+ expect(surveys).toEqual([]);
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(getSurveysByActionClassId, "123#");
+
+ test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
+ const mockErrorMessage = "Mock error message";
+ const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "0.0.1",
+ });
+
+ prisma.survey.findMany.mockRejectedValue(errToThrow);
+ await expect(getSurveys(mockId)).rejects.toThrow(DatabaseError);
+ });
+
+ test("should throw an error if there is an unknown error", async () => {
+ const mockErrorMessage = "Unknown error occurred";
+ prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
+ await expect(getSurveys(mockId)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for updateSurvey", () => {
+ beforeEach(() => {
+ vi.mocked(getActionClasses).mockResolvedValueOnce([mockActionClass] as TActionClass[]);
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
+ });
+
+ describe("Happy Path", () => {
+ test("Updates a survey successfully", async () => {
+ prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
+ prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput);
+ const updatedSurvey = await updateSurvey(updateSurveyInput);
+ expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(updateSurvey, "123#");
+
+ test("Throws ResourceNotFoundError if the survey does not exist", async () => {
+ prisma.survey.findUnique.mockRejectedValueOnce(
+ new ResourceNotFoundError("Survey", updateSurveyInput.id)
+ );
+ await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
+ const mockErrorMessage = "Mock error message";
+ const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "0.0.1",
+ });
+ prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
+ prisma.survey.update.mockRejectedValue(errToThrow);
+ await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(DatabaseError);
+ });
+
+ test("should throw an error if there is an unknown error", async () => {
+ const mockErrorMessage = "Unknown error occurred";
+ prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
+ prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage));
+ await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for getSurveyCount service", () => {
+ describe("Happy Path", () => {
+ test("Counts the total number of surveys for a given environment ID", async () => {
+ const count = await getSurveyCount(mockId);
+ expect(count).toEqual(1);
+ });
+
+ test("Returns zero count when there are no surveys for a given environment ID", async () => {
+ prisma.survey.count.mockResolvedValue(0);
+ const count = await getSurveyCount(mockId);
+ expect(count).toEqual(0);
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(getSurveyCount, "123#");
+
+ test("Throws a generic Error for other unexpected issues", async () => {
+ const mockErrorMessage = "Mock error message";
+ prisma.survey.count.mockRejectedValue(new Error(mockErrorMessage));
+
+ await expect(getSurveyCount(mockId)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for handleTriggerUpdates", () => {
+ const mockEnvironmentId = "env-123";
+ const mockActionClassId1 = "action-123";
+ const mockActionClassId2 = "action-456";
+
+ const mockActionClasses: ActionClass[] = [
+ {
+ id: mockActionClassId1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ name: "Test Action 1",
+ description: "Test action description 1",
+ type: "code",
+ key: "test-action-1",
+ noCodeConfig: null,
+ },
+ {
+ id: mockActionClassId2,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ name: "Test Action 2",
+ description: "Test action description 2",
+ type: "code",
+ key: "test-action-2",
+ noCodeConfig: null,
+ },
+ ];
+
+ test("adds new triggers correctly", () => {
+ const updatedTriggers = [
+ {
+ actionClass: {
+ id: mockActionClassId1,
+ name: "Test Action 1",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-1",
+ },
+ },
+ ] as TSurvey["triggers"];
+ const currentTriggers = [];
+
+ const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses);
+
+ expect(result).toHaveProperty("create");
+ expect(result.create).toEqual([{ actionClassId: mockActionClassId1 }]);
+ expect(surveyCache.revalidate).toHaveBeenCalledWith({ actionClassId: mockActionClassId1 });
+ });
+
+ test("removes deleted triggers correctly", () => {
+ const updatedTriggers = [];
+ const currentTriggers = [
+ {
+ actionClass: {
+ id: mockActionClassId1,
+ name: "Test Action 1",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-1",
+ },
+ },
+ ] as TSurvey["triggers"];
+
+ const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses);
+
+ expect(result).toHaveProperty("deleteMany");
+ expect(result.deleteMany).toEqual({ actionClassId: { in: [mockActionClassId1] } });
+ expect(surveyCache.revalidate).toHaveBeenCalledWith({ actionClassId: mockActionClassId1 });
+ });
+
+ test("handles both adding and removing triggers", () => {
+ const updatedTriggers = [
+ {
+ actionClass: {
+ id: mockActionClassId2,
+ name: "Test Action 2",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-2",
+ },
+ },
+ ] as TSurvey["triggers"];
+
+ const currentTriggers = [
+ {
+ actionClass: {
+ id: mockActionClassId1,
+ name: "Test Action 1",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-1",
+ },
+ },
+ ] as TSurvey["triggers"];
+
+ const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses);
+
+ expect(result).toHaveProperty("create");
+ expect(result).toHaveProperty("deleteMany");
+ expect(result.create).toEqual([{ actionClassId: mockActionClassId2 }]);
+ expect(result.deleteMany).toEqual({ actionClassId: { in: [mockActionClassId1] } });
+ expect(surveyCache.revalidate).toHaveBeenCalledTimes(2);
+ });
+
+ test("returns empty object when no triggers provided", () => {
+ // @ts-expect-error -- This is a test case to check the empty input
+ const result = handleTriggerUpdates(undefined, [], mockActionClasses);
+ expect(result).toEqual({});
+ });
+
+ test("throws InvalidInputError for invalid trigger IDs", () => {
+ const updatedTriggers = [
+ {
+ actionClass: {
+ id: "invalid-action-id",
+ name: "Invalid Action",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "invalid-action",
+ },
+ },
+ ] as TSurvey["triggers"];
+
+ const currentTriggers = [];
+
+ expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses)).toThrow(
+ InvalidInputError
+ );
+ });
+
+ test("throws InvalidInputError for duplicate trigger IDs", () => {
+ const updatedTriggers = [
+ {
+ actionClass: {
+ id: mockActionClassId1,
+ name: "Test Action 1",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-1",
+ },
+ },
+ {
+ actionClass: {
+ id: mockActionClassId1, // Duplicated ID
+ name: "Test Action 1",
+ environmentId: mockEnvironmentId,
+ type: "code",
+ key: "test-action-1",
+ },
+ },
+ ] as TSurvey["triggers"];
+ const currentTriggers = [];
+
+ expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses)).toThrow(
+ InvalidInputError
+ );
+ });
+});
+
+describe("Tests for createSurvey", () => {
+ const mockEnvironmentId = "env123";
+ const mockUserId = "user123";
+
+ const mockCreateSurveyInput = {
+ name: "Test Survey",
+ type: "app" as const,
+ createdBy: mockUserId,
+ status: "inProgress" as const,
+ welcomeCard: {
+ enabled: true,
+ headline: { default: "Welcome" },
+ html: { default: "
Welcome to our survey
" },
+ },
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ inputType: "text",
+ headline: { default: "What is your favorite color?" },
+ required: true,
+ charLimit: {
+ enabled: false,
+ },
+ },
+ {
+ id: "q2",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ inputType: "text",
+ headline: { default: "What is your favorite food?" },
+ required: true,
+ charLimit: {
+ enabled: false,
+ },
+ },
+ {
+ id: "q3",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ inputType: "text",
+ headline: { default: "What is your favorite movie?" },
+ required: true,
+ charLimit: {
+ enabled: false,
+ },
+ },
+ {
+ id: "q4",
+ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
+ headline: { default: "Select a number:" },
+ choices: [
+ { id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
+ { id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
+ { id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
+ { id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
+ ],
+ required: true,
+ },
+ {
+ id: "q5",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ inputType: "number",
+ headline: { default: "Select your age group:" },
+ required: true,
+ charLimit: {
+ enabled: false,
+ },
+ },
+ {
+ id: "q6",
+ type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
+ headline: { default: "Select your age group:" },
+ required: true,
+ choices: [
+ { id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
+ { id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
+ { id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
+ { id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
+ ],
+ },
+ ],
+ variables: [],
+ hiddenFields: { enabled: false, fieldIds: [] },
+ endings: [],
+ displayOption: "respondMultiple" as const,
+ languages: [],
+ } as TSurveyCreateInput;
+
+ const mockActionClasses = [
+ {
+ id: "action-123",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ environmentId: mockEnvironmentId,
+ name: "Test Action",
+ description: "Test action description",
+ type: "code",
+ key: "test-action",
+ noCodeConfig: null,
+ },
+ ];
+
+ beforeEach(() => {
+ vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses as TActionClass[]);
+ });
+
+ describe("Happy Path", () => {
+ test("creates a survey successfully", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
+ prisma.survey.create.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ });
+
+ const result = await createSurvey(mockEnvironmentId, mockCreateSurveyInput);
+
+ expect(prisma.survey.create).toHaveBeenCalled();
+ expect(result.name).toEqual(mockSurveyOutput.name);
+ expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
+ expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
+ });
+
+ test("creates a private segment for app surveys", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
+ prisma.survey.create.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ type: "app",
+ });
+
+ prisma.segment.create.mockResolvedValueOnce({
+ id: "segment-123",
+ environmentId: mockEnvironmentId,
+ title: mockSurveyOutput.id,
+ isPrivate: true,
+ filters: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as unknown as TSegment);
+
+ await createSurvey(mockEnvironmentId, {
+ ...mockCreateSurveyInput,
+ type: "app",
+ });
+
+ expect(prisma.segment.create).toHaveBeenCalled();
+ expect(prisma.survey.update).toHaveBeenCalled();
+ expect(segmentCache.revalidate).toHaveBeenCalled();
+ });
+
+ test("creates survey with follow-ups", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
+ const followUp = {
+ id: "followup1",
+ name: "Follow up 1",
+ trigger: { type: "response", properties: null },
+ action: {
+ type: "send-email",
+ properties: {
+ to: "abc@example.com",
+ attachResponseData: true,
+ body: "Hello",
+ from: "hello@exmaple.com",
+ replyTo: ["hello@example.com"],
+ subject: "Follow up",
+ },
+ },
+ surveyId: mockSurveyOutput.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ } as TSurveyFollowUp;
+
+ const surveyWithFollowUps = {
+ ...mockCreateSurveyInput,
+ followUps: [followUp],
+ };
+
+ prisma.survey.create.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ });
+
+ await createSurvey(mockEnvironmentId, surveyWithFollowUps);
+
+ expect(prisma.survey.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ followUps: {
+ create: [
+ expect.objectContaining({
+ name: "Follow up 1",
+ }),
+ ],
+ },
+ }),
+ })
+ );
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(createSurvey, "123#", mockCreateSurveyInput);
+
+ test("throws ResourceNotFoundError if organization not found", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
+ await expect(createSurvey(mockEnvironmentId, mockCreateSurveyInput)).rejects.toThrow(
+ ResourceNotFoundError
+ );
+ });
+
+ test("throws DatabaseError if there is a Prisma error", async () => {
+ vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
+ const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "1.0.0",
+ });
+ prisma.survey.create.mockRejectedValueOnce(mockError);
+
+ await expect(createSurvey(mockEnvironmentId, mockCreateSurveyInput)).rejects.toThrow(DatabaseError);
+ });
+ });
+});
+
+describe("Tests for getSurveyIdByResultShareKey", () => {
+ const mockResultShareKey = "share-key-123";
+
+ describe("Happy Path", () => {
+ test("returns survey ID when found", async () => {
+ prisma.survey.findFirst.mockResolvedValueOnce({
+ id: mockId,
+ } as Survey);
+
+ const result = await getSurveyIdByResultShareKey(mockResultShareKey);
+
+ expect(prisma.survey.findFirst).toHaveBeenCalledWith({
+ where: { resultShareKey: mockResultShareKey },
+ select: { id: true },
+ });
+ expect(result).toBe(mockId);
+ });
+
+ test("returns null when survey not found", async () => {
+ prisma.survey.findFirst.mockResolvedValueOnce(null);
+
+ const result = await getSurveyIdByResultShareKey(mockResultShareKey);
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("Sad Path", () => {
+ test("throws DatabaseError on Prisma error", async () => {
+ const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "1.0.0",
+ });
+ prisma.survey.findFirst.mockRejectedValueOnce(mockError);
+
+ await expect(getSurveyIdByResultShareKey(mockResultShareKey)).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws error on unexpected error", async () => {
+ prisma.survey.findFirst.mockRejectedValueOnce(new Error("Unexpected error"));
+
+ await expect(getSurveyIdByResultShareKey(mockResultShareKey)).rejects.toThrow(Error);
+ });
+ });
+});
+
+describe("Tests for loadNewSegmentInSurvey", () => {
+ const mockSurveyId = mockId;
+ const mockNewSegmentId = "segment456";
+ const mockCurrentSegmentId = "segment-123";
+ const mockEnvironmentId = "env-123";
+
+ describe("Happy Path", () => {
+ test("loads new segment successfully", async () => {
+ // Set up mocks for existing survey
+ prisma.survey.findUnique.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ });
+ // Mock segment exists
+ prisma.segment.findUnique.mockResolvedValueOnce({
+ id: mockNewSegmentId,
+ environmentId: mockEnvironmentId,
+ filters: [],
+ title: "Test Segment",
+ isPrivate: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ description: "Test Segment Description",
+ });
+ // Mock survey update
+ prisma.survey.update.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ segmentId: mockNewSegmentId,
+ });
+ const result = await loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId);
+ expect(prisma.survey.update).toHaveBeenCalledWith({
+ where: { id: mockSurveyId },
+ data: {
+ segment: {
+ connect: {
+ id: mockNewSegmentId,
+ },
+ },
+ },
+ select: expect.anything(),
+ });
+ expect(result).toEqual(
+ expect.objectContaining({
+ segmentId: mockNewSegmentId,
+ })
+ );
+ expect(surveyCache.revalidate).toHaveBeenCalledWith({ id: mockSurveyId });
+ expect(segmentCache.revalidate).toHaveBeenCalledWith({ id: mockNewSegmentId });
+ });
+
+ test("deletes private segment when changing to a new segment", async () => {
+ const mockSegment = {
+ id: mockCurrentSegmentId,
+ environmentId: mockEnvironmentId,
+ title: mockId, // Private segments have title = surveyId
+ isPrivate: true,
+ filters: [],
+ surveys: [mockSurveyId],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ description: "Test Segment Description",
+ };
+
+ // Set up mocks for existing survey with private segment
+ prisma.survey.findUnique.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ segment: mockSegment,
+ } as Survey);
+
+ // Mock segment exists
+ prisma.segment.findUnique.mockResolvedValueOnce({
+ ...mockSegment,
+ id: mockNewSegmentId,
+ environmentId: mockEnvironmentId,
+ });
+
+ // Mock survey update
+ prisma.survey.update.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ segment: {
+ id: mockNewSegmentId,
+ environmentId: mockEnvironmentId,
+ title: "Test Segment",
+ isPrivate: false,
+ filters: [],
+ surveys: [{ id: mockSurveyId }],
+ },
+ } as Survey);
+
+ // Mock segment delete
+ prisma.segment.delete.mockResolvedValueOnce({
+ id: mockCurrentSegmentId,
+ environmentId: mockEnvironmentId,
+ surveys: [{ id: mockSurveyId }],
+ } as unknown as TSegment);
+
+ await loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId);
+
+ // Verify the private segment was deleted
+ expect(prisma.segment.delete).toHaveBeenCalledWith({
+ where: { id: mockCurrentSegmentId },
+ select: expect.anything(),
+ });
+ // Verify the cache was invalidated
+ expect(segmentCache.revalidate).toHaveBeenCalledWith({ id: mockCurrentSegmentId });
+ });
+ });
+
+ describe("Sad Path", () => {
+ testInputValidation(loadNewSegmentInSurvey, "123#", "123#");
+
+ test("throws ResourceNotFoundError when survey not found", async () => {
+ prisma.survey.findUnique.mockResolvedValueOnce(null);
+
+ await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow(
+ ResourceNotFoundError
+ );
+ });
+
+ test("throws ResourceNotFoundError when segment not found", async () => {
+ // Set up mock for existing survey
+ prisma.survey.findUnique.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ });
+
+ // Segment not found
+ prisma.segment.findUnique.mockResolvedValueOnce(null);
+
+ await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow(
+ ResourceNotFoundError
+ );
+ });
+
+ test("throws DatabaseError on Prisma error", async () => {
+ // Set up mock for existing survey
+ prisma.survey.findUnique.mockResolvedValueOnce({
+ ...mockSurveyOutput,
+ });
+
+ // // Mock segment exists
+ prisma.segment.findUnique.mockResolvedValueOnce({
+ id: mockNewSegmentId,
+ environmentId: mockEnvironmentId,
+ filters: [],
+ title: "Test Segment",
+ isPrivate: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ description: "Test Segment Description",
+ });
+
+ // Mock Prisma error on update
+ const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "1.0.0",
+ });
+
+ prisma.survey.update.mockRejectedValueOnce(mockError);
+
+ await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow(DatabaseError);
+ });
+ });
+});
+
+describe("Tests for getSurveysBySegmentId", () => {
+ const mockSegmentId = "segment-123";
+
+ describe("Happy Path", () => {
+ test("returns surveys associated with a segment", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
+
+ const result = await getSurveysBySegmentId(mockSegmentId);
+
+ expect(prisma.survey.findMany).toHaveBeenCalledWith({
+ where: { segmentId: mockSegmentId },
+ select: expect.anything(),
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(
+ expect.objectContaining({
+ id: mockSurveyOutput.id,
+ })
+ );
+ });
+
+ test("returns empty array when no surveys found", async () => {
+ prisma.survey.findMany.mockResolvedValueOnce([]);
+
+ const result = await getSurveysBySegmentId(mockSegmentId);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("Sad Path", () => {
+ test("throws DatabaseError on Prisma error", async () => {
+ const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
+ code: PrismaErrorType.UniqueConstraintViolation,
+ clientVersion: "1.0.0",
+ });
+ prisma.survey.findMany.mockRejectedValueOnce(mockError);
+
+ await expect(getSurveysBySegmentId(mockSegmentId)).rejects.toThrow(DatabaseError);
+ });
+
+ test("throws error on unexpected error", async () => {
+ prisma.survey.findMany.mockRejectedValueOnce(new Error("Unexpected error"));
+
+ await expect(getSurveysBySegmentId(mockSegmentId)).rejects.toThrow(Error);
+ });
+ });
+});
diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts
index 155744d357..f440ba8d50 100644
--- a/apps/web/lib/survey/service.ts
+++ b/apps/web/lib/survey/service.ts
@@ -109,7 +109,7 @@ export const selectSurvey = {
followUps: true,
} satisfies Prisma.SurveySelect;
-const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
+export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
if (!triggers) return;
// check if all the triggers are valid
diff --git a/apps/web/lib/survey/tests/survey.test.ts b/apps/web/lib/survey/tests/survey.test.ts
deleted file mode 100644
index 0fe4eb62e1..0000000000
--- a/apps/web/lib/survey/tests/survey.test.ts
+++ /dev/null
@@ -1,476 +0,0 @@
-import { prisma } from "@/lib/__mocks__/database";
-import { evaluateLogic } from "@/lib/surveyLogic/utils";
-import { Prisma } from "@prisma/client";
-import { beforeEach, describe, expect, test } from "vitest";
-import { testInputValidation } from "vitestSetup";
-import { PrismaErrorType } from "@formbricks/database/types/error";
-import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
-import { getSurvey, getSurveyCount, getSurveys, getSurveysByActionClassId, updateSurvey } from "../service";
-import {
- mockActionClass,
- mockId,
- mockOrganizationOutput,
- mockSurveyOutput,
- mockSurveyWithLogic,
- mockTransformedSurveyOutput,
- updateSurveyInput,
-} from "./__mock__/survey.mock";
-
-beforeEach(() => {
- prisma.survey.count.mockResolvedValue(1);
-});
-
-describe("evaluateLogic with mockSurveyWithLogic", () => {
- test("should return true when q1 answer is blue", () => {
- const data = { q1: "blue" };
- const variablesData = {};
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[0].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-
- test("should return false when q1 answer is not blue", () => {
- const data = { q1: "red" };
- const variablesData = {};
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[0].logic![0].conditions,
- "default"
- );
- expect(result).toBe(false);
- });
-
- test("should return true when q1 is blue and q2 is pizza", () => {
- const data = { q1: "blue", q2: "pizza" };
- const variablesData = {};
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[1].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-
- test("should return false when q1 is blue but q2 is not pizza", () => {
- const data = { q1: "blue", q2: "burger" };
- const variablesData = {};
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[1].logic![0].conditions,
- "default"
- );
- expect(result).toBe(false);
- });
-
- test("should return true when q2 is pizza or q3 is Inception", () => {
- const data = { q2: "pizza", q3: "Inception" };
- const variablesData = {};
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[2].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-
- test("should return true when var1 is equal to single select question value", () => {
- const data = { q4: "lmao" };
- const variablesData = { siog1dabtpo3l0a3xoxw2922: "lmao" };
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[3].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-
- test("should return false when var1 is not equal to single select question value", () => {
- const data = { q4: "lol" };
- const variablesData = { siog1dabtpo3l0a3xoxw2922: "damn" };
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[3].logic![0].conditions,
- "default"
- );
- expect(result).toBe(false);
- });
-
- test("should return true when var2 is greater than 30 and less than open text number value", () => {
- const data = { q5: "40" };
- const variablesData = { km1srr55owtn2r7lkoh5ny1u: 35 };
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[4].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-
- test("should return false when var2 is not greater than 30 or greater than open text number value", () => {
- const data = { q5: "40" };
- const variablesData = { km1srr55owtn2r7lkoh5ny1u: 25 };
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[4].logic![0].conditions,
- "default"
- );
- expect(result).toBe(false);
- });
-
- test("should return for complex condition", () => {
- const data = { q6: ["lmao", "XD"], q1: "green", q2: "pizza", q3: "inspection", name: "pizza" };
- const variablesData = { siog1dabtpo3l0a3xoxw2922: "tokyo" };
-
- const result = evaluateLogic(
- mockSurveyWithLogic,
- data,
- variablesData,
- mockSurveyWithLogic.questions[5].logic![0].conditions,
- "default"
- );
- expect(result).toBe(true);
- });
-});
-
-describe("Tests for getSurvey", () => {
- describe("Happy Path", () => {
- test("Returns a survey", async () => {
- prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
- const survey = await getSurvey(mockId);
- expect(survey).toEqual(mockTransformedSurveyOutput);
- });
-
- test("Returns null if survey is not found", async () => {
- prisma.survey.findUnique.mockResolvedValueOnce(null);
- const survey = await getSurvey(mockId);
- expect(survey).toBeNull();
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(getSurvey, "123#");
-
- test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
- const mockErrorMessage = "Mock error message";
- const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
- code: PrismaErrorType.UniqueConstraintViolation,
- clientVersion: "0.0.1",
- });
- prisma.survey.findUnique.mockRejectedValue(errToThrow);
- await expect(getSurvey(mockId)).rejects.toThrow(DatabaseError);
- });
-
- test("should throw an error if there is an unknown error", async () => {
- const mockErrorMessage = "Mock error message";
- prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage));
- await expect(getSurvey(mockId)).rejects.toThrow(Error);
- });
- });
-});
-
-describe("Tests for getSurveysByActionClassId", () => {
- describe("Happy Path", () => {
- test("Returns an array of surveys for a given actionClassId", async () => {
- prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
- const surveys = await getSurveysByActionClassId(mockId);
- expect(surveys).toEqual([mockTransformedSurveyOutput]);
- });
-
- test("Returns an empty array if no surveys are found", async () => {
- prisma.survey.findMany.mockResolvedValueOnce([]);
- const surveys = await getSurveysByActionClassId(mockId);
- expect(surveys).toEqual([]);
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(getSurveysByActionClassId, "123#");
-
- test("should throw an error if there is an unknown error", async () => {
- const mockErrorMessage = "Unknown error occurred";
- prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
- await expect(getSurveysByActionClassId(mockId)).rejects.toThrow(Error);
- });
- });
-});
-
-describe("Tests for getSurveys", () => {
- describe("Happy Path", () => {
- test("Returns an array of surveys for a given environmentId, limit(optional) and offset(optional)", async () => {
- prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
- const surveys = await getSurveys(mockId);
- expect(surveys).toEqual([mockTransformedSurveyOutput]);
- });
-
- test("Returns an empty array if no surveys are found", async () => {
- prisma.survey.findMany.mockResolvedValueOnce([]);
-
- const surveys = await getSurveys(mockId);
- expect(surveys).toEqual([]);
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(getSurveysByActionClassId, "123#");
-
- test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
- const mockErrorMessage = "Mock error message";
- const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
- code: PrismaErrorType.UniqueConstraintViolation,
- clientVersion: "0.0.1",
- });
-
- prisma.survey.findMany.mockRejectedValue(errToThrow);
- await expect(getSurveys(mockId)).rejects.toThrow(DatabaseError);
- });
-
- test("should throw an error if there is an unknown error", async () => {
- const mockErrorMessage = "Unknown error occurred";
- prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage));
- await expect(getSurveys(mockId)).rejects.toThrow(Error);
- });
- });
-});
-
-describe("Tests for updateSurvey", () => {
- beforeEach(() => {
- prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
- });
- describe("Happy Path", () => {
- test("Updates a survey successfully", async () => {
- prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
- prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
- prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput);
- const updatedSurvey = await updateSurvey(updateSurveyInput);
- expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(updateSurvey, "123#");
-
- test("Throws ResourceNotFoundError if the survey does not exist", async () => {
- prisma.survey.findUnique.mockRejectedValueOnce(
- new ResourceNotFoundError("Survey", updateSurveyInput.id)
- );
- await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(ResourceNotFoundError);
- });
-
- test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
- const mockErrorMessage = "Mock error message";
- const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
- code: PrismaErrorType.UniqueConstraintViolation,
- clientVersion: "0.0.1",
- });
- prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
- prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
- prisma.survey.update.mockRejectedValue(errToThrow);
- await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(DatabaseError);
- });
-
- test("should throw an error if there is an unknown error", async () => {
- const mockErrorMessage = "Unknown error occurred";
- prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
- prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage));
- await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error);
- });
- });
-});
-
-// describe("Tests for createSurvey", () => {
-// beforeEach(() => {
-// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
-// });
-
-// describe("Happy Path", () => {
-// test("Creates a survey successfully", async () => {
-// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
-// prisma.organization.findFirst.mockResolvedValueOnce(mockOrganizationOutput);
-// prisma.actionClass.findMany.mockResolvedValue([mockActionClass]);
-// prisma.user.findMany.mockResolvedValueOnce([
-// {
-// ...mockUser,
-// twoFactorSecret: null,
-// backupCodes: null,
-// password: null,
-// identityProviderAccountId: null,
-// groupId: null,
-// role: "engineer",
-// },
-// ]);
-// prisma.user.update.mockResolvedValueOnce({
-// ...mockUser,
-// twoFactorSecret: null,
-// backupCodes: null,
-// password: null,
-// identityProviderAccountId: null,
-// groupId: null,
-// role: "engineer",
-// });
-// const createdSurvey = await createSurvey(mockId, createSurveyInput);
-// expect(createdSurvey).toEqual(mockTransformedSurveyOutput);
-// });
-// });
-
-// describe("Sad Path", () => {
-// testInputValidation(createSurvey, "123#", createSurveyInput);
-
-// test("should throw an error if there is an unknown error", async () => {
-// const mockErrorMessage = "Unknown error occurred";
-// prisma.survey.delete.mockRejectedValue(new Error(mockErrorMessage));
-// await expect(createSurvey(mockId, createSurveyInput)).rejects.toThrow(Error);
-// });
-// });
-// });
-
-// describe("Tests for duplicateSurvey", () => {
-// beforeEach(() => {
-// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
-// });
-
-// describe("Happy Path", () => {
-// test("Duplicates a survey successfully", async () => {
-// prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
-// prisma.survey.create.mockResolvedValueOnce(mockSurveyOutput);
-// // @ts-expect-error
-// prisma.environment.findUnique.mockResolvedValueOnce(mockEnvironment);
-// // @ts-expect-error
-// prisma.project.findFirst.mockResolvedValueOnce(mockProject);
-// prisma.actionClass.findFirst.mockResolvedValueOnce(mockActionClass);
-// prisma.actionClass.create.mockResolvedValueOnce(mockActionClass);
-
-// const createdSurvey = await copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId);
-// expect(createdSurvey).toEqual(mockSurveyOutput);
-// });
-// });
-
-// describe("Sad Path", () => {
-// testInputValidation(copySurveyToOtherEnvironment, "123#", "123#", "123#", "123#", "123#");
-
-// test("Throws ResourceNotFoundError if the survey does not exist", async () => {
-// prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
-// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(
-// ResourceNotFoundError
-// );
-// });
-
-// test("should throw an error if there is an unknown error", async () => {
-// const mockErrorMessage = "Unknown error occurred";
-// prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
-// await expect(copySurveyToOtherEnvironment(mockId, mockId, mockId, mockId)).rejects.toThrow(Error);
-// });
-// });
-// });
-
-// describe("Tests for getSyncSurveys", () => {
-// describe("Happy Path", () => {
-// beforeEach(() => {
-// prisma.project.findFirst.mockResolvedValueOnce({
-// ...mockProject,
-// brandColor: null,
-// highlightBorderColor: null,
-// logo: null,
-// });
-// prisma.display.findMany.mockResolvedValueOnce([mockDisplay]);
-// prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
-// });
-
-// test("Returns synced surveys", async () => {
-// prisma.survey.findMany.mockResolvedValueOnce([mockSyncSurveyOutput]);
-// prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson);
-// prisma.response.findMany.mockResolvedValue([mockResponseWithMockPerson]);
-// prisma.responseNote.findMany.mockResolvedValue([mockResponseNote]);
-
-// const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", {
-// version: "1.7.0",
-// });
-// expect(surveys).toEqual([mockTransformedSyncSurveyOutput]);
-// });
-
-// test("Returns an empty array if no surveys are found", async () => {
-// prisma.survey.findMany.mockResolvedValueOnce([]);
-// prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson);
-// const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", {
-// version: "1.7.0",
-// });
-// expect(surveys).toEqual([]);
-// });
-// });
-
-// describe("Sad Path", () => {
-// testInputValidation(getSyncSurveys, "123#", {});
-
-// test("does not find a Project", async () => {
-// prisma.project.findFirst.mockResolvedValueOnce(null);
-
-// await expect(
-// getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" })
-// ).rejects.toThrow(Error);
-// });
-
-// test("should throw an error if there is an unknown error", async () => {
-// const mockErrorMessage = "Unknown error occurred";
-// prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]);
-// prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage));
-// await expect(
-// getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" })
-// ).rejects.toThrow(Error);
-// });
-// });
-// });
-
-describe("Tests for getSurveyCount service", () => {
- describe("Happy Path", () => {
- test("Counts the total number of surveys for a given environment ID", async () => {
- const count = await getSurveyCount(mockId);
- expect(count).toEqual(1);
- });
-
- test("Returns zero count when there are no surveys for a given environment ID", async () => {
- prisma.survey.count.mockResolvedValue(0);
- const count = await getSurveyCount(mockId);
- expect(count).toEqual(0);
- });
- });
-
- describe("Sad Path", () => {
- testInputValidation(getSurveyCount, "123#");
-
- test("Throws a generic Error for other unexpected issues", async () => {
- const mockErrorMessage = "Mock error message";
- prisma.survey.count.mockRejectedValue(new Error(mockErrorMessage));
-
- await expect(getSurveyCount(mockId)).rejects.toThrow(Error);
- });
- });
-});
diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts
new file mode 100644
index 0000000000..18dee96bce
--- /dev/null
+++ b/apps/web/lib/survey/utils.test.ts
@@ -0,0 +1,254 @@
+import * as fileValidation from "@/lib/fileValidation";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { InvalidInputError } from "@formbricks/types/errors";
+import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
+import { TSegment } from "@formbricks/types/segment";
+import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
+import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
+
+describe("transformPrismaSurvey", () => {
+ test("transforms prisma survey without segment", () => {
+ const surveyPrisma = {
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: "30",
+ segment: null,
+ };
+
+ const result = transformPrismaSurvey(surveyPrisma);
+
+ expect(result).toEqual({
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: 30,
+ segment: null,
+ });
+ });
+
+ test("transforms prisma survey with segment", () => {
+ const surveyPrisma = {
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: "50",
+ segment: {
+ id: "segment1",
+ name: "Test Segment",
+ filters: [{ id: "filter1", type: "user" }],
+ surveys: [{ id: "survey1" }, { id: "survey2" }],
+ },
+ };
+
+ const result = transformPrismaSurvey(surveyPrisma);
+
+ expect(result).toEqual({
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: 50,
+ segment: {
+ id: "segment1",
+ name: "Test Segment",
+ filters: [{ id: "filter1", type: "user" }],
+ surveys: ["survey1", "survey2"],
+ },
+ });
+ });
+
+ test("transforms prisma survey with non-numeric displayPercentage", () => {
+ const surveyPrisma = {
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: "invalid",
+ };
+
+ const result = transformPrismaSurvey(surveyPrisma);
+
+ expect(result).toEqual({
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: null,
+ segment: null,
+ });
+ });
+
+ test("transforms prisma survey with undefined displayPercentage", () => {
+ const surveyPrisma = {
+ id: "survey1",
+ name: "Test Survey",
+ };
+
+ const result = transformPrismaSurvey(surveyPrisma);
+
+ expect(result).toEqual({
+ id: "survey1",
+ name: "Test Survey",
+ displayPercentage: null,
+ segment: null,
+ });
+ });
+});
+
+describe("anySurveyHasFilters", () => {
+ test("returns false when no surveys have segments", () => {
+ const surveys = [
+ { id: "survey1", name: "Survey 1" },
+ { id: "survey2", name: "Survey 2" },
+ ] as TSurvey[];
+
+ expect(anySurveyHasFilters(surveys)).toBe(false);
+ });
+
+ test("returns false when surveys have segments but no filters", () => {
+ const surveys = [
+ {
+ id: "survey1",
+ name: "Survey 1",
+ segment: {
+ id: "segment1",
+ title: "Segment 1",
+ filters: [],
+ createdAt: new Date(),
+ description: "Segment description",
+ environmentId: "env1",
+ isPrivate: true,
+ surveys: ["survey1"],
+ updatedAt: new Date(),
+ } as TSegment,
+ },
+ { id: "survey2", name: "Survey 2" },
+ ] as TSurvey[];
+
+ expect(anySurveyHasFilters(surveys)).toBe(false);
+ });
+
+ test("returns true when at least one survey has segment with filters", () => {
+ const surveys = [
+ { id: "survey1", name: "Survey 1" },
+ {
+ id: "survey2",
+ name: "Survey 2",
+ segment: {
+ id: "segment2",
+ filters: [
+ {
+ id: "filter1",
+ connector: null,
+ resource: {
+ root: { type: "attribute", contactAttributeKey: "attr-1" },
+ id: "attr-filter-1",
+ qualifier: { operator: "contains" },
+ value: "attr",
+ },
+ },
+ ],
+ createdAt: new Date(),
+ description: "Segment description",
+ environmentId: "env1",
+ isPrivate: true,
+ surveys: ["survey2"],
+ updatedAt: new Date(),
+ title: "Segment title",
+ } as TSegment,
+ },
+ ] as TSurvey[];
+
+ expect(anySurveyHasFilters(surveys)).toBe(true);
+ });
+});
+
+describe("checkForInvalidImagesInQuestions", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ test("does not throw error when no images are present", () => {
+ const questions = [
+ { id: "q1", type: TSurveyQuestionTypeEnum.OpenText },
+ { id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
+ });
+
+ test("does not throw error when all images are valid", () => {
+ vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
+
+ const questions = [
+ { id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "valid-image.jpg" },
+ { id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
+ expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("valid-image.jpg");
+ });
+
+ test("throws error when question image is invalid", () => {
+ vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
+
+ const questions = [
+ { id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "invalid-image.txt" },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
+ new InvalidInputError("Invalid image file in question 1")
+ );
+ expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("invalid-image.txt");
+ });
+
+ test("throws error when picture selection question has no choices", () => {
+ const questions = [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.PictureSelection,
+ },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
+ new InvalidInputError("Choices missing for question 1")
+ );
+ });
+
+ test("throws error when picture selection choice has invalid image", () => {
+ vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid-image.jpg");
+
+ const questions = [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.PictureSelection,
+ choices: [
+ { id: "c1", imageUrl: "valid-image.jpg" },
+ { id: "c2", imageUrl: "invalid-image.txt" },
+ ],
+ },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).toThrow(
+ new InvalidInputError("Invalid image file for choice 2 in question 1")
+ );
+
+ expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
+ expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "valid-image.jpg");
+ expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "invalid-image.txt");
+ });
+
+ test("validates all choices in picture selection questions", () => {
+ vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
+
+ const questions = [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.PictureSelection,
+ choices: [
+ { id: "c1", imageUrl: "image1.jpg" },
+ { id: "c2", imageUrl: "image2.jpg" },
+ { id: "c3", imageUrl: "image3.jpg" },
+ ],
+ },
+ ] as TSurveyQuestion[];
+
+ expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow();
+ expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
+ expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
+ expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
+ expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
+ });
+});
diff --git a/apps/web/lib/utils/action-client-middleware.test.ts b/apps/web/lib/utils/action-client-middleware.test.ts
new file mode 100644
index 0000000000..71709fc7c9
--- /dev/null
+++ b/apps/web/lib/utils/action-client-middleware.test.ts
@@ -0,0 +1,386 @@
+import { getMembershipRole } from "@/lib/membership/hooks/actions";
+import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles";
+import { cleanup } from "@testing-library/react";
+import { returnValidationErrors } from "next-safe-action";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { ZodIssue, z } from "zod";
+import { AuthorizationError } from "@formbricks/types/errors";
+import { checkAuthorizationUpdated, formatErrors } from "./action-client-middleware";
+
+vi.mock("@/lib/membership/hooks/actions", () => ({
+ getMembershipRole: vi.fn(),
+}));
+
+vi.mock("@/modules/ee/teams/lib/roles", () => ({
+ getProjectPermissionByUserId: vi.fn(),
+ getTeamRoleByTeamIdUserId: vi.fn(),
+}));
+
+vi.mock("next-safe-action", () => ({
+ returnValidationErrors: vi.fn(),
+}));
+
+describe("action-client-middleware", () => {
+ const userId = "user-1";
+ const organizationId = "org-1";
+ const projectId = "project-1";
+ const teamId = "team-1";
+
+ afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+ });
+
+ describe("formatErrors", () => {
+ // We need to access the private function for testing
+ // Using any to access the function directly
+
+ test("formats simple path ZodIssue", () => {
+ const issues = [
+ {
+ code: "custom",
+ path: ["name"],
+ message: "Name is required",
+ },
+ ] as ZodIssue[];
+
+ const result = formatErrors(issues);
+ expect(result).toEqual({
+ name: {
+ _errors: ["Name is required"],
+ },
+ });
+ });
+
+ test("formats nested path ZodIssue", () => {
+ const issues = [
+ {
+ code: "custom",
+ path: ["user", "address", "street"],
+ message: "Street is required",
+ },
+ ] as ZodIssue[];
+
+ const result = formatErrors(issues);
+ expect(result).toEqual({
+ "user.address.street": {
+ _errors: ["Street is required"],
+ },
+ });
+ });
+
+ test("formats multiple ZodIssues", () => {
+ const issues = [
+ {
+ code: "custom",
+ path: ["name"],
+ message: "Name is required",
+ },
+ {
+ code: "custom",
+ path: ["email"],
+ message: "Invalid email",
+ },
+ ] as ZodIssue[];
+
+ const result = formatErrors(issues);
+ expect(result).toEqual({
+ name: {
+ _errors: ["Name is required"],
+ },
+ email: {
+ _errors: ["Invalid email"],
+ },
+ });
+ });
+ });
+
+ describe("checkAuthorizationUpdated", () => {
+ test("returns validation errors when schema validation fails", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("owner");
+
+ const mockSchema = z.object({
+ name: z.string(),
+ });
+
+ const mockData = { name: 123 }; // Type error to trigger validation failure
+
+ vi.mocked(returnValidationErrors).mockReturnValue("validation-error" as unknown as never);
+
+ const access = [
+ {
+ type: "organization" as const,
+ schema: mockSchema,
+ data: mockData as any,
+ roles: ["owner" as const],
+ },
+ ];
+
+ const result = await checkAuthorizationUpdated({
+ userId,
+ organizationId,
+ access,
+ });
+
+ expect(returnValidationErrors).toHaveBeenCalledWith(expect.any(Object), expect.any(Object));
+ expect(result).toBe("validation-error");
+ });
+
+ test("returns true when organization access matches role", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("owner");
+
+ const access = [
+ {
+ type: "organization" as const,
+ roles: ["owner" as const],
+ },
+ ];
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+
+ test("continues checking other access items when organization role doesn't match", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "organization" as const,
+ roles: ["owner" as const],
+ },
+ {
+ type: "projectTeam" as const,
+ projectId,
+ minPermission: "read" as const,
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
+ });
+
+ test("returns true when projectTeam access matches permission", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "projectTeam" as const,
+ projectId,
+ minPermission: "read" as const,
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
+ });
+
+ test("continues checking other access items when projectTeam permission is insufficient", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "projectTeam" as const,
+ projectId,
+ minPermission: "manage" as const,
+ },
+ {
+ type: "team" as const,
+ teamId,
+ minPermission: "contributor" as const,
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId);
+ expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
+ });
+
+ test("returns true when team access matches role", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "team" as const,
+ teamId,
+ minPermission: "contributor" as const,
+ },
+ ];
+
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
+ });
+
+ test("continues checking other access items when team role is insufficient", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "team" as const,
+ teamId,
+ minPermission: "admin" as const,
+ },
+ {
+ type: "organization" as const,
+ roles: ["member" as const],
+ },
+ ];
+
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId);
+ });
+
+ test("throws AuthorizationError when no access matches", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "organization" as const,
+ roles: ["owner" as const],
+ },
+ {
+ type: "projectTeam" as const,
+ projectId,
+ minPermission: "manage" as const,
+ },
+ {
+ type: "team" as const,
+ teamId,
+ minPermission: "admin" as const,
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
+
+ await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow(
+ AuthorizationError
+ );
+ await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow(
+ "Not authorized"
+ );
+ });
+
+ test("continues to check when projectPermission is null", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "projectTeam" as const,
+ projectId,
+ minPermission: "read" as const,
+ },
+ {
+ type: "organization" as const,
+ roles: ["member" as const],
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+
+ test("continues to check when teamRole is null", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "team" as const,
+ teamId,
+ minPermission: "contributor" as const,
+ },
+ {
+ type: "organization" as const,
+ roles: ["member" as const],
+ },
+ ];
+
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue(null);
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+
+ test("returns true when schema validation passes", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("owner");
+
+ const mockSchema = z.object({
+ name: z.string(),
+ });
+
+ const mockData = { name: "test" };
+
+ const access = [
+ {
+ type: "organization" as const,
+ schema: mockSchema,
+ data: mockData,
+ roles: ["owner" as const],
+ },
+ ];
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+
+ test("handles projectTeam access without minPermission specified", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "projectTeam" as const,
+ projectId,
+ },
+ ];
+
+ vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+
+ test("handles team access without minPermission specified", async () => {
+ vi.mocked(getMembershipRole).mockResolvedValue("member");
+
+ const access = [
+ {
+ type: "team" as const,
+ teamId,
+ },
+ ];
+
+ vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor");
+
+ const result = await checkAuthorizationUpdated({ userId, organizationId, access });
+
+ expect(result).toBe(true);
+ });
+ });
+});
diff --git a/apps/web/lib/utils/action-client-middleware.ts b/apps/web/lib/utils/action-client-middleware.ts
index c2f18e0621..d7568b6bf3 100644
--- a/apps/web/lib/utils/action-client-middleware.ts
+++ b/apps/web/lib/utils/action-client-middleware.ts
@@ -7,7 +7,7 @@ import { ZodIssue, z } from "zod";
import { AuthorizationError } from "@formbricks/types/errors";
import { type TOrganizationRole } from "@formbricks/types/memberships";
-const formatErrors = (issues: ZodIssue[]): Record => {
+export const formatErrors = (issues: ZodIssue[]): Record => {
return {
...issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = {
diff --git a/apps/web/lib/utils/colors.test.ts b/apps/web/lib/utils/colors.test.ts
new file mode 100644
index 0000000000..908423fd8f
--- /dev/null
+++ b/apps/web/lib/utils/colors.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test, vi } from "vitest";
+import { hexToRGBA, isLight, mixColor } from "./colors";
+
+describe("Color utilities", () => {
+ describe("hexToRGBA", () => {
+ test("should convert hex to rgba", () => {
+ expect(hexToRGBA("#000000", 1)).toBe("rgba(0, 0, 0, 1)");
+ expect(hexToRGBA("#FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
+ expect(hexToRGBA("#FF0000", 0.8)).toBe("rgba(255, 0, 0, 0.8)");
+ });
+
+ test("should convert shorthand hex to rgba", () => {
+ expect(hexToRGBA("#000", 1)).toBe("rgba(0, 0, 0, 1)");
+ expect(hexToRGBA("#FFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
+ expect(hexToRGBA("#F00", 0.8)).toBe("rgba(255, 0, 0, 0.8)");
+ });
+
+ test("should handle hex without # prefix", () => {
+ expect(hexToRGBA("000000", 1)).toBe("rgba(0, 0, 0, 1)");
+ expect(hexToRGBA("FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)");
+ });
+
+ test("should return undefined for undefined or empty input", () => {
+ expect(hexToRGBA(undefined, 1)).toBeUndefined();
+ expect(hexToRGBA("", 0.5)).toBeUndefined();
+ });
+
+ test("should return empty string for invalid hex", () => {
+ expect(hexToRGBA("invalid", 1)).toBe("");
+ });
+ });
+
+ describe("mixColor", () => {
+ test("should mix two colors with given weight", () => {
+ expect(mixColor("#000000", "#FFFFFF", 0.5)).toBe("#808080");
+ expect(mixColor("#FF0000", "#0000FF", 0.5)).toBe("#800080");
+ expect(mixColor("#FF0000", "#00FF00", 0.75)).toBe("#40bf00");
+ });
+
+ test("should handle edge cases", () => {
+ expect(mixColor("#000000", "#FFFFFF", 0)).toBe("#000000");
+ expect(mixColor("#000000", "#FFFFFF", 1)).toBe("#ffffff");
+ });
+ });
+
+ describe("isLight", () => {
+ test("should determine if a color is light", () => {
+ expect(isLight("#FFFFFF")).toBe(true);
+ expect(isLight("#EEEEEE")).toBe(true);
+ expect(isLight("#FFFF00")).toBe(true);
+ });
+
+ test("should determine if a color is dark", () => {
+ expect(isLight("#000000")).toBe(false);
+ expect(isLight("#333333")).toBe(false);
+ expect(isLight("#0000FF")).toBe(false);
+ });
+
+ test("should handle shorthand hex colors", () => {
+ expect(isLight("#FFF")).toBe(true);
+ expect(isLight("#000")).toBe(false);
+ expect(isLight("#F00")).toBe(false);
+ });
+
+ test("should throw error for invalid colors", () => {
+ expect(() => isLight("invalid-color")).toThrow("Invalid color");
+ expect(() => isLight("#1")).toThrow("Invalid color");
+ });
+ });
+});
diff --git a/apps/web/lib/utils/colors.ts b/apps/web/lib/utils/colors.ts
index 5f8ba6d343..3b1e6d0099 100644
--- a/apps/web/lib/utils/colors.ts
+++ b/apps/web/lib/utils/colors.ts
@@ -1,4 +1,4 @@
-const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
+export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
// TODO: find a better way to handle this
if (!hex || hex === "") return undefined;
diff --git a/apps/web/lib/utils/contact.test.ts b/apps/web/lib/utils/contact.test.ts
new file mode 100644
index 0000000000..ffee4e913b
--- /dev/null
+++ b/apps/web/lib/utils/contact.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "vitest";
+import { TContactAttributes } from "@formbricks/types/contact-attribute";
+import { TResponseContact } from "@formbricks/types/responses";
+import { getContactIdentifier } from "./contact";
+
+describe("getContactIdentifier", () => {
+ test("should return email from contactAttributes when available", () => {
+ const contactAttributes: TContactAttributes = {
+ email: "test@example.com",
+ };
+ const contact: TResponseContact = {
+ id: "contact1",
+ userId: "user123",
+ };
+
+ const result = getContactIdentifier(contact, contactAttributes);
+ expect(result).toBe("test@example.com");
+ });
+
+ test("should return userId from contact when email is not available", () => {
+ const contactAttributes: TContactAttributes = {};
+ const contact: TResponseContact = {
+ id: "contact2",
+ userId: "user123",
+ };
+
+ const result = getContactIdentifier(contact, contactAttributes);
+ expect(result).toBe("user123");
+ });
+
+ test("should return empty string when both email and userId are not available", () => {
+ const contactAttributes: TContactAttributes = {};
+ const contact: TResponseContact = {
+ id: "contact3",
+ };
+
+ const result = getContactIdentifier(contact, contactAttributes);
+ expect(result).toBe("");
+ });
+
+ test("should return empty string when both contact and contactAttributes are null", () => {
+ const result = getContactIdentifier(null, null);
+ expect(result).toBe("");
+ });
+
+ test("should return userId when contactAttributes is null", () => {
+ const contact: TResponseContact = {
+ id: "contact4",
+ userId: "user123",
+ };
+
+ const result = getContactIdentifier(contact, null);
+ expect(result).toBe("user123");
+ });
+
+ test("should return email when contact is null", () => {
+ const contactAttributes: TContactAttributes = {
+ email: "test@example.com",
+ };
+
+ const result = getContactIdentifier(null, contactAttributes);
+ expect(result).toBe("test@example.com");
+ });
+});
diff --git a/apps/web/lib/utils/datetime.test.ts b/apps/web/lib/utils/datetime.test.ts
new file mode 100644
index 0000000000..6bcadf1a7d
--- /dev/null
+++ b/apps/web/lib/utils/datetime.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "vitest";
+import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
+
+describe("datetime utils", () => {
+ test("diffInDays calculates the difference in days between two dates", () => {
+ const date1 = new Date("2025-05-01");
+ const date2 = new Date("2025-05-06");
+ expect(diffInDays(date1, date2)).toBe(5);
+ });
+
+ test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
+ const date = new Date("2025-05-06");
+ expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
+ });
+
+ test("isValidDateString validates correct date strings", () => {
+ expect(isValidDateString("2025-05-06")).toBeTruthy();
+ expect(isValidDateString("06-05-2025")).toBeTruthy();
+ expect(isValidDateString("2025/05/06")).toBeFalsy();
+ expect(isValidDateString("invalid-date")).toBeFalsy();
+ });
+
+ test("getFormattedDateTimeString formats a date-time string correctly", () => {
+ const date = new Date("2025-05-06T14:30:00");
+ expect(getFormattedDateTimeString(date)).toBe("2025-05-06 14:30:00");
+ });
+});
diff --git a/apps/web/lib/utils/email.test.ts b/apps/web/lib/utils/email.test.ts
new file mode 100644
index 0000000000..e5bf58c531
--- /dev/null
+++ b/apps/web/lib/utils/email.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "vitest";
+import { isValidEmail } from "./email";
+
+describe("isValidEmail", () => {
+ test("validates correct email formats", () => {
+ // Valid email addresses
+ expect(isValidEmail("test@example.com")).toBe(true);
+ expect(isValidEmail("test.user@example.com")).toBe(true);
+ expect(isValidEmail("test+user@example.com")).toBe(true);
+ expect(isValidEmail("test_user@example.com")).toBe(true);
+ expect(isValidEmail("test-user@example.com")).toBe(true);
+ expect(isValidEmail("test'user@example.com")).toBe(true);
+ expect(isValidEmail("test@example.co.uk")).toBe(true);
+ expect(isValidEmail("test@subdomain.example.com")).toBe(true);
+ });
+
+ test("rejects invalid email formats", () => {
+ // Missing @ symbol
+ expect(isValidEmail("testexample.com")).toBe(false);
+
+ // Multiple @ symbols
+ expect(isValidEmail("test@example@com")).toBe(false);
+
+ // Invalid characters
+ expect(isValidEmail("test user@example.com")).toBe(false);
+ expect(isValidEmail("test<>user@example.com")).toBe(false);
+
+ // Missing domain
+ expect(isValidEmail("test@")).toBe(false);
+
+ // Missing local part
+ expect(isValidEmail("@example.com")).toBe(false);
+
+ // Starting or ending with dots in local part
+ expect(isValidEmail(".test@example.com")).toBe(false);
+ expect(isValidEmail("test.@example.com")).toBe(false);
+
+ // Consecutive dots
+ expect(isValidEmail("test..user@example.com")).toBe(false);
+
+ // Empty string
+ expect(isValidEmail("")).toBe(false);
+
+ // Only whitespace
+ expect(isValidEmail(" ")).toBe(false);
+
+ // TLD too short
+ expect(isValidEmail("test@example.c")).toBe(false);
+ });
+});
diff --git a/apps/web/lib/utils/file-conversion.test.ts b/apps/web/lib/utils/file-conversion.test.ts
new file mode 100644
index 0000000000..8f1d149a6f
--- /dev/null
+++ b/apps/web/lib/utils/file-conversion.test.ts
@@ -0,0 +1,63 @@
+import { AsyncParser } from "@json2csv/node";
+import { describe, expect, test, vi } from "vitest";
+import * as xlsx from "xlsx";
+import { logger } from "@formbricks/logger";
+import { convertToCsv, convertToXlsxBuffer } from "./file-conversion";
+
+// Mock the logger to capture error calls
+vi.mock("@formbricks/logger", () => ({
+ logger: { error: vi.fn() },
+}));
+
+describe("convertToCsv", () => {
+ const fields = ["name", "age"];
+ const data = [
+ { name: "Alice", age: 30 },
+ { name: "Bob", age: 25 },
+ ];
+
+ test("should convert JSON array to CSV string with header", async () => {
+ const csv = await convertToCsv(fields, data);
+ const lines = csv.trim().split("\n");
+ // json2csv quotes headers by default
+ expect(lines[0]).toBe('"name","age"');
+ expect(lines[1]).toBe('"Alice",30');
+ expect(lines[2]).toBe('"Bob",25');
+ });
+
+ test("should log an error and throw when conversion fails", async () => {
+ const parseSpy = vi.spyOn(AsyncParser.prototype, "parse").mockImplementation(
+ () =>
+ ({
+ promise: () => Promise.reject(new Error("Test parse error")),
+ }) as any
+ );
+
+ await expect(convertToCsv(fields, data)).rejects.toThrow("Failed to convert to CSV");
+ expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Failed to convert to CSV");
+
+ parseSpy.mockRestore();
+ });
+});
+
+describe("convertToXlsxBuffer", () => {
+ const fields = ["name", "age"];
+ const data = [
+ { name: "Alice", age: 30 },
+ { name: "Bob", age: 25 },
+ ];
+
+ test("should convert JSON array to XLSX buffer and preserve data", () => {
+ const buffer = convertToXlsxBuffer(fields, data);
+ const wb = xlsx.read(buffer, { type: "buffer" });
+ const sheet = wb.Sheets["Sheet1"];
+ // Skip header row (range:1) and remove internal row metadata
+ const raw = xlsx.utils.sheet_to_json>(sheet, {
+ header: fields,
+ defval: "",
+ range: 1,
+ });
+ const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
+ expect(cleaned).toEqual(data);
+ });
+});
diff --git a/apps/web/lib/utils/fileConversion.ts b/apps/web/lib/utils/file-conversion.ts
similarity index 100%
rename from apps/web/lib/utils/fileConversion.ts
rename to apps/web/lib/utils/file-conversion.ts
diff --git a/apps/web/lib/utils/headers.test.ts b/apps/web/lib/utils/headers.test.ts
new file mode 100644
index 0000000000..d213eccb16
--- /dev/null
+++ b/apps/web/lib/utils/headers.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+import { deviceType } from "./headers";
+
+describe("deviceType", () => {
+ test("should return 'phone' for mobile user agents", () => {
+ const mobileUserAgents = [
+ "Mozilla/5.0 (Linux; Android 10; SM-G960F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Mobile Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (iPad; CPU OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+",
+ "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch)",
+ "Mozilla/5.0 (iPod; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
+ "Opera/9.80 (Android; Opera Mini/36.2.2254/119.132; U; id) Presto/2.12.423 Version/12.16",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59 (Edition Campaign WPDesktop)",
+ ];
+
+ mobileUserAgents.forEach((userAgent) => {
+ expect(deviceType(userAgent)).toBe("phone");
+ });
+ });
+
+ test("should return 'desktop' for non-mobile user agents", () => {
+ const desktopUserAgents = [
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0",
+ "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
+ "",
+ ];
+
+ desktopUserAgents.forEach((userAgent) => {
+ expect(deviceType(userAgent)).toBe("desktop");
+ });
+ });
+});
diff --git a/apps/web/lib/utils/helper.test.ts b/apps/web/lib/utils/helper.test.ts
new file mode 100644
index 0000000000..860ba90238
--- /dev/null
+++ b/apps/web/lib/utils/helper.test.ts
@@ -0,0 +1,795 @@
+import * as services from "@/lib/utils/services";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ResourceNotFoundError } from "@formbricks/types/errors";
+import {
+ getEnvironmentIdFromInsightId,
+ getEnvironmentIdFromResponseId,
+ getEnvironmentIdFromSegmentId,
+ getEnvironmentIdFromSurveyId,
+ getEnvironmentIdFromTagId,
+ getFormattedErrorMessage,
+ getOrganizationIdFromActionClassId,
+ getOrganizationIdFromApiKeyId,
+ getOrganizationIdFromContactId,
+ getOrganizationIdFromDocumentId,
+ getOrganizationIdFromEnvironmentId,
+ getOrganizationIdFromInsightId,
+ getOrganizationIdFromIntegrationId,
+ getOrganizationIdFromInviteId,
+ getOrganizationIdFromLanguageId,
+ getOrganizationIdFromProjectId,
+ getOrganizationIdFromResponseId,
+ getOrganizationIdFromResponseNoteId,
+ getOrganizationIdFromSegmentId,
+ getOrganizationIdFromSurveyId,
+ getOrganizationIdFromTagId,
+ getOrganizationIdFromTeamId,
+ getOrganizationIdFromWebhookId,
+ getProductIdFromContactId,
+ getProjectIdFromActionClassId,
+ getProjectIdFromContactId,
+ getProjectIdFromDocumentId,
+ getProjectIdFromEnvironmentId,
+ getProjectIdFromInsightId,
+ getProjectIdFromIntegrationId,
+ getProjectIdFromLanguageId,
+ getProjectIdFromResponseId,
+ getProjectIdFromResponseNoteId,
+ getProjectIdFromSegmentId,
+ getProjectIdFromSurveyId,
+ getProjectIdFromTagId,
+ getProjectIdFromWebhookId,
+ isStringMatch,
+} from "./helper";
+
+// Mock all service functions
+vi.mock("@/lib/utils/services", () => ({
+ getProject: vi.fn(),
+ getEnvironment: vi.fn(),
+ getSurvey: vi.fn(),
+ getResponse: vi.fn(),
+ getContact: vi.fn(),
+ getResponseNote: vi.fn(),
+ getSegment: vi.fn(),
+ getActionClass: vi.fn(),
+ getIntegration: vi.fn(),
+ getWebhook: vi.fn(),
+ getApiKey: vi.fn(),
+ getInvite: vi.fn(),
+ getLanguage: vi.fn(),
+ getTeam: vi.fn(),
+ getInsight: vi.fn(),
+ getDocument: vi.fn(),
+ getTag: vi.fn(),
+}));
+
+describe("Helper Utilities", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("getFormattedErrorMessage", () => {
+ test("returns server error when present", () => {
+ const result = {
+ serverError: "Internal server error occurred",
+ validationErrors: {},
+ };
+ expect(getFormattedErrorMessage(result)).toBe("Internal server error occurred");
+ });
+
+ test("formats validation errors correctly with _errors", () => {
+ const result = {
+ validationErrors: {
+ _errors: ["Invalid input", "Missing required field"],
+ },
+ };
+ expect(getFormattedErrorMessage(result)).toBe("Invalid input, Missing required field");
+ });
+
+ test("formats validation errors for specific fields", () => {
+ const result = {
+ validationErrors: {
+ name: { _errors: ["Name is required"] },
+ email: { _errors: ["Email is invalid"] },
+ },
+ };
+ expect(getFormattedErrorMessage(result)).toBe("nameName is required\nemailEmail is invalid");
+ });
+
+ test("returns empty string for undefined errors", () => {
+ const result = { validationErrors: undefined };
+ expect(getFormattedErrorMessage(result)).toBe("");
+ });
+ });
+
+ describe("Organization ID retrieval functions", () => {
+ test("getOrganizationIdFromProjectId returns organization ID when project exists", async () => {
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromProjectId("project1");
+ expect(orgId).toBe("org1");
+ expect(services.getProject).toHaveBeenCalledWith("project1");
+ });
+
+ test("getOrganizationIdFromProjectId throws error when project not found", async () => {
+ vi.mocked(services.getProject).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromProjectId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ expect(services.getProject).toHaveBeenCalledWith("nonexistent");
+ });
+
+ test("getOrganizationIdFromEnvironmentId returns organization ID through project", async () => {
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromEnvironmentId("env1");
+ expect(orgId).toBe("org1");
+ expect(services.getEnvironment).toHaveBeenCalledWith("env1");
+ expect(services.getProject).toHaveBeenCalledWith("project1");
+ });
+
+ test("getOrganizationIdFromEnvironmentId throws error when environment not found", async () => {
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromSurveyId returns organization ID through environment and project", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromSurveyId("survey1");
+ expect(orgId).toBe("org1");
+ expect(services.getSurvey).toHaveBeenCalledWith("survey1");
+ expect(services.getEnvironment).toHaveBeenCalledWith("env1");
+ expect(services.getProject).toHaveBeenCalledWith("project1");
+ });
+
+ test("getOrganizationIdFromSurveyId throws error when survey not found", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromResponseId returns organization ID through the response hierarchy", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce({
+ surveyId: "survey1",
+ });
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromResponseId("response1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromResponseId throws error when response not found", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromContactId returns organization ID correctly", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromContactId("contact1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromContactId throws error when contact not found", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromTagId returns organization ID correctly", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromTagId("tag1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromTagId throws error when tag not found", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromResponseNoteId returns organization ID correctly", async () => {
+ vi.mocked(services.getResponseNote).mockResolvedValueOnce({
+ responseId: "response1",
+ });
+ vi.mocked(services.getResponse).mockResolvedValueOnce({
+ surveyId: "survey1",
+ });
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromResponseNoteId("note1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromResponseNoteId throws error when note not found", async () => {
+ vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
+
+ await expect(getOrganizationIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromSegmentId returns organization ID correctly", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromSegmentId("segment1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromSegmentId throws error when segment not found", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromActionClassId returns organization ID correctly", async () => {
+ vi.mocked(services.getActionClass).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromActionClassId("action1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromActionClassId throws error when actionClass not found", async () => {
+ vi.mocked(services.getActionClass).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromIntegrationId returns organization ID correctly", async () => {
+ vi.mocked(services.getIntegration).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromIntegrationId("integration1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromIntegrationId throws error when integration not found", async () => {
+ vi.mocked(services.getIntegration).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromWebhookId returns organization ID correctly", async () => {
+ vi.mocked(services.getWebhook).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromWebhookId("webhook1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromWebhookId throws error when webhook not found", async () => {
+ vi.mocked(services.getWebhook).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromApiKeyId returns organization ID directly", async () => {
+ vi.mocked(services.getApiKey).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromApiKeyId("apikey1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromApiKeyId throws error when apiKey not found", async () => {
+ vi.mocked(services.getApiKey).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromApiKeyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromInviteId returns organization ID directly", async () => {
+ vi.mocked(services.getInvite).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromInviteId("invite1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromInviteId throws error when invite not found", async () => {
+ vi.mocked(services.getInvite).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromInviteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromLanguageId returns organization ID correctly", async () => {
+ vi.mocked(services.getLanguage).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromLanguageId("lang1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromLanguageId throws error when language not found", async () => {
+ vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any);
+ await expect(getOrganizationIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromTeamId returns organization ID directly", async () => {
+ vi.mocked(services.getTeam).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromTeamId("team1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromTeamId throws error when team not found", async () => {
+ vi.mocked(services.getTeam).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromTeamId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromInsightId returns organization ID correctly", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromInsightId("insight1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromInsightId throws error when insight not found", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getOrganizationIdFromDocumentId returns organization ID correctly", async () => {
+ vi.mocked(services.getDocument).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+ vi.mocked(services.getProject).mockResolvedValueOnce({
+ organizationId: "org1",
+ });
+
+ const orgId = await getOrganizationIdFromDocumentId("doc1");
+ expect(orgId).toBe("org1");
+ });
+
+ test("getOrganizationIdFromDocumentId throws error when document not found", async () => {
+ vi.mocked(services.getDocument).mockResolvedValueOnce(null);
+ await expect(getOrganizationIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+ });
+
+ describe("Project ID retrieval functions", () => {
+ test("getProjectIdFromEnvironmentId returns project ID directly", async () => {
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromEnvironmentId("env1");
+ expect(projectId).toBe("project1");
+ expect(services.getEnvironment).toHaveBeenCalledWith("env1");
+ });
+
+ test("getProjectIdFromEnvironmentId throws error when environment not found", async () => {
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce(null);
+
+ await expect(getProjectIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromSurveyId returns project ID through environment", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromSurveyId("survey1");
+ expect(projectId).toBe("project1");
+ expect(services.getSurvey).toHaveBeenCalledWith("survey1");
+ expect(services.getEnvironment).toHaveBeenCalledWith("env1");
+ });
+
+ test("getProjectIdFromSurveyId throws error when survey not found", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromContactId returns project ID correctly", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromContactId("contact1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromContactId throws error when contact not found", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromInsightId returns project ID correctly", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromInsightId("insight1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromInsightId throws error when insight not found", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromSegmentId returns project ID correctly", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromSegmentId("segment1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromSegmentId throws error when segment not found", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromActionClassId returns project ID correctly", async () => {
+ vi.mocked(services.getActionClass).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromActionClassId("action1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromActionClassId throws error when actionClass not found", async () => {
+ vi.mocked(services.getActionClass).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromTagId returns project ID correctly", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromTagId("tag1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromTagId throws error when tag not found", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromLanguageId returns project ID directly", async () => {
+ vi.mocked(services.getLanguage).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromLanguageId("lang1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromLanguageId throws error when language not found", async () => {
+ vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any);
+ await expect(getProjectIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromResponseId returns project ID correctly", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce({
+ surveyId: "survey1",
+ });
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromResponseId("response1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromResponseId throws error when response not found", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromResponseNoteId returns project ID correctly", async () => {
+ vi.mocked(services.getResponseNote).mockResolvedValueOnce({
+ responseId: "response1",
+ });
+ vi.mocked(services.getResponse).mockResolvedValueOnce({
+ surveyId: "survey1",
+ });
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromResponseNoteId("note1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromResponseNoteId throws error when responseNote not found", async () => {
+ vi.mocked(services.getResponseNote).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromResponseNoteId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProductIdFromContactId returns project ID correctly", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProductIdFromContactId("contact1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProductIdFromContactId throws error when contact not found", async () => {
+ vi.mocked(services.getContact).mockResolvedValueOnce(null);
+ await expect(getProductIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromDocumentId returns project ID correctly", async () => {
+ vi.mocked(services.getDocument).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromDocumentId("doc1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromDocumentId throws error when document not found", async () => {
+ vi.mocked(services.getDocument).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromIntegrationId returns project ID correctly", async () => {
+ vi.mocked(services.getIntegration).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromIntegrationId("integration1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromIntegrationId throws error when integration not found", async () => {
+ vi.mocked(services.getIntegration).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getProjectIdFromWebhookId returns project ID correctly", async () => {
+ vi.mocked(services.getWebhook).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+ vi.mocked(services.getEnvironment).mockResolvedValueOnce({
+ projectId: "project1",
+ });
+
+ const projectId = await getProjectIdFromWebhookId("webhook1");
+ expect(projectId).toBe("project1");
+ });
+
+ test("getProjectIdFromWebhookId throws error when webhook not found", async () => {
+ vi.mocked(services.getWebhook).mockResolvedValueOnce(null);
+ await expect(getProjectIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+ });
+
+ describe("Environment ID retrieval functions", () => {
+ test("getEnvironmentIdFromSurveyId returns environment ID directly", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+
+ const environmentId = await getEnvironmentIdFromSurveyId("survey1");
+ expect(environmentId).toBe("env1");
+ });
+
+ test("getEnvironmentIdFromSurveyId throws error when survey not found", async () => {
+ vi.mocked(services.getSurvey).mockResolvedValueOnce(null);
+ await expect(getEnvironmentIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getEnvironmentIdFromResponseId returns environment ID correctly", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce({
+ surveyId: "survey1",
+ });
+ vi.mocked(services.getSurvey).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+
+ const environmentId = await getEnvironmentIdFromResponseId("response1");
+ expect(environmentId).toBe("env1");
+ });
+
+ test("getEnvironmentIdFromResponseId throws error when response not found", async () => {
+ vi.mocked(services.getResponse).mockResolvedValueOnce(null);
+ await expect(getEnvironmentIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getEnvironmentIdFromInsightId returns environment ID directly", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+
+ const environmentId = await getEnvironmentIdFromInsightId("insight1");
+ expect(environmentId).toBe("env1");
+ });
+
+ test("getEnvironmentIdFromInsightId throws error when insight not found", async () => {
+ vi.mocked(services.getInsight).mockResolvedValueOnce(null);
+ await expect(getEnvironmentIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getEnvironmentIdFromSegmentId returns environment ID directly", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+
+ const environmentId = await getEnvironmentIdFromSegmentId("segment1");
+ expect(environmentId).toBe("env1");
+ });
+
+ test("getEnvironmentIdFromSegmentId throws error when segment not found", async () => {
+ vi.mocked(services.getSegment).mockResolvedValueOnce(null);
+ await expect(getEnvironmentIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("getEnvironmentIdFromTagId returns environment ID directly", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce({
+ environmentId: "env1",
+ });
+
+ const environmentId = await getEnvironmentIdFromTagId("tag1");
+ expect(environmentId).toBe("env1");
+ });
+
+ test("getEnvironmentIdFromTagId throws error when tag not found", async () => {
+ vi.mocked(services.getTag).mockResolvedValueOnce(null);
+ await expect(getEnvironmentIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError);
+ });
+ });
+
+ describe("isStringMatch", () => {
+ test("returns true for exact matches", () => {
+ expect(isStringMatch("test", "test")).toBe(true);
+ });
+
+ test("returns true for case-insensitive matches", () => {
+ expect(isStringMatch("TEST", "test")).toBe(true);
+ expect(isStringMatch("test", "TEST")).toBe(true);
+ });
+
+ test("returns true for matches with spaces", () => {
+ expect(isStringMatch("test case", "testcase")).toBe(true);
+ expect(isStringMatch("testcase", "test case")).toBe(true);
+ });
+
+ test("returns true for matches with underscores", () => {
+ expect(isStringMatch("test_case", "testcase")).toBe(true);
+ expect(isStringMatch("testcase", "test_case")).toBe(true);
+ });
+
+ test("returns true for matches with dashes", () => {
+ expect(isStringMatch("test-case", "testcase")).toBe(true);
+ expect(isStringMatch("testcase", "test-case")).toBe(true);
+ });
+
+ test("returns true for partial matches", () => {
+ expect(isStringMatch("test", "testing")).toBe(true);
+ });
+
+ test("returns false for non-matches", () => {
+ expect(isStringMatch("test", "other")).toBe(false);
+ });
+ });
+});
diff --git a/apps/web/lib/utils/locale.test.ts b/apps/web/lib/utils/locale.test.ts
new file mode 100644
index 0000000000..e4701f06e8
--- /dev/null
+++ b/apps/web/lib/utils/locale.test.ts
@@ -0,0 +1,87 @@
+import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
+import * as nextHeaders from "next/headers";
+import { describe, expect, test, vi } from "vitest";
+import { findMatchingLocale } from "./locale";
+
+// Mock the Next.js headers function
+vi.mock("next/headers", () => ({
+ headers: vi.fn(),
+}));
+
+describe("locale", () => {
+ test("returns DEFAULT_LOCALE when Accept-Language header is missing", async () => {
+ // Set up the mock to return null for accept-language header
+ vi.mocked(nextHeaders.headers).mockReturnValue({
+ get: vi.fn().mockReturnValue(null),
+ } as any);
+
+ const result = await findMatchingLocale();
+
+ expect(result).toBe(DEFAULT_LOCALE);
+ expect(nextHeaders.headers).toHaveBeenCalled();
+ });
+
+ test("returns exact match when available", async () => {
+ // Assuming we have 'en-US' in AVAILABLE_LOCALES
+ const testLocale = AVAILABLE_LOCALES[0];
+
+ vi.mocked(nextHeaders.headers).mockReturnValue({
+ get: vi.fn().mockReturnValue(`${testLocale},fr-FR,de-DE`),
+ } as any);
+
+ const result = await findMatchingLocale();
+
+ expect(result).toBe(testLocale);
+ expect(nextHeaders.headers).toHaveBeenCalled();
+ });
+
+ test("returns normalized match when available", async () => {
+ // Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
+ const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
+
+ if (!availableLocale) {
+ // Skip this test if no English locale is available
+ return;
+ }
+
+ vi.mocked(nextHeaders.headers).mockReturnValue({
+ get: vi.fn().mockReturnValue("en-US,fr-FR,de-DE"),
+ } as any);
+
+ const result = await findMatchingLocale();
+
+ expect(result).toBe(availableLocale);
+ expect(nextHeaders.headers).toHaveBeenCalled();
+ });
+
+ test("returns DEFAULT_LOCALE when no match is found", async () => {
+ // Use a locale that should not exist in AVAILABLE_LOCALES
+ vi.mocked(nextHeaders.headers).mockReturnValue({
+ get: vi.fn().mockReturnValue("xx-XX,yy-YY"),
+ } as any);
+
+ const result = await findMatchingLocale();
+
+ expect(result).toBe(DEFAULT_LOCALE);
+ expect(nextHeaders.headers).toHaveBeenCalled();
+ });
+
+ test("handles multiple potential matches correctly", async () => {
+ // If we have multiple locales for the same language, it should return the first match
+ const germanLocale = AVAILABLE_LOCALES.find((locale) => locale.toLowerCase().startsWith("de"));
+
+ if (!germanLocale) {
+ // Skip this test if no German locale is available
+ return;
+ }
+
+ vi.mocked(nextHeaders.headers).mockReturnValue({
+ get: vi.fn().mockReturnValue("de-DE,en-US,fr-FR"),
+ } as any);
+
+ const result = await findMatchingLocale();
+
+ expect(result).toBe(germanLocale);
+ expect(nextHeaders.headers).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/lib/utils/promises.test.ts b/apps/web/lib/utils/promises.test.ts
new file mode 100644
index 0000000000..80680a1759
--- /dev/null
+++ b/apps/web/lib/utils/promises.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test, vi } from "vitest";
+import { delay, isFulfilled, isRejected } from "./promises";
+
+describe("promises utilities", () => {
+ test("delay resolves after specified time", async () => {
+ const delayTime = 100;
+
+ vi.useFakeTimers();
+ const promise = delay(delayTime);
+
+ vi.advanceTimersByTime(delayTime);
+ await promise;
+
+ vi.useRealTimers();
+ });
+
+ test("isFulfilled returns true for fulfilled promises", () => {
+ const fulfilledResult: PromiseSettledResult = {
+ status: "fulfilled",
+ value: "success",
+ };
+
+ expect(isFulfilled(fulfilledResult)).toBe(true);
+
+ if (isFulfilled(fulfilledResult)) {
+ expect(fulfilledResult.value).toBe("success");
+ }
+ });
+
+ test("isFulfilled returns false for rejected promises", () => {
+ const rejectedResult: PromiseSettledResult = {
+ status: "rejected",
+ reason: "error",
+ };
+
+ expect(isFulfilled(rejectedResult)).toBe(false);
+ });
+
+ test("isRejected returns true for rejected promises", () => {
+ const rejectedResult: PromiseSettledResult = {
+ status: "rejected",
+ reason: "error",
+ };
+
+ expect(isRejected(rejectedResult)).toBe(true);
+
+ if (isRejected(rejectedResult)) {
+ expect(rejectedResult.reason).toBe("error");
+ }
+ });
+
+ test("isRejected returns false for fulfilled promises", () => {
+ const fulfilledResult: PromiseSettledResult = {
+ status: "fulfilled",
+ value: "success",
+ };
+
+ expect(isRejected(fulfilledResult)).toBe(false);
+ });
+
+ test("delay can be used in actual timing scenarios", async () => {
+ const mockCallback = vi.fn();
+
+ setTimeout(mockCallback, 50);
+ await delay(100);
+
+ expect(mockCallback).toHaveBeenCalled();
+ });
+
+ test("type guard functions work correctly with Promise.allSettled", async () => {
+ const promises = [Promise.resolve("success"), Promise.reject("failure")];
+
+ const results = await Promise.allSettled(promises);
+
+ const fulfilled = results.filter(isFulfilled);
+ const rejected = results.filter(isRejected);
+
+ expect(fulfilled.length).toBe(1);
+ expect(fulfilled[0].value).toBe("success");
+
+ expect(rejected.length).toBe(1);
+ expect(rejected[0].reason).toBe("failure");
+ });
+});
diff --git a/apps/web/lib/utils/recall.test.ts b/apps/web/lib/utils/recall.test.ts
new file mode 100644
index 0000000000..027378cffc
--- /dev/null
+++ b/apps/web/lib/utils/recall.test.ts
@@ -0,0 +1,516 @@
+import { getLocalizedValue } from "@/lib/i18n/utils";
+import { structuredClone } from "@/lib/pollyfills/structuredClone";
+import { describe, expect, test, vi } from "vitest";
+import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
+import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
+import {
+ checkForEmptyFallBackValue,
+ extractFallbackValue,
+ extractId,
+ extractIds,
+ extractRecallInfo,
+ fallbacks,
+ findRecallInfoById,
+ getFallbackValues,
+ getRecallItems,
+ headlineToRecall,
+ parseRecallInfo,
+ recallToHeadline,
+ replaceHeadlineRecall,
+ replaceRecallInfoWithUnderline,
+} from "./recall";
+
+// Mock dependencies
+vi.mock("@/lib/i18n/utils", () => ({
+ getLocalizedValue: vi.fn().mockImplementation((obj, lang) => {
+ return typeof obj === "string" ? obj : obj[lang] || obj["default"] || "";
+ }),
+}));
+
+vi.mock("@/lib/pollyfills/structuredClone", () => ({
+ structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
+}));
+
+vi.mock("@/lib/utils/datetime", () => ({
+ isValidDateString: vi.fn((value) => {
+ try {
+ return !isNaN(new Date(value as string).getTime());
+ } catch {
+ return false;
+ }
+ }),
+ formatDateWithOrdinal: vi.fn((date) => {
+ return "January 1st, 2023";
+ }),
+}));
+
+describe("recall utility functions", () => {
+ describe("extractId", () => {
+ test("extracts ID correctly from a string with recall pattern", () => {
+ const text = "This is a #recall:question123 example";
+ const result = extractId(text);
+ expect(result).toBe("question123");
+ });
+
+ test("returns null when no ID is found", () => {
+ const text = "This has no recall pattern";
+ const result = extractId(text);
+ expect(result).toBeNull();
+ });
+
+ test("returns null for malformed recall pattern", () => {
+ const text = "This is a #recall: malformed pattern";
+ const result = extractId(text);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("extractIds", () => {
+ test("extracts multiple IDs from a string with multiple recall patterns", () => {
+ const text = "This has #recall:id1 and #recall:id2 and #recall:id3";
+ const result = extractIds(text);
+ expect(result).toEqual(["id1", "id2", "id3"]);
+ });
+
+ test("returns empty array when no IDs are found", () => {
+ const text = "This has no recall patterns";
+ const result = extractIds(text);
+ expect(result).toEqual([]);
+ });
+
+ test("handles mixed content correctly", () => {
+ const text = "Text #recall:id1 more text #recall:id2";
+ const result = extractIds(text);
+ expect(result).toEqual(["id1", "id2"]);
+ });
+ });
+
+ describe("extractFallbackValue", () => {
+ test("extracts fallback value correctly", () => {
+ const text = "Text #recall:id1/fallback:defaultValue# more text";
+ const result = extractFallbackValue(text);
+ expect(result).toBe("defaultValue");
+ });
+
+ test("returns empty string when no fallback value is found", () => {
+ const text = "Text with no fallback";
+ const result = extractFallbackValue(text);
+ expect(result).toBe("");
+ });
+
+ test("handles empty fallback value", () => {
+ const text = "Text #recall:id1/fallback:# more text";
+ const result = extractFallbackValue(text);
+ expect(result).toBe("");
+ });
+ });
+
+ describe("extractRecallInfo", () => {
+ test("extracts complete recall info from text", () => {
+ const text = "This is #recall:id1/fallback:default# text";
+ const result = extractRecallInfo(text);
+ expect(result).toBe("#recall:id1/fallback:default#");
+ });
+
+ test("returns null when no recall info is found", () => {
+ const text = "This has no recall info";
+ const result = extractRecallInfo(text);
+ expect(result).toBeNull();
+ });
+
+ test("extracts recall info for a specific ID when provided", () => {
+ const text = "This has #recall:id1/fallback:default1# and #recall:id2/fallback:default2#";
+ const result = extractRecallInfo(text, "id2");
+ expect(result).toBe("#recall:id2/fallback:default2#");
+ });
+ });
+
+ describe("findRecallInfoById", () => {
+ test("finds recall info by ID", () => {
+ const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#";
+ const result = findRecallInfoById(text, "id2");
+ expect(result).toBe("#recall:id2/fallback:value2#");
+ });
+
+ test("returns null when ID is not found", () => {
+ const text = "Text #recall:id1/fallback:value1#";
+ const result = findRecallInfoById(text, "id2");
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("recallToHeadline", () => {
+ test("converts recall pattern to headline format without slash", () => {
+ const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
+ const survey: TSurvey = {
+ id: "test-survey",
+ questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
+ hiddenFields: { fieldIds: [] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = recallToHeadline(headline, survey, false, "en");
+ expect(result.en).toBe("How do you like @Product Question?");
+ });
+
+ test("converts recall pattern to headline format with slash", () => {
+ const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
+ const survey: TSurvey = {
+ id: "test-survey",
+ questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[],
+ hiddenFields: { fieldIds: [] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = recallToHeadline(headline, survey, true, "en");
+ expect(result.en).toBe("Rate /Product Question\\");
+ });
+
+ test("handles hidden fields in recall", () => {
+ const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
+ const survey: TSurvey = {
+ id: "test-survey",
+ questions: [],
+ hiddenFields: { fieldIds: ["email"] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = recallToHeadline(headline, survey, false, "en");
+ expect(result.en).toBe("Your email is @email");
+ });
+
+ test("handles variables in recall", () => {
+ const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
+ const survey: TSurvey = {
+ id: "test-survey",
+ questions: [],
+ hiddenFields: { fieldIds: [] },
+ variables: [{ id: "plan", name: "Subscription Plan" }],
+ } as unknown as TSurvey;
+
+ const result = recallToHeadline(headline, survey, false, "en");
+ expect(result.en).toBe("Your plan is @Subscription Plan");
+ });
+
+ test("returns unchanged headline when no recall pattern is found", () => {
+ const headline = { en: "Regular headline with no recall" };
+ const survey = {} as TSurvey;
+
+ const result = recallToHeadline(headline, survey, false, "en");
+ expect(result).toEqual(headline);
+ });
+
+ test("handles nested recall patterns", () => {
+ const headline = {
+ en: "This is #recall:outer/fallback:withnbsp#recall:inner/fallback:nested#nbsptext#",
+ };
+ const survey: TSurvey = {
+ id: "test-survey",
+ questions: [
+ { id: "outer", headline: { en: "Outer with @inner" } },
+ { id: "inner", headline: { en: "Inner value" } },
+ ] as unknown as TSurveyQuestion[],
+ hiddenFields: { fieldIds: [] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = recallToHeadline(headline, survey, false, "en");
+ expect(result.en).toBe("This is @Outer with @inner");
+ });
+ });
+
+ describe("replaceRecallInfoWithUnderline", () => {
+ test("replaces recall info with underline", () => {
+ const text = "This is a #recall:id1/fallback:default# example";
+ const result = replaceRecallInfoWithUnderline(text);
+ expect(result).toBe("This is a ___ example");
+ });
+
+ test("replaces multiple recall infos with underlines", () => {
+ const text = "This #recall:id1/fallback:v1# has #recall:id2/fallback:v2# multiple recalls";
+ const result = replaceRecallInfoWithUnderline(text);
+ expect(result).toBe("This ___ has ___ multiple recalls");
+ });
+
+ test("returns unchanged text when no recall info is present", () => {
+ const text = "This has no recall info";
+ const result = replaceRecallInfoWithUnderline(text);
+ expect(result).toBe(text);
+ });
+ });
+
+ describe("checkForEmptyFallBackValue", () => {
+ test("identifies question with empty fallback value", () => {
+ const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
+ const survey: TSurvey = {
+ questions: [
+ {
+ id: "q1",
+ headline: questionHeadline,
+ },
+ ] as unknown as TSurveyQuestion[],
+ } as unknown as TSurvey;
+
+ vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
+
+ const result = checkForEmptyFallBackValue(survey, "en");
+ expect(result).toBe(survey.questions[0]);
+ });
+
+ test("identifies question with empty fallback in subheader", () => {
+ const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
+ const survey: TSurvey = {
+ questions: [
+ {
+ id: "q1",
+ headline: { en: "Normal question" },
+ subheader: questionSubheader,
+ },
+ ] as unknown as TSurveyQuestion[],
+ } as unknown as TSurvey;
+
+ vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en);
+
+ const result = checkForEmptyFallBackValue(survey, "en");
+ expect(result).toBe(survey.questions[0]);
+ });
+
+ test("returns null when no empty fallback values are found", () => {
+ const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
+ const survey: TSurvey = {
+ questions: [
+ {
+ id: "q1",
+ headline: questionHeadline,
+ },
+ ] as unknown as TSurveyQuestion[],
+ } as unknown as TSurvey;
+
+ vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en);
+
+ const result = checkForEmptyFallBackValue(survey, "en");
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("replaceHeadlineRecall", () => {
+ test("processes all questions in a survey", () => {
+ const survey: TSurvey = {
+ questions: [
+ {
+ id: "q1",
+ headline: { en: "Question with #recall:id1/fallback:default#" },
+ },
+ {
+ id: "q2",
+ headline: { en: "Another with #recall:id2/fallback:other#" },
+ },
+ ] as unknown as TSurveyQuestion[],
+ hiddenFields: { fieldIds: [] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj)));
+
+ const result = replaceHeadlineRecall(survey, "en");
+
+ // Verify recallToHeadline was called for each question
+ expect(result).not.toBe(survey); // Should be a clone
+ expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
+ expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
+ });
+ });
+
+ describe("getRecallItems", () => {
+ test("extracts recall items from text", () => {
+ const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
+ const survey: TSurvey = {
+ questions: [
+ { id: "id1", headline: { en: "Question One" } },
+ { id: "id2", headline: { en: "Question Two" } },
+ ] as unknown as TSurveyQuestion[],
+ hiddenFields: { fieldIds: [] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = getRecallItems(text, survey, "en");
+
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe("id1");
+ expect(result[0].label).toBe("Question One");
+ expect(result[0].type).toBe("question");
+ expect(result[1].id).toBe("id2");
+ expect(result[1].label).toBe("Question Two");
+ expect(result[1].type).toBe("question");
+ });
+
+ test("handles hidden fields in recall items", () => {
+ const text = "Text with #recall:hidden1/fallback:val1#";
+ const survey: TSurvey = {
+ questions: [],
+ hiddenFields: { fieldIds: ["hidden1"] },
+ variables: [],
+ } as unknown as TSurvey;
+
+ const result = getRecallItems(text, survey, "en");
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("hidden1");
+ expect(result[0].type).toBe("hiddenField");
+ });
+
+ test("handles variables in recall items", () => {
+ const text = "Text with #recall:var1/fallback:val1#";
+ const survey: TSurvey = {
+ questions: [],
+ hiddenFields: { fieldIds: [] },
+ variables: [{ id: "var1", name: "Variable One" }],
+ } as unknown as TSurvey;
+
+ const result = getRecallItems(text, survey, "en");
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("var1");
+ expect(result[0].label).toBe("Variable One");
+ expect(result[0].type).toBe("variable");
+ });
+
+ test("returns empty array when no recall items are found", () => {
+ const text = "Text with no recall items";
+ const survey: TSurvey = {} as TSurvey;
+
+ const result = getRecallItems(text, survey, "en");
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("getFallbackValues", () => {
+ test("extracts fallback values from text", () => {
+ const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#";
+ const result = getFallbackValues(text);
+
+ expect(result).toEqual({
+ id1: "value1",
+ id2: "value2",
+ });
+ });
+
+ test("returns empty object when no fallback values are found", () => {
+ const text = "Text with no fallback values";
+ const result = getFallbackValues(text);
+ expect(result).toEqual({});
+ });
+ });
+
+ describe("headlineToRecall", () => {
+ test("transforms headlines to recall info", () => {
+ const text = "What do you think of @Product?";
+ const recallItems: TSurveyRecallItem[] = [{ id: "product", label: "Product", type: "question" }];
+ const fallbacks: fallbacks = {
+ product: "our product",
+ };
+
+ const result = headlineToRecall(text, recallItems, fallbacks);
+ expect(result).toBe("What do you think of #recall:product/fallback:our product#?");
+ });
+
+ test("transforms multiple headlines", () => {
+ const text = "Rate @Product made by @Company";
+ const recallItems: TSurveyRecallItem[] = [
+ { id: "product", label: "Product", type: "question" },
+ { id: "company", label: "Company", type: "question" },
+ ];
+ const fallbacks: fallbacks = {
+ product: "our product",
+ company: "our company",
+ };
+
+ const result = headlineToRecall(text, recallItems, fallbacks);
+ expect(result).toBe(
+ "Rate #recall:product/fallback:our product# made by #recall:company/fallback:our company#"
+ );
+ });
+ });
+
+ describe("parseRecallInfo", () => {
+ test("replaces recall info with response data", () => {
+ const text = "Your answer was #recall:q1/fallback:not-provided#";
+ const responseData: TResponseData = {
+ q1: "Yes definitely",
+ };
+
+ const result = parseRecallInfo(text, responseData);
+ expect(result).toBe("Your answer was Yes definitely");
+ });
+
+ test("uses fallback when response data is missing", () => {
+ const text = "Your answer was #recall:q1/fallback:notnbspprovided#";
+ const responseData: TResponseData = {
+ q2: "Some other answer",
+ };
+
+ const result = parseRecallInfo(text, responseData);
+ expect(result).toBe("Your answer was not provided");
+ });
+
+ test("formats date values", () => {
+ const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
+ const responseData: TResponseData = {
+ joinDate: "2023-01-01",
+ };
+
+ const result = parseRecallInfo(text, responseData);
+ expect(result).toBe("You joined on January 1st, 2023");
+ });
+
+ test("formats array values as comma-separated list", () => {
+ const text = "Your selections: #recall:preferences/fallback:none#";
+ const responseData: TResponseData = {
+ preferences: ["Option A", "Option B", "Option C"],
+ };
+
+ const result = parseRecallInfo(text, responseData);
+ expect(result).toBe("Your selections: Option A, Option B, Option C");
+ });
+
+ test("uses variables when available", () => {
+ const text = "Welcome back, #recall:username/fallback:user#";
+ const variables: TResponseVariables = {
+ username: "John Doe",
+ };
+
+ const result = parseRecallInfo(text, {}, variables);
+ expect(result).toBe("Welcome back, John Doe");
+ });
+
+ test("prioritizes variables over response data", () => {
+ const text = "Your email is #recall:email/fallback:no-email#";
+ const responseData: TResponseData = {
+ email: "response@example.com",
+ };
+ const variables: TResponseVariables = {
+ email: "variable@example.com",
+ };
+
+ const result = parseRecallInfo(text, responseData, variables);
+ expect(result).toBe("Your email is variable@example.com");
+ });
+
+ test("handles withSlash parameter", () => {
+ const text = "Your name is #recall:name/fallback:anonymous#";
+ const variables: TResponseVariables = {
+ name: "John Doe",
+ };
+
+ const result = parseRecallInfo(text, {}, variables, true);
+ expect(result).toBe("Your name is #/John Doe\\#");
+ });
+
+ test("handles 'nbsp' in fallback values", () => {
+ const text = "Default spacing: #recall:space/fallback:nonnbspbreaking#";
+
+ const result = parseRecallInfo(text);
+ expect(result).toBe("Default spacing: non breaking");
+ });
+ });
+});
diff --git a/apps/web/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts
index 57f429cc9a..0c98e9a69e 100644
--- a/apps/web/lib/utils/recall.ts
+++ b/apps/web/lib/utils/recall.ts
@@ -124,6 +124,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
const recalls = text.match(/#recall:[^ ]+/g);
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
};
+
for (const question of survey.questions) {
if (
findRecalls(getLocalizedValue(question.headline, language)) ||
diff --git a/apps/web/lib/utils/services.test.ts b/apps/web/lib/utils/services.test.ts
new file mode 100644
index 0000000000..00562c7b63
--- /dev/null
+++ b/apps/web/lib/utils/services.test.ts
@@ -0,0 +1,737 @@
+import { validateInputs } from "@/lib/utils/validate";
+import { Prisma } from "@prisma/client";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { prisma } from "@formbricks/database";
+import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
+import {
+ getActionClass,
+ getApiKey,
+ getContact,
+ getDocument,
+ getEnvironment,
+ getInsight,
+ getIntegration,
+ getInvite,
+ getLanguage,
+ getProject,
+ getResponse,
+ getResponseNote,
+ getSegment,
+ getSurvey,
+ getTag,
+ getTeam,
+ getWebhook,
+ isProjectPartOfOrganization,
+ isTeamPartOfOrganization,
+} from "./services";
+
+// Mock all dependencies
+vi.mock("@/lib/utils/validate", () => ({
+ validateInputs: vi.fn(),
+}));
+
+vi.mock("@formbricks/database", () => ({
+ prisma: {
+ actionClass: {
+ findUnique: vi.fn(),
+ },
+ apiKey: {
+ findUnique: vi.fn(),
+ },
+ environment: {
+ findUnique: vi.fn(),
+ },
+ integration: {
+ findUnique: vi.fn(),
+ },
+ invite: {
+ findUnique: vi.fn(),
+ },
+ language: {
+ findFirst: vi.fn(),
+ },
+ project: {
+ findUnique: vi.fn(),
+ },
+ response: {
+ findUnique: vi.fn(),
+ },
+ responseNote: {
+ findUnique: vi.fn(),
+ },
+ survey: {
+ findUnique: vi.fn(),
+ },
+ tag: {
+ findUnique: vi.fn(),
+ },
+ webhook: {
+ findUnique: vi.fn(),
+ },
+ team: {
+ findUnique: vi.fn(),
+ },
+ insight: {
+ findUnique: vi.fn(),
+ },
+ document: {
+ findUnique: vi.fn(),
+ },
+ contact: {
+ findUnique: vi.fn(),
+ },
+ segment: {
+ findUnique: vi.fn(),
+ },
+ },
+}));
+
+// Mock cache
+vi.mock("@/lib/cache", () => ({
+ cache: vi.fn((fn) => fn),
+}));
+
+// Mock react cache
+vi.mock("react", () => ({
+ cache: vi.fn((fn) => fn),
+}));
+
+// Mock all cache modules
+vi.mock("@/lib/actionClass/cache", () => ({
+ actionClassCache: {
+ tag: {
+ byId: vi.fn((id) => `actionClass-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/api-key", () => ({
+ apiKeyCache: {
+ tag: {
+ byId: vi.fn((id) => `apiKey-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/environment/cache", () => ({
+ environmentCache: {
+ tag: {
+ byId: vi.fn((id) => `environment-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/integration/cache", () => ({
+ integrationCache: {
+ tag: {
+ byId: vi.fn((id) => `integration-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/invite", () => ({
+ inviteCache: {
+ tag: {
+ byId: vi.fn((id) => `invite-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/project/cache", () => ({
+ projectCache: {
+ tag: {
+ byId: vi.fn((id) => `project-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/response/cache", () => ({
+ responseCache: {
+ tag: {
+ byId: vi.fn((id) => `response-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/responseNote/cache", () => ({
+ responseNoteCache: {
+ tag: {
+ byResponseId: vi.fn((id) => `response-${id}-notes`),
+ byId: vi.fn((id) => `responseNote-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/survey/cache", () => ({
+ surveyCache: {
+ tag: {
+ byId: vi.fn((id) => `survey-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/tag/cache", () => ({
+ tagCache: {
+ tag: {
+ byId: vi.fn((id) => `tag-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/webhook", () => ({
+ webhookCache: {
+ tag: {
+ byId: vi.fn((id) => `webhook-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/team", () => ({
+ teamCache: {
+ tag: {
+ byId: vi.fn((id) => `team-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/contact", () => ({
+ contactCache: {
+ tag: {
+ byId: vi.fn((id) => `contact-${id}`),
+ },
+ },
+}));
+
+vi.mock("@/lib/cache/segment", () => ({
+ segmentCache: {
+ tag: {
+ byId: vi.fn((id) => `segment-${id}`),
+ },
+ },
+}));
+
+describe("Service Functions", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe("getActionClass", () => {
+ const actionClassId = "action123";
+
+ test("returns the action class when found", async () => {
+ const mockActionClass = { environmentId: "env123" };
+ vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
+
+ const result = await getActionClass(actionClassId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
+ where: { id: actionClassId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockActionClass);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("Database error"));
+
+ await expect(getActionClass(actionClassId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getApiKey", () => {
+ const apiKeyId = "apiKey123";
+
+ test("returns the api key when found", async () => {
+ const mockApiKey = { organizationId: "org123" };
+ vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKey);
+
+ const result = await getApiKey(apiKeyId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
+ where: { id: apiKeyId },
+ select: { organizationId: true },
+ });
+ expect(result).toEqual(mockApiKey);
+ });
+
+ test("throws InvalidInputError if apiKeyId is empty", async () => {
+ await expect(getApiKey("")).rejects.toThrow(InvalidInputError);
+ expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getApiKey(apiKeyId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getEnvironment", () => {
+ const environmentId = "env123";
+
+ test("returns the environment when found", async () => {
+ const mockEnvironment = { projectId: "proj123" };
+ vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
+
+ const result = await getEnvironment(environmentId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.environment.findUnique).toHaveBeenCalledWith({
+ where: { id: environmentId },
+ select: { projectId: true },
+ });
+ expect(result).toEqual(mockEnvironment);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.environment.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getEnvironment(environmentId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getIntegration", () => {
+ const integrationId = "int123";
+
+ test("returns the integration when found", async () => {
+ const mockIntegration = { environmentId: "env123" };
+ vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
+
+ const result = await getIntegration(integrationId);
+ expect(prisma.integration.findUnique).toHaveBeenCalledWith({
+ where: { id: integrationId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockIntegration);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.integration.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getIntegration(integrationId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getInvite", () => {
+ const inviteId = "invite123";
+
+ test("returns the invite when found", async () => {
+ const mockInvite = { organizationId: "org123" };
+ vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite);
+
+ const result = await getInvite(inviteId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.invite.findUnique).toHaveBeenCalledWith({
+ where: { id: inviteId },
+ select: { organizationId: true },
+ });
+ expect(result).toEqual(mockInvite);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.invite.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getInvite(inviteId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getLanguage", () => {
+ const languageId = "lang123";
+
+ test("returns the language when found", async () => {
+ const mockLanguage = { projectId: "proj123" };
+ vi.mocked(prisma.language.findFirst).mockResolvedValue(mockLanguage);
+
+ const result = await getLanguage(languageId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.language.findFirst).toHaveBeenCalledWith({
+ where: { id: languageId },
+ select: { projectId: true },
+ });
+ expect(result).toEqual(mockLanguage);
+ });
+
+ test("throws ResourceNotFoundError when language not found", async () => {
+ vi.mocked(prisma.language.findFirst).mockResolvedValue(null);
+
+ await expect(getLanguage(languageId)).rejects.toThrow(ResourceNotFoundError);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.language.findFirst).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getLanguage(languageId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getProject", () => {
+ const projectId = "proj123";
+
+ test("returns the project when found", async () => {
+ const mockProject = { organizationId: "org123" };
+ vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
+
+ const result = await getProject(projectId);
+ expect(prisma.project.findUnique).toHaveBeenCalledWith({
+ where: { id: projectId },
+ select: { organizationId: true },
+ });
+ expect(result).toEqual(mockProject);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.project.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getProject(projectId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getResponse", () => {
+ const responseId = "resp123";
+
+ test("returns the response when found", async () => {
+ const mockResponse = { surveyId: "survey123" };
+ vi.mocked(prisma.response.findUnique).mockResolvedValue(mockResponse);
+
+ const result = await getResponse(responseId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.response.findUnique).toHaveBeenCalledWith({
+ where: { id: responseId },
+ select: { surveyId: true },
+ });
+ expect(result).toEqual(mockResponse);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.response.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getResponse(responseId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getResponseNote", () => {
+ const responseNoteId = "note123";
+
+ test("returns the response note when found", async () => {
+ const mockResponseNote = { responseId: "resp123" };
+ vi.mocked(prisma.responseNote.findUnique).mockResolvedValue(mockResponseNote);
+
+ const result = await getResponseNote(responseNoteId);
+ expect(prisma.responseNote.findUnique).toHaveBeenCalledWith({
+ where: { id: responseNoteId },
+ select: { responseId: true },
+ });
+ expect(result).toEqual(mockResponseNote);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.responseNote.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getResponseNote(responseNoteId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getSurvey", () => {
+ const surveyId = "survey123";
+
+ test("returns the survey when found", async () => {
+ const mockSurvey = { environmentId: "env123" };
+ vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
+
+ const result = await getSurvey(surveyId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.survey.findUnique).toHaveBeenCalledWith({
+ where: { id: surveyId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockSurvey);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.survey.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getTag", () => {
+ const tagId = "tag123";
+
+ test("returns the tag when found", async () => {
+ const mockTag = { environmentId: "env123" };
+ vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag);
+
+ const result = await getTag(tagId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.tag.findUnique).toHaveBeenCalledWith({
+ where: { id: tagId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockTag);
+ });
+ });
+
+ describe("getWebhook", () => {
+ const webhookId = "webhook123";
+
+ test("returns the webhook when found", async () => {
+ const mockWebhook = { environmentId: "env123" };
+ vi.mocked(prisma.webhook.findUnique).mockResolvedValue(mockWebhook);
+
+ const result = await getWebhook(webhookId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
+ where: { id: webhookId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockWebhook);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.webhook.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getWebhook(webhookId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getTeam", () => {
+ const teamId = "team123";
+
+ test("returns the team when found", async () => {
+ const mockTeam = { organizationId: "org123" };
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam);
+
+ const result = await getTeam(teamId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.team.findUnique).toHaveBeenCalledWith({
+ where: { id: teamId },
+ select: { organizationId: true },
+ });
+ expect(result).toEqual(mockTeam);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.team.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getTeam(teamId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getInsight", () => {
+ const insightId = "insight123";
+
+ test("returns the insight when found", async () => {
+ const mockInsight = { environmentId: "env123" };
+ vi.mocked(prisma.insight.findUnique).mockResolvedValue(mockInsight);
+
+ const result = await getInsight(insightId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.insight.findUnique).toHaveBeenCalledWith({
+ where: { id: insightId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockInsight);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.insight.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getInsight(insightId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getDocument", () => {
+ const documentId = "doc123";
+
+ test("returns the document when found", async () => {
+ const mockDocument = { environmentId: "env123" };
+ vi.mocked(prisma.document.findUnique).mockResolvedValue(mockDocument);
+
+ const result = await getDocument(documentId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.document.findUnique).toHaveBeenCalledWith({
+ where: { id: documentId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockDocument);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.document.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getDocument(documentId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("isProjectPartOfOrganization", () => {
+ const projectId = "proj123";
+ const organizationId = "org123";
+
+ test("returns true when project belongs to organization", async () => {
+ vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId });
+
+ const result = await isProjectPartOfOrganization(organizationId, projectId);
+ expect(result).toBe(true);
+ });
+
+ test("returns false when project belongs to different organization", async () => {
+ vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "otherOrg" });
+
+ const result = await isProjectPartOfOrganization(organizationId, projectId);
+ expect(result).toBe(false);
+ });
+
+ test("throws ResourceNotFoundError when project not found", async () => {
+ vi.mocked(prisma.project.findUnique).mockResolvedValue(null);
+
+ await expect(isProjectPartOfOrganization(organizationId, projectId)).rejects.toThrow(
+ ResourceNotFoundError
+ );
+ });
+ });
+
+ describe("isTeamPartOfOrganization", () => {
+ const teamId = "team123";
+ const organizationId = "org123";
+
+ test("returns true when team belongs to organization", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId });
+
+ const result = await isTeamPartOfOrganization(organizationId, teamId);
+ expect(result).toBe(true);
+ });
+
+ test("returns false when team belongs to different organization", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId: "otherOrg" });
+
+ const result = await isTeamPartOfOrganization(organizationId, teamId);
+ expect(result).toBe(false);
+ });
+
+ test("throws ResourceNotFoundError when team not found", async () => {
+ vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
+
+ await expect(isTeamPartOfOrganization(organizationId, teamId)).rejects.toThrow(ResourceNotFoundError);
+ });
+ });
+
+ describe("getContact", () => {
+ const contactId = "contact123";
+
+ test("returns the contact when found", async () => {
+ const mockContact = { environmentId: "env123" };
+ vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
+
+ const result = await getContact(contactId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.contact.findUnique).toHaveBeenCalledWith({
+ where: { id: contactId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockContact);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.contact.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getContact(contactId)).rejects.toThrow(DatabaseError);
+ });
+ });
+
+ describe("getSegment", () => {
+ const segmentId = "segment123";
+
+ test("returns the segment when found", async () => {
+ const mockSegment = { environmentId: "env123" };
+ vi.mocked(prisma.segment.findUnique).mockResolvedValue(mockSegment);
+
+ const result = await getSegment(segmentId);
+ expect(validateInputs).toHaveBeenCalled();
+ expect(prisma.segment.findUnique).toHaveBeenCalledWith({
+ where: { id: segmentId },
+ select: { environmentId: true },
+ });
+ expect(result).toEqual(mockSegment);
+ });
+
+ test("throws DatabaseError when database operation fails", async () => {
+ vi.mocked(prisma.segment.findUnique).mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError("Error", {
+ code: "P2002",
+ clientVersion: "4.7.0",
+ })
+ );
+
+ await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError);
+ });
+ });
+});
diff --git a/apps/web/lib/utils/single-use-surveys.test.ts b/apps/web/lib/utils/single-use-surveys.test.ts
new file mode 100644
index 0000000000..ccd2813b24
--- /dev/null
+++ b/apps/web/lib/utils/single-use-surveys.test.ts
@@ -0,0 +1,115 @@
+import * as crypto from "@/lib/crypto";
+import { env } from "@/lib/env";
+import cuid2 from "@paralleldrive/cuid2";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys";
+
+vi.mock("@/lib/crypto", () => ({
+ symmetricEncrypt: vi.fn(),
+ symmetricDecrypt: vi.fn(),
+}));
+
+vi.mock(
+ "@paralleldrive/cuid2",
+ async (importOriginal: () => Promise) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ createId: vi.fn(),
+ isCuid: vi.fn(),
+ };
+ }
+);
+
+vi.mock("@/lib/env", () => ({
+ env: {
+ ENCRYPTION_KEY: "test-encryption-key",
+ },
+}));
+
+describe("Single Use Surveys", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("generateSurveySingleUseId", () => {
+ test("returns plain cuid when encryption is disabled", () => {
+ const createIdMock = vi.spyOn(cuid2, "createId");
+ createIdMock.mockReturnValueOnce("test-cuid");
+
+ const result = generateSurveySingleUseId(false);
+
+ expect(result).toBe("test-cuid");
+ expect(createIdMock).toHaveBeenCalledTimes(1);
+ expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
+ });
+
+ test("returns encrypted cuid when encryption is enabled", () => {
+ const createIdMock = vi.spyOn(cuid2, "createId");
+ createIdMock.mockReturnValueOnce("test-cuid");
+ vi.mocked(crypto.symmetricEncrypt).mockReturnValueOnce("encrypted-test-cuid");
+
+ const result = generateSurveySingleUseId(true);
+
+ expect(result).toBe("encrypted-test-cuid");
+ expect(createIdMock).toHaveBeenCalledTimes(1);
+ expect(crypto.symmetricEncrypt).toHaveBeenCalledWith("test-cuid", env.ENCRYPTION_KEY);
+ });
+
+ test("throws error when encryption key is missing", () => {
+ vi.mocked(env).ENCRYPTION_KEY = "";
+ const createIdMock = vi.spyOn(cuid2, "createId");
+ createIdMock.mockReturnValueOnce("test-cuid");
+
+ expect(() => generateSurveySingleUseId(true)).toThrow("ENCRYPTION_KEY is not set");
+
+ // Restore encryption key for subsequent tests
+ vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
+ });
+ });
+
+ describe("generateSurveySingleUseIds", () => {
+ beforeEach(() => {
+ vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
+ });
+
+ test("generates multiple single use IDs", () => {
+ const createIdMock = vi.spyOn(cuid2, "createId");
+ createIdMock
+ .mockReturnValueOnce("test-cuid-1")
+ .mockReturnValueOnce("test-cuid-2")
+ .mockReturnValueOnce("test-cuid-3");
+
+ const result = generateSurveySingleUseIds(3, false);
+
+ expect(result).toEqual(["test-cuid-1", "test-cuid-2", "test-cuid-3"]);
+ expect(createIdMock).toHaveBeenCalledTimes(3);
+ });
+
+ test("generates encrypted IDs when encryption is enabled", () => {
+ const createIdMock = vi.spyOn(cuid2, "createId");
+
+ createIdMock.mockReturnValueOnce("test-cuid-1").mockReturnValueOnce("test-cuid-2");
+
+ vi.mocked(crypto.symmetricEncrypt)
+ .mockReturnValueOnce("encrypted-test-cuid-1")
+ .mockReturnValueOnce("encrypted-test-cuid-2");
+
+ const result = generateSurveySingleUseIds(2, true);
+
+ expect(result).toEqual(["encrypted-test-cuid-1", "encrypted-test-cuid-2"]);
+ expect(createIdMock).toHaveBeenCalledTimes(2);
+ expect(crypto.symmetricEncrypt).toHaveBeenCalledTimes(2);
+ });
+
+ test("returns empty array when count is zero", () => {
+ const result = generateSurveySingleUseIds(0, false);
+
+ const createIdMock = vi.spyOn(cuid2, "createId");
+ createIdMock.mockReturnValueOnce("test-cuid");
+
+ expect(result).toEqual([]);
+ expect(createIdMock).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/web/lib/utils/singleUseSurveys.ts b/apps/web/lib/utils/single-use-surveys.ts
similarity index 55%
rename from apps/web/lib/utils/singleUseSurveys.ts
rename to apps/web/lib/utils/single-use-surveys.ts
index 926a9a132b..05af0a193b 100644
--- a/apps/web/lib/utils/singleUseSurveys.ts
+++ b/apps/web/lib/utils/single-use-surveys.ts
@@ -1,4 +1,4 @@
-import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
+import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
import cuid2 from "@paralleldrive/cuid2";
@@ -26,24 +26,3 @@ export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean):
return singleUseIds;
};
-
-// validate the survey single use id
-export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
- try {
- let decryptedCuid: string | null = null;
-
- if (!env.ENCRYPTION_KEY) {
- throw new Error("ENCRYPTION_KEY is not set");
- }
-
- decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
-
- if (cuid2.isCuid(decryptedCuid)) {
- return decryptedCuid;
- } else {
- return undefined;
- }
- } catch (error) {
- return undefined;
- }
-};
diff --git a/apps/web/lib/utils/strings.test.ts b/apps/web/lib/utils/strings.test.ts
new file mode 100644
index 0000000000..bf45d6e1d5
--- /dev/null
+++ b/apps/web/lib/utils/strings.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, test } from "vitest";
+import {
+ capitalizeFirstLetter,
+ isCapitalized,
+ sanitizeString,
+ startsWithVowel,
+ truncate,
+ truncateText,
+} from "./strings";
+
+describe("String Utilities", () => {
+ describe("capitalizeFirstLetter", () => {
+ test("capitalizes the first letter of a string", () => {
+ expect(capitalizeFirstLetter("hello")).toBe("Hello");
+ });
+
+ test("returns empty string if input is null", () => {
+ expect(capitalizeFirstLetter(null)).toBe("");
+ });
+
+ test("returns empty string if input is empty string", () => {
+ expect(capitalizeFirstLetter("")).toBe("");
+ });
+
+ test("doesn't change already capitalized string", () => {
+ expect(capitalizeFirstLetter("Hello")).toBe("Hello");
+ });
+
+ test("handles single character string", () => {
+ expect(capitalizeFirstLetter("a")).toBe("A");
+ });
+ });
+
+ describe("truncate", () => {
+ test("returns the string as is if length is less than the specified length", () => {
+ expect(truncate("hello", 10)).toBe("hello");
+ });
+
+ test("truncates the string and adds ellipsis if length exceeds the specified length", () => {
+ expect(truncate("hello world", 5)).toBe("hello...");
+ });
+
+ test("returns empty string if input is falsy", () => {
+ expect(truncate("", 5)).toBe("");
+ });
+
+ test("handles exact length match correctly", () => {
+ expect(truncate("hello", 5)).toBe("hello");
+ });
+ });
+
+ describe("sanitizeString", () => {
+ test("replaces special characters with delimiter", () => {
+ expect(sanitizeString("hello@world")).toBe("hello_world");
+ });
+
+ test("keeps alphanumeric and allowed characters", () => {
+ expect(sanitizeString("hello-world.123")).toBe("hello-world.123");
+ });
+
+ test("truncates string to specified length", () => {
+ const longString = "a".repeat(300);
+ expect(sanitizeString(longString).length).toBe(255);
+ });
+
+ test("uses custom delimiter when provided", () => {
+ expect(sanitizeString("hello@world", "-")).toBe("hello-world");
+ });
+
+ test("uses custom length when provided", () => {
+ expect(sanitizeString("hello world", "_", 5)).toBe("hello");
+ });
+ });
+
+ describe("isCapitalized", () => {
+ test("returns true for capitalized strings", () => {
+ expect(isCapitalized("Hello")).toBe(true);
+ });
+
+ test("returns false for non-capitalized strings", () => {
+ expect(isCapitalized("hello")).toBe(false);
+ });
+
+ test("handles single uppercase character", () => {
+ expect(isCapitalized("A")).toBe(true);
+ });
+
+ test("handles single lowercase character", () => {
+ expect(isCapitalized("a")).toBe(false);
+ });
+ });
+
+ describe("startsWithVowel", () => {
+ test("returns true for strings starting with lowercase vowels", () => {
+ expect(startsWithVowel("apple")).toBe(true);
+ expect(startsWithVowel("elephant")).toBe(true);
+ expect(startsWithVowel("igloo")).toBe(true);
+ expect(startsWithVowel("octopus")).toBe(true);
+ expect(startsWithVowel("umbrella")).toBe(true);
+ });
+
+ test("returns true for strings starting with uppercase vowels", () => {
+ expect(startsWithVowel("Apple")).toBe(true);
+ expect(startsWithVowel("Elephant")).toBe(true);
+ expect(startsWithVowel("Igloo")).toBe(true);
+ expect(startsWithVowel("Octopus")).toBe(true);
+ expect(startsWithVowel("Umbrella")).toBe(true);
+ });
+
+ test("returns false for strings starting with consonants", () => {
+ expect(startsWithVowel("banana")).toBe(false);
+ expect(startsWithVowel("Carrot")).toBe(false);
+ });
+
+ test("returns false for empty strings", () => {
+ expect(startsWithVowel("")).toBe(false);
+ });
+ });
+
+ describe("truncateText", () => {
+ test("returns the string as is if length is less than the specified limit", () => {
+ expect(truncateText("hello", 10)).toBe("hello");
+ });
+
+ test("truncates the string and adds ellipsis if length exceeds the specified limit", () => {
+ expect(truncateText("hello world", 5)).toBe("hello...");
+ });
+
+ test("handles exact limit match correctly", () => {
+ expect(truncateText("hello", 5)).toBe("hello");
+ });
+ });
+});
diff --git a/apps/web/lib/utils/styling.test.ts b/apps/web/lib/utils/styling.test.ts
new file mode 100644
index 0000000000..298321cc23
--- /dev/null
+++ b/apps/web/lib/utils/styling.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, test } from "vitest";
+import { TJsEnvironmentStateProject, TJsEnvironmentStateSurvey } from "@formbricks/types/js";
+import { getStyling } from "./styling";
+
+describe("Styling Utilities", () => {
+ test("returns project styling when project does not allow style overwrite", () => {
+ const project: TJsEnvironmentStateProject = {
+ styling: {
+ allowStyleOverwrite: false,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ },
+ } as unknown as TJsEnvironmentStateProject;
+
+ const survey: TJsEnvironmentStateSurvey = {
+ styling: {
+ overwriteThemeStyling: true,
+ brandColor: "#ffffff",
+ highlightBorderColor: "#ffffff",
+ },
+ } as unknown as TJsEnvironmentStateSurvey;
+
+ expect(getStyling(project, survey)).toBe(project.styling);
+ });
+
+ test("returns project styling when project allows style overwrite but survey does not overwrite", () => {
+ const project: TJsEnvironmentStateProject = {
+ styling: {
+ allowStyleOverwrite: true,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ },
+ } as unknown as TJsEnvironmentStateProject;
+
+ const survey: TJsEnvironmentStateSurvey = {
+ styling: {
+ overwriteThemeStyling: false,
+ brandColor: "#ffffff",
+ highlightBorderColor: "#ffffff",
+ },
+ } as unknown as TJsEnvironmentStateSurvey;
+
+ expect(getStyling(project, survey)).toBe(project.styling);
+ });
+
+ test("returns survey styling when both project and survey allow style overwrite", () => {
+ const project: TJsEnvironmentStateProject = {
+ styling: {
+ allowStyleOverwrite: true,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ },
+ } as unknown as TJsEnvironmentStateProject;
+
+ const survey: TJsEnvironmentStateSurvey = {
+ styling: {
+ overwriteThemeStyling: true,
+ brandColor: "#ffffff",
+ highlightBorderColor: "#ffffff",
+ },
+ } as unknown as TJsEnvironmentStateSurvey;
+
+ expect(getStyling(project, survey)).toBe(survey.styling);
+ });
+
+ test("returns project styling when project allows style overwrite but survey styling is undefined", () => {
+ const project: TJsEnvironmentStateProject = {
+ styling: {
+ allowStyleOverwrite: true,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ },
+ } as unknown as TJsEnvironmentStateProject;
+
+ const survey: TJsEnvironmentStateSurvey = {
+ styling: undefined,
+ } as unknown as TJsEnvironmentStateSurvey;
+
+ expect(getStyling(project, survey)).toBe(project.styling);
+ });
+
+ test("returns project styling when project allows style overwrite but survey overwriteThemeStyling is undefined", () => {
+ const project: TJsEnvironmentStateProject = {
+ styling: {
+ allowStyleOverwrite: true,
+ brandColor: "#000000",
+ highlightBorderColor: "#000000",
+ },
+ } as unknown as TJsEnvironmentStateProject;
+
+ const survey: TJsEnvironmentStateSurvey = {
+ styling: {
+ brandColor: "#ffffff",
+ highlightBorderColor: "#ffffff",
+ },
+ } as unknown as TJsEnvironmentStateSurvey;
+
+ expect(getStyling(project, survey)).toBe(project.styling);
+ });
+});
diff --git a/apps/web/lib/utils/templates.test.ts b/apps/web/lib/utils/templates.test.ts
new file mode 100644
index 0000000000..421f8fd623
--- /dev/null
+++ b/apps/web/lib/utils/templates.test.ts
@@ -0,0 +1,164 @@
+import { getLocalizedValue } from "@/lib/i18n/utils";
+import { structuredClone } from "@/lib/pollyfills/structuredClone";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { TProject } from "@formbricks/types/project";
+import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
+import { TTemplate } from "@formbricks/types/templates";
+import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
+
+// Mock the imported functions
+vi.mock("@/lib/i18n/utils", () => ({
+ getLocalizedValue: vi.fn(),
+}));
+
+vi.mock("@/lib/pollyfills/structuredClone", () => ({
+ structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
+}));
+
+describe("Template Utilities", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("replaceQuestionPresetPlaceholders", () => {
+ test("returns original question when project is not provided", () => {
+ const question: TSurveyQuestion = {
+ id: "test-id",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: {
+ default: "Test Question $[projectName]",
+ },
+ } as unknown as TSurveyQuestion;
+
+ const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
+
+ expect(result).toEqual(question);
+ expect(structuredClone).not.toHaveBeenCalled();
+ });
+
+ test("replaces projectName placeholder in subheader", () => {
+ const question: TSurveyQuestion = {
+ id: "test-id",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: {
+ default: "Test Question",
+ },
+ subheader: {
+ default: "Subheader for $[projectName]",
+ },
+ } as unknown as TSurveyQuestion;
+
+ const project: TProject = {
+ id: "project-id",
+ name: "Test Project",
+ organizationId: "org-id",
+ } as unknown as TProject;
+
+ // Mock for headline and subheader with correct return values
+ vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
+ vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
+
+ const result = replaceQuestionPresetPlaceholders(question, project);
+
+ expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
+ expect(result.subheader?.default).toBe("Subheader for Test Project");
+ });
+
+ test("handles missing headline and subheader", () => {
+ const question: TSurveyQuestion = {
+ id: "test-id",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ } as unknown as TSurveyQuestion;
+
+ const project: TProject = {
+ id: "project-id",
+ name: "Test Project",
+ organizationId: "org-id",
+ } as unknown as TProject;
+
+ const result = replaceQuestionPresetPlaceholders(question, project);
+
+ expect(structuredClone).toHaveBeenCalledWith(question);
+ expect(result).toEqual(question);
+ expect(getLocalizedValue).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("replacePresetPlaceholders", () => {
+ test("replaces projectName placeholder in template name and questions", () => {
+ const template: TTemplate = {
+ id: "template-1",
+ name: "Test Template",
+ description: "Template Description",
+ preset: {
+ name: "$[projectName] Feedback",
+ questions: [
+ {
+ id: "q1",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: {
+ default: "How do you like $[projectName]?",
+ },
+ },
+ {
+ id: "q2",
+ type: TSurveyQuestionTypeEnum.OpenText,
+ headline: {
+ default: "Another question",
+ },
+ subheader: {
+ default: "About $[projectName]",
+ },
+ },
+ ],
+ },
+ category: "product",
+ } as unknown as TTemplate;
+
+ const project = {
+ name: "Awesome App",
+ };
+
+ // Mock getLocalizedValue to return the original strings with placeholders
+ vi.mocked(getLocalizedValue)
+ .mockReturnValueOnce("How do you like $[projectName]?")
+ .mockReturnValueOnce("Another question")
+ .mockReturnValueOnce("About $[projectName]");
+
+ const result = replacePresetPlaceholders(template, project);
+
+ expect(result.preset.name).toBe("Awesome App Feedback");
+ expect(structuredClone).toHaveBeenCalledWith(template.preset);
+
+ // Verify that replaceQuestionPresetPlaceholders was applied to both questions
+ expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3);
+ expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?");
+ expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App");
+ });
+
+ test("maintains other template properties", () => {
+ const template: TTemplate = {
+ id: "template-1",
+ name: "Test Template",
+ description: "Template Description",
+ preset: {
+ name: "$[projectName] Feedback",
+ questions: [],
+ },
+ category: "product",
+ } as unknown as TTemplate;
+
+ const project = {
+ name: "Awesome App",
+ };
+
+ const result = replacePresetPlaceholders(template, project) as unknown as {
+ name: string;
+ description: string;
+ };
+
+ expect(result.name).toBe(template.name);
+ expect(result.description).toBe(template.description);
+ });
+ });
+});
diff --git a/apps/web/lib/utils/url.test.ts b/apps/web/lib/utils/url.test.ts
new file mode 100644
index 0000000000..739c1282bb
--- /dev/null
+++ b/apps/web/lib/utils/url.test.ts
@@ -0,0 +1,49 @@
+import { cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
+import { isValidCallbackUrl, testURLmatch } from "./url";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("testURLmatch", () => {
+ const testCases: [string, string, TActionClassPageUrlRule, string][] = [
+ ["https://example.com", "https://example.com", "exactMatch", "yes"],
+ ["https://example.com", "https://example.com/page", "contains", "no"],
+ ["https://example.com/page", "https://example.com", "startsWith", "yes"],
+ ["https://example.com/page", "page", "endsWith", "yes"],
+ ["https://example.com", "https://other.com", "notMatch", "yes"],
+ ["https://example.com", "other", "notContains", "yes"],
+ ];
+
+ test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => {
+ expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule)).toBe(expected);
+ });
+
+ test("throws an error for invalid match type", () => {
+ expect(() =>
+ testURLmatch("https://example.com", "https://example.com", "invalidRule" as TActionClassPageUrlRule)
+ ).toThrow("Invalid match type");
+ });
+});
+
+describe("isValidCallbackUrl", () => {
+ const WEBAPP_URL = "https://webapp.example.com";
+
+ test("returns true for valid callback URL", () => {
+ expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true);
+ });
+
+ test("returns false for invalid scheme", () => {
+ expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false);
+ });
+
+ test("returns false for invalid domain", () => {
+ expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false);
+ });
+
+ test("returns false for malformed URL", () => {
+ expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
+ });
+});
diff --git a/apps/web/lib/utils/validate.test.ts b/apps/web/lib/utils/validate.test.ts
new file mode 100644
index 0000000000..737779476c
--- /dev/null
+++ b/apps/web/lib/utils/validate.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { z } from "zod";
+import { logger } from "@formbricks/logger";
+import { ValidationError } from "@formbricks/types/errors";
+import { validateInputs } from "./validate";
+
+vi.mock("@formbricks/logger", () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}));
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("validateInputs", () => {
+ test("validates inputs successfully", () => {
+ const schema = z.string();
+ const result = validateInputs(["valid", schema]);
+
+ expect(result).toEqual(["valid"]);
+ });
+
+ test("throws ValidationError for invalid inputs", () => {
+ const schema = z.string();
+
+ expect(() => validateInputs([123, schema])).toThrow(ValidationError);
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.stringContaining("Validation failed")
+ );
+ });
+
+ test("validates multiple inputs successfully", () => {
+ const stringSchema = z.string();
+ const numberSchema = z.number();
+
+ const result = validateInputs(["valid", stringSchema], [42, numberSchema]);
+
+ expect(result).toEqual(["valid", 42]);
+ });
+
+ test("throws ValidationError for one of multiple invalid inputs", () => {
+ const stringSchema = z.string();
+ const numberSchema = z.number();
+
+ expect(() => validateInputs(["valid", stringSchema], ["invalid", numberSchema])).toThrow(ValidationError);
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.stringContaining("Validation failed")
+ );
+ });
+});
diff --git a/apps/web/lib/utils/video-upload.test.ts b/apps/web/lib/utils/video-upload.test.ts
new file mode 100644
index 0000000000..61cce8f629
--- /dev/null
+++ b/apps/web/lib/utils/video-upload.test.ts
@@ -0,0 +1,131 @@
+import { cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import {
+ checkForLoomUrl,
+ checkForVimeoUrl,
+ checkForYoutubeUrl,
+ convertToEmbedUrl,
+ extractLoomId,
+ extractVimeoId,
+ extractYoutubeId,
+} from "./video-upload";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("checkForYoutubeUrl", () => {
+ test("returns true for valid YouTube URLs", () => {
+ expect(checkForYoutubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
+ expect(checkForYoutubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true);
+ expect(checkForYoutubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
+ expect(checkForYoutubeUrl("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
+ expect(checkForYoutubeUrl("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
+ expect(checkForYoutubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true);
+ });
+
+ test("returns false for invalid YouTube URLs", () => {
+ expect(checkForYoutubeUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBe(false);
+ expect(checkForYoutubeUrl("invalid-url")).toBe(false);
+ expect(checkForYoutubeUrl("http://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false); // Non-HTTPS protocol
+ });
+});
+
+describe("extractYoutubeId", () => {
+ test("extracts video ID from YouTube URLs", () => {
+ expect(extractYoutubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
+ expect(extractYoutubeId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
+ expect(extractYoutubeId("https://youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
+ expect(extractYoutubeId("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
+ });
+
+ test("returns null for invalid YouTube URLs", () => {
+ expect(extractYoutubeId("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeNull();
+ expect(extractYoutubeId("invalid-url")).toBeNull();
+ expect(extractYoutubeId("https://youtube.com/notavalidpath")).toBeNull();
+ });
+});
+
+describe("convertToEmbedUrl", () => {
+ test("converts YouTube URL to embed URL", () => {
+ expect(convertToEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
+ "https://www.youtube.com/embed/dQw4w9WgXcQ"
+ );
+ expect(convertToEmbedUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(
+ "https://www.youtube.com/embed/dQw4w9WgXcQ"
+ );
+ });
+
+ test("converts Vimeo URL to embed URL", () => {
+ expect(convertToEmbedUrl("https://vimeo.com/123456789")).toBe("https://player.vimeo.com/video/123456789");
+ expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
+ "https://player.vimeo.com/video/123456789"
+ );
+ });
+
+ test("converts Loom URL to embed URL", () => {
+ expect(convertToEmbedUrl("https://www.loom.com/share/abcdef123456")).toBe(
+ "https://www.loom.com/embed/abcdef123456"
+ );
+ expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
+ "https://www.loom.com/embed/abcdef123456"
+ );
+ });
+
+ test("returns undefined for unsupported URLs", () => {
+ expect(convertToEmbedUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeUndefined();
+ expect(convertToEmbedUrl("invalid-url")).toBeUndefined();
+ });
+});
+
+// Testing private functions by importing them through the module system
+describe("checkForVimeoUrl", () => {
+ test("returns true for valid Vimeo URLs", () => {
+ expect(checkForVimeoUrl("https://vimeo.com/123456789")).toBe(true);
+ expect(checkForVimeoUrl("https://www.vimeo.com/123456789")).toBe(true);
+ });
+
+ test("returns false for invalid Vimeo URLs", () => {
+ expect(checkForVimeoUrl("https://www.invalid.com/123456789")).toBe(false);
+ expect(checkForVimeoUrl("invalid-url")).toBe(false);
+ expect(checkForVimeoUrl("http://vimeo.com/123456789")).toBe(false); // Non-HTTPS protocol
+ });
+});
+
+describe("checkForLoomUrl", () => {
+ test("returns true for valid Loom URLs", () => {
+ expect(checkForLoomUrl("https://loom.com/share/abcdef123456")).toBe(true);
+ expect(checkForLoomUrl("https://www.loom.com/share/abcdef123456")).toBe(true);
+ });
+
+ test("returns false for invalid Loom URLs", () => {
+ expect(checkForLoomUrl("https://www.invalid.com/share/abcdef123456")).toBe(false);
+ expect(checkForLoomUrl("invalid-url")).toBe(false);
+ expect(checkForLoomUrl("http://loom.com/share/abcdef123456")).toBe(false); // Non-HTTPS protocol
+ });
+});
+
+describe("extractVimeoId", () => {
+ test("extracts video ID from Vimeo URLs", () => {
+ expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
+ expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
+ });
+
+ test("returns null for invalid Vimeo URLs", () => {
+ expect(extractVimeoId("https://www.invalid.com/123456789")).toBeNull();
+ expect(extractVimeoId("invalid-url")).toBeNull();
+ });
+});
+
+describe("extractLoomId", () => {
+ test("extracts video ID from Loom URLs", () => {
+ expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
+ expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
+ });
+
+ test("returns null for invalid Loom URLs", async () => {
+ expect(extractLoomId("https://www.invalid.com/share/abcdef123456")).toBeNull();
+ expect(extractLoomId("invalid-url")).toBeNull();
+ expect(extractLoomId("https://loom.com/invalid/abcdef123456")).toBeNull();
+ });
+});
diff --git a/apps/web/lib/utils/videoUpload.ts b/apps/web/lib/utils/video-upload.ts
similarity index 82%
rename from apps/web/lib/utils/videoUpload.ts
rename to apps/web/lib/utils/video-upload.ts
index bae60fc30b..74ddddfc03 100644
--- a/apps/web/lib/utils/videoUpload.ts
+++ b/apps/web/lib/utils/video-upload.ts
@@ -15,13 +15,12 @@ export const checkForYoutubeUrl = (url: string): boolean => {
const hostname = youtubeUrl.hostname;
return youtubeDomains.includes(hostname);
- } catch (err) {
- // invalid URL
+ } catch {
return false;
}
};
-const checkForVimeoUrl = (url: string): boolean => {
+export const checkForVimeoUrl = (url: string): boolean => {
try {
const vimeoUrl = new URL(url);
@@ -31,13 +30,12 @@ const checkForVimeoUrl = (url: string): boolean => {
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
- } catch (err) {
- // invalid URL
+ } catch {
return false;
}
};
-const checkForLoomUrl = (url: string): boolean => {
+export const checkForLoomUrl = (url: string): boolean => {
try {
const loomUrl = new URL(url);
@@ -47,8 +45,7 @@ const checkForLoomUrl = (url: string): boolean => {
const hostname = loomUrl.hostname;
return loomDomains.includes(hostname);
- } catch (err) {
- // invalid URL
+ } catch {
return false;
}
};
@@ -65,8 +62,8 @@ export const extractYoutubeId = (url: string): string | null => {
];
regExpList.some((regExp) => {
- const match = url.match(regExp);
- if (match && match[1]) {
+ const match = regExp.exec(url);
+ if (match?.[1]) {
id = match[1];
return true;
}
@@ -76,23 +73,25 @@ export const extractYoutubeId = (url: string): string | null => {
return id || null;
};
-const extractVimeoId = (url: string): string | null => {
+export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
- const match = url.match(regExp);
+ const match = regExp.exec(url);
- if (match && match[1]) {
+ if (match?.[1]) {
return match[1];
}
+
return null;
};
-const extractLoomId = (url: string): string | null => {
+export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
- const match = url.match(regExp);
+ const match = regExp.exec(url);
- if (match && match[1]) {
+ if (match?.[1]) {
return match[1];
}
+
return null;
};
diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
index 2de060fbf3..ff68bee140 100644
--- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
+++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx
@@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
{t("environments.settings.general.logo_in_email_header")}
-
+
{logoUrl && (
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
-
+
+
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
diff --git a/apps/web/modules/survey/list/actions.ts b/apps/web/modules/survey/list/actions.ts
index b288b6e73b..06acc610a9 100644
--- a/apps/web/modules/survey/list/actions.ts
+++ b/apps/web/modules/survey/list/actions.ts
@@ -8,7 +8,7 @@ import {
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
-import { generateSurveySingleUseId } from "@/lib/utils/singleUseSurveys";
+import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
diff --git a/apps/web/modules/ui/components/file-input/components/video-settings.tsx b/apps/web/modules/ui/components/file-input/components/video-settings.tsx
index efbdfdf288..27b66ceaa3 100644
--- a/apps/web/modules/ui/components/file-input/components/video-settings.tsx
+++ b/apps/web/modules/ui/components/file-input/components/video-settings.tsx
@@ -1,6 +1,6 @@
"use client";
-import { checkForYoutubeUrl, convertToEmbedUrl, extractYoutubeId } from "@/lib/utils/videoUpload";
+import { checkForYoutubeUrl, convertToEmbedUrl, extractYoutubeId } from "@/lib/utils/video-upload";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx
index c6304d5ae8..8f5cf3b265 100644
--- a/apps/web/modules/ui/components/file-input/index.tsx
+++ b/apps/web/modules/ui/components/file-input/index.tsx
@@ -237,7 +237,7 @@ export const FileInput = ({
/>
{file.uploaded ? (
handleRemove(idx)}>
@@ -255,7 +255,7 @@ export const FileInput = ({
{file.uploaded ? (
handleRemove(idx)}>
@@ -295,7 +295,7 @@ export const FileInput = ({
/>
{selectedFiles[0].uploaded ? (
handleRemove(0)}>
@@ -311,7 +311,7 @@ export const FileInput = ({
{selectedFiles[0].uploaded ? (
handleRemove(0)}>