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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 21:23:30 +07:00
parent 783b5b74f1
commit cbd8fb6784
2 changed files with 174 additions and 0 deletions

View File

@@ -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');
});
});
});

View File

@@ -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<number, ErrorCode> = {
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,