Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
770 lines
26 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|