diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts index a3d061f0059a..6d65006592b1 100644 --- a/apps/web/app/api/v1/auth.test.ts +++ b/apps/web/app/api/v1/auth.test.ts @@ -1,9 +1,15 @@ import { NextRequest } from "next/server"; import { describe, expect, test, vi } from "vitest"; import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; +import { + DatabaseError, + InvalidInputError, + ResourceNotFoundError, + UniqueConstraintError, +} from "@formbricks/types/errors"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { authenticateRequest } from "./auth"; +import { authenticateRequest, handleErrorResponse } from "./auth"; vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({ getApiKeyWithPermissions: vi.fn(), @@ -193,3 +199,53 @@ describe("authenticateRequest", () => { expect(result).toBeNull(); }); }); + +describe("handleErrorResponse", () => { + test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => { + const response = handleErrorResponse(new Error("NotAuthenticated")); + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.code).toBe("not_authenticated"); + }); + + test("returns 401 unauthorized for 'Unauthorized' message", async () => { + const response = handleErrorResponse(new Error("Unauthorized")); + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.code).toBe("unauthorized"); + }); + + test("returns 409 conflict for UniqueConstraintError", async () => { + const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists")); + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.code).toBe("conflict"); + expect(body.message).toBe("Action with name foo already exists"); + }); + + test("returns 400 badRequest for DatabaseError", async () => { + const response = handleErrorResponse(new DatabaseError("db boom")); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("db boom"); + }); + + test("returns 400 badRequest for InvalidInputError", async () => { + const response = handleErrorResponse(new InvalidInputError("bad input")); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe("bad input"); + }); + + test("returns 400 badRequest for ResourceNotFoundError", async () => { + const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1")); + expect(response.status).toBe(400); + }); + + test("returns 500 internalServerError for unknown errors", async () => { + const response = handleErrorResponse(new Error("something else")); + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.message).toBe("Some error occurred"); + }); +}); diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index 93187f803a7f..ccd1f6985313 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -1,6 +1,11 @@ import { NextRequest } from "next/server"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + DatabaseError, + InvalidInputError, + ResourceNotFoundError, + UniqueConstraintError, +} from "@formbricks/types/errors"; import { responses } from "@/app/lib/api/response"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; @@ -40,6 +45,9 @@ export const handleErrorResponse = (error: any): Response => { case "Unauthorized": return responses.unauthorizedResponse(); default: + if (error instanceof UniqueConstraintError) { + return responses.conflictResponse(error.message); + } if ( error instanceof DatabaseError || error instanceof InvalidInputError || diff --git a/apps/web/app/api/v1/management/action-classes/route.ts b/apps/web/app/api/v1/management/action-classes/route.ts index 227c9f143739..bcb26b90ab5f 100644 --- a/apps/web/app/api/v1/management/action-classes/route.ts +++ b/apps/web/app/api/v1/management/action-classes/route.ts @@ -1,6 +1,6 @@ import { logger } from "@formbricks/logger"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; @@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({ response: responses.successResponse(actionClass), }; } catch (error) { + if (error instanceof UniqueConstraintError) { + return { + response: responses.conflictResponse(error.message), + }; + } if (error instanceof DatabaseError) { return { response: responses.badRequestResponse(error.message), diff --git a/apps/web/lib/actionClass/service.test.ts b/apps/web/lib/actionClass/service.test.ts index 119aa828632e..1bbb11aca611 100644 --- a/apps/web/lib/actionClass/service.test.ts +++ b/apps/web/lib/actionClass/service.test.ts @@ -1,12 +1,16 @@ +import { Prisma } from "@prisma/client"; import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { TActionClass } from "@formbricks/types/action-classes"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes"; +import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors"; import { + createActionClass, deleteActionClass, getActionClass, getActionClassByEnvironmentIdAndName, getActionClasses, + updateActionClass, } from "./service"; vi.mock("@formbricks/database", () => ({ @@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({ findFirst: vi.fn(), findUnique: vi.fn(), delete: vi.fn(), + create: vi.fn(), + update: vi.fn(), }, }, })); @@ -178,4 +184,147 @@ describe("ActionClass Service", () => { await expect(deleteActionClass("id4")).rejects.toThrow("unknown"); }); }); + + describe("createActionClass", () => { + const codeInput: TActionClassInput = { + name: "Code Action", + description: "desc", + type: "code", + key: "code-action-key", + environmentId: "env-create", + }; + + const buildPrismaUniqueError = (target: string[]) => + Object.assign( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + }), + { meta: { target } } + ); + + test("should create and return the action class", async () => { + const created: TActionClass = { + id: "id-create", + createdAt: new Date(), + updatedAt: new Date(), + name: codeInput.name, + description: codeInput.description ?? null, + type: "code", + key: codeInput.type === "code" ? codeInput.key : null, + noCodeConfig: null, + environmentId: codeInput.environmentId, + }; + vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never); + + const result = await createActionClass(codeInput.environmentId, codeInput); + expect(result).toEqual(created); + }); + + test("should throw UniqueConstraintError on P2002 with target field", async () => { + vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"])); + + await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow( + UniqueConstraintError + ); + await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow( + `Action with name ${codeInput.name} already exists` + ); + }); + + test("should throw UniqueConstraintError on P2002 even when target is missing", async () => { + vi.mocked(prisma.actionClass.create).mockRejectedValue( + Object.assign( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + }), + { meta: undefined } + ) + ); + + await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow( + UniqueConstraintError + ); + }); + + test("should throw DatabaseError for non-P2002 errors", async () => { + vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom")); + + await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError); + await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow( + `Database error when creating an action for environment ${codeInput.environmentId}` + ); + }); + }); + + describe("updateActionClass", () => { + const updateInput: Partial = { + name: "Renamed Action", + description: "updated desc", + type: "code", + key: "renamed-key", + environmentId: "env-update", + }; + + const buildPrismaUniqueError = (target: string[]) => + Object.assign( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + }), + { meta: { target } } + ); + + test("should update and return the action class", async () => { + const updated = { + id: "id-update", + createdAt: new Date(), + updatedAt: new Date(), + name: updateInput.name, + description: updateInput.description ?? null, + type: "code" as const, + key: "renamed-key", + noCodeConfig: null, + environmentId: updateInput.environmentId, + surveyTriggers: [], + }; + vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never); + + const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput); + expect(result).toEqual(updated); + }); + + test("should throw UniqueConstraintError on P2002 with target field", async () => { + vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"])); + + await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow( + UniqueConstraintError + ); + await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow( + `Action with name ${updateInput.name} already exists` + ); + }); + + test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => { + vi.mocked(prisma.actionClass.update).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "test", + }) + ); + + await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow( + DatabaseError + ); + }); + + test("should rethrow unknown errors", async () => { + vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom")); + + await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow( + "boom" + ); + }); + }); }); diff --git a/apps/web/lib/actionClass/service.ts b/apps/web/lib/actionClass/service.ts index 6eb6066c1348..97110867f8a0 100644 --- a/apps/web/lib/actionClass/service.ts +++ b/apps/web/lib/actionClass/service.ts @@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors"; import { ITEMS_PER_PAGE } from "../constants"; import { validateInputs } from "../utils/validate"; @@ -135,7 +135,7 @@ export const createActionClass = async ( error.code === PrismaErrorType.UniqueConstraintViolation ) { const targetField = (error.meta?.target as string[] | undefined)?.[0]; - throw new DatabaseError( + throw new UniqueConstraintError( `Action with ${targetField} ${targetField ? (actionClass as Record)[targetField] : ""} already exists` ); } @@ -185,7 +185,7 @@ export const updateActionClass = async ( error.code === PrismaErrorType.UniqueConstraintViolation ) { const targetField = (error.meta?.target as string[] | undefined)?.[0]; - throw new DatabaseError( + throw new UniqueConstraintError( `Action with ${targetField} ${targetField ? (inputActionClass as Record)[targetField] : ""} already exists` ); } diff --git a/apps/web/lib/utils/action-client/index.test.ts b/apps/web/lib/utils/action-client/index.test.ts index a740bc274682..9355c8d5121f 100644 --- a/apps/web/lib/utils/action-client/index.test.ts +++ b/apps/web/lib/utils/action-client/index.test.ts @@ -12,6 +12,7 @@ import { OperationNotAllowedError, ResourceNotFoundError, TooManyRequestsError, + UniqueConstraintError, UnknownError, ValidationError, isExpectedError, @@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => { "OperationNotAllowedError", "TooManyRequestsError", "InvalidPasswordResetTokenError", + "UniqueConstraintError", ]; expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length); @@ -91,6 +93,7 @@ describe("isExpectedError (shared helper)", () => { { ErrorClass: ValidationError, args: ["Invalid data"] }, { ErrorClass: OperationNotAllowedError, args: ["Not allowed"] }, { ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] }, + { ErrorClass: UniqueConstraintError, args: ["Already exists"] }, ])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => { const error = new (ErrorClass as any)(...args); expect(isExpectedError(error)).toBe(true); @@ -186,6 +189,14 @@ describe("actionClient handleServerError", () => { expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE); expect(Sentry.captureException).not.toHaveBeenCalled(); }); + + test("UniqueConstraintError returns its message and is not sent to Sentry", async () => { + const result = await executeThrowingAction( + new UniqueConstraintError("Action with name foo already exists") + ); + expect(result?.serverError).toBe("Action with name foo already exists"); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); }); describe("unexpected errors SHOULD be reported to Sentry", () => { diff --git a/apps/web/modules/survey/editor/lib/action-class.test.ts b/apps/web/modules/survey/editor/lib/action-class.test.ts index 9bd1a12894ef..b6c657c5fdf1 100644 --- a/apps/web/modules/survey/editor/lib/action-class.test.ts +++ b/apps/web/modules/survey/editor/lib/action-class.test.ts @@ -1,9 +1,9 @@ -import { ActionClass } from "@prisma/client"; +import { ActionClass, Prisma } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { TActionClassInput } from "@formbricks/types/action-classes"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors"; import { createActionClass } from "./action-class"; vi.mock("@formbricks/database", () => ({ @@ -99,14 +99,19 @@ describe("createActionClass", () => { expect(result).toEqual(createdAction); }); - test("should throw DatabaseError for unique constraint violation", async () => { - const prismaError = { - code: PrismaErrorType.UniqueConstraintViolation, - meta: { target: ["name"] }, - }; + test("should throw UniqueConstraintError for unique constraint violation", async () => { + const prismaError = Object.assign( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "test", + }), + { meta: { target: ["name"] } } + ); vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError); - await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(DatabaseError); + await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow( + UniqueConstraintError + ); }); test("should throw DatabaseError for other database errors", async () => { diff --git a/apps/web/modules/survey/editor/lib/action-class.ts b/apps/web/modules/survey/editor/lib/action-class.ts index fadccd674aba..60c2836ea6a4 100644 --- a/apps/web/modules/survey/editor/lib/action-class.ts +++ b/apps/web/modules/survey/editor/lib/action-class.ts @@ -2,7 +2,7 @@ import { ActionClass, Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { TActionClassInput } from "@formbricks/types/action-classes"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors"; export const createActionClass = async ( environmentId: string, @@ -32,7 +32,7 @@ export const createActionClass = async ( error.code === PrismaErrorType.UniqueConstraintViolation ) { const targetField = (error.meta?.target as string[] | undefined)?.[0]; - throw new DatabaseError( + throw new UniqueConstraintError( `Action with ${targetField} ${targetField ? (actionClass as Record)[targetField] : ""} already exists` ); } diff --git a/packages/types/errors.ts b/packages/types/errors.ts index 30829e572261..72ef1acb5ad9 100644 --- a/packages/types/errors.ts +++ b/packages/types/errors.ts @@ -160,6 +160,7 @@ export const EXPECTED_ERROR_NAMES = new Set([ "OperationNotAllowedError", "TooManyRequestsError", "InvalidPasswordResetTokenError", + "UniqueConstraintError", ]); /**