Files
flyer-crawler.projectium.com/src/controllers/health.controller.test.ts
Torben Sorensen 2d2cd52011
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Massive Dependency Modernization Project
2026-02-13 00:34:22 -08:00

770 lines
26 KiB
TypeScript

// 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<typeof dbConnection>;
const mockedRedisConnection = redisConnection as Mocked<typeof redisConnection> & {
get: ReturnType<typeof vi.fn>;
};
const mockedFs = fs as Mocked<typeof fs>;
// Cast queues module for test assertions
const mockedQueues = mockQueuesModule as {
flyerQueue: { getJobCounts: ReturnType<typeof vi.fn> };
emailQueue: { getJobCounts: ReturnType<typeof vi.fn> };
analyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
weeklyAnalyticsQueue: { getJobCounts: ReturnType<typeof vi.fn> };
cleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
tokenCleanupQueue: { getJobCounts: ReturnType<typeof vi.fn> };
receiptQueue: { getJobCounts: ReturnType<typeof vi.fn> };
expiryAlertQueue: { getJobCounts: ReturnType<typeof vi.fn> };
barcodeQueue: { getJobCounts: ReturnType<typeof vi.fn> };
};
// ============================================================================
// 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<string, { error?: string }>;
};
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<string, { alive: boolean }>;
};
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<string, { alive: boolean }>;
};
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);
});
});
});