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
200 changes: 200 additions & 0 deletions apps/api/src/common/exception/filters/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* GlobalExceptionFilter 테스트
*
* Prisma P2002 매핑, BusinessException, HttpException, 알 수 없는 에러 처리 검증
*/

import { ErrorCode } from "@aido/errors";
import { HttpException, HttpStatus } from "@nestjs/common";
import { PinoLogger } from "nestjs-pino";
import { Prisma } from "@/generated/prisma/client";
import { BusinessExceptions } from "../services/business-exception.service";
import { GlobalExceptionFilter } from "./global-exception.filter";

describe("GlobalExceptionFilter", () => {
let filter: GlobalExceptionFilter;
let mockLogger: jest.Mocked<PinoLogger>;
let mockResponse: { status: jest.Mock; json: jest.Mock };
let mockRequest: { method: string; url: string; user?: { userId: string } };
let mockHost: { switchToHttp: jest.Mock };

beforeEach(() => {
mockLogger = {
setContext: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
} as unknown as jest.Mocked<PinoLogger>;

mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};

mockRequest = {
method: "POST",
url: "/test",
};

mockHost = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: () => mockResponse,
getRequest: () => mockRequest,
}),
};

filter = new GlobalExceptionFilter(mockLogger);
});

describe("P2002 Prisma 에러 처리", () => {
it("알려진 constraint(email)를 BusinessException으로 매핑해야 한다", () => {
// Given
const error = new Prisma.PrismaClientKnownRequestError(
"Unique constraint",
{
code: "P2002",
meta: { target: ["email"] },
clientVersion: "7.0.0",
},
);

// When
filter.catch(error, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.EMAIL_0501);
});

it("알려진 constraint(userId_name)를 BusinessException으로 매핑해야 한다", () => {
// Given
const error = new Prisma.PrismaClientKnownRequestError(
"Unique constraint",
{
code: "P2002",
meta: { target: ["userId", "name"] },
clientVersion: "7.0.0",
},
);

// When
filter.catch(error, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.TODO_CATEGORY_0853);
});

it("알려진 constraint(followerId_followingId)를 BusinessException으로 매핑해야 한다", () => {
// Given
const error = new Prisma.PrismaClientKnownRequestError(
"Unique constraint",
{
code: "P2002",
meta: { target: ["followerId", "followingId"] },
clientVersion: "7.0.0",
},
);

// When
filter.catch(error, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.FOLLOW_0901);
});

it("알 수 없는 constraint를 SYS_0004 (409)로 폴백해야 한다", () => {
// Given
const error = new Prisma.PrismaClientKnownRequestError(
"Unique constraint",
{
code: "P2002",
meta: { target: ["unknownField"] },
clientVersion: "7.0.0",
},
);

// When
filter.catch(error, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.SYS_0004);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("Unknown P2002 constraint"),
);
});

it("P2002가 아닌 Prisma 에러는 500 SYS_0001로 처리해야 한다", () => {
// Given
const error = new Prisma.PrismaClientKnownRequestError(
"Foreign key constraint",
{
code: "P2003",
meta: {},
clientVersion: "7.0.0",
},
);

// When
filter.catch(error, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.SYS_0001);
});
});

describe("기존 동작 유지", () => {
it("BusinessException을 올바르게 처리해야 한다", () => {
// Given
const exception = BusinessExceptions.todoCategoryNotFound(1);

// When
filter.catch(exception, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(exception.getStatus());
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.TODO_CATEGORY_0851);
});

it("HttpException을 올바르게 처리해야 한다", () => {
// Given
const exception = new HttpException(
{ message: "Bad Request" },
HttpStatus.BAD_REQUEST,
);

// When
filter.catch(exception, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.SYS_0002);
});

it("알 수 없는 에러를 500 SYS_0001로 처리해야 한다", () => {
// Given
const exception = new Error("unexpected error");

// When
filter.catch(exception, mockHost as never);

// Then
expect(mockResponse.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
const jsonArg = mockResponse.json.mock.calls[0][0];
expect(jsonArg.error.code).toBe(ErrorCode.SYS_0001);
});
});
});
62 changes: 61 additions & 1 deletion apps/api/src/common/exception/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import {
} from "@nestjs/common";
import type { Request, Response } from "express";
import { PinoLogger } from "nestjs-pino";
import { Prisma } from "@/generated/prisma/client";
import type { ErrorResponse } from "../interfaces/error.interface";
import { BusinessException } from "../services/business-exception.service";
import {
BusinessException,
BusinessExceptions,
} from "../services/business-exception.service";

/**
* 전역 예외 필터
Expand Down Expand Up @@ -63,6 +67,14 @@ export class GlobalExceptionFilter implements ExceptionFilter {
timestamp: Date.now(),
};
}
} else if (
exception instanceof Prisma.PrismaClientKnownRequestError &&
exception.code === "P2002"
) {
// Prisma unique constraint 위반 처리
const businessException = this.mapP2002ToBusinessException(exception);
statusCode = businessException.getStatus();
errorResponse = businessException.getResponse() as ErrorResponse;
} else {
// 알 수 없는 예외 처리
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
Expand Down Expand Up @@ -100,4 +112,52 @@ export class GlobalExceptionFilter implements ExceptionFilter {

response.status(statusCode).json(errorResponse);
}

/**
* Prisma P2002 (unique constraint violation) → BusinessException 매핑
*
* 알려진 constraint는 구체적인 에러 코드로, 미지의 constraint는 SYS_0004로 폴백
*/
private mapP2002ToBusinessException(
error: InstanceType<typeof Prisma.PrismaClientKnownRequestError>,
): BusinessException {
const target = error.meta?.target;
const constraintKey = Array.isArray(target)
? target.join("_")
: String(target ?? "unknown");

const constraintMap: Record<string, () => BusinessException> = {
User_email_key: () => BusinessExceptions.emailAlreadyRegistered(""),
email: () => BusinessExceptions.emailAlreadyRegistered(""),
User_userTag_key: () =>
BusinessExceptions.internalServerError({ detail: "userTag collision" }),
userTag: () =>
BusinessExceptions.internalServerError({ detail: "userTag collision" }),
TodoCategory_userId_name_key: () =>
BusinessExceptions.todoCategoryNameDuplicate(""),
userId_name: () => BusinessExceptions.todoCategoryNameDuplicate(""),
Follow_followerId_followingId_key: () =>
BusinessExceptions.followRequestAlreadySent(""),
followerId_followingId: () =>
BusinessExceptions.followRequestAlreadySent(""),
Account_provider_providerAccountId_key: () =>
BusinessExceptions.accountAlreadyExists(),
provider_providerAccountId: () =>
BusinessExceptions.accountAlreadyExists(),
Account_userId_provider_key: () =>
BusinessExceptions.accountAlreadyExists(),
userId_provider: () => BusinessExceptions.accountAlreadyExists(),
};

const factory = constraintMap[constraintKey];
if (factory) {
return factory();
}

// 알 수 없는 constraint → warn 로그 + SYS_0004 폴백
this.logger.warn(
`Unknown P2002 constraint: ${constraintKey} (meta: ${JSON.stringify(error.meta)})`,
);
return BusinessExceptions.concurrentModification();
}
}
40 changes: 39 additions & 1 deletion apps/api/src/modules/auth/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ import { SessionBuilder, UserBuilder } from "@test/builders";
type TransactionCallback = (tx: any) => Promise<any>;

import { CacheService } from "@/common/cache/cache.service";
import { BusinessException } from "@/common/exception/services/business-exception.service";
import {
BusinessException,
BusinessExceptions,
} from "@/common/exception/services/business-exception.service";
import { DatabaseService } from "@/database";
import { Prisma } from "@/generated/prisma/client";
import { TodoCategoryRepository } from "../../todo-category/todo-category.repository";
import { REVOKE_REASON, SECURITY_EVENT } from "../constants/auth.constants";
import { AccountRepository } from "../repositories/account.repository";
Expand Down Expand Up @@ -242,6 +246,40 @@ describe("AuthService", () => {
);
});

it("P2002 unique constraint(이메일 중복) 시 emailAlreadyRegistered를 던져야 한다", async () => {
// Given - 트랜잭션에서 P2002 발생 (동시 가입 race condition)
database.$transaction.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
meta: { target: ["email"] },
clientVersion: "7.0.0",
}),
);

// When & Then
await expect(service.register(registerInput)).rejects.toThrow(
BusinessExceptions.emailAlreadyRegistered(registerInput.email),
);
});

it("P2002 unique constraint(이메일 외) 시 원본 에러를 re-throw해야 한다", async () => {
// Given - userTag 충돌 등 email이 아닌 P2002
const prismaError = new Prisma.PrismaClientKnownRequestError(
"Unique constraint",
{
code: "P2002",
meta: { target: ["userTag"] },
clientVersion: "7.0.0",
},
);
database.$transaction.mockRejectedValue(prismaError);

// When & Then - emailAlreadyRegistered가 아닌 원본 에러가 던져져야 함
await expect(service.register(registerInput)).rejects.toThrow(
prismaError,
);
});

it("이메일 전송 실패해도 회원가입은 성공한다", async () => {
// Given
const mockUser = UserBuilder.create()
Expand Down
Loading