From cbd8fb6784d5b7a7ee14d55d9fa40a050fb4452e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 21:23:30 +0700 Subject: [PATCH] fix(shared): handle Prisma errors in GlobalExceptionFilter to return proper HTTP status codes Prisma errors (P2025 record not found, P2002 unique constraint, P2003 foreign key) were falling through to the catch-all handler and returning 500 Internal Server Error instead of appropriate 404/409/400. This caused GET /listings/:id with a non-existent ID to return 500 when the Prisma layer threw before the application null check. Co-Authored-By: Paperclip --- .../__tests__/global-exception.filter.spec.ts | 112 ++++++++++++++++++ .../filters/global-exception.filter.ts | 62 ++++++++++ 2 files changed, 174 insertions(+) diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts index 114cad4..404fe5c 100644 --- a/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts +++ b/apps/api/src/modules/shared/infrastructure/__tests__/global-exception.filter.spec.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; import { DomainException } from '../../domain/domain-exception'; import { ErrorCode } from '../../domain/error-codes'; @@ -95,4 +96,115 @@ describe('GlobalExceptionFilter', () => { expect(host.json).toHaveBeenCalledWith(expect.objectContaining({ timestamp: expect.any(String) })); }); + + describe('Prisma error handling', () => { + it('should handle P2025 (record not found) as 404', () => { + const exception = new Prisma.PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found.', + { code: 'P2025', clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: 'The requested resource was not found', + }), + ); + }); + + it('should handle P2002 (unique constraint) as 409', () => { + const exception = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`email`)', + { code: 'P2002', clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.CONFLICT, + errorCode: ErrorCode.CONFLICT, + message: 'A record with the given identifier already exists', + }), + ); + }); + + it('should handle P2003 (foreign key constraint) as 400', () => { + const exception = new Prisma.PrismaClientKnownRequestError( + 'Foreign key constraint failed on the field: `propertyId`', + { code: 'P2003', clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.BAD_REQUEST, + errorCode: ErrorCode.BAD_REQUEST, + message: 'Referenced record does not exist', + }), + ); + }); + + it('should handle unknown Prisma known errors as 500', () => { + const exception = new Prisma.PrismaClientKnownRequestError( + 'Some other error', + { code: 'P2999', clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error', + }), + ); + }); + + it('should handle PrismaClientValidationError as 400', () => { + const exception = new Prisma.PrismaClientValidationError( + 'Invalid query argument', + { clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + expect(host.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(host.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.BAD_REQUEST, + errorCode: ErrorCode.BAD_REQUEST, + message: 'Invalid request parameters', + }), + ); + }); + + it('should not leak internal Prisma error details', () => { + const exception = new Prisma.PrismaClientKnownRequestError( + 'Detailed internal error with table names and column info', + { code: 'P2025', clientVersion: '5.0.0' }, + ); + const host = createMockHost(); + + filter.catch(exception, host as never); + + const response = host.json.mock.calls[0][0]; + expect(response.message).not.toContain('table'); + expect(response.message).not.toContain('column'); + }); + }); }); diff --git a/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts index 823d12d..b13c01c 100644 --- a/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts +++ b/apps/api/src/modules/shared/infrastructure/filters/global-exception.filter.ts @@ -5,6 +5,7 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import type { Request, Response } from 'express'; import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception'; import { ErrorCode } from '../../domain/error-codes'; @@ -60,6 +61,21 @@ export class GlobalExceptionFilter implements ExceptionFilter { }; } + // Handle Prisma-specific errors to avoid leaking 500s for known DB conditions + if (exception instanceof Prisma.PrismaClientKnownRequestError) { + return this.buildPrismaKnownErrorResponse(exception, correlationId); + } + + if (exception instanceof Prisma.PrismaClientValidationError) { + return { + statusCode: HttpStatus.BAD_REQUEST, + errorCode: ErrorCode.BAD_REQUEST, + message: 'Invalid request parameters', + correlationId, + timestamp: new Date().toISOString(), + }; + } + return { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.INTERNAL_ERROR, @@ -69,6 +85,52 @@ export class GlobalExceptionFilter implements ExceptionFilter { }; } + private buildPrismaKnownErrorResponse( + exception: Prisma.PrismaClientKnownRequestError, + correlationId?: string, + ): ErrorResponseBody { + switch (exception.code) { + // P2025: Record not found (update/delete on non-existent row) + case 'P2025': + return { + statusCode: HttpStatus.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: 'The requested resource was not found', + correlationId, + timestamp: new Date().toISOString(), + }; + + // P2002: Unique constraint violation + case 'P2002': + return { + statusCode: HttpStatus.CONFLICT, + errorCode: ErrorCode.CONFLICT, + message: 'A record with the given identifier already exists', + correlationId, + timestamp: new Date().toISOString(), + }; + + // P2003: Foreign key constraint violation + case 'P2003': + return { + statusCode: HttpStatus.BAD_REQUEST, + errorCode: ErrorCode.BAD_REQUEST, + message: 'Referenced record does not exist', + correlationId, + timestamp: new Date().toISOString(), + }; + + default: + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error', + correlationId, + timestamp: new Date().toISOString(), + }; + } + } + private httpStatusToErrorCode(status: number): ErrorCode { const map: Record = { [HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,