feat(phase-2): workstream 6 — Web Dashboard UI
- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
- Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
- Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
Credentials (generate/rotate/revoke, new secret shown once),
AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
- Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
- All destructive actions gated by ConfirmDialog
- Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
150
tests/unit/routes/health.test.ts
Normal file
150
tests/unit/routes/health.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Unit tests for src/routes/health.ts
|
||||
*
|
||||
* Tests the GET /health endpoint via the createHealthRouter factory.
|
||||
* PostgreSQL and Redis dependencies are fully mocked — no live services required.
|
||||
*/
|
||||
|
||||
import express, { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
import { createHealthRouter } from '../../../src/routes/health';
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Builds a mock pg PoolClient with controllable query/release. */
|
||||
function makePoolClient(queryError?: Error): jest.Mocked<Pick<PoolClient, 'query' | 'release'>> {
|
||||
return {
|
||||
query: queryError
|
||||
? jest.fn().mockRejectedValue(queryError)
|
||||
: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }),
|
||||
release: jest.fn(),
|
||||
} as unknown as jest.Mocked<Pick<PoolClient, 'query' | 'release'>>;
|
||||
}
|
||||
|
||||
/** Builds a mock pg Pool whose connect() resolves or rejects on demand. */
|
||||
function makePool(connectError?: Error, queryError?: Error): jest.Mocked<Pool> {
|
||||
return {
|
||||
connect: connectError
|
||||
? jest.fn().mockRejectedValue(connectError)
|
||||
: jest.fn().mockResolvedValue(makePoolClient(queryError)),
|
||||
} as unknown as jest.Mocked<Pool>;
|
||||
}
|
||||
|
||||
/** Builds a mock Redis client whose ping() resolves or rejects on demand. */
|
||||
function makeRedis(pingError?: Error): jest.Mocked<RedisClientType> {
|
||||
return {
|
||||
ping: pingError
|
||||
? jest.fn().mockRejectedValue(pingError)
|
||||
: jest.fn().mockResolvedValue('PONG'),
|
||||
} as unknown as jest.Mocked<RedisClientType>;
|
||||
}
|
||||
|
||||
/** Creates a minimal Express app with the health router mounted at /health. */
|
||||
function buildApp(pool: jest.Mocked<Pool>, redis: jest.Mocked<RedisClientType>): Application {
|
||||
const app = express();
|
||||
app.use('/health', createHealthRouter(pool as unknown as Pool, redis as unknown as RedisClientType));
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
describe('when both PostgreSQL and Redis are healthy', () => {
|
||||
it('returns 200 with status ok and both services connected', async () => {
|
||||
const app = buildApp(makePool(), makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'ok',
|
||||
services: {
|
||||
postgres: 'connected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes version and uptime fields in the response', async () => {
|
||||
const app = buildApp(makePool(), makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(typeof response.body.version).toBe('string');
|
||||
expect(typeof response.body.uptime).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when PostgreSQL connect() throws', () => {
|
||||
it('returns 503 with status degraded and postgres disconnected, redis connected', async () => {
|
||||
const pool = makePool(new Error('PG connection refused'));
|
||||
const app = buildApp(pool, makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when Redis ping() throws', () => {
|
||||
it('returns 503 with status degraded and postgres connected, redis disconnected', async () => {
|
||||
const app = buildApp(makePool(), makeRedis(new Error('Redis ECONNREFUSED')));
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'connected',
|
||||
redis: 'disconnected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when both PostgreSQL and Redis fail', () => {
|
||||
it('returns 503 with status degraded and both services disconnected', async () => {
|
||||
const pool = makePool(new Error('PG down'));
|
||||
const app = buildApp(pool, makeRedis(new Error('Redis down')));
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'disconnected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when PostgreSQL query() throws (connect succeeds but query fails)', () => {
|
||||
it('returns 503 with postgres disconnected', async () => {
|
||||
const pool = makePool(undefined, new Error('PG query error'));
|
||||
const app = buildApp(pool, makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user