vps-health-api/__tests__/health.test.js
le king fu 79cb813767 refactor: extract CPU/RAM/disk collection into metrics.js
Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.

Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
  spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
  The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
  serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
  running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
  there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
  payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
  Verified by mutation: serializing the two calls fails the latency test
  at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.

Resolves #11
2026-08-16 11:30:03 -04:00

139 lines
4.8 KiB
JavaScript

const http = require("node:http");
const { setTimeout: delay } = require("node:timers/promises");
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
const TOKEN = "test-token";
let logtoServer;
let logtoUrl;
let logtoDelayMs;
let server;
let baseUrl;
let handler;
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
// simulate a slow IdP without touching the network.
function startLogtoStub() {
logtoDelayMs = 0;
logtoServer = http.createServer(async (req, res) => {
if (logtoDelayMs > 0) await delay(logtoDelayMs);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
});
return new Promise((resolve) => {
logtoServer.listen(0, "127.0.0.1", () => {
const { port } = logtoServer.address();
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
resolve();
});
});
}
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function close(target) {
return new Promise((resolve) => {
if (!target) return resolve();
target.close(() => resolve());
});
}
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
await startLogtoStub();
process.env.HEALTH_TOKEN = TOKEN;
process.env.LOGTO_HEALTH_URL = logtoUrl;
await startServer();
});
afterEach(async () => {
await close(server);
await close(logtoServer);
});
describe("metrics module", () => {
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
expect(typeof getCpuPercent).toBe("function");
expect(typeof getDisk).toBe("function");
expect(typeof collectMetrics).toBe("function");
});
test("getDisk returns the four numeric disk fields", () => {
const disk = getDisk();
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
});
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
const metrics = await collectMetrics();
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(metrics.cpu.cores).toBeGreaterThan(0);
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
});
});
describe("GET /health", () => {
test("401 on invalid token", async () => {
const { status } = await get("/health", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("200 returns the documented payload, field for field", async () => {
const { status, body } = await get("/health");
expect(status).toBe(200);
// Key order is part of the contract consumed by the admin dashboard.
expect(Object.keys(body)).toEqual([
"timestamp",
"hostname",
"uptime",
"cpu",
"memory",
"disk",
"logto",
]);
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(body.logto.status).toBe("up");
expect(typeof body.logto.responseTimeMs).toBe("number");
expect(typeof body.uptime).toBe("number");
expect(typeof body.hostname).toBe("string");
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
// Latency regression guard. Metrics collection (500ms CPU sample) and the
// Logto check must run concurrently: serializing them would make /health take
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
logtoDelayMs = 1200;
const start = performance.now();
const { status, body } = await get("/health");
const elapsed = performance.now() - start;
expect(status).toBe(200);
expect(body.logto.status).toBe("up");
expect(elapsed).toBeLessThan(1500);
}, 15000);
});