All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 20m31s
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
// 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<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(() => {
|
|
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');
|
|
});
|
|
});
|
|
});
|