From 07e9a7c007fe556cb07fc19f3e380cf81f89487e Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Wed, 7 May 2025 17:57:48 +0530 Subject: [PATCH] chore: tests for `lib/utils` and `lib/survey` (#5676) --- .../surveys/[surveyId]/singleUseIds/route.ts | 2 +- apps/web/lib/i18n/i18n.mock.ts | 2 +- apps/web/lib/response/service.ts | 2 +- apps/web/lib/response/tests/response.test.ts | 2 +- .../{tests => }/__mock__/survey.mock.ts | 2 +- apps/web/lib/survey/auth.test.ts | 113 ++ apps/web/lib/survey/cache.test.ts | 122 ++ apps/web/lib/survey/service.test.ts | 1037 +++++++++++++++++ apps/web/lib/survey/service.ts | 2 +- apps/web/lib/survey/tests/survey.test.ts | 476 -------- apps/web/lib/survey/utils.test.ts | 254 ++++ .../utils/action-client-middleware.test.ts | 386 ++++++ .../web/lib/utils/action-client-middleware.ts | 2 +- apps/web/lib/utils/colors.test.ts | 70 ++ apps/web/lib/utils/colors.ts | 2 +- apps/web/lib/utils/contact.test.ts | 64 + apps/web/lib/utils/datetime.test.ts | 27 + apps/web/lib/utils/email.test.ts | 50 + apps/web/lib/utils/file-conversion.test.ts | 63 + .../{fileConversion.ts => file-conversion.ts} | 0 apps/web/lib/utils/headers.test.ts | 36 + apps/web/lib/utils/helper.test.ts | 795 +++++++++++++ apps/web/lib/utils/locale.test.ts | 87 ++ apps/web/lib/utils/promises.test.ts | 84 ++ apps/web/lib/utils/recall.test.ts | 516 ++++++++ apps/web/lib/utils/recall.ts | 1 + apps/web/lib/utils/services.test.ts | 737 ++++++++++++ apps/web/lib/utils/single-use-surveys.test.ts | 115 ++ ...gleUseSurveys.ts => single-use-surveys.ts} | 23 +- apps/web/lib/utils/strings.test.ts | 133 +++ apps/web/lib/utils/styling.test.ts | 100 ++ apps/web/lib/utils/templates.test.ts | 164 +++ apps/web/lib/utils/url.test.ts | 49 + apps/web/lib/utils/validate.test.ts | 54 + apps/web/lib/utils/video-upload.test.ts | 131 +++ .../utils/{videoUpload.ts => video-upload.ts} | 31 +- .../email-customization-settings.tsx | 6 +- apps/web/modules/survey/list/actions.ts | 2 +- .../file-input/components/video-settings.tsx | 2 +- .../ui/components/file-input/index.tsx | 8 +- 40 files changed, 5221 insertions(+), 531 deletions(-) rename apps/web/lib/survey/{tests => }/__mock__/survey.mock.ts (99%) create mode 100644 apps/web/lib/survey/auth.test.ts create mode 100644 apps/web/lib/survey/cache.test.ts create mode 100644 apps/web/lib/survey/service.test.ts delete mode 100644 apps/web/lib/survey/tests/survey.test.ts create mode 100644 apps/web/lib/survey/utils.test.ts create mode 100644 apps/web/lib/utils/action-client-middleware.test.ts create mode 100644 apps/web/lib/utils/colors.test.ts create mode 100644 apps/web/lib/utils/contact.test.ts create mode 100644 apps/web/lib/utils/datetime.test.ts create mode 100644 apps/web/lib/utils/email.test.ts create mode 100644 apps/web/lib/utils/file-conversion.test.ts rename apps/web/lib/utils/{fileConversion.ts => file-conversion.ts} (100%) create mode 100644 apps/web/lib/utils/headers.test.ts create mode 100644 apps/web/lib/utils/helper.test.ts create mode 100644 apps/web/lib/utils/locale.test.ts create mode 100644 apps/web/lib/utils/promises.test.ts create mode 100644 apps/web/lib/utils/recall.test.ts create mode 100644 apps/web/lib/utils/services.test.ts create mode 100644 apps/web/lib/utils/single-use-surveys.test.ts rename apps/web/lib/utils/{singleUseSurveys.ts => single-use-surveys.ts} (55%) create mode 100644 apps/web/lib/utils/strings.test.ts create mode 100644 apps/web/lib/utils/styling.test.ts create mode 100644 apps/web/lib/utils/templates.test.ts create mode 100644 apps/web/lib/utils/url.test.ts create mode 100644 apps/web/lib/utils/validate.test.ts create mode 100644 apps/web/lib/utils/video-upload.test.ts rename apps/web/lib/utils/{videoUpload.ts => video-upload.ts} (82%) 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)}>