// src/middleware/errorHandler.test.ts import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest'; import supertest from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; import { errorHandler } from './errorHandler'; // This was a duplicate, fixed. import { DatabaseError } from '../services/processingErrors'; import { 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.')); }); 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 vi.stubEnv('NODE_ENV', 'test'); }); afterEach(() => { vi.unstubAllEnvs(); // Clean up environment variable stubs after each test }); afterAll(() => { consoleErrorSpy.mockRestore(); // Restore console.error after all tests }); 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)); console.log('[DEBUG] errorHandler.test.ts: Received 500 error response with ID:', response.body.errorId); 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 = { path: '/headers-sent-error', method: 'GET' }; const mockResponseDirect: Partial = { 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(() => { vi.stubEnv('NODE_ENV', 'production'); }); 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'); }); }); });