Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion apps/web/app/api/v1/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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");
});
});
10 changes: 9 additions & 1 deletion apps/web/app/api/v1/auth.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 ||
Expand Down
7 changes: 6 additions & 1 deletion apps/web/app/api/v1/management/action-classes/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
Expand Down
153 changes: 151 additions & 2 deletions apps/web/lib/actionClass/service.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
Expand Down Expand Up @@ -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<TActionClassInput> = {
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"
);
});
});
});
6 changes: 3 additions & 3 deletions apps/web/lib/actionClass/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown>)[targetField] : ""} already exists`
);
}
Expand Down Expand Up @@ -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<string, unknown>)[targetField] : ""} already exists`
);
}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/lib/utils/action-client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
UniqueConstraintError,
UnknownError,
ValidationError,
isExpectedError,
Expand Down Expand Up @@ -74,6 +75,7 @@ describe("isExpectedError (shared helper)", () => {
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
"UniqueConstraintError",
];

expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
Expand All @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down
21 changes: 13 additions & 8 deletions apps/web/modules/survey/editor/lib/action-class.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading