From 665c7c6bf1f90e76cbd931f61109ccf813d3c67f Mon Sep 17 00:00:00 2001 From: victorvhs017 <115753265+victorvhs017@users.noreply.github.com> Date: Mon, 5 May 2025 12:38:16 +0700 Subject: [PATCH] chore: add tests to api V1 (#5593) Co-authored-by: Piyush Gupta --- .../lib/notificationResponse.test.ts | 276 +++++++++ .../weekly-summary/lib/organization.test.ts | 48 ++ .../cron/weekly-summary/lib/project.test.ts | 570 ++++++++++++++++++ .../app/sync/lib/contact.test.ts | 99 +++ .../app/sync/lib/survey.test.ts | 309 ++++++++++ .../app/sync/lib/utils.test.ts | 247 ++++++++ .../environment/lib/actionClass.test.ts | 86 +++ .../environment/lib/project.test.ts | 120 ++++ .../environment/lib/survey.test.ts | 143 +++++ .../responses/lib/contact.test.ts | 160 +++++ .../responses/lib/response.test.ts | 201 ++++++ .../storage/lib/uploadPrivateFile.test.ts | 103 ++++ .../components/segment-settings.test.tsx | 1 - .../ee/role-management/actions.test.ts | 15 +- .../web/modules/ee/role-management/actions.ts | 3 +- apps/web/vite.config.mts | 1 + 16 files changed, 2371 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/api/cron/weekly-summary/lib/notificationResponse.test.ts create mode 100644 apps/web/app/api/cron/weekly-summary/lib/organization.test.ts create mode 100644 apps/web/app/api/cron/weekly-summary/lib/project.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/environment/lib/project.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts diff --git a/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.test.ts b/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.test.ts new file mode 100644 index 0000000000..9bdcc87cbd --- /dev/null +++ b/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.test.ts @@ -0,0 +1,276 @@ +import { convertResponseValue } from "@/lib/responses"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { + TWeeklyEmailResponseData, + TWeeklySummaryEnvironmentData, + TWeeklySummarySurveyData, +} from "@formbricks/types/weekly-summary"; +import { getNotificationResponse } from "./notificationResponse"; + +vi.mock("@/lib/responses", () => ({ + convertResponseValue: vi.fn(), +})); + +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: vi.fn((survey) => survey), +})); + +describe("getNotificationResponse", () => { + afterEach(() => { + cleanup(); + }); + + test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => { + const mockSurveys = [ + { + id: "survey1", + name: "Survey 1", + status: "inProgress", + questions: [ + { + id: "question1", + headline: { default: "Question 1" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display1" }], + responses: [ + { id: "response1", finished: true, data: { question1: "Answer 1" } }, + { id: "response2", finished: false, data: { question1: "Answer 2" } }, + ], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + { + id: "survey2", + name: "Survey 2", + status: "inProgress", + questions: [ + { + id: "question2", + headline: { default: "Question 2" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display2" }], + responses: [ + { id: "response3", finished: true, data: { question2: "Answer 3" } }, + { id: "response4", finished: true, data: { question2: "Answer 4" } }, + { id: "response5", finished: false, data: { question2: "Answer 5" } }, + ], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + ] as unknown as TWeeklySummarySurveyData[]; + + const mockEnvironment = { + id: "env1", + surveys: mockSurveys, + } as unknown as TWeeklySummaryEnvironmentData; + + const projectName = "Project Name"; + + const notificationResponse = getNotificationResponse(mockEnvironment, projectName); + + expect(notificationResponse).toBeDefined(); + expect(notificationResponse.environmentId).toBe("env1"); + expect(notificationResponse.projectName).toBe(projectName); + expect(notificationResponse.surveys).toHaveLength(2); + + expect(notificationResponse.insights.totalCompletedResponses).toBe(3); + expect(notificationResponse.insights.totalDisplays).toBe(2); + expect(notificationResponse.insights.totalResponses).toBe(5); + expect(notificationResponse.insights.completionRate).toBe(60); + expect(notificationResponse.insights.numLiveSurvey).toBe(2); + + expect(notificationResponse.surveys[0].id).toBe("survey1"); + expect(notificationResponse.surveys[0].name).toBe("Survey 1"); + expect(notificationResponse.surveys[0].status).toBe("inProgress"); + expect(notificationResponse.surveys[0].responseCount).toBe(2); + + expect(notificationResponse.surveys[1].id).toBe("survey2"); + expect(notificationResponse.surveys[1].name).toBe("Survey 2"); + expect(notificationResponse.surveys[1].status).toBe("inProgress"); + expect(notificationResponse.surveys[1].responseCount).toBe(3); + }); + + test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => { + const mockSurveys = [ + { + id: "survey1", + name: "Survey 1", + status: "inProgress", + questions: [ + { + id: "question1", + headline: { default: "Question 1" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display1" }], + responses: [ + { id: "response1", finished: true, data: { question1: "Answer 1" } }, + { id: "response2", finished: false, data: { question1: "Answer 2" } }, + ], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + { + id: "survey2", + name: "Survey 2", + status: "inProgress", + questions: [ + { + id: "question2", + headline: { default: "Question 2" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display2" }], + responses: [ + { id: "response3", finished: true, data: { question2: "Answer 3" } }, + { id: "response4", finished: true, data: { question2: "Answer 4" } }, + { id: "response5", finished: false, data: { question2: "Answer 5" } }, + ], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + { + id: "survey3", + name: "Survey 3", + status: "inProgress", + questions: [ + { + id: "question3", + headline: { default: "Question 3" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display3" }], + responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + ] as unknown as TWeeklySummarySurveyData[]; + + const mockEnvironment = { + id: "env1", + surveys: mockSurveys, + } as unknown as TWeeklySummaryEnvironmentData; + + const projectName = "Project Name"; + + const notificationResponse = getNotificationResponse(mockEnvironment, projectName); + + expect(notificationResponse).toBeDefined(); + expect(notificationResponse.environmentId).toBe("env1"); + expect(notificationResponse.projectName).toBe(projectName); + expect(notificationResponse.surveys).toHaveLength(3); + + expect(notificationResponse.insights.totalCompletedResponses).toBe(3); + expect(notificationResponse.insights.totalDisplays).toBe(3); + expect(notificationResponse.insights.totalResponses).toBe(6); + expect(notificationResponse.insights.completionRate).toBe(50); + expect(notificationResponse.insights.numLiveSurvey).toBe(3); + + expect(notificationResponse.surveys[0].id).toBe("survey1"); + expect(notificationResponse.surveys[0].name).toBe("Survey 1"); + expect(notificationResponse.surveys[0].status).toBe("inProgress"); + expect(notificationResponse.surveys[0].responseCount).toBe(2); + + expect(notificationResponse.surveys[1].id).toBe("survey2"); + expect(notificationResponse.surveys[1].name).toBe("Survey 2"); + expect(notificationResponse.surveys[1].status).toBe("inProgress"); + expect(notificationResponse.surveys[1].responseCount).toBe(3); + + expect(notificationResponse.surveys[2].id).toBe("survey3"); + expect(notificationResponse.surveys[2].name).toBe("Survey 3"); + expect(notificationResponse.surveys[2].status).toBe("inProgress"); + expect(notificationResponse.surveys[2].responseCount).toBe(1); + }); + + test("should return default insights and an empty surveys array when the environment contains no surveys", () => { + const mockEnvironment = { + id: "env1", + surveys: [], + } as unknown as TWeeklySummaryEnvironmentData; + + const projectName = "Project Name"; + + const notificationResponse = getNotificationResponse(mockEnvironment, projectName); + + expect(notificationResponse).toBeDefined(); + expect(notificationResponse.environmentId).toBe("env1"); + expect(notificationResponse.projectName).toBe(projectName); + expect(notificationResponse.surveys).toHaveLength(0); + + expect(notificationResponse.insights.totalCompletedResponses).toBe(0); + expect(notificationResponse.insights.totalDisplays).toBe(0); + expect(notificationResponse.insights.totalResponses).toBe(0); + expect(notificationResponse.insights.completionRate).toBe(0); + expect(notificationResponse.insights.numLiveSurvey).toBe(0); + }); + + test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => { + const mockSurveys = [ + { + id: "survey1", + name: "Survey 1", + status: "inProgress", + questions: [ + { + id: "question1", + headline: { default: "Question 1" }, + type: "text", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display1" }], + responses: [ + { id: "response1", finished: true, data: {} }, // Response missing data for question1 + ], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + ] as unknown as TWeeklySummarySurveyData[]; + + const mockEnvironment = { + id: "env1", + surveys: mockSurveys, + } as unknown as TWeeklySummaryEnvironmentData; + + const projectName = "Project Name"; + + // Mock the convertResponseValue function to handle the missing data case + vi.mocked(convertResponseValue).mockReturnValue(""); + + const notificationResponse = getNotificationResponse(mockEnvironment, projectName); + + expect(notificationResponse).toBeDefined(); + expect(notificationResponse.surveys).toHaveLength(1); + expect(notificationResponse.surveys[0].responses).toHaveLength(1); + expect(notificationResponse.surveys[0].responses[0].responseValue).toBe(""); + }); + + test("should handle unsupported question types gracefully", () => { + const mockSurveys = [ + { + id: "survey1", + name: "Survey 1", + status: "inProgress", + questions: [ + { + id: "question1", + headline: { default: "Question 1" }, + type: "unsupported", + } as unknown as TSurveyQuestion, + ], + displays: [{ id: "display1" }], + responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }], + } as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] }, + ] as unknown as TWeeklySummarySurveyData[]; + + const mockEnvironment = { + id: "env1", + surveys: mockSurveys, + } as unknown as TWeeklySummaryEnvironmentData; + + const projectName = "Project Name"; + + vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response"); + + const notificationResponse = getNotificationResponse(mockEnvironment, projectName); + + expect(notificationResponse).toBeDefined(); + expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response"); + }); +}); diff --git a/apps/web/app/api/cron/weekly-summary/lib/organization.test.ts b/apps/web/app/api/cron/weekly-summary/lib/organization.test.ts new file mode 100644 index 0000000000..4fe250acd9 --- /dev/null +++ b/apps/web/app/api/cron/weekly-summary/lib/organization.test.ts @@ -0,0 +1,48 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getOrganizationIds } from "./organization"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findMany: vi.fn(), + }, + }, +})); + +describe("Organization", () => { + afterEach(() => { + cleanup(); + }); + + test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => { + const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }]; + + vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations); + + const organizationIds = await getOrganizationIds(); + + expect(organizationIds).toEqual(["org1", "org2", "org3"]); + expect(prisma.organization.findMany).toHaveBeenCalledTimes(1); + expect(prisma.organization.findMany).toHaveBeenCalledWith({ + select: { + id: true, + }, + }); + }); + + test("getOrganizationIds should return an empty array when the database contains no organizations", async () => { + vi.mocked(prisma.organization.findMany).mockResolvedValue([]); + + const organizationIds = await getOrganizationIds(); + + expect(organizationIds).toEqual([]); + expect(prisma.organization.findMany).toHaveBeenCalledTimes(1); + expect(prisma.organization.findMany).toHaveBeenCalledWith({ + select: { + id: true, + }, + }); + }); +}); diff --git a/apps/web/app/api/cron/weekly-summary/lib/project.test.ts b/apps/web/app/api/cron/weekly-summary/lib/project.test.ts new file mode 100644 index 0000000000..c3de4eefe5 --- /dev/null +++ b/apps/web/app/api/cron/weekly-summary/lib/project.test.ts @@ -0,0 +1,570 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getProjectsByOrganizationId } from "./project"; + +const mockProjects = [ + { + id: "project1", + name: "Project 1", + environments: [ + { + id: "env1", + type: "production", + surveys: [], + attributeKeys: [], + }, + ], + organization: { + memberships: [ + { + user: { + id: "user1", + email: "test@example.com", + notificationSettings: { + weeklySummary: { + project1: true, + }, + }, + locale: "en", + }, + }, + ], + }, + }, +]; + +const sevenDaysAgo = new Date(); +sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days + +const mockProjectsWithNoEnvironments = [ + { + id: "project3", + name: "Project 3", + environments: [], + organization: { + memberships: [ + { + user: { + id: "user1", + email: "test@example.com", + notificationSettings: { + weeklySummary: { + project3: true, + }, + }, + locale: "en", + }, + }, + ], + }, + }, +]; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { + findMany: vi.fn(), + }, + }, +})); + +describe("Project Management", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe("getProjectsByOrganizationId", () => { + test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects); + + const organizationId = "testOrgId"; + const projects = await getProjectsByOrganizationId(organizationId); + + expect(projects).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: organizationId, + }, + select: { + id: true, + name: true, + environments: { + where: { + type: "production", + }, + select: { + id: true, + surveys: { + where: { + NOT: { + AND: [ + { status: "completed" }, + { + responses: { + none: { + createdAt: { + gte: expect.any(Date), + }, + }, + }, + }, + ], + }, + status: { + not: "draft", + }, + }, + select: { + id: true, + name: true, + questions: true, + status: true, + responses: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + finished: true, + data: true, + }, + orderBy: { + createdAt: "desc", + }, + }, + displays: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + }, + }, + hiddenFields: true, + }, + }, + attributeKeys: { + select: { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + environmentId: true, + key: true, + isUnique: true, + }, + }, + }, + }, + organization: { + select: { + memberships: { + select: { + user: { + select: { + id: true, + email: true, + notificationSettings: true, + locale: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + test("handles date calculations correctly across DST boundaries", async () => { + const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary) + const sevenDaysAgo = new Date(mockDate); + sevenDaysAgo.setDate(mockDate.getDate() - 7); + + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects); + + const organizationId = "testOrgId"; + await getProjectsByOrganizationId(organizationId); + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + organizationId: organizationId, + }, + select: expect.objectContaining({ + environments: expect.objectContaining({ + select: expect.objectContaining({ + surveys: expect.objectContaining({ + where: expect.objectContaining({ + NOT: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: "completed" }), + expect.objectContaining({ + responses: expect.objectContaining({ + none: expect.objectContaining({ + createdAt: expect.objectContaining({ + gte: sevenDaysAgo, + }), + }), + }), + }), + ]), + }), + }), + }), + }), + }), + }), + }) + ); + + vi.useRealTimers(); + }); + + test("includes surveys with 'completed' status but responses within the last 7 days", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects); + + const organizationId = "testOrgId"; + const projects = await getProjectsByOrganizationId(organizationId); + + expect(projects).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: organizationId, + }, + select: { + id: true, + name: true, + environments: { + where: { + type: "production", + }, + select: { + id: true, + surveys: { + where: { + NOT: { + AND: [ + { status: "completed" }, + { + responses: { + none: { + createdAt: { + gte: expect.any(Date), + }, + }, + }, + }, + ], + }, + status: { + not: "draft", + }, + }, + select: { + id: true, + name: true, + questions: true, + status: true, + responses: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + finished: true, + data: true, + }, + orderBy: { + createdAt: "desc", + }, + }, + displays: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + }, + }, + hiddenFields: true, + }, + }, + attributeKeys: { + select: { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + environmentId: true, + key: true, + isUnique: true, + }, + }, + }, + }, + organization: { + select: { + memberships: { + select: { + user: { + select: { + id: true, + email: true, + notificationSettings: true, + locale: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + test("returns an empty array when an invalid organization ID is provided", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]); + + const invalidOrganizationId = "invalidOrgId"; + const projects = await getProjectsByOrganizationId(invalidOrganizationId); + + expect(projects).toEqual([]); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: invalidOrganizationId, + }, + select: { + id: true, + name: true, + environments: { + where: { + type: "production", + }, + select: { + id: true, + surveys: { + where: { + NOT: { + AND: [ + { status: "completed" }, + { + responses: { + none: { + createdAt: { + gte: expect.any(Date), + }, + }, + }, + }, + ], + }, + status: { + not: "draft", + }, + }, + select: { + id: true, + name: true, + questions: true, + status: true, + responses: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + finished: true, + data: true, + }, + orderBy: { + createdAt: "desc", + }, + }, + displays: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + }, + }, + hiddenFields: true, + }, + }, + attributeKeys: { + select: { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + environmentId: true, + key: true, + isUnique: true, + }, + }, + }, + }, + organization: { + select: { + memberships: { + select: { + user: { + select: { + id: true, + email: true, + notificationSettings: true, + locale: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + test("handles projects with no environments", async () => { + vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments); + + const organizationId = "testOrgId"; + const projects = await getProjectsByOrganizationId(organizationId); + + expect(projects).toEqual(mockProjectsWithNoEnvironments); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId: organizationId, + }, + select: { + id: true, + name: true, + environments: { + where: { + type: "production", + }, + select: { + id: true, + surveys: { + where: { + NOT: { + AND: [ + { status: "completed" }, + { + responses: { + none: { + createdAt: { + gte: expect.any(Date), + }, + }, + }, + }, + ], + }, + status: { + not: "draft", + }, + }, + select: { + id: true, + name: true, + questions: true, + status: true, + responses: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + finished: true, + data: true, + }, + orderBy: { + createdAt: "desc", + }, + }, + displays: { + where: { + createdAt: { + gte: expect.any(Date), + }, + }, + select: { + id: true, + }, + }, + hiddenFields: true, + }, + }, + attributeKeys: { + select: { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + environmentId: true, + key: true, + isUnique: true, + }, + }, + }, + }, + organization: { + select: { + memberships: { + select: { + user: { + select: { + id: true, + email: true, + notificationSettings: true, + locale: true, + }, + }, + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts new file mode 100644 index 0000000000..fbcffde6cb --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts @@ -0,0 +1,99 @@ +import { cache } from "@/lib/cache"; +import { TContact } from "@/modules/ee/contacts/types/contact"; +import { afterEach, 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(), + }, + }, +})); + +// Mock cache\ +vi.mock("@/lib/cache", async () => { + const actual = await vi.importActual("@/lib/cache"); + return { + ...(actual as any), + cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function + }; +}); + +const environmentId = "test-environment-id"; +const userId = "test-user-id"; +const contactId = "test-contact-id"; + +const contactMock: Partial & { + attributes: { value: string; attributeKey: { key: string } }[]; +} = { + id: contactId, + attributes: [ + { attributeKey: { key: "userId" }, value: userId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], +}; + +describe("getContactByUserId", () => { + beforeEach(() => { + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return contact if found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, + }, + value: userId, + }, + }, + }, + select: { + id: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); + expect(contact).toEqual(contactMock); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + 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/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts new file mode 100644 index 0000000000..33669982e9 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts @@ -0,0 +1,309 @@ +import { cache } from "@/lib/cache"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { getSurveys } from "@/lib/survey/service"; +import { anySurveyHasFilters } from "@/lib/survey/utils"; +import { diffInDays } from "@/lib/utils/datetime"; +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 { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TProject } from "@formbricks/types/project"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getSyncSurveys } from "./survey"; + +// Mock dependencies +vi.mock("@/lib/cache", async () => { + const actual = await vi.importActual("@/lib/cache"); + return { + ...(actual as any), + cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function + }; +}); + +vi.mock("@/lib/project/service", () => ({ + getProjectByEnvironmentId: vi.fn(), +})); +vi.mock("@/lib/survey/service", () => ({ + getSurveys: vi.fn(), +})); +vi.mock("@/lib/survey/utils", () => ({ + anySurveyHasFilters: vi.fn(), +})); +vi.mock("@/lib/utils/datetime", () => ({ + diffInDays: vi.fn(), +})); +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); +vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ + evaluateSegment: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + display: { + findMany: vi.fn(), + }, + response: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const environmentId = "test-env-id"; +const contactId = "test-contact-id"; +const contactAttributes = { userId: "user1", email: "test@example.com" }; +const deviceType = "desktop"; + +const mockProject = { + id: "proj1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + environments: [], + recontactDays: 10, + inAppSurveyBranding: true, + linkSurveyBranding: true, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + languages: [], +} as unknown as TProject; + +const baseSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey 1", + environmentId: environmentId, + type: "app", + status: "inProgress", + questions: [], + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + segment: null, + surveyClosedMessage: null, + singleUse: null, + styling: null, + pin: null, + resultShareKey: null, + displayLimit: null, + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + endings: [], + triggers: [], + languages: [], + variables: [], + hiddenFields: { enabled: false }, + createdBy: null, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + isBackButtonHidden: false, + followUps: [], + recaptcha: { enabled: false, threshold: 0.5 }, +}; + +describe("getSyncSurveys", () => { + beforeEach(() => { + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject); + vi.mocked(prisma.display.findMany).mockResolvedValue([]); + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + vi.mocked(anySurveyHasFilters).mockReturnValue(false); + vi.mocked(evaluateSegment).mockResolvedValue(true); + vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should throw error if product not found", async () => { + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null); + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + "Product not found" + ); + }); + + test("should return empty array if no surveys found", async () => { + vi.mocked(getSurveys).mockResolvedValue([]); + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + }); + + test("should return empty array if no 'app' type surveys in progress", async () => { + const surveys: TSurvey[] = [ + { ...baseSurvey, id: "s1", type: "link", status: "inProgress" }, + { ...baseSurvey, id: "s2", type: "app", status: "paused" }, + ]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + }); + + test("should filter by displayOption 'displayOnce'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Already displayed + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should filter by displayOption 'displayMultiple'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); // Already responded + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should filter by displayOption 'displaySome'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([ + { id: "d1", surveyId: "s1", contactId }, + { id: "d2", surveyId: "s1", contactId }, + ]); // Display limit reached + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Within limit + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + + // Test with response already submitted + vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); + const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result3).toEqual([]); + }); + + test("should not filter by displayOption 'respondMultiple'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); + vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual(surveys); + }); + + test("should filter by product recontactDays if survey recontactDays is null", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + const displayDate = new Date(); + vi.mocked(prisma.display.findMany).mockResolvedValue([ + { id: "d1", surveyId: "s2", contactId, createdAt: displayDate }, // Display for another survey + ]); + + vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10) + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate); + + vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should return surveys if no segment filters exist", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(false); + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual(surveys); + expect(evaluateSegment).not.toHaveBeenCalled(); + }); + + test("should evaluate segment filters if they exist", async () => { + const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(true); + + // Case 1: Segment evaluation matches + vi.mocked(evaluateSegment).mockResolvedValue(true); + const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result1).toEqual(surveys); + expect(evaluateSegment).toHaveBeenCalledWith( + { + attributes: contactAttributes, + deviceType, + environmentId, + contactId, + userId: contactAttributes.userId, + }, + segment.filters + ); + + // Case 2: Segment evaluation does not match + vi.mocked(evaluateSegment).mockResolvedValue(false); + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual([]); + }); + + test("should handle Prisma errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "test", + }); + vi.mocked(getSurveys).mockRejectedValue(prismaError); + + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + DatabaseError + ); + expect(logger.error).toHaveBeenCalledWith(prismaError); + }); + + test("should handle general errors", async () => { + const generalError = new Error("Something went wrong"); + vi.mocked(getSurveys).mockRejectedValue(generalError); + + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + generalError + ); + }); + + test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => { + const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(true); + vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out + + // This scenario is tricky to force directly as the code checks `if (!surveys)` before returning. + // However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw. + // We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test. + // Let's assume the filter logic works correctly and test the intended path. + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); // Expect empty array, not an error in this case. + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts new file mode 100644 index 0000000000..89c25f905e --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts @@ -0,0 +1,247 @@ +import { parseRecallInfo } from "@/lib/utils/recall"; +import { describe, expect, test, vi } from "vitest"; +import { TAttributes } from "@formbricks/types/attributes"; +import { TLanguage } from "@formbricks/types/project"; +import { + TSurvey, + TSurveyEnding, + TSurveyQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { replaceAttributeRecall } from "./utils"; + +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: vi.fn((text, attributes) => { + const recallPattern = /recall:([a-zA-Z0-9_-]+)/; + const match = text.match(recallPattern); + if (match && match[1]) { + const recallKey = match[1]; + const attributeValue = attributes[recallKey]; + if (attributeValue !== undefined) { + return text.replace(recallPattern, `parsed-${attributeValue}`); + } + } + return text; // Return original text if no match or attribute not found + }), +})); + +const baseSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + environmentId: "env1", + type: "app", + status: "inProgress", + questions: [], + endings: [], + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + languages: [ + { language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true }, + ], + triggers: [], + recontactDays: null, + displayLimit: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + hiddenFields: { enabled: false }, + variables: [], + createdBy: null, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + isBackButtonHidden: false, + followUps: [], + recaptcha: { enabled: false, threshold: 0.5 }, + displayOption: "displayOnce", + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + segment: null, + pin: null, + resultShareKey: null, +}; + +const attributes: TAttributes = { + name: "John Doe", + email: "john.doe@example.com", + plan: "premium", +}; + +describe("replaceAttributeRecall", () => { + test("should replace recall info in question headlines and subheaders", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Hello recall:name!" }, + subheader: { default: "Your email is recall:email" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!"); + expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes); + }); + + test("should replace recall info in welcome card headline", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + welcomeCard: { + enabled: true, + headline: { default: "Welcome, recall:name!" }, + html: { default: "

Some content

" }, + buttonLabel: { default: "Start" }, + timeToFinish: false, + showResponseCount: false, + }, + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes); + }); + + test("should replace recall info in end screen headlines and subheaders", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + endings: [ + { + type: "endScreen", + headline: { default: "Thank you, recall:name!" }, + subheader: { default: "Your plan: recall:plan" }, + buttonLabel: { default: "Finish" }, + buttonLink: "https://example.com", + } as unknown as TSurveyEnding, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.endings[0].type).toBe("endScreen"); + if (result.endings[0].type === "endScreen") { + expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!"); + expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes); + } + }); + + test("should handle multiple languages", () => { + const surveyMultiLang: TSurvey = { + ...baseSurvey, + languages: [ + { language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true }, + { language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true }, + ], + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Hello recall:name!", es: "Hola recall:name!" }, + required: true, + buttonLabel: { default: "Next", es: "Siguiente" }, + placeholder: { default: "Type here...", es: "Escribe aquĆ­..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyMultiLang, attributes); + expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!"); + expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes); + }); + + test("should not replace if recall key is not in attributes", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Your company: recall:company" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.questions[0].headline.default).toBe("Your company: recall:company"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes); + }); + + test("should handle surveys with no recall information", async () => { + const surveyNoRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Just a regular question" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome!" }, + html: { default: "

Some content

" }, + buttonLabel: { default: "Start" }, + timeToFinish: false, + showResponseCount: false, + }, + endings: [ + { + type: "endScreen", + headline: { default: "Thank you!" }, + buttonLabel: { default: "Finish" }, + } as unknown as TSurveyEnding, + ], + }; + const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo"); + + const result = replaceAttributeRecall(surveyNoRecall, attributes); + expect(result).toEqual(surveyNoRecall); // Should be unchanged + expect(parseRecallInfoSpy).not.toHaveBeenCalled(); + parseRecallInfoSpy.mockRestore(); + }); + + test("should handle surveys with empty questions, endings, or disabled welcome card", async () => { + const surveyEmpty: TSurvey = { + ...baseSurvey, + questions: [], + endings: [], + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + }; + const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo"); + + const result = replaceAttributeRecall(surveyEmpty, attributes); + expect(result).toEqual(surveyEmpty); + expect(parseRecallInfoSpy).not.toHaveBeenCalled(); + parseRecallInfoSpy.mockRestore(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.test.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.test.ts new file mode 100644 index 0000000000..b53fd6db66 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.test.ts @@ -0,0 +1,86 @@ +import { cache } from "@/lib/cache"; +import { validateInputs } from "@/lib/utils/validate"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TJsEnvironmentStateActionClass } from "@formbricks/types/js"; +import { getActionClassesForEnvironmentState } from "./actionClass"; + +// Mock dependencies +vi.mock("@/lib/cache"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + actionClass: { + findMany: vi.fn(), + }, + }, +})); + +const environmentId = "test-environment-id"; +const mockActionClasses: TJsEnvironmentStateActionClass[] = [ + { + id: "action1", + type: "code", + name: "Code Action", + key: "code-action", + noCodeConfig: null, + }, + { + id: "action2", + type: "noCode", + name: "No Code Action", + key: null, + noCodeConfig: { type: "click" } as TActionClassNoCodeConfig, + }, +]; + +describe("getActionClassesForEnvironmentState", () => { + test("should return action classes successfully", async () => { + vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + + const result = await getActionClassesForEnvironmentState(environmentId); + + expect(result).toEqual(mockActionClasses); + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // ZId is an object + expect(prisma.actionClass.findMany).toHaveBeenCalledWith({ + where: { environmentId }, + select: { + id: true, + type: true, + name: true, + key: true, + noCodeConfig: true, + }, + }); + expect(cache).toHaveBeenCalledWith( + expect.any(Function), + [`getActionClassesForEnvironmentState-${environmentId}`], + { tags: [`environments-${environmentId}-actionClasses`] } + ); + }); + + test("should throw DatabaseError on prisma error", async () => { + const mockError = new Error("Prisma error"); + vi.mocked(prisma.actionClass.findMany).mockRejectedValue(mockError); + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + + await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError); + await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow( + `Database error when fetching actions for environment ${environmentId}` + ); + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); + expect(prisma.actionClass.findMany).toHaveBeenCalled(); + expect(cache).toHaveBeenCalledWith( + expect.any(Function), + [`getActionClassesForEnvironmentState-${environmentId}`], + { tags: [`environments-${environmentId}-actionClasses`] } + ); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.test.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.test.ts new file mode 100644 index 0000000000..8904bc2d10 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.test.ts @@ -0,0 +1,120 @@ +import { cache } from "@/lib/cache"; +import { projectCache } from "@/lib/project/cache"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TJsEnvironmentStateProject } from "@formbricks/types/js"; +import { getProjectForEnvironmentState } from "./project"; + +// Mock dependencies +vi.mock("@/lib/cache"); +vi.mock("@/lib/project/cache"); +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); +vi.mock("@/lib/utils/validate"); // Mock validateInputs if needed, though it's often tested elsewhere + +const environmentId = "test-environment-id"; +const mockProject: TJsEnvironmentStateProject = { + id: "test-project-id", + recontactDays: 30, + clickOutsideClose: true, + darkOverlay: false, + placement: "bottomRight", + inAppSurveyBranding: true, + styling: { allowStyleOverwrite: false }, +}; + +describe("getProjectForEnvironmentState", () => { + beforeEach(() => { + vi.resetAllMocks(); + + // Mock cache implementation + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + + // Mock projectCache tags + vi.mocked(projectCache.tag.byEnvironmentId).mockReturnValue(`project-env-${environmentId}`); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return project state successfully", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject); + + const result = await getProjectForEnvironmentState(environmentId); + + expect(result).toEqual(mockProject); + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + select: { + id: true, + recontactDays: true, + clickOutsideClose: true, + darkOverlay: true, + placement: true, + inAppSurveyBranding: true, + styling: true, + }, + }); + expect(cache).toHaveBeenCalledTimes(1); + expect(cache).toHaveBeenCalledWith( + expect.any(Function), + [`getProjectForEnvironmentState-${environmentId}`], + { + tags: [`project-env-${environmentId}`], + } + ); + }); + + test("should return null if project not found", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(null); + + const result = await getProjectForEnvironmentState(environmentId); + + expect(result).toBeNull(); + expect(prisma.project.findFirst).toHaveBeenCalledTimes(1); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should throw DatabaseError on PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2001", + clientVersion: "test", + }); + vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError); + + await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting project for environment state"); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should re-throw unknown errors", async () => { + const unknownError = new Error("Something went wrong"); + vi.mocked(prisma.project.findFirst).mockRejectedValue(unknownError); + + await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(unknownError); + expect(logger.error).not.toHaveBeenCalled(); // Should not log unknown errors here + expect(cache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.test.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.test.ts new file mode 100644 index 0000000000..12dc654bde --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.test.ts @@ -0,0 +1,143 @@ +import { cache } from "@/lib/cache"; +import { validateInputs } from "@/lib/utils/validate"; +import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { getSurveysForEnvironmentState } from "./survey"; + +// Mock dependencies +vi.mock("@/lib/cache"); +vi.mock("@/lib/utils/validate"); +vi.mock("@/modules/survey/lib/utils"); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const environmentId = "test-environment-id"; + +const mockPrismaSurvey = { + id: "survey-1", + welcomeCard: { enabled: false }, + name: "Test Survey", + questions: [], + variables: [], + type: "app", + showLanguageSwitch: false, + languages: [], + endings: [], + autoClose: null, + styling: null, + status: "inProgress", + recaptcha: null, + segment: null, + recontactDays: null, + displayLimit: null, + displayOption: "displayOnce", + hiddenFields: { enabled: false }, + isBackButtonHidden: false, + triggers: [], + displayPercentage: null, + delay: 0, + projectOverwrites: null, +}; + +const mockTransformedSurvey: TJsEnvironmentStateSurvey = { + id: "survey-1", + welcomeCard: { enabled: false } as TJsEnvironmentStateSurvey["welcomeCard"], + name: "Test Survey", + questions: [], + variables: [], + type: "app", + showLanguageSwitch: false, + languages: [], + endings: [], + autoClose: null, + styling: null, + status: "inProgress", + recaptcha: null, + segment: null, + recontactDays: null, + displayLimit: null, + displayOption: "displayOnce", + hiddenFields: { enabled: false }, + isBackButtonHidden: false, + triggers: [], + displayPercentage: null, + delay: 0, + projectOverwrites: null, +}; + +describe("getSurveysForEnvironmentState", () => { + beforeEach(() => { + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + vi.mocked(validateInputs).mockReturnValue([environmentId]); // Assume validation passes + vi.mocked(transformPrismaSurvey).mockReturnValue(mockTransformedSurvey); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return transformed surveys on successful fetch", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurvey]); + + const result = await getSurveysForEnvironmentState(environmentId); + + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId }, + select: expect.any(Object), // Check if select is called, specific fields are in the original code + }); + expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey); + expect(result).toEqual([mockTransformedSurvey]); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should return an empty array if no surveys are found", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([]); + + const result = await getSurveysForEnvironmentState(environmentId); + + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId }, + select: expect.any(Object), + }); + expect(transformPrismaSurvey).not.toHaveBeenCalled(); + expect(result).toEqual([]); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); + + await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys for environment state"); + }); + + test("should rethrow unknown errors", async () => { + const unknownError = new Error("Something went wrong"); + vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError); + + await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(unknownError); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts new file mode 100644 index 0000000000..1c00b9cf28 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts @@ -0,0 +1,160 @@ +import { cache } from "@/lib/cache"; +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 { getContact, getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +// Mock cache module +vi.mock("@/lib/cache"); + +// Mock react 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 mockContactId = "test-contact-id"; +const mockEnvironmentId = "test-env-id"; +const mockUserId = "test-user-id"; + +describe("Contact API Lib", () => { + beforeEach(() => { + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("getContact", () => { + test("should return contact if found", async () => { + const mockContactData = { id: mockContactId }; + vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData); + + const contact = await getContact(mockContactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + expect(contact).toEqual(mockContactData); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(null); + + const contact = await getContact(mockContactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + expect(contact).toBeNull(); + }); + + test("should throw DatabaseError on Prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError); + + await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError); + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + }); + }); + + describe("getContactByUserId", () => { + test("should return contact with formatted attributes if found", async () => { + const mockContactData = { + id: mockContactId, + attributes: [ + { attributeKey: { key: "userId" }, value: mockUserId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], + }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData); + + const contact = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toEqual({ + id: mockContactId, + attributes: { + userId: mockUserId, + email: "test@example.com", + }, + }); + }); + + test("should return null if contact not found by userId", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toBeNull(); + }); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts new file mode 100644 index 0000000000..eb40aac841 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts @@ -0,0 +1,201 @@ +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TResponseInput } from "@formbricks/types/responses"; +import { createResponse } from "./response"; + +let mockIsFormbricksCloud = false; + +vi.mock("@/lib/constants", () => ({ + get IS_FORMBRICKS_CLOUD() { + return mockIsFormbricksCloud; + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getMonthlyOrganizationResponseCount: vi.fn(), + getOrganizationByEnvironmentId: vi.fn(), +})); + +vi.mock("@/lib/posthogServer", () => ({ + sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(), +})); + +vi.mock("@/lib/response/cache", () => ({ + responseCache: { + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/response/utils", () => ({ + calculateTtcTotal: vi.fn((ttc) => ttc), +})); + +vi.mock("@/lib/responseNote/cache", () => ({ + responseNoteCache: { + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/telemetry", () => ({ + captureTelemetry: vi.fn(), +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("./contact", () => ({ + getContactByUserId: vi.fn(), +})); + +const environmentId = "test-environment-id"; +const surveyId = "test-survey-id"; +const organizationId = "test-organization-id"; +const responseId = "test-response-id"; + +const mockOrganization = { + id: organizationId, + name: "Test Org", + billing: { + limits: { monthly: { responses: 100 } }, + plan: "free", + }, +}; + +const mockResponseInput: TResponseInput = { + environmentId, + surveyId, + userId: null, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, +}; + +const mockResponsePrisma = { + id: responseId, + createdAt: new Date(), + updatedAt: new Date(), + surveyId, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: null, + displayId: null, + tags: [], + notes: [], +}; + +describe("createResponse", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any); + vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc); + }); + + afterEach(() => { + mockIsFormbricksCloud = false; + }); + + test("should handle finished response and calculate TTC", async () => { + const finishedInput = { ...mockResponseInput, finished: true }; + await createResponse(finishedInput); + expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc); + expect(prisma.response.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ finished: true }), + }) + ); + }); + + test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, { + plan: "free", + limits: { + projects: null, + monthly: { + responses: 100, + miu: null, + }, + }, + }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2002", + clientVersion: "test", + }); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + }); + + test("should throw original error on other Prisma errors", async () => { + const genericError = new Error("Generic database error"); + vi.mocked(prisma.response.create).mockRejectedValue(genericError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); + }); + + test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + const posthogError = new Error("PostHog error"); + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + await createResponse(mockResponseInput); + + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts new file mode 100644 index 0000000000..cad4f776af --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts @@ -0,0 +1,103 @@ +import { getUploadSignedUrl } from "@/lib/storage/service"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { uploadPrivateFile } from "./uploadPrivateFile"; + +vi.mock("@/lib/storage/service", () => ({ + getUploadSignedUrl: vi.fn(), +})); + +describe("uploadPrivateFile", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => { + const mockSignedUrlResponse = { + signedUrl: "mocked-signed-url", + presignedFields: { field1: "value1" }, + fileUrl: "mocked-file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); + + expect(resultData).toEqual({ + data: mockSignedUrlResponse, + }); + }); + + test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => { + const mockSignedUrlResponse = { + signedUrl: "mocked-signed-url", + presignedFields: { field1: "value1" }, + fileUrl: "mocked-file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + const isBiggerFileUploadAllowed = true; + + const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith( + fileName, + environmentId, + fileType, + "private", + isBiggerFileUploadAllowed + ); + + expect(resultData).toEqual({ + data: mockSignedUrlResponse, + }); + }); + + test("should return an internal server error response when getUploadSignedUrl throws an error", async () => { + vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable")); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + + expect(result.status).toBe(500); + const resultData = await result.json(); + expect(resultData).toEqual({ + code: "internal_server_error", + details: {}, + message: "Internal server error", + }); + }); + + test("should return an internal server error response when fileName has no extension", async () => { + vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found")); + + const fileName = "test-file"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); + expect(result.status).toBe(500); + expect(resultData).toEqual({ + code: "internal_server_error", + details: {}, + message: "Internal server error", + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/segments/components/segment-settings.test.tsx b/apps/web/modules/ee/contacts/segments/components/segment-settings.test.tsx index fb4b8fd980..b14d882985 100644 --- a/apps/web/modules/ee/contacts/segments/components/segment-settings.test.tsx +++ b/apps/web/modules/ee/contacts/segments/components/segment-settings.test.tsx @@ -466,7 +466,6 @@ describe("SegmentSettings", () => { expect(updatedSaveButton.getAttribute("data-loading")).not.toBe("true"); }); - // [Tusk] FAILING TEST test("should add a filter to the segment when a valid filter is selected in the filter modal", async () => { // Render component render(); diff --git a/apps/web/modules/ee/role-management/actions.test.ts b/apps/web/modules/ee/role-management/actions.test.ts index 43fe857b1f..3288dbdc75 100644 --- a/apps/web/modules/ee/role-management/actions.test.ts +++ b/apps/web/modules/ee/role-management/actions.test.ts @@ -4,7 +4,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware" import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { TUpdateInviteAction, - TUpdateMembershipAction, checkRoleManagementPermission, updateInviteAction, updateMembershipAction, @@ -215,7 +214,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "member" }, }, - } as unknown as TUpdateMembershipAction) + } as any) ).rejects.toThrow(new AuthenticationError("User not a member of this organization")); }); @@ -231,7 +230,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "member" }, }, - } as unknown as TUpdateMembershipAction) + } as any) ).rejects.toThrow(new OperationNotAllowedError("User management is disabled")); }); @@ -248,7 +247,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "billing" }, }, - } as unknown as TUpdateMembershipAction) + } as any) ).rejects.toThrow(new ValidationError("Billing role is not allowed")); }); @@ -268,7 +267,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "billing" }, }, - } as unknown as TUpdateMembershipAction); + } as any); expect(result).toEqual({ id: "membership-123", role: "billing" }); }); @@ -286,7 +285,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "owner" }, }, - } as unknown as TUpdateMembershipAction) + } as any) ).rejects.toThrow(new OperationNotAllowedError("Managers can only assign users to the member role")); }); @@ -305,7 +304,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "member" }, }, - } as unknown as TUpdateMembershipAction); + } as any); expect(result).toEqual({ id: "membership-123", role: "member" }); expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" }); @@ -326,7 +325,7 @@ describe("Role Management Actions", () => { organizationId: "org-123", data: { role: "member" }, }, - } as unknown as TUpdateMembershipAction); + } as any); expect(result).toEqual({ id: "membership-123", role: "member" }); expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" }); diff --git a/apps/web/modules/ee/role-management/actions.ts b/apps/web/modules/ee/role-management/actions.ts index 54a199090e..cc82f81f8d 100644 --- a/apps/web/modules/ee/role-management/actions.ts +++ b/apps/web/modules/ee/role-management/actions.ts @@ -11,8 +11,7 @@ import { updateMembership } from "@/modules/ee/role-management/lib/membership"; import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites"; import { z } from "zod"; import { ZId, ZUuid } from "@formbricks/types/common"; -import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors"; -import { AuthenticationError } from "@formbricks/types/errors"; +import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors"; import { ZMembershipUpdateInput } from "@formbricks/types/memberships"; export const checkRoleManagementPermission = async (organizationId: string) => { diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 2d2ed03957..b034efcd6e 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -88,6 +88,7 @@ export default defineConfig({ "modules/organization/settings/teams/actions.ts", "modules/organization/settings/api-keys/lib/**/*.ts", "app/api/v1/**/*.ts", + "app/api/cron/**/*.ts", "modules/api/v2/management/auth/*.ts", "modules/organization/settings/api-keys/components/*.tsx", "modules/survey/hooks/*.tsx",