Files
flyer-crawler.projectium.com/src/middleware/errorHandler.test.ts

320 lines
12 KiB
TypeScript

// src/middleware/errorHandler.test.ts
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
import {
DatabaseError,
ForeignKeyConstraintError,
UniqueConstraintError,
ValidationError,
NotFoundError,
} from '../services/db/errors.db';
import type { Logger } from 'pino';
// Create a mock logger that we can inject into requests and assert against.
// We only mock the methods we intend to spy on. The rest of the complex Pino
// Logger type is satisfied by casting, which is a common and clean testing practice.
const { mockLogger } = vi.hoisted(() => {
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn().mockReturnThis(),
};
return { mockLogger };
});
// Mock the global logger as a fallback, though our tests will focus on req.log
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
// Mock console.error for testing NODE_ENV === 'test' behavior
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// 1. Create a minimal Express app for testing
const app = express();
app.use(express.json());
// Add a middleware to inject our mock logger into each request as `req.log`
app.use((req: Request, res: Response, next: NextFunction) => {
req.log = mockLogger as unknown as Logger;
next();
});
// 2. Setup test routes that intentionally throw different kinds of errors
app.get('/generic-error', (req, res, next) => {
next(new Error('A generic server error occurred.'));
});
app.get('/http-error-404', (req, res, next) => {
// Create an error object with a custom status property for testing.
const err = new Error('Resource not found') as Error & { status?: number };
err.status = 404; // This is a common pattern in Express for non-custom errors.
next(err);
});
app.get('/not-found-error', (req, res, next) => {
next(new NotFoundError('Specific resource missing'));
});
app.get('/fk-error', (req, res, next) => {
next(new ForeignKeyConstraintError('The referenced item does not exist.'));
});
app.get('/unique-error', (req, res, next) => {
next(new UniqueConstraintError('This item already exists.'));
});
app.get('/db-error-500', (req, res, next) => {
next(new DatabaseError('A database connection issue occurred.', 500));
});
app.get('/unauthorized-error-no-status', (req, res, next) => {
const err = new Error('Invalid Token') as Error & { status?: number };
err.name = 'UnauthorizedError'; // Simulate an error from a library like express-jwt
next(err);
});
app.get('/unauthorized-error-with-status', (req, res, next) => {
const err = new Error('Invalid Token') as Error & { status?: number };
err.name = 'UnauthorizedError';
err.status = 401; // Explicitly set status
next(err);
});
app.get('/validation-error', (req, res, next) => {
const validationIssues = [{ path: ['body', 'email'], message: 'Invalid email format' }];
next(new ValidationError(validationIssues, 'Input validation failed'));
});
// 3. Apply the errorHandler middleware *after* all the routes
app.use(errorHandler);
describe('errorHandler Middleware', () => {
beforeEach(() => {
vi.clearAllMocks();
consoleErrorSpy.mockClear(); // Clear spy for console.error
// Ensure NODE_ENV is set to 'test' for console.error logging
process.env.NODE_ENV = 'test';
});
afterAll(() => {
consoleErrorSpy.mockRestore(); // Restore console.error after all tests
delete process.env.NODE_ENV; // Clean up environment variable
});
it('should return a generic 500 error for a standard Error object', async () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
// In test/dev, we now expect a stack trace for 5xx errors.
expect(response.body.message).toBe('A generic server error occurred.');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
errorId: expect.any(String),
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
expect.any(Error),
);
});
it('should use the status code and message from an Error object with a custom status (404)', async () => {
const response = await supertest(app).get('/http-error-404');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Resource not found' });
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
statusCode: 404,
},
'Client Error on GET /http-error-404: Resource not found',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a NotFoundError with a 404 status', async () => {
const response = await supertest(app).get('/not-found-error');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Specific resource missing' });
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(NotFoundError),
statusCode: 404,
},
'Client Error on GET /not-found-error: Specific resource missing',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a ForeignKeyConstraintError with a 400 status and the specific error message', async () => {
const response = await supertest(app).get('/fk-error');
expect(response.status).toBe(400);
expect(response.body).toEqual({ message: 'The referenced item does not exist.' });
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(ForeignKeyConstraintError),
statusCode: 400,
},
'Client Error on GET /fk-error: The referenced item does not exist.',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a UniqueConstraintError with a 409 status and the specific error message', async () => {
const response = await supertest(app).get('/unique-error');
expect(response.status).toBe(409); // 409 Conflict
expect(response.body).toEqual({ message: 'This item already exists.' });
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(UniqueConstraintError),
statusCode: 409,
},
'Client Error on GET /unique-error: This item already exists.',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a ValidationError with a 400 status and include the validation errors array', async () => {
const response = await supertest(app).get('/validation-error');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Input validation failed');
expect(response.body.errors).toBeDefined();
expect(response.body.errors).toEqual([
{ path: ['body', 'email'], message: 'Invalid email format' },
]);
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(ValidationError),
validationErrors: [{ path: ['body', 'email'], message: 'Invalid email format' }],
statusCode: 400,
},
'Client Error on GET /validation-error: Input validation failed',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle a DatabaseError with a 500 status and a generic message', async () => {
const response = await supertest(app).get('/db-error-500');
expect(response.status).toBe(500);
// In test/dev, we now expect a stack trace for 5xx errors.
expect(response.body.message).toBe('A database connection issue occurred.');
expect(response.body.stack).toBeDefined();
expect(response.body.errorId).toEqual(expect.any(String));
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(DatabaseError),
errorId: expect.any(String),
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/),
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(/--- \[TEST\] UNHANDLED ERROR \(ID: \w+\) ---/),
expect.any(DatabaseError),
);
});
it('should handle an UnauthorizedError with default 401 status', async () => {
const response = await supertest(app).get('/unauthorized-error-no-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
statusCode: 401,
},
'Client Error on GET /unauthorized-error-no-status: Invalid Token',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should handle an UnauthorizedError with explicit status', async () => {
const response = await supertest(app).get('/unauthorized-error-with-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
statusCode: 401,
},
'Client Error on GET /unauthorized-error-with-status: Invalid Token',
);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should call next(err) if headers have already been sent', () => {
// Supertest doesn't easily allow simulating res.headersSent = true mid-request
// We need to mock the express response object directly for this specific test.
const mockRequestDirect: Partial<Request> = { path: '/headers-sent-error', method: 'GET' };
const mockResponseDirect: Partial<Response> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
headersSent: true, // Manually set headersSent to true
};
const mockNextDirect = vi.fn();
const error = new Error('Error after headers sent');
errorHandler(
error,
mockRequestDirect as Request,
mockResponseDirect as Response,
mockNextDirect,
);
expect(mockNextDirect).toHaveBeenCalledWith(error);
expect(mockResponseDirect.status).not.toHaveBeenCalled();
expect(mockResponseDirect.json).not.toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled(); // Should not log if delegated
expect(consoleErrorSpy).not.toHaveBeenCalled(); // Should not log if delegated
});
describe('when NODE_ENV is "production"', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
afterAll(() => {
process.env.NODE_ENV = 'test'; // Reset for other test files
});
it('should return a generic message with an error ID for a 500 error', async () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
expect(response.body.message).toMatch(
/An unexpected server error occurred. Please reference error ID: \w+/,
);
expect(response.body.stack).toBeUndefined();
});
it('should return the actual error message for client errors (4xx) in production', async () => {
const response = await supertest(app).get('/http-error-404');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Resource not found');
});
});
});