From ce8b019e930af67eb579e43b52dfc9271ae155eb Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Thu, 8 May 2025 16:22:55 +0530 Subject: [PATCH] chore: modules/survey/lib and modules/ee/contacts/api/v1 (#5711) --- .../[userId]/attributes/lib/contact.test.ts | 169 ++++++++++ .../contacts/[userId]/lib/attributes.test.ts | 67 ++++ .../contacts/[userId]/lib/contact.test.ts | 91 ++++++ .../[userId]/lib/person-state.test.ts | 200 ++++++++++++ .../lib/{personState.ts => person-state.ts} | 13 +- .../contacts/[userId]/lib/segments.test.ts | 190 +++++++++++ .../contacts/[userId]/lib/segments.ts | 6 +- .../identify/contacts/[userId]/route.ts | 2 +- .../[environmentId]/user/lib/contact.test.ts | 78 +++++ .../[environmentId]/user/lib/segments.test.ts | 199 ++++++++++++ .../[environmentId]/user/lib/segments.ts | 6 +- .../user/lib/update-user.test.ts | 243 ++++++++++++++ .../user/lib/user-state.test.ts | 132 ++++++++ .../[environmentId]/user/lib/user-state.ts | 9 +- .../lib/contact-attribute-key.test.ts | 297 ++++++++++++++++++ .../lib/contact-attribute-key.ts | 3 +- .../lib/contact-attribute-keys.test.ts | 152 +++++++++ .../lib/contact-attribute-keys.ts | 2 +- .../lib/contact-attributes.test.ts | 119 +++++++ .../contacts/[contactId]/lib/contact.test.ts | 152 +++++++++ .../management/contacts/lib/contacts.test.ts | 99 ++++++ .../modules/survey/lib/action-class.test.ts | 142 +++++++++ .../modules/survey/lib/client-utils.test.ts | 28 ++ .../modules/survey/lib/organization.test.ts | 153 +++++++++ apps/web/modules/survey/lib/project.test.ts | 148 +++++++++ apps/web/modules/survey/lib/response.test.ts | 83 +++++ apps/web/modules/survey/lib/survey.test.ts | 124 ++++++++ apps/web/modules/survey/lib/survey.ts | 24 +- .../survey/lib/tests/client-utils.test.ts | 17 - apps/web/modules/survey/lib/utils.test.ts | 249 +++++++++++++++ 30 files changed, 3155 insertions(+), 42 deletions(-) create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/lib/contact.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.test.ts rename apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/{personState.ts => person-state.ts} (92%) create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/contact.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/update-user.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/lib/contact.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts create mode 100644 apps/web/modules/survey/lib/action-class.test.ts create mode 100644 apps/web/modules/survey/lib/client-utils.test.ts create mode 100644 apps/web/modules/survey/lib/organization.test.ts create mode 100644 apps/web/modules/survey/lib/project.test.ts create mode 100644 apps/web/modules/survey/lib/response.test.ts create mode 100644 apps/web/modules/survey/lib/survey.test.ts delete mode 100644 apps/web/modules/survey/lib/tests/client-utils.test.ts create mode 100644 apps/web/modules/survey/lib/utils.test.ts diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/lib/contact.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/lib/contact.test.ts new file mode 100644 index 0000000000..8db61e016f --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/lib/contact.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getContactByUserIdWithAttributes } from "./contact"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +const mockEnvironmentId = "testEnvironmentId"; +const mockUserId = "testUserId"; +const mockContactId = "testContactId"; + +describe("getContactByUserIdWithAttributes", () => { + test("should return contact with filtered attributes when found", async () => { + const mockUpdatedAttributes = { email: "new@example.com", plan: "premium" }; + const mockDbContact = { + id: mockContactId, + attributes: [ + { attributeKey: { key: "email" }, value: "new@example.com" }, + { attributeKey: { key: "plan" }, value: "premium" }, + ], + }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockDbContact as any); + + const result = await getContactByUserIdWithAttributes( + mockEnvironmentId, + mockUserId, + mockUpdatedAttributes + ); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + attributes: { + some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId }, + }, + }, + select: { + id: true, + attributes: { + where: { + attributeKey: { + key: { + in: Object.keys(mockUpdatedAttributes), + }, + }, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + expect(result).toEqual(mockDbContact); + }); + + test("should return null if contact not found", async () => { + const mockUpdatedAttributes = { email: "new@example.com" }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const result = await getContactByUserIdWithAttributes( + mockEnvironmentId, + mockUserId, + mockUpdatedAttributes + ); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + attributes: { + some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId }, + }, + }, + select: { + id: true, + attributes: { + where: { + attributeKey: { + key: { + in: Object.keys(mockUpdatedAttributes), + }, + }, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + expect(result).toBeNull(); + }); + + test("should handle empty updatedAttributes", async () => { + const mockUpdatedAttributes = {}; + const mockDbContact = { + id: mockContactId, + attributes: [], // No attributes should be fetched if updatedAttributes is empty + }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockDbContact as any); + + const result = await getContactByUserIdWithAttributes( + mockEnvironmentId, + mockUserId, + mockUpdatedAttributes + ); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + attributes: { + some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId }, + }, + }, + select: { + id: true, + attributes: { + where: { + attributeKey: { + key: { + in: [], // Object.keys({}) results in an empty array + }, + }, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + expect(result).toEqual(mockDbContact); + }); + + test("should return contact with only requested attributes even if DB stores more", async () => { + const mockUpdatedAttributes = { email: "new@example.com" }; // only request email + // The prisma call will filter attributes based on `Object.keys(mockUpdatedAttributes)` + const mockPrismaResponse = { + id: mockContactId, + attributes: [{ attributeKey: { key: "email" }, value: "new@example.com" }], + }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockPrismaResponse as any); + + const result = await getContactByUserIdWithAttributes( + mockEnvironmentId, + mockUserId, + mockUpdatedAttributes + ); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + attributes: { + some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId }, + }, + }, + select: { + id: true, + attributes: { + where: { + attributeKey: { + key: { + in: ["email"], + }, + }, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + expect(result).toEqual(mockPrismaResponse); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes.test.ts new file mode 100644 index 0000000000..2ca4fd5028 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { ValidationError } from "@formbricks/types/errors"; +import { getContactAttributes } from "./attributes"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttribute: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact-attribute", () => ({ + contactAttributeCache: { + tag: { + byContactId: vi.fn((contactId) => `contact-${contactId}-contactAttributes`), + }, + }, +})); + +const mockContactId = "xn8b8ol97q2pcp8dnlpsfs1m"; + +describe("getContactAttributes", () => { + test("should return transformed attributes when found", async () => { + const mockContactAttributes = [ + { attributeKey: { key: "email" }, value: "test@example.com" }, + { attributeKey: { key: "name" }, value: "Test User" }, + ]; + const expectedTransformedAttributes = { + email: "test@example.com", + name: "Test User", + }; + + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue(mockContactAttributes); + + const result = await getContactAttributes(mockContactId); + + expect(result).toEqual(expectedTransformedAttributes); + expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({ + where: { + contactId: mockContactId, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }); + }); + + test("should return an empty object when no attributes are found", async () => { + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); + + const result = await getContactAttributes(mockContactId); + + expect(result).toEqual({}); + expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({ + where: { + contactId: mockContactId, + }, + select: { attributeKey: { select: { key: true } }, value: true }, + }); + }); + + test("should throw a ValidationError when contactId is invalid", async () => { + const invalidContactId = "hello-world"; + + await expect(getContactAttributes(invalidContactId)).rejects.toThrowError(ValidationError); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact.test.ts new file mode 100644 index 0000000000..4bb85223b1 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +const mockEnvironmentId = "clxmg5n79000008l9df7b8nh8"; +const mockUserId = "dpqs2axc6v3b5cjcgtnqhwov"; +const mockContactId = "clxmg5n79000108l9df7b8xyz"; + +const mockReturnedContact = { + id: mockContactId, + environmentId: mockEnvironmentId, + createdAt: new Date("2024-01-01T10:00:00.000Z"), + updatedAt: new Date("2024-01-01T11:00:00.000Z"), +}; + +describe("getContactByUserId", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("should return contact if found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockReturnedContact as any); + + const result = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(result).toEqual(mockReturnedContact); + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + }); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const result = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(result).toBeNull(); + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + }); + }); + + test("should call prisma.contact.findFirst with correct parameters", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockReturnedContact as any); + await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(prisma.contact.findFirst).toHaveBeenCalledTimes(1); + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.test.ts new file mode 100644 index 0000000000..d3b8013947 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.test.ts @@ -0,0 +1,200 @@ +import { getEnvironment } from "@/lib/environment/service"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TEnvironment } from "@formbricks/types/environment"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { TOrganization } from "@formbricks/types/organizations"; +import { getContactByUserId } from "./contact"; +import { getPersonState } from "./person-state"; +import { getPersonSegmentIds } from "./segments"; + +vi.mock("@/lib/environment/service", () => ({ + getEnvironment: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact", () => ({ + getContactByUserId: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + create: vi.fn(), + }, + response: { + findMany: vi.fn(), + }, + display: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("./segments", () => ({ + getPersonSegmentIds: vi.fn(), +})); + +const mockEnvironmentId = "jubz514cwdmjvnbadsfd7ez3"; +const mockUserId = "huli1kfpw1r6vn00vjxetdob"; +const mockContactId = "e71zwzi6zgrdzutbb0q8spui"; +const mockProjectId = "d6o07l7ieizdioafgelrioao"; +const mockOrganizationId = "xa4oltlfkmqq3r4e3m3ocss1"; +const mockDevice = "desktop"; + +const mockEnvironment: TEnvironment = { + id: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: mockProjectId, + appSetupCompleted: false, +}; + +const mockOrganization: TOrganization = { + id: mockOrganizationId, + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Organization", + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { projects: 1, monthly: { responses: 100, miu: 100 } }, + periodStart: new Date(), + }, + isAIEnabled: false, +}; + +const mockResolvedContactFromGetContactByUserId = { + id: mockContactId, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: mockEnvironmentId, + userId: mockUserId, +}; + +const mockResolvedContactFromPrismaCreate = { + id: mockContactId, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: mockEnvironmentId, + userId: mockUserId, +}; + +describe("getPersonState", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should throw ResourceNotFoundError if environment is not found", async () => { + vi.mocked(getEnvironment).mockResolvedValue(null); + await expect( + getPersonState({ environmentId: mockEnvironmentId, userId: mockUserId, device: mockDevice }) + ).rejects.toThrow(new ResourceNotFoundError("environment", mockEnvironmentId)); + }); + + test("should throw ResourceNotFoundError if organization is not found", async () => { + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect( + getPersonState({ environmentId: mockEnvironmentId, userId: mockUserId, device: mockDevice }) + ).rejects.toThrow(new ResourceNotFoundError("organization", mockEnvironmentId)); + }); + + test("should return person state if contact exists", async () => { + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization); + vi.mocked(getContactByUserId).mockResolvedValue(mockResolvedContactFromGetContactByUserId); + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + vi.mocked(prisma.display.findMany).mockResolvedValue([]); + vi.mocked(getPersonSegmentIds).mockResolvedValue([]); + + const result = await getPersonState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + device: mockDevice, + }); + + expect(result.state.contactId).toBe(mockContactId); + expect(result.state.userId).toBe(mockUserId); + expect(result.state.segments).toEqual([]); + expect(result.state.displays).toEqual([]); + expect(result.state.responses).toEqual([]); + expect(result.state.lastDisplayAt).toBeNull(); + expect(result.revalidateProps).toBeUndefined(); + expect(prisma.contact.create).not.toHaveBeenCalled(); + }); + + test("should create contact and return person state if contact does not exist", async () => { + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization); + vi.mocked(getContactByUserId).mockResolvedValue(null); + vi.mocked(prisma.contact.create).mockResolvedValue(mockResolvedContactFromPrismaCreate as any); + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + vi.mocked(prisma.display.findMany).mockResolvedValue([]); + vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]); + + const result = await getPersonState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + device: mockDevice, + }); + + expect(prisma.contact.create).toHaveBeenCalledWith({ + data: { + environment: { connect: { id: mockEnvironmentId } }, + attributes: { + create: [ + { + attributeKey: { + connect: { key_environmentId: { key: "userId", environmentId: mockEnvironmentId } }, + }, + value: mockUserId, + }, + ], + }, + }, + }); + expect(result.state.contactId).toBe(mockContactId); + expect(result.state.userId).toBe(mockUserId); + expect(result.state.segments).toEqual(["segment1"]); + expect(result.revalidateProps).toEqual({ contactId: mockContactId, revalidate: true }); + }); + + test("should correctly map displays and responses", async () => { + const displayDate = new Date(); + const mockDisplays = [ + { surveyId: "survey1", createdAt: displayDate }, + { surveyId: "survey2", createdAt: new Date(displayDate.getTime() - 1000) }, + ]; + const mockResponses = [{ surveyId: "survey1" }, { surveyId: "survey3" }]; + + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization); + vi.mocked(getContactByUserId).mockResolvedValue(mockResolvedContactFromGetContactByUserId); + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponses as any); + vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplays as any); + vi.mocked(getPersonSegmentIds).mockResolvedValue([]); + + const result = await getPersonState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + device: mockDevice, + }); + + expect(result.state.displays).toEqual( + mockDisplays.map((d) => ({ surveyId: d.surveyId, createdAt: d.createdAt })) + ); + expect(result.state.responses).toEqual(mockResponses.map((r) => r.surveyId)); + expect(result.state.lastDisplayAt).toEqual(displayDate); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.ts similarity index 92% rename from apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts rename to apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.ts index e9d8151a9d..53e2bf72bb 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/personState.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/person-state.ts @@ -86,7 +86,7 @@ export const getPersonState = async ({ }, }); - const contactDisplayes = await prisma.display.findMany({ + const contactDisplays = await prisma.display.findMany({ where: { contactId: contact.id, }, @@ -98,21 +98,22 @@ export const getPersonState = async ({ const segments = await getPersonSegmentIds(environmentId, contact.id, userId, device); + const sortedContactDisplaysDate = contactDisplays?.toSorted( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + )[0]?.createdAt; + // If the person exists, return the persons's state const userState: TJsPersonState["data"] = { contactId: contact.id, userId, segments, displays: - contactDisplayes?.map((display) => ({ + contactDisplays?.map((display) => ({ surveyId: display.surveyId, createdAt: display.createdAt, })) ?? [], responses: contactResponses?.map((response) => response.surveyId) ?? [], - lastDisplayAt: - contactDisplayes.length > 0 - ? contactDisplayes.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt - : null, + lastDisplayAt: contactDisplays?.length > 0 ? sortedContactDisplaysDate : null, }; return { diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.test.ts new file mode 100644 index 0000000000..a134ae814f --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.test.ts @@ -0,0 +1,190 @@ +import { contactAttributeCache } from "@/lib/cache/contact-attribute"; +import { segmentCache } from "@/lib/cache/segment"; +import { getContactAttributes } from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes"; +import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TBaseFilter } from "@formbricks/types/segment"; +import { getPersonSegmentIds, getSegments } from "./segments"; + +vi.mock("@/lib/cache/contact-attribute", () => ({ + contactAttributeCache: { + tag: { + byContactId: vi.fn((contactId) => `contactAttributeCache-contactId-${contactId}`), + }, + }, +})); + +vi.mock("@/lib/cache/segment", () => ({ + segmentCache: { + tag: { + byEnvironmentId: vi.fn((environmentId) => `segmentCache-environmentId-${environmentId}`), + }, + }, +})); + +vi.mock( + "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes", + () => ({ + getContactAttributes: vi.fn(), + }) +); + +vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ + evaluateSegment: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + segment: { + findMany: vi.fn(), + }, + }, +})); + +const mockEnvironmentId = "bbn7e47f6etoai6usxezxd4a"; +const mockContactId = "cworhmq5yqvnb0tsfw9yka4b"; +const mockContactUserId = "xrgbcxn5y9so92igacthutfw"; +const mockDeviceType = "desktop"; + +const mockSegmentsData = [ + { id: "segment1", filters: [{}] as TBaseFilter[] }, + { id: "segment2", filters: [{}] as TBaseFilter[] }, +]; + +const mockContactAttributesData = { + attribute1: "value1", + attribute2: "value2", +}; + +describe("segments lib", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getSegments", () => { + test("should return segments successfully", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData); + + const result = await getSegments(mockEnvironmentId); + + expect(prisma.segment.findMany).toHaveBeenCalledWith({ + where: { environmentId: mockEnvironmentId }, + select: { id: true, filters: true }, + }); + + expect(result).toEqual(mockSegmentsData); + expect(segmentCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const mockErrorMessage = "Prisma error"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + vi.mocked(prisma.segment.findMany).mockRejectedValueOnce(errToThrow); + await expect(getSegments(mockEnvironmentId)).rejects.toThrow(DatabaseError); + }); + + test("should throw original error on other errors", async () => { + const genericError = new Error("Test Generic Error"); + + vi.mocked(prisma.segment.findMany).mockRejectedValueOnce(genericError); + await expect(getSegments(mockEnvironmentId)).rejects.toThrow("Test Generic Error"); + }); + }); + + describe("getPersonSegmentIds", () => { + beforeEach(() => { + vi.mocked(getContactAttributes).mockResolvedValue(mockContactAttributesData); + vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData); // Mock for getSegments call + }); + + test("should return person segment IDs successfully", async () => { + vi.mocked(evaluateSegment).mockResolvedValue(true); // All segments evaluate to true + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(getContactAttributes).toHaveBeenCalledWith(mockContactId); + expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length); + + mockSegmentsData.forEach((segment) => { + expect(evaluateSegment).toHaveBeenCalledWith( + { + attributes: mockContactAttributesData, + deviceType: mockDeviceType, + environmentId: mockEnvironmentId, + contactId: mockContactId, + userId: mockContactUserId, + }, + segment.filters + ); + }); + + expect(result).toEqual(mockSegmentsData.map((s) => s.id)); + expect(segmentCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId); + expect(contactAttributeCache.tag.byContactId).toHaveBeenCalledWith(mockContactId); + }); + + test("should return empty array if no segments exist", async () => { + // @ts-expect-error -- this is a valid test case to check for null + vi.mocked(prisma.segment.findMany).mockResolvedValue(null); // No segments + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toEqual([]); + expect(getContactAttributes).not.toHaveBeenCalled(); + expect(evaluateSegment).not.toHaveBeenCalled(); + }); + + test("should return empty array if segments is null", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue(null as any); // segments is null + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toEqual([]); + expect(getContactAttributes).not.toHaveBeenCalled(); + expect(evaluateSegment).not.toHaveBeenCalled(); + }); + + test("should return only matching segment IDs", async () => { + vi.mocked(evaluateSegment) + .mockResolvedValueOnce(true) // First segment matches + .mockResolvedValueOnce(false); // Second segment does not match + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockDeviceType + ); + + expect(result).toEqual([mockSegmentsData[0].id]); + expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length); + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.ts index 23a732faba..209b447e98 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/segments.ts @@ -11,14 +11,16 @@ import { ZId, ZString } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; import { TBaseFilter } from "@formbricks/types/segment"; -const getSegments = reactCache((environmentId: string) => +export const getSegments = reactCache((environmentId: string) => cache( async () => { try { - return prisma.segment.findMany({ + const segments = await prisma.segment.findMany({ where: { environmentId }, select: { id: true, filters: true }, }); + + return segments; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts index ead57b3447..57710c99f1 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route.ts @@ -6,7 +6,7 @@ import { NextRequest, userAgent } from "next/server"; import { logger } from "@formbricks/logger"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZJsUserIdentifyInput } from "@formbricks/types/js"; -import { getPersonState } from "./lib/personState"; +import { getPersonState } from "./lib/person-state"; export const OPTIONS = async (): Promise => { return responses.successResponse({}, true); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/contact.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/contact.test.ts new file mode 100644 index 0000000000..a9db686eac --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/contact.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getContactByUserIdWithAttributes } from "./contact"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +const environmentId = "testEnvironmentId"; +const userId = "testUserId"; + +const mockContactDbData = { + id: "contactId123", + attributes: [ + { attributeKey: { key: "userId" }, value: userId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], +}; + +describe("getContactByUserIdWithAttributes", () => { + test("should return contact with attributes when found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData); + + const contact = await getContactByUserIdWithAttributes(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId, + attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } }, + }, + select: { + id: true, + attributes: { + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + + expect(contact).toEqual({ + id: "contactId123", + attributes: [ + { + attributeKey: { key: "userId" }, + value: userId, + }, + { + attributeKey: { key: "email" }, + value: "test@example.com", + }, + ], + }); + }); + + test("should return null when contact not found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserIdWithAttributes(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + environmentId, + attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } }, + }, + select: { + id: true, + attributes: { + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + + expect(contact).toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts new file mode 100644 index 0000000000..02aee6cef9 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.test.ts @@ -0,0 +1,199 @@ +import { contactAttributeCache } from "@/lib/cache/contact-attribute"; +import { segmentCache } from "@/lib/cache/segment"; +import { validateInputs } from "@/lib/utils/validate"; +import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TBaseFilter } from "@formbricks/types/segment"; +import { getPersonSegmentIds, getSegments } from "./segments"; + +vi.mock("@/lib/cache/contact-attribute", () => ({ + contactAttributeCache: { + tag: { + byContactId: vi.fn((contactId) => `contactAttributeCache-contactId-${contactId}`), + }, + }, +})); + +vi.mock("@/lib/cache/segment", () => ({ + segmentCache: { + tag: { + byEnvironmentId: vi.fn((environmentId) => `segmentCache-environmentId-${environmentId}`), + }, + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ + evaluateSegment: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + segment: { + findMany: vi.fn(), + }, + }, +})); + +const mockEnvironmentId = "test-environment-id"; +const mockContactId = "test-contact-id"; +const mockContactUserId = "test-contact-user-id"; +const mockAttributes = { email: "test@example.com" }; +const mockDeviceType = "desktop"; + +const mockSegmentsData = [ + { id: "segment1", filters: [{}] as TBaseFilter[] }, + { id: "segment2", filters: [{}] as TBaseFilter[] }, +]; + +describe("segments lib", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getSegments", () => { + test("should return segments successfully", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData); + + const result = await getSegments(mockEnvironmentId); + + expect(prisma.segment.findMany).toHaveBeenCalledWith({ + where: { environmentId: mockEnvironmentId }, + select: { id: true, filters: true }, + }); + + expect(result).toEqual(mockSegmentsData); + expect(segmentCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2001", + clientVersion: "2.0.0", + }); + + vi.mocked(prisma.segment.findMany).mockRejectedValue(prismaError); + + await expect(getSegments(mockEnvironmentId)).rejects.toThrow(DatabaseError); + }); + + test("should throw generic error if not Prisma error", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.segment.findMany).mockRejectedValue(genericError); + + await expect(getSegments(mockEnvironmentId)).rejects.toThrow("Test Generic Error"); + }); + }); + + describe("getPersonSegmentIds", () => { + beforeEach(() => { + vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData); // Mock for getSegments call + }); + + test("should return person segment IDs successfully", async () => { + vi.mocked(evaluateSegment).mockResolvedValue(true); // All segments evaluate to true + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockAttributes, + mockDeviceType + ); + + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.segment.findMany).toHaveBeenCalledWith({ + where: { environmentId: mockEnvironmentId }, + select: { id: true, filters: true }, + }); + + expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length); + mockSegmentsData.forEach((segment) => { + expect(evaluateSegment).toHaveBeenCalledWith( + { + attributes: mockAttributes, + deviceType: mockDeviceType, + environmentId: mockEnvironmentId, + contactId: mockContactId, + userId: mockContactUserId, + }, + segment.filters + ); + }); + expect(result).toEqual(mockSegmentsData.map((s) => s.id)); + expect(segmentCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId); + expect(contactAttributeCache.tag.byContactId).toHaveBeenCalledWith(mockContactId); + }); + + test("should return empty array if no segments exist", async () => { + vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockAttributes, + mockDeviceType + ); + + expect(result).toEqual([]); + expect(evaluateSegment).not.toHaveBeenCalled(); + }); + + test("should return empty array if segments exist but none match", async () => { + vi.mocked(evaluateSegment).mockResolvedValue(false); // All segments evaluate to false + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockAttributes, + mockDeviceType + ); + expect(result).toEqual([]); + expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length); + }); + + test("should call validateInputs with correct parameters", async () => { + await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockAttributes, + mockDeviceType + ); + expect(validateInputs).toHaveBeenCalledWith( + [mockEnvironmentId, expect.anything()], + [mockContactId, expect.anything()], + [mockContactUserId, expect.anything()] + ); + }); + + test("should return only matching segment IDs", async () => { + vi.mocked(evaluateSegment) + .mockResolvedValueOnce(true) // First segment matches + .mockResolvedValueOnce(false); // Second segment does not match + + const result = await getPersonSegmentIds( + mockEnvironmentId, + mockContactId, + mockContactUserId, + mockAttributes, + mockDeviceType + ); + + expect(result).toEqual([mockSegmentsData[0].id]); + expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length); + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts index 68c0fd1dc6..e95312f82d 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments.ts @@ -10,14 +10,16 @@ import { ZId, ZString } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; import { TBaseFilter } from "@formbricks/types/segment"; -const getSegments = reactCache((environmentId: string) => +export const getSegments = reactCache((environmentId: string) => cache( async () => { try { - return prisma.segment.findMany({ + const segments = await prisma.segment.findMany({ where: { environmentId }, select: { id: true, filters: true }, }); + + return segments; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/update-user.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/update-user.test.ts new file mode 100644 index 0000000000..2eaaa7a72d --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/update-user.test.ts @@ -0,0 +1,243 @@ +import { contactCache } from "@/lib/cache/contact"; +import { getEnvironment } from "@/lib/environment/service"; +import { updateAttributes } from "@/modules/ee/contacts/lib/attributes"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TEnvironment } from "@formbricks/types/environment"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { getContactByUserIdWithAttributes } from "./contact"; +import { updateUser } from "./update-user"; +import { getUserState } from "./user-state"; + +vi.mock("@/lib/cache/contact", () => ({ + contactCache: { + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/environment/service", () => ({ + getEnvironment: vi.fn(), +})); + +vi.mock("@/modules/ee/contacts/lib/attributes", () => ({ + updateAttributes: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + create: vi.fn(), + }, + }, +})); + +vi.mock("./contact", () => ({ + getContactByUserIdWithAttributes: vi.fn(), +})); + +vi.mock("./user-state", () => ({ + getUserState: vi.fn(), +})); + +const mockEnvironmentId = "test-environment-id"; +const mockUserId = "test-user-id"; +const mockContactId = "test-contact-id"; +const mockProjectId = "v7cxgsb4pzupdkr9xs14ldmb"; + +const mockEnvironment: TEnvironment = { + id: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + appSetupCompleted: false, + projectId: mockProjectId, +}; + +const mockContactAttributes = [ + { attributeKey: { key: "userId" }, value: mockUserId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, +]; + +const mockContact = { + id: mockContactId, + environmentId: mockEnvironmentId, + attributes: mockContactAttributes, + createdAt: new Date(), + updatedAt: new Date(), + name: null, + email: null, +}; + +const mockUserState = { + surveys: [], + noCodeActionClasses: [], + attributeClasses: [], + contactId: mockContactId, + userId: mockUserId, + displays: [], + responses: [], + segments: [], + lastDisplayAt: null, +}; + +describe("updateUser", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); + vi.mocked(getUserState).mockResolvedValue(mockUserState); + vi.mocked(updateAttributes).mockResolvedValue({ success: true, messages: [] }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should throw ResourceNotFoundError if environment is not found", async () => { + vi.mocked(getEnvironment).mockResolvedValue(null); + await expect(updateUser(mockEnvironmentId, mockUserId, "desktop")).rejects.toThrow( + new ResourceNotFoundError("environment", mockEnvironmentId) + ); + }); + + test("should create a new contact if not found", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(null); + vi.mocked(prisma.contact.create).mockResolvedValue({ + id: mockContactId, + attributes: [{ attributeKey: { key: "userId" }, value: mockUserId }], + } as any); // Type assertion for mock + + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop"); + + expect(prisma.contact.create).toHaveBeenCalledWith({ + data: { + environment: { connect: { id: mockEnvironmentId } }, + attributes: { + create: [ + { + attributeKey: { + connect: { key_environmentId: { key: "userId", environmentId: mockEnvironmentId } }, + }, + value: mockUserId, + }, + ], + }, + }, + select: { + id: true, + attributes: { + select: { attributeKey: { select: { key: true } }, value: true }, + }, + }, + }); + expect(contactCache.revalidate).toHaveBeenCalledWith({ + environmentId: mockEnvironmentId, + userId: mockUserId, + id: mockContactId, + }); + expect(result.state.data).toEqual(expect.objectContaining(mockUserState)); + expect(result.messages).toEqual([]); + }); + + test("should update existing contact attributes", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const newAttributes = { email: "new@example.com", language: "en" }; + + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes); + + expect(updateAttributes).toHaveBeenCalledWith( + mockContactId, + mockUserId, + mockEnvironmentId, + newAttributes + ); + expect(result.state.data?.language).toBe("en"); + expect(result.messages).toEqual([]); + }); + + test("should not update attributes if they are the same", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const existingAttributes = { email: "test@example.com" }; // Same as in mockContact + + await updateUser(mockEnvironmentId, mockUserId, "desktop", existingAttributes); + + expect(updateAttributes).not.toHaveBeenCalled(); + }); + + test("should return messages from updateAttributes if any", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const newAttributes = { company: "Formbricks" }; + const updateMessages = ["Attribute 'company' created."]; + vi.mocked(updateAttributes).mockResolvedValue({ success: true, messages: updateMessages }); + + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes); + + expect(updateAttributes).toHaveBeenCalledWith( + mockContactId, + mockUserId, + mockEnvironmentId, + newAttributes + ); + expect(result.messages).toEqual(updateMessages); + }); + + test("should use device type 'phone'", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + await updateUser(mockEnvironmentId, mockUserId, "phone"); + expect(getUserState).toHaveBeenCalledWith( + expect.objectContaining({ + device: "phone", + }) + ); + }); + + test("should use device type 'desktop'", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + await updateUser(mockEnvironmentId, mockUserId, "desktop"); + expect(getUserState).toHaveBeenCalledWith( + expect.objectContaining({ + device: "desktop", + }) + ); + }); + + test("should set language from attributes if provided and update is successful", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const newAttributes = { language: "de" }; + vi.mocked(updateAttributes).mockResolvedValue({ success: true, messages: [] }); + + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes); + + expect(result.state.data?.language).toBe("de"); + }); + + test("should not set language from attributes if update is not successful", async () => { + const initialContactWithLanguage = { + ...mockContact, + attributes: [...mockContact.attributes, { attributeKey: { key: "language" }, value: "fr" }], + }; + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(initialContactWithLanguage); + const newAttributes = { language: "de" }; + vi.mocked(updateAttributes).mockResolvedValue({ success: false, messages: ["Update failed"] }); + + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes); + + // Language should remain 'fr' from the initial contact attributes, not 'de' + expect(result.state.data?.language).toBe("fr"); + }); + + test("should handle empty attributes object", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", {}); + expect(updateAttributes).not.toHaveBeenCalled(); + expect(result.state.data).toEqual(expect.objectContaining(mockUserState)); + expect(result.messages).toEqual([]); + }); + + test("should handle undefined attributes", async () => { + vi.mocked(getContactByUserIdWithAttributes).mockResolvedValue(mockContact); + const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", undefined); + expect(updateAttributes).not.toHaveBeenCalled(); + expect(result.state.data).toEqual(expect.objectContaining(mockUserState)); + expect(result.messages).toEqual([]); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts new file mode 100644 index 0000000000..1c0e917af0 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TJsPersonState } from "@formbricks/types/js"; +import { getPersonSegmentIds } from "./segments"; +import { getUserState } from "./user-state"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + findMany: vi.fn(), + }, + display: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("./segments", () => ({ + getPersonSegmentIds: vi.fn(), +})); + +const mockEnvironmentId = "test-environment-id"; +const mockUserId = "test-user-id"; +const mockContactId = "test-contact-id"; +const mockDevice = "desktop"; +const mockAttributes = { email: "test@example.com" }; + +describe("getUserState", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return user state with empty responses and displays", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + vi.mocked(prisma.display.findMany).mockResolvedValue([]); + vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]); + + const result = await getUserState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + contactId: mockContactId, + device: mockDevice, + attributes: mockAttributes, + }); + + expect(prisma.response.findMany).toHaveBeenCalledWith({ + where: { contactId: mockContactId }, + select: { surveyId: true }, + }); + expect(prisma.display.findMany).toHaveBeenCalledWith({ + where: { contactId: mockContactId }, + select: { surveyId: true, createdAt: true }, + }); + expect(getPersonSegmentIds).toHaveBeenCalledWith( + mockEnvironmentId, + mockContactId, + mockUserId, + mockAttributes, + mockDevice + ); + expect(result).toEqual({ + contactId: mockContactId, + userId: mockUserId, + segments: ["segment1"], + displays: [], + responses: [], + lastDisplayAt: null, + }); + }); + + test("should return user state with responses and displays, and sort displays by createdAt", async () => { + const mockDate1 = new Date("2023-01-01T00:00:00.000Z"); + const mockDate2 = new Date("2023-01-02T00:00:00.000Z"); + + const mockResponses = [{ surveyId: "survey1" }, { surveyId: "survey2" }]; + const mockDisplays = [ + { surveyId: "survey3", createdAt: mockDate1 }, + { surveyId: "survey4", createdAt: mockDate2 }, // most recent + ]; + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponses); + vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplays); + vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]); + + const result = await getUserState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + contactId: mockContactId, + device: mockDevice, + attributes: mockAttributes, + }); + + expect(result).toEqual({ + contactId: mockContactId, + userId: mockUserId, + segments: ["segment2", "segment3"], + displays: [ + { surveyId: "survey3", createdAt: mockDate1 }, + { surveyId: "survey4", createdAt: mockDate2 }, + ], + responses: ["survey1", "survey2"], + lastDisplayAt: mockDate2, + }); + }); + + test("should handle null responses and displays from prisma (though unlikely)", async () => { + // This case tests the nullish coalescing, though prisma.findMany usually returns [] + vi.mocked(prisma.response.findMany).mockResolvedValue(null as any); + vi.mocked(prisma.display.findMany).mockResolvedValue(null as any); + vi.mocked(getPersonSegmentIds).mockResolvedValue([]); + + const result = await getUserState({ + environmentId: mockEnvironmentId, + userId: mockUserId, + contactId: mockContactId, + device: mockDevice, + attributes: mockAttributes, + }); + + expect(result).toEqual({ + contactId: mockContactId, + userId: mockUserId, + segments: [], + displays: [], + responses: [], + lastDisplayAt: null, + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts index cf31db75b5..62dce794ef 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/user-state.ts @@ -56,6 +56,10 @@ export const getUserState = async ({ const segments = await getPersonSegmentIds(environmentId, contactId, userId, attributes, device); + const sortedContactDisplaysDate = contactDisplays?.toSorted( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + )[0]?.createdAt; + // If the person exists, return the persons's state const userState: TJsPersonState["data"] = { contactId, @@ -67,10 +71,7 @@ export const getUserState = async ({ createdAt: display.createdAt, })) ?? [], responses: contactResponses?.map((response) => response.surveyId) ?? [], - lastDisplayAt: - contactDisplays.length > 0 - ? contactDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt - : null, + lastDisplayAt: contactDisplays?.length > 0 ? sortedContactDisplaysDate : null, }; return userState; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.test.ts new file mode 100644 index 0000000000..d8e11a6beb --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.test.ts @@ -0,0 +1,297 @@ +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TContactAttributeKey, TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key"; +import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { TContactAttributeKeyUpdateInput } from "../types/contact-attribute-keys"; +import { + createContactAttributeKey, + deleteContactAttributeKey, + getContactAttributeKey, + updateContactAttributeKey, +} from "./contact-attribute-key"; + +// Mock dependencies +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttributeKey: { + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + count: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact-attribute-key", () => ({ + contactAttributeKeyCache: { + tag: { + byId: vi.fn((id) => `contactAttributeKey-${id}`), + byEnvironmentId: vi.fn((environmentId) => `environments-${environmentId}-contactAttributeKeys`), + byEnvironmentIdAndKey: vi.fn( + (environmentId, key) => `contactAttributeKey-environment-${environmentId}-key-${key}` + ), + }, + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 10, // Default mock value for tests + }; +}); + +// Constants used in tests +const mockContactAttributeKeyId = "drw0gc3oa67q113w68wdif0x"; +const mockEnvironmentId = "fndlzrzlqw8c6zu9jfwxf34k"; +const mockKey = "testKey"; +const mockName = "Test Key"; + +const mockContactAttributeKey: TContactAttributeKey = { + id: mockContactAttributeKeyId, + createdAt: new Date(), + updatedAt: new Date(), + name: mockName, + key: mockKey, + environmentId: mockEnvironmentId, + type: "custom" as TContactAttributeKeyType, + description: "A test key", + isUnique: false, +}; + +// Define a compatible type for test data, as TContactAttributeKeyUpdateInput might be complex +interface TMockContactAttributeKeyUpdateInput { + description?: string | null; +} + +describe("getContactAttributeKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return contact attribute key if found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(mockContactAttributeKey); + + const result = await getContactAttributeKey(mockContactAttributeKeyId); + + expect(result).toEqual(mockContactAttributeKey); + expect(prisma.contactAttributeKey.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactAttributeKeyId }, + }); + expect(contactAttributeKeyCache.tag.byId).toHaveBeenCalledWith(mockContactAttributeKeyId); + }); + + test("should return null if contact attribute key not found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(null); + + const result = await getContactAttributeKey(mockContactAttributeKeyId); + + expect(result).toBeNull(); + expect(prisma.contactAttributeKey.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactAttributeKeyId }, + }); + }); + + test("should throw DatabaseError if Prisma call fails", async () => { + const errorMessage = "Prisma findUnique error"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P1000", clientVersion: "test" }) + ); + + await expect(getContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(DatabaseError); + await expect(getContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(errorMessage); + }); + + test("should throw generic error if non-Prisma error occurs", async () => { + const errorMessage = "Some other error"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValue(new Error(errorMessage)); + + await expect(getContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(Error); + await expect(getContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(errorMessage); + }); +}); + +describe("createContactAttributeKey", () => { + const type: TContactAttributeKeyType = "custom"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should create and return a new contact attribute key", async () => { + const createdAttributeKey = { ...mockContactAttributeKey, id: "new_cak_id", key: mockKey, type }; + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(createdAttributeKey); + + const result = await createContactAttributeKey(mockEnvironmentId, mockKey, type); + + expect(result).toEqual(createdAttributeKey); + expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ + where: { environmentId: mockEnvironmentId }, + }); + expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({ + data: { + key: mockKey, + name: mockKey, // As per implementation + type, + environment: { connect: { id: mockEnvironmentId } }, + }, + }); + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: createdAttributeKey.id, + environmentId: createdAttributeKey.environmentId, + key: createdAttributeKey.key, + }); + }); + + test("should throw OperationNotAllowedError if max attribute classes reached", async () => { + // MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT is mocked to 10 + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(10); + + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow( + OperationNotAllowedError + ); + expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ + where: { environmentId: mockEnvironmentId }, + }); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + }); + + test("should throw Prisma error if prisma.contactAttributeKey.count fails", async () => { + const errorMessage = "Prisma count error"; + const prismaError = new Prisma.PrismaClientKnownRequestError(errorMessage, { + code: "P1000", + clientVersion: "test", + }); + vi.mocked(prisma.contactAttributeKey.count).mockRejectedValue(prismaError); + + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(prismaError); + }); + + test("should throw DatabaseError if Prisma create fails", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); // Below limit + const errorMessage = "Prisma create error"; + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" }) + ); + + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(DatabaseError); + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage); + }); + + test("should throw generic error if non-Prisma error occurs during create", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(5); + const errorMessage = "Some other error during create"; + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage)); + + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(Error); + await expect(createContactAttributeKey(mockEnvironmentId, mockKey, type)).rejects.toThrow(errorMessage); + }); +}); + +describe("deleteContactAttributeKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should delete contact attribute key and revalidate cache", async () => { + const deletedAttributeKey = { ...mockContactAttributeKey }; + vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValue(deletedAttributeKey); + + const result = await deleteContactAttributeKey(mockContactAttributeKeyId); + + expect(result).toEqual(deletedAttributeKey); + expect(prisma.contactAttributeKey.delete).toHaveBeenCalledWith({ + where: { id: mockContactAttributeKeyId }, + }); + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: deletedAttributeKey.id, + environmentId: deletedAttributeKey.environmentId, + key: deletedAttributeKey.key, + }); + }); + + test("should throw DatabaseError if Prisma delete fails", async () => { + const errorMessage = "Prisma delete error"; + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2025", clientVersion: "test" }) + ); + + await expect(deleteContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(DatabaseError); + await expect(deleteContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(errorMessage); + }); + + test("should throw generic error if non-Prisma error occurs during delete", async () => { + const errorMessage = "Some other error during delete"; + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValue(new Error(errorMessage)); + + await expect(deleteContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(Error); + await expect(deleteContactAttributeKey(mockContactAttributeKeyId)).rejects.toThrow(errorMessage); + }); +}); + +describe("updateContactAttributeKey", () => { + const updateData: TMockContactAttributeKeyUpdateInput = { + description: "Updated description", + }; + // Cast to TContactAttributeKeyUpdateInput for the function call, if strict typing is needed beyond the mock. + const typedUpdateData = updateData as TContactAttributeKeyUpdateInput; + + const updatedAttributeKey = { + ...mockContactAttributeKey, + description: updateData.description, + updatedAt: new Date(), // Update timestamp + } as ContactAttributeKey; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should update contact attribute key and revalidate cache", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockResolvedValue(updatedAttributeKey); + + const result = await updateContactAttributeKey(mockContactAttributeKeyId, typedUpdateData); + + expect(result).toEqual(updatedAttributeKey); + expect(prisma.contactAttributeKey.update).toHaveBeenCalledWith({ + where: { id: mockContactAttributeKeyId }, + data: { description: updateData.description }, + }); + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: updatedAttributeKey.id, + environmentId: updatedAttributeKey.environmentId, + key: updatedAttributeKey.key, + }); + }); + + test("should throw DatabaseError if Prisma update fails", async () => { + const errorMessage = "Prisma update error"; + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2025", clientVersion: "test" }) + ); + + await expect(updateContactAttributeKey(mockContactAttributeKeyId, typedUpdateData)).rejects.toThrow( + DatabaseError + ); + await expect(updateContactAttributeKey(mockContactAttributeKeyId, typedUpdateData)).rejects.toThrow( + errorMessage + ); + }); + + test("should throw generic error if non-Prisma error occurs during update", async () => { + const errorMessage = "Some other error during update"; + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValue(new Error(errorMessage)); + + await expect(updateContactAttributeKey(mockContactAttributeKeyId, typedUpdateData)).rejects.toThrow( + Error + ); + await expect(updateContactAttributeKey(mockContactAttributeKeyId, typedUpdateData)).rejects.toThrow( + errorMessage + ); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts index 4f5fb4ae7b..563edb7a53 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -42,12 +42,13 @@ export const getContactAttributeKey = reactCache( } )() ); + export const createContactAttributeKey = async ( environmentId: string, key: string, type: TContactAttributeKeyType ): Promise => { - validateInputs([environmentId, ZId], [name, ZString], [type, ZContactAttributeKeyType]); + validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]); const contactAttributeKeysCount = await prisma.contactAttributeKey.count({ where: { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts new file mode 100644 index 0000000000..f3e45f1836 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.test.ts @@ -0,0 +1,152 @@ +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { TContactAttributeKeyType } from "@formbricks/types/contact-attribute-key"; +import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { createContactAttributeKey, getContactAttributeKeys } from "./contact-attribute-keys"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttributeKey: { + findMany: vi.fn(), + create: vi.fn(), + count: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact-attribute-key", () => ({ + contactAttributeKeyCache: { + tag: { + byEnvironmentId: vi.fn((id) => `contactAttributeKey-environment-${id}`), + }, + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/validate"); + +describe("getContactAttributeKeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return contact attribute keys when found", async () => { + const mockEnvironmentIds = ["env1", "env2"]; + const mockAttributeKeys = [ + { id: "key1", environmentId: "env1", name: "Key One", key: "keyOne", type: "custom" }, + { id: "key2", environmentId: "env2", name: "Key Two", key: "keyTwo", type: "custom" }, + ]; + vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockAttributeKeys); + + const result = await getContactAttributeKeys(mockEnvironmentIds); + + expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: mockEnvironmentIds } }, + }); + expect(result).toEqual(mockAttributeKeys); + expect(contactAttributeKeyCache.tag.byEnvironmentId).toHaveBeenCalledTimes(mockEnvironmentIds.length); + }); + + test("should throw DatabaseError if Prisma call fails", async () => { + const mockEnvironmentIds = ["env1"]; + const errorMessage = "Prisma error"; + vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P1000", clientVersion: "test" }) + ); + + await expect(getContactAttributeKeys(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + }); + + test("should throw generic error if non-Prisma error occurs", async () => { + const mockEnvironmentIds = ["env1"]; + const errorMessage = "Some other error"; + + const errToThrow = new Prisma.PrismaClientKnownRequestError(errorMessage, { + clientVersion: "0.0.1", + code: PrismaErrorType.UniqueConstraintViolation, + }); + vi.mocked(prisma.contactAttributeKey.findMany).mockRejectedValue(errToThrow); + await expect(getContactAttributeKeys(mockEnvironmentIds)).rejects.toThrow(errorMessage); + }); +}); + +describe("createContactAttributeKey", () => { + const environmentId = "testEnvId"; + const key = "testKey"; + const type: TContactAttributeKeyType = "custom"; + const mockCreatedAttributeKey = { + id: "newKeyId", + environmentId, + name: key, + key, + type, + createdAt: new Date(), + updatedAt: new Date(), + isUnique: false, + description: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should create and return a new contact attribute key", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue({ + ...mockCreatedAttributeKey, + description: null, // ensure description is explicitly null if that's the case + }); + + const result = await createContactAttributeKey(environmentId, key, type); + + expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } }); + expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({ + data: { + key, + name: key, + type, + environment: { connect: { id: environmentId } }, + }, + }); + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: mockCreatedAttributeKey.id, + environmentId: mockCreatedAttributeKey.environmentId, + key: mockCreatedAttributeKey.key, + }); + expect(result).toEqual(mockCreatedAttributeKey); + }); + + test("should throw OperationNotAllowedError if max attribute classes reached", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT); + + await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow( + OperationNotAllowedError + ); + expect(prisma.contactAttributeKey.count).toHaveBeenCalledWith({ where: { environmentId } }); + expect(prisma.contactAttributeKey.create).not.toHaveBeenCalled(); + }); + + test("should throw DatabaseError if Prisma create fails", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); + const errorMessage = "Prisma create error"; + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError(errorMessage, { code: "P2000", clientVersion: "test" }) + ); + + await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(DatabaseError); + await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage); + }); + + test("should throw generic error if non-Prisma error occurs during create", async () => { + vi.mocked(prisma.contactAttributeKey.count).mockResolvedValue(0); + const errorMessage = "Some other create error"; + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(new Error(errorMessage)); + + await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(Error); + await expect(createContactAttributeKey(environmentId, key, type)).rejects.toThrow(errorMessage); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts index 4dfc473e12..984d62e00a 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts @@ -42,7 +42,7 @@ export const createContactAttributeKey = async ( key: string, type: TContactAttributeKeyType ): Promise => { - validateInputs([environmentId, ZId], [name, ZString], [type, ZContactAttributeKeyType]); + validateInputs([environmentId, ZId], [key, ZString], [type, ZContactAttributeKeyType]); const contactAttributeKeysCount = await prisma.contactAttributeKey.count({ where: { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.test.ts new file mode 100644 index 0000000000..d96360862f --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attributes/lib/contact-attributes.test.ts @@ -0,0 +1,119 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getContactAttributes } from "./contact-attributes"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttribute: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact-attribute", () => ({ + contactAttributeCache: { + tag: { + byEnvironmentId: vi.fn((environmentId) => `contactAttributes-${environmentId}`), + }, + }, +})); + +const mockEnvironmentId1 = "testEnvId1"; +const mockEnvironmentId2 = "testEnvId2"; +const mockEnvironmentIds = [mockEnvironmentId1, mockEnvironmentId2]; + +const mockContactAttributes = [ + { + id: "attr1", + value: "value1", + attributeKeyId: "key1", + contactId: "contact1", + createdAt: new Date(), + updatedAt: new Date(), + attributeKey: { + id: "key1", + key: "attrKey1", + name: "Attribute Key 1", + description: "Description 1", + environmentId: mockEnvironmentId1, + isUnique: false, + type: "custom", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + id: "attr2", + value: "value2", + attributeKeyId: "key2", + contactId: "contact2", + createdAt: new Date(), + updatedAt: new Date(), + attributeKey: { + id: "key2", + key: "attrKey2", + name: "Attribute Key 2", + description: "Description 2", + environmentId: mockEnvironmentId2, + isUnique: false, + type: "custom", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, +]; + +describe("getContactAttributes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return contact attributes when found", async () => { + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue(mockContactAttributes as any); + + const result = await getContactAttributes(mockEnvironmentIds); + + expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({ + where: { + attributeKey: { + environmentId: { in: mockEnvironmentIds }, + }, + }, + }); + expect(result).toEqual(mockContactAttributes); + }); + + test("should throw DatabaseError when PrismaClientKnownRequestError occurs", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2001", + clientVersion: "test", + }); + vi.mocked(prisma.contactAttribute.findMany).mockRejectedValue(prismaError); + + await expect(getContactAttributes(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + }); + + test("should throw generic error when an unknown error occurs", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.contactAttribute.findMany).mockRejectedValue(genericError); + + await expect(getContactAttributes(mockEnvironmentIds)).rejects.toThrow(genericError); + }); + + test("should return empty array when no contact attributes are found", async () => { + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); + + const result = await getContactAttributes(mockEnvironmentIds); + + expect(result).toEqual([]); + expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({ + where: { + attributeKey: { + environmentId: { in: mockEnvironmentIds }, + }, + }, + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/lib/contact.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/lib/contact.test.ts new file mode 100644 index 0000000000..c8e20c217c --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/lib/contact.test.ts @@ -0,0 +1,152 @@ +import { contactCache } from "@/lib/cache/contact"; +import { Contact, Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { deleteContact, getContact } from "./contact"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findUnique: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact", () => ({ + contactCache: { + revalidate: vi.fn(), + tag: { + byId: vi.fn((id) => `contact-${id}`), + }, + }, +})); + +const mockContactId = "eegeo7qmz9sn5z85fi76lg8o"; +const mockEnvironmentId = "sv7jqr9qjmayp1hc6xm7rfud"; +const mockContact = { + id: mockContactId, + environmentId: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + attributes: [], +}; + +describe("contact lib", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getContact", () => { + test("should return contact if found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact); + const result = await getContact(mockContactId); + + expect(result).toEqual(mockContact); + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } }); + expect(contactCache.tag.byId).toHaveBeenCalledWith(mockContactId); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(null); + const result = await getContact(mockContactId); + + expect(result).toBeNull(); + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ where: { id: mockContactId } }); + }); + + test("should throw DatabaseError if prisma throws PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2001", + clientVersion: "2.0.0", + }); + vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError); + + await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError); + }); + + test("should throw error for other errors", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.contact.findUnique).mockRejectedValue(genericError); + + await expect(getContact(mockContactId)).rejects.toThrow(genericError); + }); + }); + + describe("deleteContact", () => { + const mockDeletedContact = { + id: mockContactId, + environmentId: mockEnvironmentId, + attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }], + } as unknown as Contact; + + const mockDeletedContactWithUserId = { + id: mockContactId, + environmentId: mockEnvironmentId, + attributes: [ + { attributeKey: { key: "email" }, value: "test@example.com" }, + { attributeKey: { key: "userId" }, value: "user123" }, + ], + } as unknown as Contact; + + test("should delete contact and revalidate cache", async () => { + vi.mocked(prisma.contact.delete).mockResolvedValue(mockDeletedContact); + await deleteContact(mockContactId); + + expect(prisma.contact.delete).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { + id: true, + environmentId: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); + expect(contactCache.revalidate).toHaveBeenCalledWith({ + id: mockContactId, + userId: undefined, + environmentId: mockEnvironmentId, + }); + }); + + test("should delete contact and revalidate cache with userId", async () => { + vi.mocked(prisma.contact.delete).mockResolvedValue(mockDeletedContactWithUserId); + await deleteContact(mockContactId); + + expect(prisma.contact.delete).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { + id: true, + environmentId: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); + expect(contactCache.revalidate).toHaveBeenCalledWith({ + id: mockContactId, + userId: "user123", + environmentId: mockEnvironmentId, + }); + }); + + test("should throw DatabaseError if prisma throws PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2001", + clientVersion: "2.0.0", + }); + vi.mocked(prisma.contact.delete).mockRejectedValue(prismaError); + + await expect(deleteContact(mockContactId)).rejects.toThrow(DatabaseError); + }); + + test("should throw error for other errors", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.contact.delete).mockRejectedValue(genericError); + + await expect(deleteContact(mockContactId)).rejects.toThrow(genericError); + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts new file mode 100644 index 0000000000..a8e8438a95 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts @@ -0,0 +1,99 @@ +import { contactCache } from "@/lib/cache/contact"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getContacts } from "./contacts"; + +vi.mock("@/lib/cache/contact", () => ({ + contactCache: { + tag: { + byEnvironmentId: vi.fn((id) => `contact-environment-${id}`), + }, + }, +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findMany: vi.fn(), + }, + }, +})); + +const mockEnvironmentId1 = "ay70qluzic16hu8fu6xrqebq"; +const mockEnvironmentId2 = "raeeymwqrn9iqwe5rp13vwem"; +const mockEnvironmentIds = [mockEnvironmentId1, mockEnvironmentId2]; + +const mockContacts = [ + { + id: "contactId1", + environmentId: mockEnvironmentId1, + name: "Contact 1", + email: "contact1@example.com", + attributes: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "contactId2", + environmentId: mockEnvironmentId2, + name: "Contact 2", + email: "contact2@example.com", + attributes: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, +]; + +describe("getContacts", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return contacts for given environmentIds", async () => { + vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts); + + const result = await getContacts(mockEnvironmentIds); + + expect(prisma.contact.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: mockEnvironmentIds } }, + }); + expect(result).toEqual(mockContacts); + expect(contactCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId1); + expect(contactCache.tag.byEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId2); + }); + + test("should throw DatabaseError on PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2002", + clientVersion: "2.0.0", + }); + vi.mocked(prisma.contact.findMany).mockRejectedValue(prismaError); + + await expect(getContacts(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + expect(prisma.contact.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: mockEnvironmentIds } }, + }); + }); + + test("should throw original error for other errors", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.contact.findMany).mockRejectedValue(genericError); + + await expect(getContacts(mockEnvironmentIds)).rejects.toThrow(genericError); + expect(prisma.contact.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: mockEnvironmentIds } }, + }); + }); + + test("should use cache with correct tags", async () => { + vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts); + + await getContacts(mockEnvironmentIds); + }); +}); diff --git a/apps/web/modules/survey/lib/action-class.test.ts b/apps/web/modules/survey/lib/action-class.test.ts new file mode 100644 index 0000000000..0d86990eed --- /dev/null +++ b/apps/web/modules/survey/lib/action-class.test.ts @@ -0,0 +1,142 @@ +import { actionClassCache } from "@/lib/actionClass/cache"; +import { cache } from "@/lib/cache"; +import { validateInputs } from "@/lib/utils/validate"; +import { type ActionClass } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { getActionClasses } from "./action-class"; + +// Mock dependencies +vi.mock("@/lib/actionClass/cache", () => ({ + actionClassCache: { + tag: { + byEnvironmentId: vi.fn((environmentId: string) => `actionClass-environment-${environmentId}`), + }, + }, +})); + +vi.mock("@/lib/cache", () => ({ + cache: vi.fn((fn) => fn), // Mock cache to just return the function +})); + +vi.mock("@/lib/utils/validate"); + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + actionClass: { + findMany: vi.fn(), + }, + }, +})); + +// Mock react's cache +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock react's cache to just return the function + }; +}); + +const environmentId = "test-environment-id"; +const mockActionClasses: ActionClass[] = [ + { + id: "action1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 1", + description: "Description 1", + type: "code", + noCodeConfig: null, + environmentId: environmentId, + key: "key1", + }, + { + id: "action2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 2", + description: "Description 2", + type: "noCode", + noCodeConfig: { + type: "click", + elementSelector: { cssSelector: ".btn" }, + urlFilters: [], + }, + environmentId: environmentId, + key: null, + }, +]; + +describe("getActionClasses", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Redefine the mock for cache before each test to ensure it's clean + vi.mocked(cache).mockImplementation((fn) => fn); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return action classes successfully", async () => { + vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); + + const result = await getActionClasses(environmentId); + + expect(result).toEqual(mockActionClasses); + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); + expect(prisma.actionClass.findMany).toHaveBeenCalledWith({ + where: { + environmentId: environmentId, + }, + orderBy: { + createdAt: "asc", + }, + }); + expect(cache).toHaveBeenCalledTimes(1); + expect(actionClassCache.tag.byEnvironmentId).toHaveBeenCalledWith(environmentId); + }); + + test("should throw DatabaseError when prisma.actionClass.findMany fails", async () => { + const errorMessage = "Prisma error"; + vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error(errorMessage)); + + await expect(getActionClasses(environmentId)).rejects.toThrow(DatabaseError); + await expect(getActionClasses(environmentId)).rejects.toThrow( + `Database error when fetching actions for environment ${environmentId}` + ); + + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); + expect(prisma.actionClass.findMany).toHaveBeenCalledTimes(2); // Called twice due to rejection + expect(cache).toHaveBeenCalledTimes(2); + }); + + test("should throw ValidationError when validateInputs fails", async () => { + const validationErrorMessage = "Validation failed"; + vi.mocked(validateInputs).mockImplementation(() => { + throw new ValidationError(validationErrorMessage); + }); + + await expect(getActionClasses(environmentId)).rejects.toThrow(ValidationError); + await expect(getActionClasses(environmentId)).rejects.toThrow(validationErrorMessage); + + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); + expect(prisma.actionClass.findMany).not.toHaveBeenCalled(); + expect(cache).toHaveBeenCalledTimes(2); // cache wrapper is still called + }); + + test("should use reactCache and our custom cache", async () => { + vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); + // We need to import the actual react cache to test it with vi.spyOn if we weren't mocking it. + // However, since we are mocking it to be a pass-through, we just check if our main cache is called. + + await getActionClasses(environmentId); + + expect(cache).toHaveBeenCalledTimes(1); + // Check if the function passed to react.cache (which is our main cache function due to mocking) was called + // This is implicitly tested by cache being called. + }); +}); diff --git a/apps/web/modules/survey/lib/client-utils.test.ts b/apps/web/modules/survey/lib/client-utils.test.ts new file mode 100644 index 0000000000..a3c4039ae6 --- /dev/null +++ b/apps/web/modules/survey/lib/client-utils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { copySurveyLink } from "./client-utils"; + +describe("copySurveyLink", () => { + const surveyUrl = "https://app.formbricks.com/s/someSurveyId"; + + test("should return the surveyUrl with suId when singleUseId is provided", () => { + const singleUseId = "someSingleUseId"; + const result = copySurveyLink(surveyUrl, singleUseId); + expect(result).toBe(`${surveyUrl}?suId=${singleUseId}`); + }); + + test("should return just the surveyUrl when singleUseId is not provided", () => { + const result = copySurveyLink(surveyUrl); + expect(result).toBe(surveyUrl); + }); + + test("should return just the surveyUrl when singleUseId is an empty string", () => { + const singleUseId = ""; + const result = copySurveyLink(surveyUrl, singleUseId); + expect(result).toBe(surveyUrl); + }); + + test("should return just the surveyUrl when singleUseId is undefined", () => { + const result = copySurveyLink(surveyUrl, undefined); + expect(result).toBe(surveyUrl); + }); +}); diff --git a/apps/web/modules/survey/lib/organization.test.ts b/apps/web/modules/survey/lib/organization.test.ts new file mode 100644 index 0000000000..cfc29cb84b --- /dev/null +++ b/apps/web/modules/survey/lib/organization.test.ts @@ -0,0 +1,153 @@ +import { Organization, Prisma } from "@prisma/client"; +import { Mocked, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "./organization"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +// Mock organizationCache tags +vi.mock("@/lib/organization/cache", () => ({ + organizationCache: { + tag: { + byEnvironmentId: vi.fn((id) => `org-env-${id}`), + byId: vi.fn((id) => `org-${id}`), + }, + }, +})); + +// Mock reactCache +vi.mock("react", () => ({ + cache: vi.fn((fn) => fn), // reactCache(fn) returns fn, which is then invoked +})); + +const mockPrismaOrganization = prisma.organization as Mocked; + +describe("getOrganizationIdFromEnvironmentId", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); // Restore spies and mocks + }); + + test("should return organization ID if found", async () => { + const mockEnvId = "env_test123"; + const mockOrgId = "org_test456"; + mockPrismaOrganization.findFirst.mockResolvedValueOnce({ id: mockOrgId } as Organization); + + const result = await getOrganizationIdFromEnvironmentId(mockEnvId); + + expect(result).toBe(mockOrgId); + expect(mockPrismaOrganization.findFirst).toHaveBeenCalledWith({ + where: { + projects: { + some: { + environments: { + some: { id: mockEnvId }, + }, + }, + }, + }, + select: { + id: true, + }, + }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + const mockEnvId = "env_test123_notfound"; + mockPrismaOrganization.findFirst.mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromEnvironmentId(mockEnvId)).rejects.toThrow(ResourceNotFoundError); + await expect(getOrganizationIdFromEnvironmentId(mockEnvId)).rejects.toThrow("Organization not found"); + }); + + test("should propagate prisma error", async () => { + const mockEnvId = "env_test123_dberror"; + const errorMessage = "Database connection lost"; + mockPrismaOrganization.findFirst.mockRejectedValueOnce(new Error(errorMessage)); + + await expect(getOrganizationIdFromEnvironmentId(mockEnvId)).rejects.toThrow(Error); + }); +}); + +describe("getOrganizationAIKeys", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); // Restore spies and mocks + }); + + const mockOrgId = "org_test789"; + const mockOrganizationData: Pick = { + isAIEnabled: true, + billing: { + plan: "free", + stripeCustomerId: null, + period: "monthly", + periodStart: new Date(), + limits: { + monthly: { responses: null, miu: null }, + projects: null, + }, + }, // Prisma.JsonValue compatible + }; + + test("should return organization AI keys if found", async () => { + mockPrismaOrganization.findUnique.mockResolvedValueOnce( + mockOrganizationData as Organization // Cast to full Organization for mock purposes + ); + + const result = await getOrganizationAIKeys(mockOrgId); + + expect(result).toEqual(mockOrganizationData); + expect(mockPrismaOrganization.findUnique).toHaveBeenCalledWith({ + where: { + id: mockOrgId, + }, + select: { + isAIEnabled: true, + billing: true, + }, + }); + }); + + test("should return null if organization not found", async () => { + mockPrismaOrganization.findUnique.mockResolvedValueOnce(null); + + const result = await getOrganizationAIKeys(mockOrgId); + expect(result).toBeNull(); + }); + + test("should throw DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Unique constraint failed on table"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + mockPrismaOrganization.findUnique.mockRejectedValueOnce(errToThrow); + await expect(getOrganizationAIKeys(mockOrgId)).rejects.toThrow(DatabaseError); + }); + + test("should re-throw other errors from prisma", async () => { + const errorMessage = "Some other unexpected DB error"; + const genericError = new Error(errorMessage); + + mockPrismaOrganization.findUnique.mockRejectedValueOnce(genericError); + await expect(getOrganizationAIKeys(mockOrgId)).rejects.toThrow(genericError); + }); +}); diff --git a/apps/web/modules/survey/lib/project.test.ts b/apps/web/modules/survey/lib/project.test.ts new file mode 100644 index 0000000000..74f4477f73 --- /dev/null +++ b/apps/web/modules/survey/lib/project.test.ts @@ -0,0 +1,148 @@ +import { Prisma, Project } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getProjectWithTeamIdsByEnvironmentId } from "./project"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock reactCache as it's a React-specific import and not needed for these tests +vi.mock("react", () => ({ + cache: vi.fn((fn) => fn), +})); + +const environmentId = "test-environment-id"; +const mockProjectPrisma = { + id: "clq6167un000008l56jd8s3f9", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + createdById: null, + projectTeams: [{ teamId: "team1" }, { teamId: "team2" }], + environments: [], + surveys: [], + webhooks: [], + apiKey: null, + styling: { + allowStyleOverwrite: true, + }, + variables: {}, + languages: [], + recontactDays: 0, + inAppSurveyBranding: false, + linkSurveyBranding: false, + placement: "bottomRight", + clickOutsideClose: false, + darkOverlay: false, + segment: null, + surveyClosedMessage: null, + singleUseId: null, + verifyEmail: null, + productOverwrites: null, + brandColor: null, + highlightBorderColor: null, + responseCount: null, + organizationId: "clq6167un000008l56jd8s3f9", + config: { channel: "app", industry: "eCommerce" }, + logo: null, +} as Project; + +const mockProjectWithTeam: Project & { teamIds: string[] } = { + ...mockProjectPrisma, + teamIds: ["team1", "team2"], +}; + +describe("getProjectWithTeamIdsByEnvironmentId", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return project with team IDs when project is found", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProjectPrisma); + + const project = await getProjectWithTeamIdsByEnvironmentId(environmentId); + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + include: { + projectTeams: { + select: { + teamId: true, + }, + }, + }, + }); + + expect(project).toEqual(mockProjectWithTeam); + }); + + test("should return null when project is not found", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(null); + + const project = await getProjectWithTeamIdsByEnvironmentId(environmentId); + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + include: { + projectTeams: { + select: { + teamId: true, + }, + }, + }, + }); + expect(project).toBeNull(); + }); + + test("should throw DatabaseError when prisma query fails", async () => { + const mockErrorMessage = "Prisma error"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + vi.mocked(prisma.project.findFirst).mockRejectedValue(errToThrow); + + await expect(getProjectWithTeamIdsByEnvironmentId(environmentId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalled(); + }); + + test("should rethrow error if not PrismaClientKnownRequestError", async () => { + const errorMessage = "Some other error"; + const error = new Error(errorMessage); + vi.mocked(prisma.project.findFirst).mockRejectedValue(error); + + await expect(getProjectWithTeamIdsByEnvironmentId(environmentId)).rejects.toThrow(error); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/survey/lib/response.test.ts b/apps/web/modules/survey/lib/response.test.ts new file mode 100644 index 0000000000..7f7785cdad --- /dev/null +++ b/apps/web/modules/survey/lib/response.test.ts @@ -0,0 +1,83 @@ +import { cache } from "@/lib/cache"; +import { responseCache } from "@/lib/response/cache"; +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getResponseCountBySurveyId } from "./response"; + +// Mock dependencies +vi.mock("@/lib/cache", () => ({ + cache: vi.fn((fn) => fn), +})); + +vi.mock("@/lib/response/cache", () => ({ + responseCache: { + tag: { + bySurveyId: vi.fn((surveyId) => `survey-${surveyId}-responses`), + }, + }, +})); + +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock react's cache to just return the function + }; +}); + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + count: vi.fn(), + }, + }, +})); + +const surveyId = "test-survey-id"; + +describe("getResponseCountBySurveyId", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return the response count for a survey", async () => { + const mockCount = 5; + vi.mocked(prisma.response.count).mockResolvedValue(mockCount); + + const result = await getResponseCountBySurveyId(surveyId); + + expect(result).toBe(mockCount); + expect(prisma.response.count).toHaveBeenCalledWith({ + where: { surveyId }, + }); + expect(cache).toHaveBeenCalledTimes(1); + expect(responseCache.tag.bySurveyId).toHaveBeenCalledWith(surveyId); + }); + + test("should throw DatabaseError if PrismaClientKnownRequestError occurs", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2002", + clientVersion: "2.0.0", + }); + vi.mocked(prisma.response.count).mockRejectedValue(prismaError); + + await expect(getResponseCountBySurveyId(surveyId)).rejects.toThrow(DatabaseError); + expect(prisma.response.count).toHaveBeenCalledWith({ + where: { surveyId }, + }); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should throw generic error if an unknown error occurs", async () => { + const genericError = new Error("Test Generic Error"); + vi.mocked(prisma.response.count).mockRejectedValue(genericError); + + await expect(getResponseCountBySurveyId(surveyId)).rejects.toThrow(genericError); + expect(prisma.response.count).toHaveBeenCalledWith({ + where: { surveyId }, + }); + expect(cache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/modules/survey/lib/survey.test.ts b/apps/web/modules/survey/lib/survey.test.ts new file mode 100644 index 0000000000..2e27332bfc --- /dev/null +++ b/apps/web/modules/survey/lib/survey.test.ts @@ -0,0 +1,124 @@ +import { Organization, Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getOrganizationBilling, getSurvey } from "./survey"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findFirst: vi.fn(), + }, + survey: { + findUnique: vi.fn(), + }, + }, +})); + +// Mock surveyCache +vi.mock("@/lib/survey/cache", () => ({ + surveyCache: { + tag: { + byId: vi.fn((id) => `survey-${id}`), + }, + }, +})); + +// Mock organizationCache +vi.mock("@/lib/organization/cache", () => ({ + organizationCache: { + tag: { + byId: vi.fn((id) => `organization-${id}`), + }, + }, +})); + +// Mock transformPrismaSurvey +vi.mock("@/modules/survey/lib/utils", () => ({ + transformPrismaSurvey: vi.fn((survey) => survey), +})); + +describe("Survey Library Tests", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getOrganizationBilling", () => { + test("should return organization billing when found", async () => { + const mockBilling = { + stripeCustomerId: "cus_123", + features: { linkSurvey: { status: "active" } }, + subscriptionStatus: "active", + nextRenewalDate: new Date(), + } as unknown as Organization["billing"]; + vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce({ billing: mockBilling } as any); + + const billing = await getOrganizationBilling("org_123"); + expect(billing).toEqual(mockBilling); + expect(prisma.organization.findFirst).toHaveBeenCalledWith({ + where: { id: "org_123" }, + select: { billing: true }, + }); + }); + + test("should throw ResourceNotFoundError when organization not found", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null); + await expect(getOrganizationBilling("org_nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError on Prisma client known request error", async () => { + const mockErrorMessage = "Prisma error"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.organization.findFirst).mockRejectedValue(errToThrow); + await expect(getOrganizationBilling("org_dberror")).rejects.toThrow(DatabaseError); + }); + + test("should throw other errors", async () => { + const genericError = new Error("Generic error"); + vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(genericError); + await expect(getOrganizationBilling("org_error")).rejects.toThrow(genericError); + }); + }); + + describe("getSurvey", () => { + test("should return survey when found", async () => { + const mockSurvey = { id: "survey_123", name: "Test Survey" } as unknown as TSurvey; + vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey as any); // Type assertion needed due to complex select + + const survey = await getSurvey("survey_123"); + expect(survey).toEqual(mockSurvey); + expect(prisma.survey.findUnique).toHaveBeenCalledWith({ + where: { id: "survey_123" }, + select: expect.any(Object), // selectSurvey is a large object, checking for existence + }); + }); + + test("should throw ResourceNotFoundError when survey not found", async () => { + vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null); + await expect(getSurvey("survey_nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError on Prisma client known request error", async () => { + const mockErrorMessage = "Prisma error"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + vi.mocked(prisma.survey.findUnique).mockRejectedValue(errToThrow); + await expect(getSurvey("survey_dberror")).rejects.toThrow(DatabaseError); + }); + + test("should throw other errors", async () => { + const genericError = new Error("Generic error"); + vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(genericError); + await expect(getSurvey("survey_error")).rejects.toThrow(genericError); + }); + }); +}); diff --git a/apps/web/modules/survey/lib/survey.ts b/apps/web/modules/survey/lib/survey.ts index 6912d0c129..bd38b6319b 100644 --- a/apps/web/modules/survey/lib/survey.ts +++ b/apps/web/modules/survey/lib/survey.ts @@ -126,16 +126,24 @@ export const getSurvey = reactCache( async (surveyId: string): Promise => cache( async () => { - const survey = await prisma.survey.findUnique({ - where: { id: surveyId }, - select: selectSurvey, - }); + try { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: selectSurvey, + }); - if (!survey) { - throw new ResourceNotFoundError("Survey", surveyId); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + return transformPrismaSurvey(survey); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; } - - return transformPrismaSurvey(survey); }, [`survey-editor-getSurvey-${surveyId}`], { diff --git a/apps/web/modules/survey/lib/tests/client-utils.test.ts b/apps/web/modules/survey/lib/tests/client-utils.test.ts deleted file mode 100644 index 5d88a2fba7..0000000000 --- a/apps/web/modules/survey/lib/tests/client-utils.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { copySurveyLink } from "../client-utils"; - -describe("copySurveyLink", () => { - test("appends singleUseId when provided", () => { - const surveyUrl = "http://example.com/survey"; - const singleUseId = "12345"; - const result = copySurveyLink(surveyUrl, singleUseId); - expect(result).toBe("http://example.com/survey?suId=12345"); - }); - - test("returns original surveyUrl when singleUseId is not provided", () => { - const surveyUrl = "http://example.com/survey"; - const result = copySurveyLink(surveyUrl); - expect(result).toBe(surveyUrl); - }); -}); diff --git a/apps/web/modules/survey/lib/utils.test.ts b/apps/web/modules/survey/lib/utils.test.ts new file mode 100644 index 0000000000..43fa42b713 --- /dev/null +++ b/apps/web/modules/survey/lib/utils.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "vitest"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey, TSurveyFilterCriteria, TSurveyStatus, TSurveyType } from "@formbricks/types/surveys/types"; +import { anySurveyHasFilters, buildOrderByClause, buildWhereClause, transformPrismaSurvey } from "./utils"; + +describe("Survey Utils", () => { + describe("transformPrismaSurvey", () => { + test("should transform a Prisma survey object with a segment", () => { + const surveyPrisma = { + id: "survey1", + name: "Test Survey", + displayPercentage: "50.5", + segment: { + id: "segment1", + title: "Test Segment", + filters: [], + surveys: [{ id: "survey1" }, { id: "survey2" }], + }, + // other survey properties + }; + + const expectedSegment = { + id: "segment1", + title: "Test Segment", + filters: [], + surveys: ["survey1", "survey2"], + } as unknown as TSegment; + + const expectedTransformedSurvey: TSurvey = { + id: "survey1", + name: "Test Survey", + displayPercentage: 50.5, + segment: expectedSegment, + // other survey properties + } as TSurvey; // Cast to TSurvey to satisfy type checker for other missing props + + const result = transformPrismaSurvey(surveyPrisma); + expect(result.displayPercentage).toBe(expectedTransformedSurvey.displayPercentage); + expect(result.segment).toEqual(expectedTransformedSurvey.segment); + // Check other properties if necessary, ensuring they are passed through + expect(result.id).toBe(expectedTransformedSurvey.id); + expect(result.name).toBe(expectedTransformedSurvey.name); + }); + + test("should transform a Prisma survey object without a segment", () => { + const surveyPrisma = { + id: "survey2", + name: "Another Survey", + displayPercentage: "75", + segment: null, + // other survey properties + }; + + const expectedTransformedSurvey: TSurvey = { + id: "survey2", + name: "Another Survey", + displayPercentage: 75, + segment: null, + // other survey properties + } as TSurvey; + + const result = transformPrismaSurvey(surveyPrisma); + expect(result.displayPercentage).toBe(expectedTransformedSurvey.displayPercentage); + expect(result.segment).toBeNull(); + expect(result.id).toBe(expectedTransformedSurvey.id); + expect(result.name).toBe(expectedTransformedSurvey.name); + }); + + test("should handle null displayPercentage", () => { + const surveyPrisma = { + id: "survey3", + name: "Survey with null percentage", + displayPercentage: null, + segment: null, + }; + const result = transformPrismaSurvey(surveyPrisma); + expect(result.displayPercentage).toBeNull(); + }); + + test("should handle undefined displayPercentage", () => { + const surveyPrisma = { + id: "survey4", + name: "Survey with undefined percentage", + displayPercentage: undefined, + segment: null, + }; + const result = transformPrismaSurvey(surveyPrisma); + expect(result.displayPercentage).toBeNull(); + }); + + test("should transform for TJsEnvironmentStateSurvey type", () => { + const surveyPrisma = { + id: "surveyJs", + name: "JS Survey", + displayPercentage: "10.0", + segment: null, + // other specific TJsEnvironmentStateSurvey properties if any + }; + const result = transformPrismaSurvey(surveyPrisma); + expect(result.displayPercentage).toBe(10.0); + expect(result.segment).toBeNull(); + expect(result.id).toBe("surveyJs"); + }); + }); + + describe("buildWhereClause", () => { + test("should return an empty AND array if no filterCriteria is provided", () => { + const result = buildWhereClause(); + expect(result).toEqual({ AND: [] }); + }); + + test("should build where clause for name", () => { + const filterCriteria: TSurveyFilterCriteria = { name: "Test Survey" }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toContainEqual({ name: { contains: "Test Survey", mode: "insensitive" } }); + }); + + test("should build where clause for status", () => { + const filterCriteria: TSurveyFilterCriteria = { status: ["draft", "paused"] }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toContainEqual({ status: { in: ["draft", "paused"] } }); + }); + + test("should build where clause for type", () => { + const filterCriteria: TSurveyFilterCriteria = { type: ["link", "app"] }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toContainEqual({ type: { in: ["link", "app"] } }); + }); + + test("should build where clause for createdBy 'you'", () => { + const filterCriteria: TSurveyFilterCriteria = { + createdBy: { value: ["you"], userId: "user123" }, + }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toContainEqual({ createdBy: "user123" }); + }); + + test("should build where clause for createdBy 'others'", () => { + const filterCriteria: TSurveyFilterCriteria = { + createdBy: { value: ["others"], userId: "user123" }, + }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toContainEqual({ + OR: [ + { + createdBy: { + not: "user123", + }, + }, + { + createdBy: null, + }, + ], + }); + }); + + test("should build where clause for multiple criteria", () => { + const filterCriteria: TSurveyFilterCriteria = { + name: "Feedback Survey", + status: ["inProgress" as TSurveyStatus], + type: ["app" as TSurveyType], + createdBy: { value: ["you"], userId: "user456" }, + }; + const result = buildWhereClause(filterCriteria); + expect(result.AND).toEqual([ + { name: { contains: "Feedback Survey", mode: "insensitive" } }, + { status: { in: ["inProgress" as TSurveyStatus] } }, + { type: { in: ["app" as TSurveyType] } }, + { createdBy: "user456" }, + ]); + }); + + test("should not add createdBy clause if value is empty or not 'you' or 'others'", () => { + let filterCriteria: TSurveyFilterCriteria = { createdBy: { value: [], userId: "user123" } }; + let result = buildWhereClause(filterCriteria); + expect(result.AND).not.toContainEqual(expect.objectContaining({ createdBy: expect.anything() })); + + filterCriteria = { createdBy: { value: ["others"], userId: "user123" } }; + result = buildWhereClause(filterCriteria); + expect(result.AND).not.toContainEqual(expect.objectContaining({ createdBy: expect.anything() })); + }); + }); + + describe("buildOrderByClause", () => { + test("should return undefined if no sortBy is provided", () => { + const result = buildOrderByClause(); + expect(result).toBeUndefined(); + }); + + test("should return orderBy clause for name", () => { + const result = buildOrderByClause("name"); + expect(result).toEqual([{ name: "asc" }]); + }); + + test("should return orderBy clause for createdAt", () => { + const result = buildOrderByClause("createdAt"); + expect(result).toEqual([{ createdAt: "desc" }]); + }); + + test("should return orderBy clause for updatedAt", () => { + const result = buildOrderByClause("updatedAt"); + expect(result).toEqual([{ updatedAt: "desc" }]); + }); + + test("should default to updatedAt for unknown sortBy value", () => { + const result = buildOrderByClause("invalidSortBy" as any); + expect(result).toEqual([{ updatedAt: "desc" }]); + }); + }); + + describe("anySurveyHasFilters", () => { + test("should return true if any survey has segment filters", () => { + const surveys: TSurvey[] = [ + { id: "1", name: "Survey 1", segment: { id: "seg1", filters: [{ id: "f1" }] } } as TSurvey, + { id: "2", name: "Survey 2", segment: null } as TSurvey, + ]; + expect(anySurveyHasFilters(surveys)).toBe(true); + }); + + test("should return false if no survey has segment filters", () => { + const surveys: TSurvey[] = [ + { id: "1", name: "Survey 1", segment: { id: "seg1", filters: [] } } as unknown as TSurvey, + { id: "2", name: "Survey 2", segment: null } as TSurvey, + ]; + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + + test("should return false if surveys array is empty", () => { + const surveys: TSurvey[] = []; + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + + test("should return false if segment is null or filters are undefined", () => { + const surveys: TSurvey[] = [ + { id: "1", name: "Survey 1", segment: null } as TSurvey, + { id: "2", name: "Survey 2", segment: { id: "seg2" } } as TSurvey, // filters undefined + ]; + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + + test("should handle surveys that are not TSurvey but TJsEnvironmentStateSurvey (no segment)", () => { + const surveys = [ + { id: "js1", name: "JS Survey 1" }, // TJsEnvironmentStateSurvey like, no segment property + ] as any[]; // Using any[] to simulate mixed types or types without segment + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + }); +});