manicanldes-backend/test/error-handling.spec.ts

110 lines
3.9 KiB
TypeScript

import { ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { ApiExceptionFilter } from '../src/common/errors/api-exception.filter';
import { AppError } from '../src/common/errors/app-error';
import { ERRORS } from '../src/common/errors/error-catalog';
import { classifyError } from '../src/common/errors/classify-error';
import { Prisma } from '../src/generated/prisma/client';
describe('safe error responses and logs', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'error').mockImplementation();
});
afterEach(() => jest.restoreAllMocks());
function dispatch(error: unknown, headersSent = false) {
const response = {
locals: { requestId: 'server-generated-id' },
headersSent,
setHeader: jest.fn(),
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const host = {
switchToHttp: () => ({
getRequest: () => ({
method: 'POST',
route: { path: '/products/:id' },
body: { password: 'SECRET' },
headers: { authorization: 'Bearer SECRET' },
originalUrl: '/products?token=SECRET',
}),
getResponse: () => response,
}),
} as unknown as ArgumentsHost;
new ApiExceptionFilter().catch(error, host);
return response;
}
it('returns stable codes and correlation IDs without leaking raw faults', () => {
const response = dispatch(new Error('SQL password=SECRET token=SECRET'));
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
code: 'INTERNAL_FAILURE',
requestId: 'server-generated-id',
statusCode: 500,
}),
);
const output = JSON.stringify([
response.json.mock.calls,
jest.mocked(Logger.prototype.error).mock.calls,
]);
expect(output).not.toContain('SECRET');
expect(output).not.toContain('password=');
expect(output).toContain('faultId');
});
it('keeps validation field names and unique business error messages', () => {
const response = dispatch(
new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', ['price']),
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ fields: ['price'], code: 'REQUEST_INVALID' }),
);
const messages = Object.values(ERRORS).map((value) => value[1]);
expect(new Set(messages).size).toBe(messages.length);
});
it('does not try to write a second response', () => {
expect(
dispatch(new AppError('STOCK_INSUFFICIENT'), true).json,
).not.toHaveBeenCalled();
});
it('adds retry guidance for throttling', () => {
expect(
dispatch(new AppError('RATE_LIMITED')).setHeader,
).toHaveBeenCalledWith('Retry-After', '60');
});
it.each([
['P2002', 'RECORD_CONFLICT'],
['P2003', 'REFERENCE_INVALID'],
['P2004', 'DATA_CONSTRAINT'],
['P2025', 'RECORD_NOT_FOUND'],
['P2028', 'DATABASE_BUSY'],
['P2034', 'TRANSACTION_CONFLICT'],
['P9999', 'INTERNAL_FAILURE'],
])('classifies database error %s as %s', (databaseCode, code) => {
const error = new Prisma.PrismaClientKnownRequestError('SECRET', {
code: databaseCode,
clientVersion: 'test',
});
expect(classifyError(error).code).toBe(code);
});
it('classifies parser, transport and connection failures', () => {
expect(classifyError({ type: 'entity.too.large' }).code).toBe(
'REQUEST_TOO_LARGE',
);
expect(classifyError({ type: 'entity.parse.failed' }).code).toBe(
'REQUEST_MALFORMED',
);
expect(classifyError(new HttpException('SECRET', 404)).code).toBe(
'ROUTE_NOT_FOUND',
);
expect(classifyError(new HttpException('SECRET', 502)).code).toBe(
'INTERNAL_FAILURE',
);
expect(
classifyError(
new Prisma.PrismaClientInitializationError('SECRET', 'test'),
).code,
).toBe('DATABASE_BUSY');
expect(classifyError(null).code).toBe('INTERNAL_FAILURE');
});
});