diff --git a/apps/web/app/api/v1/management/me/lib/utils.test.ts b/apps/web/app/api/v1/management/me/lib/utils.test.ts new file mode 100644 index 0000000000..4e1633187e --- /dev/null +++ b/apps/web/app/api/v1/management/me/lib/utils.test.ts @@ -0,0 +1,62 @@ +import { getSessionUser } from "@/app/api/v1/management/me/lib/utils"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { mockUser } from "@/modules/auth/lib/mock-data"; +import { cleanup } from "@testing-library/react"; +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +describe("getSessionUser", () => { + afterEach(() => { + cleanup(); + }); + + test("should return the user object when valid req and res are provided", async () => { + const mockReq = {} as NextApiRequest; + const mockRes = {} as NextApiResponse; + + vi.mocked(getServerSession).mockResolvedValue({ user: mockUser }); + + const user = await getSessionUser(mockReq, mockRes); + + expect(user).toEqual(mockUser); + expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions); + }); + + test("should return the user object when neither req nor res are provided", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: mockUser }); + + const user = await getSessionUser(); + + expect(user).toEqual(mockUser); + expect(getServerSession).toHaveBeenCalledWith(authOptions); + }); + + test("should return undefined if no session exists", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const user = await getSessionUser(); + + expect(user).toBeUndefined(); + }); + + test("should return null when session exists and user property is null", async () => { + const mockReq = {} as NextApiRequest; + const mockRes = {} as NextApiResponse; + + vi.mocked(getServerSession).mockResolvedValue({ user: null }); + + const user = await getSessionUser(mockReq, mockRes); + + expect(user).toBeNull(); + expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions); + }); +}); diff --git a/apps/web/app/api/v1/management/responses/lib/contact.test.ts b/apps/web/app/api/v1/management/responses/lib/contact.test.ts new file mode 100644 index 0000000000..df115206a5 --- /dev/null +++ b/apps/web/app/api/v1/management/responses/lib/contact.test.ts @@ -0,0 +1,121 @@ +import { cache } from "@/lib/cache"; +import { contactCache } from "@/lib/cache/contact"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TContactAttributes } from "@formbricks/types/contact-attribute"; +import { getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache"); + +const environmentId = "test-env-id"; +const userId = "test-user-id"; +const contactId = "test-contact-id"; + +const mockContactDbData = { + id: contactId, + attributes: [ + { attributeKey: { key: "userId" }, value: userId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + { attributeKey: { key: "plan" }, value: "premium" }, + ], +}; + +const expectedContactAttributes: TContactAttributes = { + userId: userId, + email: "test@example.com", + plan: "premium", +}; + +describe("getContactByUserId", () => { + beforeEach(() => { + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + }); + + test("should return contact with attributes when found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData); + + 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({ + id: contactId, + attributes: expectedContactAttributes, + }); + expect(cache).toHaveBeenCalledWith( + expect.any(Function), + [`getContactByUserIdForResponsesApi-${environmentId}-${userId}`], + { + tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], + } + ); + }); + + test("should return null when contact is 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(); + expect(cache).toHaveBeenCalledWith( + expect.any(Function), + [`getContactByUserIdForResponsesApi-${environmentId}-${userId}`], + { + tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], + } + ); + }); +}); diff --git a/apps/web/app/api/v1/management/responses/lib/response.test.ts b/apps/web/app/api/v1/management/responses/lib/response.test.ts new file mode 100644 index 0000000000..57e7815164 --- /dev/null +++ b/apps/web/app/api/v1/management/responses/lib/response.test.ts @@ -0,0 +1,347 @@ +import { cache } from "@/lib/cache"; +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { responseCache } from "@/lib/response/cache"; +import { getResponseContact } from "@/lib/response/service"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { responseNoteCache } from "@/lib/responseNote/cache"; +import { validateInputs } from "@/lib/utils/validate"; +import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client"; +import { 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 { TResponse, TResponseInput } from "@formbricks/types/responses"; +import { getContactByUserId } from "./contact"; +import { createResponse, getResponsesByEnvironmentIds } from "./response"; + +// Mock Data +const environmentId = "test-environment-id"; +const organizationId = "test-organization-id"; +const mockUserId = "test-user-id"; +const surveyId = "test-survey-id"; +const displayId = "test-display-id"; +const responseId = "test-response-id"; + +const mockOrganization = { + id: organizationId, + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit +} as unknown as Organization; + +const mockResponseInput: TResponseInput = { + environmentId, + surveyId, + displayId, + finished: true, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5 }, + language: "en", +}; + +const mockResponseInputWithUserId: TResponseInput = { + ...mockResponseInput, + userId: mockUserId, +}; + +// Prisma response structure (simplified) +const mockResponsePrisma = { + id: responseId, + createdAt: new Date(), + updatedAt: new Date(), + surveyId, + finished: true, + endingId: null, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5, total: 10 }, // Assume calculateTtcTotal adds 'total' + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: "en", + displayId, + contact: null, // Prisma relation + tags: [], // Prisma relation + notes: [], // Prisma relation +} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed + +const mockResponse: TResponse = { + id: responseId, + createdAt: mockResponsePrisma.createdAt, + updatedAt: mockResponsePrisma.updatedAt, + surveyId, + finished: true, + endingId: null, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5, total: 10 }, + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: "en", + displayId, + contact: null, // Transformed structure + tags: [], // Transformed structure + notes: [], // Transformed structure +}; + +const mockEnvironmentIds = [environmentId, "env-2"]; +const mockLimit = 10; +const mockOffset = 5; + +const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "response-2" }]; +const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }]; + +// Mock dependencies +vi.mock("@/lib/cache"); +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: true, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/posthogServer"); +vi.mock("@/lib/response/cache"); +vi.mock("@/lib/response/service"); +vi.mock("@/lib/response/utils"); +vi.mock("@/lib/responseNote/cache"); +vi.mock("@/lib/telemetry"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + create: vi.fn(), + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger"); +vi.mock("./contact"); + +describe("Response Lib Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + // No need to mock IS_FORMBRICKS_CLOUD here anymore unless specifically changing it from the default + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + }); + + describe("createResponse", () => { + test("should create a response successfully with userId", async () => { + const mockContact = { id: "contact1", attributes: { userId: mockUserId } }; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue({ + ...mockResponsePrisma, + }); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); + + const response = await createResponse(mockResponseInputWithUserId); + + expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId); + expect(prisma.response.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + contact: { connect: { id: mockContact.id } }, + contactAttributes: mockContact.attributes, + }), + }) + ); + expect(responseCache.revalidate).toHaveBeenCalledWith( + expect.objectContaining({ + contactId: mockContact.id, + userId: mockUserId, + }) + ); + expect(responseNoteCache.revalidate).toHaveBeenCalled(); + expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError); + expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId); + expect(prisma.response.create).not.toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "2.0", + }); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(genericError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); + }); + + describe("Cloud specific tests", () => { + test("should check response limit and send event if limit reached", async () => { + // IS_FORMBRICKS_CLOUD is true by default from the top-level mock + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled(); + }); + + test("should check response limit and not send event if limit not reached", async () => { + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => { + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + const posthogError = new Error("Posthog error"); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + // Expecting successful response creation despite PostHog error + const response = await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + expect(response).toEqual(mockResponse); // Should still return the created response + }); + }); + }); + + describe("getResponsesByEnvironmentIds", () => { + test("should return responses successfully", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma); + vi.mocked(getResponseContact).mockReturnValue(null); // Assume no contact for simplicity + + const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds); + + expect(validateInputs).toHaveBeenCalledTimes(1); + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + survey: { + environmentId: { in: mockEnvironmentIds }, + }, + }, + orderBy: [{ createdAt: "desc" }], + take: undefined, + skip: undefined, + }) + ); + expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length); + expect(responses).toEqual(mockTransformedResponses); + expect(cache).toHaveBeenCalled(); + }); + + test("should return responses with limit and offset", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma); + vi.mocked(getResponseContact).mockReturnValue(null); + + await getResponsesByEnvironmentIds(mockEnvironmentIds, mockLimit, mockOffset); + + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: mockLimit, + skip: mockOffset, + }) + ); + expect(cache).toHaveBeenCalled(); + }); + + test("should return empty array if no responses found", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + + const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds); + + expect(responses).toEqual([]); + expect(prisma.response.findMany).toHaveBeenCalled(); + expect(getResponseContact).not.toHaveBeenCalled(); + expect(cache).toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "2.0", + }); + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + expect(cache).toHaveBeenCalled(); + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError); + expect(cache).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts new file mode 100644 index 0000000000..04b3c3f702 --- /dev/null +++ b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts @@ -0,0 +1,58 @@ +import { responses } from "@/app/lib/api/response"; +import { getUploadSignedUrl } from "@/lib/storage/service"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getSignedUrlForPublicFile } from "./getSignedUrl"; + +vi.mock("@/app/lib/api/response", () => ({ + responses: { + successResponse: vi.fn((data) => ({ data })), + internalServerErrorResponse: vi.fn((message) => ({ message })), + }, +})); + +vi.mock("@/lib/storage/service", () => ({ + getUploadSignedUrl: vi.fn(), +})); + +describe("getSignedUrlForPublicFile", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should return success response with signed URL data", async () => { + const mockFileName = "test.jpg"; + const mockEnvironmentId = "env123"; + const mockFileType = "image/jpeg"; + const mockSignedUrlResponse = { + signedUrl: "http://example.com/signed-url", + signingData: { signature: "sig", timestamp: 123, uuid: "uuid" }, + updatedFileName: "test--fid--uuid.jpg", + fileUrl: "http://example.com/file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); + expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse); + expect(result).toEqual({ data: mockSignedUrlResponse }); + }); + + test("should return internal server error response when getUploadSignedUrl throws an error", async () => { + const mockFileName = "test.png"; + const mockEnvironmentId = "env456"; + const mockFileType = "image/png"; + const mockError = new Error("Failed to get signed URL"); + + vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError); + + const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); + expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error"); + expect(result).toEqual({ message: "Internal server error" }); + }); +}); diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts new file mode 100644 index 0000000000..a1a0093c6f --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts @@ -0,0 +1,153 @@ +import { segmentCache } from "@/lib/cache/segment"; +import { responseCache } from "@/lib/response/cache"; +import { surveyCache } from "@/lib/survey/cache"; +import { validateInputs } from "@/lib/utils/validate"; +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 { deleteSurvey } from "./surveys"; + +// Mock dependencies +vi.mock("@/lib/cache/segment", () => ({ + segmentCache: { + revalidate: vi.fn(), + }, +})); +vi.mock("@/lib/response/cache", () => ({ + responseCache: { + revalidate: vi.fn(), + }, +})); +vi.mock("@/lib/survey/cache", () => ({ + surveyCache: { + revalidate: vi.fn(), + }, +})); +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + delete: vi.fn(), + }, + segment: { + delete: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const surveyId = "clq5n7p1q0000m7z0h5p6g3r2"; +const environmentId = "clq5n7p1q0000m7z0h5p6g3r3"; +const segmentId = "clq5n7p1q0000m7z0h5p6g3r4"; +const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5"; +const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6"; + +const mockDeletedSurveyAppPrivateSegment = { + id: surveyId, + environmentId, + type: "app", + segment: { id: segmentId, isPrivate: true }, + triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }], + resultShareKey: "shareKey123", +}; + +const mockDeletedSurveyLink = { + id: surveyId, + environmentId, + type: "link", + segment: null, + triggers: [], + resultShareKey: null, +}; + +describe("deleteSurvey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should delete a link survey without a segment and revalidate caches", async () => { + vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any); + + const deletedSurvey = await deleteSurvey(surveyId); + + expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]); + expect(prisma.survey.delete).toHaveBeenCalledWith({ + where: { id: surveyId }, + include: { + segment: true, + triggers: { include: { actionClass: true } }, + }, + }); + expect(prisma.segment.delete).not.toHaveBeenCalled(); + expect(segmentCache.revalidate).not.toHaveBeenCalled(); // No segment to revalidate + expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId }); + expect(surveyCache.revalidate).toHaveBeenCalledTimes(1); // Only for surveyId + expect(surveyCache.revalidate).toHaveBeenCalledWith({ + id: surveyId, + environmentId, + resultShareKey: undefined, + }); + expect(deletedSurvey).toEqual(mockDeletedSurveyLink); + }); + + test("should handle PrismaClientKnownRequestError during survey deletion", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); + expect(prisma.segment.delete).not.toHaveBeenCalled(); + expect(segmentCache.revalidate).not.toHaveBeenCalled(); + expect(responseCache.revalidate).not.toHaveBeenCalled(); + expect(surveyCache.revalidate).not.toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError during segment deletion", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", { + code: "P2003", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any); + vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); + expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } }); + // Caches might have been partially revalidated before the error + }); + + test("should handle generic errors during deletion", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.survey.delete).mockRejectedValue(genericError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError); + expect(logger.error).not.toHaveBeenCalled(); // Should not log generic errors here + expect(prisma.segment.delete).not.toHaveBeenCalled(); + }); + + test("should throw validation error for invalid surveyId", async () => { + const invalidSurveyId = "invalid-id"; + const validationError = new Error("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError); + expect(prisma.survey.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts b/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts new file mode 100644 index 0000000000..2006cf47ca --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts @@ -0,0 +1,187 @@ +import { cache } from "@/lib/cache"; +import { selectSurvey } from "@/lib/survey/service"; +import { transformPrismaSurvey } from "@/lib/survey/utils"; +import { validateInputs } from "@/lib/utils/validate"; +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 { TSurvey } from "@formbricks/types/surveys/types"; +import { getSurveys } from "./surveys"; + +// Mock dependencies +vi.mock("@/lib/cache"); +vi.mock("@/lib/survey/cache"); +vi.mock("@/lib/survey/utils"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger"); +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function + }; +}); + +const environmentId1 = "env1"; +const environmentId2 = "env2"; +const surveyId1 = "survey1"; +const surveyId2 = "survey2"; +const surveyId3 = "survey3"; + +const mockSurveyPrisma1 = { + id: surveyId1, + environmentId: environmentId1, + name: "Survey 1", + updatedAt: new Date(), +}; +const mockSurveyPrisma2 = { + id: surveyId2, + environmentId: environmentId1, + name: "Survey 2", + updatedAt: new Date(), +}; +const mockSurveyPrisma3 = { + id: surveyId3, + environmentId: environmentId2, + name: "Survey 3", + updatedAt: new Date(), +}; + +const mockSurveyTransformed1: TSurvey = { + ...mockSurveyPrisma1, + displayPercentage: null, + segment: null, +} as TSurvey; +const mockSurveyTransformed2: TSurvey = { + ...mockSurveyPrisma2, + displayPercentage: null, + segment: null, +} as TSurvey; +const mockSurveyTransformed3: TSurvey = { + ...mockSurveyPrisma3, + displayPercentage: null, + segment: null, +} as TSurvey; + +describe("getSurveys (Management API)", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Mock the cache function to simply execute the underlying function + vi.mocked(cache).mockImplementation((fn) => async () => { + return fn(); + }); + vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({ + ...survey, + displayPercentage: null, + segment: null, + })); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return surveys for a single environment ID with limit and offset", async () => { + const limit = 1; + const offset = 1; + vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]); + + const surveys = await getSurveys([environmentId1], limit, offset); + + expect(validateInputs).toHaveBeenCalledWith( + [[environmentId1], expect.any(Object)], + [limit, expect.any(Object)], + [offset, expect.any(Object)] + ); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: [environmentId1] } }, + select: selectSurvey, + orderBy: { updatedAt: "desc" }, + take: limit, + skip: offset, + }); + expect(transformPrismaSurvey).toHaveBeenCalledTimes(1); + expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2); + expect(surveys).toEqual([mockSurveyTransformed2]); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should return surveys for multiple environment IDs without limit and offset", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([ + mockSurveyPrisma1, + mockSurveyPrisma2, + mockSurveyPrisma3, + ]); + + const surveys = await getSurveys([environmentId1, environmentId2]); + + expect(validateInputs).toHaveBeenCalledWith( + [[environmentId1, environmentId2], expect.any(Object)], + [undefined, expect.any(Object)], + [undefined, expect.any(Object)] + ); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: [environmentId1, environmentId2] } }, + select: selectSurvey, + orderBy: { updatedAt: "desc" }, + take: undefined, + skip: undefined, + }); + expect(transformPrismaSurvey).toHaveBeenCalledTimes(3); + expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should return an empty array if no surveys are found", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([]); + + const surveys = await getSurveys([environmentId1]); + + expect(prisma.survey.findMany).toHaveBeenCalled(); + expect(transformPrismaSurvey).not.toHaveBeenCalled(); + expect(surveys).toEqual([]); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2021", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); + + await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys"); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError); + + await expect(getSurveys([environmentId1])).rejects.toThrow(genericError); + expect(logger.error).not.toHaveBeenCalled(); + expect(cache).toHaveBeenCalledTimes(1); + }); + + test("should throw validation error for invalid input", async () => { + const invalidEnvId = "invalid-env"; + const validationError = new Error("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError); + expect(prisma.survey.findMany).not.toHaveBeenCalled(); + expect(cache).toHaveBeenCalledTimes(1); // Cache wrapper is still called + }); +}); diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts new file mode 100644 index 0000000000..3f12ac8cdb --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts @@ -0,0 +1,108 @@ +import { webhookCache } from "@/lib/cache/webhook"; +import { Webhook } from "@prisma/client"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { ValidationError } from "@formbricks/types/errors"; +import { deleteWebhook } from "./webhook"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + webhook: { + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/webhook", () => ({ + webhookCache: { + tag: { + byId: () => "mockTag", + }, + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), + ValidationError: class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + }, +})); + +describe("deleteWebhook", () => { + afterEach(() => { + cleanup(); + }); + + test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook); + + const deletedWebhook = await deleteWebhook("test-webhook-id"); + + expect(deletedWebhook).toEqual(mockedWebhook); + expect(prisma.webhook.delete).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + expect(webhookCache.revalidate).toHaveBeenCalled(); + }); + + test("should delete the webhook and call webhookCache.revalidate with correct parameters", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook); + + const deletedWebhook = await deleteWebhook("test-webhook-id"); + + expect(deletedWebhook).toEqual(mockedWebhook); + expect(prisma.webhook.delete).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + expect(webhookCache.revalidate).toHaveBeenCalledWith({ + id: mockedWebhook.id, + environmentId: mockedWebhook.environmentId, + source: mockedWebhook.source, + }); + }); + + test("should throw an error when called with an invalid webhook ID format", async () => { + const { validateInputs } = await import("@/lib/utils/validate"); + (validateInputs as any).mockImplementation(() => { + throw new ValidationError("Validation failed"); + }); + + await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError); + + expect(prisma.webhook.delete).not.toHaveBeenCalled(); + expect(webhookCache.revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.test.ts b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts new file mode 100644 index 0000000000..2f5a289712 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts @@ -0,0 +1,203 @@ +import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook"; +import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; +import { webhookCache } from "@/lib/cache/webhook"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma, WebhookSource } from "@prisma/client"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + webhook: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/webhook", () => ({ + webhookCache: { + revalidate: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +describe("createWebhook", () => { + afterEach(() => { + cleanup(); + }); + + test("should create a webhook and revalidate the cache when provided with valid input data", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + const createdWebhook = { + id: "webhook-id", + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user" as WebhookSource, + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + createdAt: new Date(), + updatedAt: new Date(), + } as any; + + vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); + + const result = await createWebhook(webhookInput); + + expect(validateInputs).toHaveBeenCalled(); + + expect(prisma.webhook.create).toHaveBeenCalledWith({ + data: { + url: webhookInput.url, + name: webhookInput.name, + source: webhookInput.source, + surveyIds: webhookInput.surveyIds, + triggers: webhookInput.triggers, + environment: { + connect: { + id: webhookInput.environmentId, + }, + }, + }, + }); + + expect(webhookCache.revalidate).toHaveBeenCalledWith({ + id: createdWebhook.id, + environmentId: createdWebhook.environmentId, + source: createdWebhook.source, + }); + + expect(result).toEqual(createdWebhook); + }); + + test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => { + const invalidWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: 123, // Invalid URL + source: "user" as WebhookSource, + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(validateInputs).mockImplementation(() => { + throw new ValidationError("Validation failed"); + }); + + await expect(createWebhook(invalidWebhookInput as any)).rejects.toThrowError(ValidationError); + }); + + test("should throw a DatabaseError if a PrismaClientKnownRequestError occurs", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }) + ); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + }); + + test("should call webhookCache.revalidate with the correct parameters after successfully creating a webhook", async () => { + const webhookInput: TWebhookInput = { + environmentId: "env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1"], + }; + + const createdWebhook = { + id: "webhook123", + environmentId: "env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1"], + createdAt: new Date(), + updatedAt: new Date(), + } as any; + + vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); + + await createWebhook(webhookInput); + + expect(webhookCache.revalidate).toHaveBeenCalledWith({ + id: createdWebhook.id, + environmentId: createdWebhook.environmentId, + source: createdWebhook.source, + }); + }); + + test("should throw a DatabaseError when provided with invalid surveyIds", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["invalid-survey-id"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Foreign key constraint violation")); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + }); + + test("should handle edge case URLs that are technically valid but problematic", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "http://localhost:3000", // Example of a potentially problematic URL + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new DatabaseError("Invalid URL")); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.webhook.create).toHaveBeenCalledWith({ + data: { + url: webhookInput.url, + name: webhookInput.name, + source: webhookInput.source, + surveyIds: webhookInput.surveyIds, + triggers: webhookInput.triggers, + environment: { + connect: { + id: webhookInput.environmentId, + }, + }, + }, + }); + + expect(webhookCache.revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/surveys/src/lib/response.queue.test.ts b/packages/surveys/src/lib/response.queue.test.ts index f52d47d6b2..1ec749608c 100644 --- a/packages/surveys/src/lib/response.queue.test.ts +++ b/packages/surveys/src/lib/response.queue.test.ts @@ -51,7 +51,7 @@ describe("delay", () => { test("resolves after specified ms", async () => { const start = Date.now(); await delay(50); - expect(Date.now() - start).toBeGreaterThanOrEqual(50); + expect(Date.now() - start).toBeGreaterThanOrEqual(49); // Using 49 to account for execution time }); });