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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user