// src/controllers/health.controller.test.ts // ============================================================================ // HEALTH CONTROLLER UNIT TESTS // ============================================================================ // Unit tests for the HealthController class. These tests verify controller // logic in isolation by mocking external dependencies like database, Redis, // and file system access. // ============================================================================ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ============================================================================ // MOCK SETUP // ============================================================================ // Mock all external dependencies before importing the controller module. // ============================================================================ // Mock tsoa decorators and Controller class (required before controller import) // tsoa is used at compile-time for code generation but needs to be mocked for Vitest vi.mock('tsoa', () => ({ Controller: class Controller { protected setStatus(_status: number): void { // Mock setStatus } }, Get: () => () => {}, Route: () => () => {}, Tags: () => () => {}, SuccessResponse: () => () => {}, Response: () => () => {}, })); // Mock database connection module vi.mock('../services/db/connection.db', () => ({ checkTablesExist: vi.fn(), getPoolStatus: vi.fn(), getPool: vi.fn(), })); // Mock file system module vi.mock('node:fs/promises', () => ({ default: { access: vi.fn(), constants: { W_OK: 1 }, }, })); // Mock Redis connection from queue service vi.mock('../services/queueService.server', () => ({ connection: { ping: vi.fn(), get: vi.fn(), }, })); // Use vi.hoisted to create mock queue objects available during vi.mock hoisting const { mockQueuesModule } = vi.hoisted(() => { const createMockQueue = () => ({ getJobCounts: vi.fn().mockResolvedValue({ waiting: 0, active: 0, failed: 0, delayed: 0, }), }); return { mockQueuesModule: { flyerQueue: createMockQueue(), emailQueue: createMockQueue(), analyticsQueue: createMockQueue(), weeklyAnalyticsQueue: createMockQueue(), cleanupQueue: createMockQueue(), tokenCleanupQueue: createMockQueue(), receiptQueue: createMockQueue(), expiryAlertQueue: createMockQueue(), barcodeQueue: createMockQueue(), }, }; }); // Mock the queues.server module vi.mock('../services/queues.server', () => mockQueuesModule); // Import mocked modules after mock definitions import * as dbConnection from '../services/db/connection.db'; import { connection as redisConnection } from '../services/queueService.server'; import fs from 'node:fs/promises'; import { HealthController } from './health.controller'; // Cast mocked modules for type-safe access const mockedDbConnection = dbConnection as Mocked; const mockedRedisConnection = redisConnection as Mocked & { get: ReturnType; }; const mockedFs = fs as Mocked; // Cast queues module for test assertions const mockedQueues = mockQueuesModule as { flyerQueue: { getJobCounts: ReturnType }; emailQueue: { getJobCounts: ReturnType }; analyticsQueue: { getJobCounts: ReturnType }; weeklyAnalyticsQueue: { getJobCounts: ReturnType }; cleanupQueue: { getJobCounts: ReturnType }; tokenCleanupQueue: { getJobCounts: ReturnType }; receiptQueue: { getJobCounts: ReturnType }; expiryAlertQueue: { getJobCounts: ReturnType }; barcodeQueue: { getJobCounts: ReturnType }; }; // ============================================================================ // TEST SUITE // ============================================================================ describe('HealthController', () => { let controller: HealthController; beforeEach(() => { vi.clearAllMocks(); controller = new HealthController(); }); afterEach(() => { vi.useRealTimers(); }); // ========================================================================== // BASIC HEALTH CHECKS // ========================================================================== describe('ping()', () => { it('should return a pong response', async () => { const result = await controller.ping(); expect(result.success).toBe(true); expect(result.data).toEqual({ message: 'pong' }); }); }); // ========================================================================== // KUBERNETES PROBES (ADR-020) // ========================================================================== describe('live()', () => { it('should return ok status with timestamp', async () => { const result = await controller.live(); expect(result.success).toBe(true); expect(result.data.status).toBe('ok'); expect(result.data.timestamp).toBeDefined(); expect(() => new Date(result.data.timestamp)).not.toThrow(); }); }); describe('ready()', () => { it('should return healthy status when all services are healthy', async () => { // Arrange: Mock all services as healthy const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); mockedRedisConnection.ping.mockResolvedValue('PONG'); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('healthy'); expect(result.data.services.database.status).toBe('healthy'); expect(result.data.services.redis.status).toBe('healthy'); expect(result.data.services.storage.status).toBe('healthy'); expect(result.data.uptime).toBeDefined(); expect(result.data.timestamp).toBeDefined(); } }); it('should return degraded status when database pool has high waiting count', async () => { // Arrange: Mock database as degraded (waitingCount > 3) const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 2, waitingCount: 5, }); mockedRedisConnection.ping.mockResolvedValue('PONG'); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('degraded'); expect(result.data.services.database.status).toBe('degraded'); } }); it('should return unhealthy status when database is unavailable', async () => { // Arrange: Mock database as unhealthy const mockPool = { query: vi.fn().mockRejectedValue(new Error('Connection failed')) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedRedisConnection.ping.mockResolvedValue('PONG'); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toBe('Service unhealthy'); const details = result.error.details as { status: string; services: { database: { status: string; message: string } }; }; expect(details.status).toBe('unhealthy'); expect(details.services.database.status).toBe('unhealthy'); expect(details.services.database.message).toBe('Connection failed'); } }); it('should return unhealthy status when Redis is unavailable', async () => { // Arrange: Mock Redis as unhealthy const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); mockedRedisConnection.ping.mockRejectedValue(new Error('Redis connection refused')); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(false); if (!result.success) { const details = result.error.details as { status: string; services: { redis: { status: string; message: string } }; }; expect(details.status).toBe('unhealthy'); expect(details.services.redis.status).toBe('unhealthy'); expect(details.services.redis.message).toBe('Redis connection refused'); } }); it('should return unhealthy when Redis returns unexpected ping response', async () => { // Arrange const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); mockedRedisConnection.ping.mockResolvedValue('UNEXPECTED'); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(false); if (!result.success) { const details = result.error.details as { services: { redis: { status: string; message: string } }; }; expect(details.services.redis.status).toBe('unhealthy'); expect(details.services.redis.message).toContain('Unexpected ping response'); } }); it('should still return healthy when storage is unhealthy but critical services are healthy', async () => { // Arrange: Storage unhealthy, but db and redis healthy const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); mockedRedisConnection.ping.mockResolvedValue('PONG'); mockedFs.access.mockRejectedValue(new Error('Permission denied')); // Act const result = await controller.ready(); // Assert: Storage is not critical, so should still be healthy/200 expect(result.success).toBe(true); if (result.success) { expect(result.data.services.storage.status).toBe('unhealthy'); } }); it('should handle database error with non-Error object', async () => { // Arrange const mockPool = { query: vi.fn().mockRejectedValue('String error') }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedRedisConnection.ping.mockResolvedValue('PONG'); mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.ready(); // Assert expect(result.success).toBe(false); if (!result.success) { const details = result.error.details as { services: { database: { message: string } } }; expect(details.services.database.message).toBe('Database connection failed'); } }); }); describe('startup()', () => { it('should return started status when database is healthy', async () => { // Arrange const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); // Act const result = await controller.startup(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('started'); expect(result.data.database.status).toBe('healthy'); expect(result.data.timestamp).toBeDefined(); } }); it('should return error when database is unhealthy during startup', async () => { // Arrange const mockPool = { query: vi.fn().mockRejectedValue(new Error('Database not ready')) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); // Act const result = await controller.startup(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toBe('Waiting for database connection'); const details = result.error.details as { status: string; database: { status: string; message: string }; }; expect(details.status).toBe('starting'); expect(details.database.status).toBe('unhealthy'); expect(details.database.message).toBe('Database not ready'); } }); it('should return started with degraded database when pool has high waiting count', async () => { // Arrange const mockPool = { query: vi.fn().mockResolvedValue({ rows: [{ 1: 1 }] }) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 2, waitingCount: 5, }); // Act const result = await controller.startup(); // Assert: Degraded is not unhealthy, so startup should succeed expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('started'); expect(result.data.database.status).toBe('degraded'); } }); }); // ========================================================================== // INDIVIDUAL SERVICE HEALTH CHECKS // ========================================================================== describe('dbSchema()', () => { it('should return success when all tables exist', async () => { // Arrange mockedDbConnection.checkTablesExist.mockResolvedValue([]); // Act const result = await controller.dbSchema(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.message).toBe('All required database tables exist.'); } }); it('should return error when tables are missing', async () => { // Arrange mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table_1', 'missing_table_2']); // Act const result = await controller.dbSchema(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toContain('Missing tables: missing_table_1, missing_table_2'); } }); }); describe('storage()', () => { it('should return success when storage is accessible', async () => { // Arrange mockedFs.access.mockResolvedValue(undefined); // Act const result = await controller.storage(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.message).toContain('is accessible and writable'); } }); it('should return error when storage is not accessible', async () => { // Arrange mockedFs.access.mockRejectedValue(new Error('EACCES: permission denied')); // Act const result = await controller.storage(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toContain('Storage check failed'); } }); }); describe('dbPool()', () => { it('should return success for a healthy pool status', async () => { // Arrange mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 10, idleCount: 8, waitingCount: 1, }); // Act const result = await controller.dbPool(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.message).toContain('Pool Status: 10 total, 8 idle, 1 waiting'); expect(result.data.totalCount).toBe(10); expect(result.data.idleCount).toBe(8); expect(result.data.waitingCount).toBe(1); } }); it('should return error for an unhealthy pool status', async () => { // Arrange mockedDbConnection.getPoolStatus.mockReturnValue({ totalCount: 20, idleCount: 5, waitingCount: 15, }); // Act const result = await controller.dbPool(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toContain('Pool may be under stress'); expect(result.error.message).toContain('Pool Status: 20 total, 5 idle, 15 waiting'); } }); }); describe('time()', () => { it('should return current server time, year, and week', async () => { // Arrange const fakeDate = new Date('2024-03-15T10:30:00.000Z'); vi.useFakeTimers(); vi.setSystemTime(fakeDate); // Act const result = await controller.time(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.currentTime).toBe('2024-03-15T10:30:00.000Z'); expect(result.data.year).toBe(2024); expect(result.data.week).toBe(11); } }); }); describe('redis()', () => { it('should return success when Redis ping is successful', async () => { // Arrange mockedRedisConnection.ping.mockResolvedValue('PONG'); // Act const result = await controller.redis(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.message).toBe('Redis connection is healthy.'); } }); it('should return error when Redis ping fails', async () => { // Arrange mockedRedisConnection.ping.mockRejectedValue(new Error('Connection timed out')); // Act const result = await controller.redis(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toBe('Connection timed out'); } }); it('should return error when Redis returns unexpected response', async () => { // Arrange mockedRedisConnection.ping.mockResolvedValue('OK'); // Act const result = await controller.redis(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toContain('Unexpected Redis ping response: OK'); } }); }); // ========================================================================== // QUEUE HEALTH MONITORING (ADR-053) // ========================================================================== describe('queues()', () => { // Helper function to set all queue mocks const setAllQueueMocks = (jobCounts: { waiting: number; active: number; failed: number; delayed: number; }) => { mockedQueues.flyerQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.emailQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(jobCounts); mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(jobCounts); }; it('should return healthy status when all queues and workers are healthy', async () => { // Arrange setAllQueueMocks({ waiting: 5, active: 2, failed: 1, delayed: 0 }); // Mock Redis heartbeat responses (all healthy) const recentTimestamp = new Date(Date.now() - 10000).toISOString(); const heartbeatValue = JSON.stringify({ timestamp: recentTimestamp, pid: 1234, host: 'test-host', }); mockedRedisConnection.get.mockResolvedValue(heartbeatValue); // Act const result = await controller.queues(); // Assert expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('healthy'); expect(result.data.queues['flyer-processing']).toEqual({ waiting: 5, active: 2, failed: 1, delayed: 0, }); expect(result.data.workers['flyer-processing']).toEqual({ alive: true, lastSeen: recentTimestamp, pid: 1234, host: 'test-host', }); } }); it('should return unhealthy status when a queue is unavailable', async () => { // Arrange: flyerQueue fails, others succeed mockedQueues.flyerQueue.getJobCounts.mockRejectedValue(new Error('Redis connection lost')); const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; mockedQueues.emailQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.analyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.weeklyAnalyticsQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.cleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.tokenCleanupQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.receiptQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.expiryAlertQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedQueues.barcodeQueue.getJobCounts.mockResolvedValue(healthyJobCounts); mockedRedisConnection.get.mockResolvedValue(null); // Act const result = await controller.queues(); // Assert expect(result.success).toBe(false); if (!result.success) { expect(result.error.message).toBe('One or more queues or workers unavailable'); const details = result.error.details as { status: string; queues: Record; }; expect(details.status).toBe('unhealthy'); expect(details.queues['flyer-processing']).toEqual({ error: 'Redis connection lost' }); } }); it('should return unhealthy status when a worker heartbeat is stale', async () => { // Arrange const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; setAllQueueMocks(healthyJobCounts); // Stale heartbeat (> 60s ago) const staleTimestamp = new Date(Date.now() - 120000).toISOString(); const staleHeartbeat = JSON.stringify({ timestamp: staleTimestamp, pid: 1234, host: 'test-host', }); let callCount = 0; mockedRedisConnection.get.mockImplementation(() => { callCount++; return Promise.resolve(callCount === 1 ? staleHeartbeat : null); }); // Act const result = await controller.queues(); // Assert expect(result.success).toBe(false); if (!result.success) { const details = result.error.details as { status: string; workers: Record; }; expect(details.status).toBe('unhealthy'); expect(details.workers['flyer-processing']).toEqual({ alive: false }); } }); it('should return unhealthy status when worker heartbeat is missing', async () => { // Arrange const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; setAllQueueMocks(healthyJobCounts); mockedRedisConnection.get.mockResolvedValue(null); // Act const result = await controller.queues(); // Assert expect(result.success).toBe(false); if (!result.success) { const details = result.error.details as { status: string; workers: Record; }; expect(details.status).toBe('unhealthy'); expect(details.workers['flyer-processing']).toEqual({ alive: false }); } }); it('should handle Redis connection errors gracefully for heartbeat checks', async () => { // Arrange const healthyJobCounts = { waiting: 0, active: 0, failed: 0, delayed: 0 }; setAllQueueMocks(healthyJobCounts); mockedRedisConnection.get.mockRejectedValue(new Error('Redis connection lost')); // Act const result = await controller.queues(); // Assert: Heartbeat fetch errors are treated as non-critical expect(result.success).toBe(true); if (result.success) { expect(result.data.status).toBe('healthy'); expect(result.data.workers['flyer-processing']).toEqual({ alive: false, error: 'Redis connection lost', }); } }); }); // ========================================================================== // BASE CONTROLLER INTEGRATION // ========================================================================== describe('BaseController integration', () => { it('should use success helper for consistent response format', async () => { const result = await controller.ping(); expect(result).toHaveProperty('success', true); expect(result).toHaveProperty('data'); }); it('should use error helper for consistent error format', async () => { // Arrange: Make database check fail mockedDbConnection.checkTablesExist.mockResolvedValue(['missing_table']); // Act const result = await controller.dbSchema(); // Assert expect(result).toHaveProperty('success', false); expect(result).toHaveProperty('error'); if (!result.success) { expect(result.error).toHaveProperty('code'); expect(result.error).toHaveProperty('message'); } }); it('should set HTTP status codes via setStatus', async () => { // Arrange: Make startup probe fail const mockPool = { query: vi.fn().mockRejectedValue(new Error('No database')) }; mockedDbConnection.getPool.mockReturnValue(mockPool as never); // Act const result = await controller.startup(); // Assert: The controller called setStatus(503) internally // We can verify this by checking the result structure is an error expect(result.success).toBe(false); }); }); });