Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions backend/src/__tests__/scalingRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,96 @@ describe('POST /api/v1/scaling/refresh-view', () => {
expect(sqlCall).toContain('mv_org_daily_tx_summary');
});
});

// ─── Part 25: latency-percentiles ────────────────────────────────────────────

describe('GET /api/v1/scaling/latency-percentiles (Part 25)', () => {
afterEach(() => jest.clearAllMocks());

it('returns 200 with percentile rows', async () => {
const fakeRows = [
{
endpoint: 'GET /api/v1/employees',
method: 'GET',
p50_ms: 45,
p95_ms: 120,
p99_ms: 350,
total_observations: '1200',
window_start: new Date().toISOString(),
},
];
mockQuery.mockResolvedValue({ rows: fakeRows });

const res = await request(app).get('/api/v1/scaling/latency-percentiles');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].p99_ms).toBe(350);
expect(res.body.meta).toMatchObject({ count: 1 });
});

it('returns empty array when no histogram data exists', async () => {
mockQuery.mockResolvedValue({ rows: [] });

const res = await request(app).get('/api/v1/scaling/latency-percentiles');

expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(0);
});

it('caps limit at 100', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await request(app).get('/api/v1/scaling/latency-percentiles?limit=9999');
const passedLimit = mockQuery.mock.calls[0][1][1];
expect(passedLimit).toBe(100);
});

it('uses provided windowMinutes in query', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await request(app).get('/api/v1/scaling/latency-percentiles?windowMinutes=30');
const passedWindow = mockQuery.mock.calls[0][1][0];
expect(passedWindow).toBe(30);
});
});

// ─── Part 25: pool-history ────────────────────────────────────────────────────

describe('GET /api/v1/scaling/pool-history (Part 25)', () => {
afterEach(() => jest.clearAllMocks());

it('returns 200 with pool snapshot rows', async () => {
const fakeRows = [
{
id: '1',
total_conns: 10,
idle_conns: 7,
waiting_clients: 0,
recorded_at: new Date().toISOString(),
},
];
mockQuery.mockResolvedValue({ rows: fakeRows });

const res = await request(app).get('/api/v1/scaling/pool-history');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].total_conns).toBe(10);
});

it('caps limit at 200', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await request(app).get('/api/v1/scaling/pool-history?limit=9999');
const passedLimit = mockQuery.mock.calls[0][1][0];
expect(passedLimit).toBe(200);
});

it('returns 500 on DB error', async () => {
mockQuery.mockRejectedValue(new Error('connection lost'));

const res = await request(app).get('/api/v1/scaling/pool-history');

expect(res.status).toBe(500);
});
});
6 changes: 6 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ app.get('/api/v1/health', HealthController.getHealthStatus);
app.get('/api/health', HealthController.getHealthStatus);
app.get('/health', HealthController.getHealthStatus);

// Kubernetes-style probes
app.get('/api/v1/health/live', HealthController.getLiveness);
app.get('/api/v1/health/ready', HealthController.getReadiness);
app.get('/health/live', HealthController.getLiveness);
app.get('/health/ready', HealthController.getReadiness);

// ─── 404 ─────────────────────────────────────────────────────────────────────
app.use((req, res) => {
res.status(404).json({
Expand Down
84 changes: 83 additions & 1 deletion backend/src/controllers/__tests__/healthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jest.mock('../../config/database.js', () => ({
}));

jest.mock('ioredis', () => {
const mRedis = {
const mRedis = {
ping: jest.fn(),
on: jest.fn(),
};
Expand All @@ -30,6 +30,10 @@ const app = express();
app.get('/api/health', HealthController.getHealthStatus);
app.get('/api/v1/health', HealthController.getHealthStatus);
app.get('/health', HealthController.getHealthStatus);
app.get('/api/v1/health/live', HealthController.getLiveness);
app.get('/api/v1/health/ready', HealthController.getReadiness);
app.get('/health/live', HealthController.getLiveness);
app.get('/health/ready', HealthController.getReadiness);

describe('HealthController health endpoints', () => {
let redisClient: any;
Expand Down Expand Up @@ -102,3 +106,81 @@ describe('HealthController health endpoints', () => {
expect(response.body.dependencies.redis.error).toBe('Redis timeout');
});
});

describe('HealthController liveness probe', () => {
it('GET /api/v1/health/live returns 200 without any dependency checks', async () => {
const response = await request(app).get('/api/v1/health/live');

expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
expect(response.body.uptime).toBeDefined();
expect(response.body.timestamp).toBeDefined();
});

it('GET /health/live also returns 200', async () => {
const response = await request(app).get('/health/live');

expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
});

it('liveness probe does not call pool.query', async () => {
(pool.query as jest.Mock).mockClear();
await request(app).get('/api/v1/health/live');
expect(pool.query).not.toHaveBeenCalled();
});
});

describe('HealthController readiness probe', () => {
let redisClient: any;

beforeEach(() => {
redisClient = new Redis();
jest.clearAllMocks();
});

it('GET /api/v1/health/ready returns 200 when database and redis are reachable', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockResolvedValueOnce('PONG');

const response = await request(app).get('/api/v1/health/ready');

expect(response.status).toBe(200);
expect(response.body.status).toBe('ready');
expect(response.body.checks.database.status).toBe('connected');
expect(response.body.checks.redis.status).toBe('connected');
});

it('GET /api/v1/health/ready returns 503 when database is down', async () => {
(pool.query as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED'));
redisClient.ping.mockResolvedValueOnce('PONG');

const response = await request(app).get('/api/v1/health/ready');

expect(response.status).toBe(503);
expect(response.body.status).toBe('not_ready');
expect(response.body.checks.database.status).toBe('disconnected');
expect(response.body.checks.database.error).toBe('ECONNREFUSED');
});

it('GET /api/v1/health/ready returns 503 when redis is down', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockRejectedValueOnce(new Error('Redis ECONNREFUSED'));

const response = await request(app).get('/api/v1/health/ready');

expect(response.status).toBe(503);
expect(response.body.status).toBe('not_ready');
expect(response.body.checks.redis.status).toBe('disconnected');
});

it('GET /health/ready also works on the short path', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
redisClient.ping.mockResolvedValueOnce('PONG');

const response = await request(app).get('/health/ready');

expect(response.status).toBe(200);
expect(response.body.status).toBe('ready');
});
});
61 changes: 59 additions & 2 deletions backend/src/controllers/healthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ if (config.REDIS_URL) {
redisClient = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 1,
retryStrategy: () => null, // Fail fast for health check
commandTimeout: 1000, // 1 second timeout
commandTimeout: 1000, // 1 second timeout
});

redisClient.on('error', (err) => {
logger.warn('Health Check Redis client error', { error: err.message });
});
Expand Down Expand Up @@ -76,6 +76,63 @@ function measureEventLoopLag(): Promise<number> {
}

export class HealthController {
/**
* GET /health/live (liveness probe)
* Returns 200 immediately — no dependency checks. Used by k8s/Docker to
* confirm the process is alive. Should never block or timeout.
*/
static getLiveness(_req: Request, res: Response): void {
res.status(200).json({
status: 'alive',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
}

/**
* GET /health/ready (readiness probe)
* Returns 200 if all critical dependencies are reachable, 503 otherwise.
* Used by load-balancers to gate traffic until the instance is ready.
*/
static async getReadiness(_req: Request, res: Response): Promise<void> {
const checks: { database: DependencyStatus; redis: DependencyStatus } = {
database: { status: 'unknown' },
redis: { status: 'unknown' },
};
let ready = true;

const dbStart = Date.now();
try {
await pool.query('SELECT 1');
checks.database = { status: 'connected', latencyMs: Date.now() - dbStart };
} catch (error: any) {
ready = false;
checks.database = { status: 'disconnected', error: error.message };
logger.error('Readiness check: database unavailable', error);
}

if (redisClient) {
const redisStart = Date.now();
try {
await redisClient.ping();
checks.redis = { status: 'connected', latencyMs: Date.now() - redisStart };
} catch (error: any) {
ready = false;
checks.redis = { status: 'disconnected', error: error.message };
logger.error('Readiness check: redis unavailable', error);
}
} else {
checks.redis = { status: 'not_configured' };
}

const httpStatus = ready ? 200 : 503;
res.status(httpStatus).json({
status: ready ? 'ready' : 'not_ready',
timestamp: new Date().toISOString(),
checks,
});
}

static async getHealthStatus(_req: Request, res: Response) {
const start = Date.now();
const statusReport: HealthStatusResponse = {
Expand Down
50 changes: 50 additions & 0 deletions backend/src/controllers/migrationStatusController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Request, Response, NextFunction } from 'express';
import { MigrationStatusService } from '../services/migrationStatusService.js';
import logger from '../utils/logger.js';

const service = new MigrationStatusService();

export class MigrationStatusController {
/**
* GET /api/v1/migrations/status
* Full migration status: applied, pending, and rollback history.
*/
static async getStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const report = await service.getStatus();
res.json({ success: true, data: report });
} catch (err) {
logger.error({ err }, '[MigrationStatusController] getStatus failed');
next(err);
}
}

/**
* GET /api/v1/migrations/applied
* List only migrations that have been applied to this database.
*/
static async getApplied(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const applied = await service.getApplied();
res.json({ success: true, data: applied, count: applied.length });
} catch (err) {
logger.error({ err }, '[MigrationStatusController] getApplied failed');
next(err);
}
}

/**
* GET /api/v1/migrations/rollbacks
* History of rollback events, ordered most-recent first.
*/
static async getRollbackHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limit = Math.min(Number(req.query['limit'] ?? 20), 100);
const history = await service.getRollbackHistory(limit);
res.json({ success: true, data: history, count: history.length });
} catch (err) {
logger.error({ err }, '[MigrationStatusController] getRollbackHistory failed');
next(err);
}
}
}
Loading
Loading