All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m5s
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
// src/routes/health.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import { connection as redisConnection } from '../services/queueService.server';
|
|
import fs from 'node:fs/promises';
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
import { mockLogger } from '../tests/utils/mockLogger';
|
|
|
|
// 1. Mock the dependencies of the health router.
|
|
vi.mock('../services/db/connection.db', () => ({
|
|
checkTablesExist: vi.fn(),
|
|
getPoolStatus: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
access: vi.fn(),
|
|
constants: { W_OK: 1 },
|
|
},
|
|
}));
|
|
|
|
// In this case, it's the redisConnection from the queueService.
|
|
vi.mock('../services/queueService.server', () => ({
|
|
// We need to mock the `connection` export which is an object with a `ping` method.
|
|
connection: {
|
|
ping: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Import the router and mocked modules AFTER all mocks are defined.
|
|
import healthRouter from './health.routes';
|
|
import * as dbConnection from '../services/db/connection.db';
|
|
|
|
// Mock the logger to keep test output clean.
|
|
vi.mock('../services/logger.server', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
// Cast the mocked import to a Mocked type for type-safe access to mock functions.
|
|
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection>;
|
|
const mockedDbConnection = dbConnection as Mocked<typeof dbConnection>;
|
|
const mockedFs = fs as Mocked<typeof fs>;
|
|
|
|
const { logger } = await import('../services/logger.server');
|
|
|
|
// 2. Create a minimal Express app to host the router for testing.
|
|
const app = createTestApp({ router: healthRouter, basePath: '/api/health' });
|
|
|
|
describe('Health Routes (/api/health)', () => {
|
|
beforeEach(() => {
|
|
// Clear mock history before each test to ensure isolation.
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers(); // Restore real timers after each test
|
|
});
|
|
|
|
describe('GET /ping', () => {
|
|
it('should return 200 OK with "pong"', async () => {
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/ping');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.text).toBe('pong');
|
|
});
|
|
});
|
|
|
|
describe('GET /redis', () => {
|
|
it('should return 200 OK if Redis ping is successful', async () => {
|
|
// Arrange: Simulate a successful ping by having the mock resolve to 'PONG'.
|
|
mockedRedisConnection.ping.mockResolvedValue('PONG');
|
|
|
|
// Act: Make a request to the endpoint.
|
|
const response = await supertest(app).get('/api/health/redis');
|
|
|
|
// Assert: Check for the correct status and response body.
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
message: 'Redis connection is healthy.',
|
|
});
|
|
});
|
|
|
|
it('should return 500 if Redis ping fails', async () => {
|
|
// Arrange: Simulate a failure by having the mock reject with an error.
|
|
const redisError = new Error('Connection timed out');
|
|
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/redis');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('Connection timed out');
|
|
});
|
|
|
|
it('should return 500 if Redis ping returns an unexpected response', async () => {
|
|
// Arrange: Simulate a successful ping but with an unexpected reply.
|
|
mockedRedisConnection.ping.mockResolvedValue('OK'); // Not 'PONG'
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/redis');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
|
|
});
|
|
});
|
|
|
|
describe('GET /time', () => {
|
|
it('should return the current server time, year, and week number', async () => {
|
|
// Arrange: Set a specific date to test against using fake timers.
|
|
const fakeDate = new Date('2024-03-15T10:30:00.000Z'); // This is a Friday, in the 11th week of 2024
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(fakeDate);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/time');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.currentTime).toBe('2024-03-15T10:30:00.000Z');
|
|
expect(response.body.year).toBe(2024);
|
|
expect(response.body.week).toBe(11);
|
|
});
|
|
});
|
|
|
|
describe('GET /db-schema', () => {
|
|
it('should return 200 OK if all tables exist', async () => {
|
|
// Arrange: Mock the service function to return an empty array (no missing tables)
|
|
mockedDbConnection.checkTablesExist.mockResolvedValue([]);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/db-schema');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.message).toBe('All required database tables exist.');
|
|
});
|
|
|
|
it('should return 500 if tables are missing', async () => {
|
|
// Arrange: Mock the service function to return missing table names
|
|
mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table_1', 'missing_table_2']);
|
|
|
|
const response = await supertest(app).get('/api/health/db-schema');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toContain('Missing tables: missing_table_1, missing_table_2');
|
|
// The error is passed to next(), so the global error handler would log it, not the route handler itself.
|
|
});
|
|
|
|
it('should return 500 if the database check fails', async () => {
|
|
// Arrange: Mock the service function to reject with a generic error
|
|
const dbError = new Error('DB connection failed');
|
|
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
|
|
|
|
const response = await supertest(app).get('/api/health/db-schema');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
|
expect(response.body.stack).toBeDefined();
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
console.log('[DEBUG] health.routes.test.ts: Verifying logger.error for DB schema check failure');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
|
|
it('should return 500 if the database check fails with a non-Error object', async () => {
|
|
// Arrange: Mock the service function to reject with a non-error object
|
|
const dbError = { message: 'DB connection failed' };
|
|
mockedDbConnection.checkTablesExist.mockRejectedValue(dbError);
|
|
|
|
const response = await supertest(app).get('/api/health/db-schema');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('DB connection failed'); // This is the message from the original error
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.objectContaining({ message: 'DB connection failed' }),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET /storage', () => {
|
|
it('should return 200 OK if storage is accessible and writable', async () => {
|
|
// Arrange: Mock fs.access to resolve, indicating success.
|
|
mockedFs.access.mockResolvedValue(undefined);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/storage');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.message).toContain('is accessible and writable');
|
|
});
|
|
|
|
it('should return 500 if storage is not accessible or writable', async () => {
|
|
// Arrange: Mock fs.access to reject, indicating failure.
|
|
const accessError = new Error('EACCES: permission denied');
|
|
mockedFs.access.mockRejectedValue(accessError);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/storage');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toContain('Storage check failed.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
|
|
it('should return 500 if storage check fails with a non-Error object', async () => {
|
|
// Arrange: Mock fs.access to reject with a non-error object.
|
|
const accessError = { message: 'EACCES: permission denied' };
|
|
mockedFs.access.mockRejectedValue(accessError);
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/storage');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toContain('Storage check failed.');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET /db-pool', () => {
|
|
it('should return 200 OK for a healthy pool status', async () => {
|
|
// Arrange: Mock a healthy pool status (waitingCount < 5)
|
|
mockedDbConnection.getPoolStatus.mockReturnValue({
|
|
totalCount: 10,
|
|
idleCount: 8,
|
|
waitingCount: 1,
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/db-pool');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting.');
|
|
});
|
|
|
|
it('should return 500 for an unhealthy pool status', async () => {
|
|
// Arrange: Mock an unhealthy pool status (waitingCount >= 5)
|
|
mockedDbConnection.getPoolStatus.mockReturnValue({
|
|
totalCount: 20,
|
|
idleCount: 5,
|
|
waitingCount: 15,
|
|
});
|
|
|
|
// Act
|
|
const response = await supertest(app).get('/api/health/db-pool');
|
|
|
|
// Assert
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.message).toContain('Pool may be under stress.');
|
|
expect(response.body.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting.');
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'Database pool health check shows high waiting count: 15',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should return 500 if getPoolStatus throws an error', async () => {
|
|
// Arrange: Mock getPoolStatus to throw an error
|
|
const poolError = new Error('Pool is not initialized');
|
|
mockedDbConnection.getPoolStatus.mockImplementation(() => {
|
|
throw poolError;
|
|
});
|
|
|
|
const response = await supertest(app).get('/api/health/db-pool');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
|
|
it('should return 500 if getPoolStatus throws a non-Error object', async () => {
|
|
// Arrange: Mock getPoolStatus to throw a non-error object
|
|
const poolError = { message: 'Pool is not initialized' };
|
|
mockedDbConnection.getPoolStatus.mockImplementation(() => {
|
|
throw poolError;
|
|
});
|
|
|
|
const response = await supertest(app).get('/api/health/db-pool');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('Pool is not initialized'); // This is the message from the original error
|
|
expect(response.body.stack).toBeDefined();
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.objectContaining({ message: 'Pool is not initialized' }),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
|
|
describe('GET /redis', () => {
|
|
it('should return 500 if Redis ping fails', async () => {
|
|
const redisError = new Error('Connection timed out');
|
|
mockedRedisConnection.ping.mockRejectedValue(redisError);
|
|
|
|
const response = await supertest(app).get('/api/health/redis');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toBe('Connection timed out');
|
|
expect(response.body.stack).toBeDefined();
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
console.log('[DEBUG] health.routes.test.ts: Checking if logger.error was called with the correct pattern');
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
|
|
it('should return 500 if Redis ping returns an unexpected response', async () => {
|
|
mockedRedisConnection.ping.mockResolvedValue('OK'); // Not 'PONG'
|
|
|
|
const response = await supertest(app).get('/api/health/redis');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.message).toContain('Unexpected Redis ping response: OK');
|
|
expect(response.body.stack).toBeDefined();
|
|
expect(response.body.errorId).toEqual(expect.any(String));
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: expect.any(Error),
|
|
}),
|
|
expect.stringMatching(/Unhandled API Error \(ID: [\w-]+\)/),
|
|
);
|
|
});
|
|
});
|
|
});
|