test: lock in app health endpoint criteria (E00-S02-T03)

This commit is contained in:
implementer
2026-08-29 00:21:34 +00:00
parent 9c049ce6b5
commit f7257f9258
2 changed files with 278 additions and 17 deletions
+46 -17
View File
@@ -1,7 +1,7 @@
/**
* Docker Compose baseline test — locks in the [E00-S02-T01/T02] `docker
* compose up -d` DB + app baseline and the PostgreSQL health gate for the
* workspace.
* Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03] `docker
* compose up -d` DB + app baseline, the PostgreSQL health gate and the app
* health endpoint for the workspace.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "docker compose up -d starts the database" → the committed
@@ -16,10 +16,11 @@
* `compose.yaml` declares an `app` service built from the committed
* `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim +
* frozen pnpm install → `tsc` build of `@personal-blog/server` →
* `node apps/server/dist/index.js`),
* with a published default port. The real-stack probe asserts the `app`
* container is created and starts cleanly (exit 0 when the placeholder
* process exits).
* `node apps/server/dist/index.js`), with a published default port. Since
* T03 the real-stack probe asserts the `app` container stays **running**
* (the server now serves the health endpoint instead of exiting) and the
* health endpoint answers HTTP 200 with a healthy body inside the
* container.
* - "PostgreSQL health check gates application start" → the committed
* `compose.yaml` declares a `healthcheck` on the `db` service that probes
* readiness with `pg_isready` against the same credentials the database
@@ -315,7 +316,7 @@ test('compose.yaml exists, parses, and declares exactly the db and app services'
assert.deepEqual(
Object.keys(compose.services ?? {}).sort(),
['app', 'db'],
'compose.yaml must declare exactly the "db" and "app" services at T01',
'compose.yaml must declare exactly the "db" and "app" services at T01/T02/T03',
);
});
@@ -434,16 +435,44 @@ test('docker compose up -d starts the database and application containers', { sk
const app = containers.find((c) => field(c, 'Service', 'service') === 'app');
assert.ok(app, 'docker compose up -d must create the "app" container');
const appState = String(field(app, 'State', 'state') ?? '');
const appExit = Number(field(app, 'ExitCode', 'exit_code', 'exitCode'));
if (/exited/i.test(appState)) {
assert.equal(
appExit,
0,
`the "app" container exited non-zero (exit ${appExit}) — the server entrypoint must start cleanly`,
);
} else {
assert.match(appState, /running|up/i, `the "app" container must start after "docker compose up -d" (state: "${appState}")`);
assert.match(
appState,
/running|up/i,
`the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`,
);
// T03: the app serves the health endpoint — HTTP smoke test against the
// endpoint inside the app container (no host-port dependency), polling
// until it answers or times out.
let healthOutput = '';
let healthOk = false;
for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) {
const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', `
fetch('http://127.0.0.1:3000/health')
.then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); })
.catch(() => process.exit(2));
`], { cwd: REPO_ROOT, timeout: 15_000 });
const output = String(probe.stdout ?? '') + String(probe.stderr ?? '');
if (probe.status === 0) {
healthOk = true;
healthOutput = output;
} else if (probe.status === 1) {
healthOutput = output; // answered but not 2xx — fail fast
break;
} else {
run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry
}
}
assert.ok(
healthOk,
`GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`,
);
assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`);
assert.match(
healthOutput,
/"status":"ok"/,
`GET /health must report a healthy application (got: "${healthOutput.trim()}")`,
);
} finally {
run('docker', ['compose', 'down'], { cwd: REPO_ROOT });
}
+232
View File
@@ -0,0 +1,232 @@
/**
* App health endpoint test — locks in the [E00-S02-T03] `GET /health`
* endpoint for the workspace server.
*
* Acceptance criteria covered (each test fails without the committed state):
* - "app health endpoint succeeds" → the committed
* `apps/server/src/index.ts` creates an HTTP server (`node:http`
* `createServer`), listens on the application port (default 3000,
* `PORT`-overridable) and answers `GET /health` with HTTP 200. The
* "HTTP smoke test" boots the committed server source (Node type
* stripping, no build step) on an ephemeral port and makes a real
* `GET /health` request, asserting a 2xx response.
* - "the endpoint reports a healthy application" → the `/health` response
* body is JSON reporting a healthy application (`{"status":"ok"}`),
* asserted statically against the committed source and by the smoke test.
*
* Run: `node --test tests/health-endpoint.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createServer as createNetServer } from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
/** The committed server entrypoint under test. */
const SERVER_SRC = 'apps/server/src/index.ts';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Static assertions on the committed server entrypoint
// ---------------------------------------------------------------------------
/**
* Asserts the committed server entrypoint answers `GET /health` with HTTP 200
* and reports a healthy application. Fails fast on a missing/placeholder
* entrypoint; the mutation probes below prove the assertions are non-vacuous.
*/
function assertHealthEndpointSource(src) {
assert.match(
src,
/createServer\(/,
'the server entrypoint must create an HTTP server (node:http createServer)',
);
assert.match(
src,
/'\/health'/,
"the server entrypoint must route the health endpoint (GET /health)",
);
assert.match(
src,
/sendJson\(res, 200/,
'the health route must answer with HTTP 200 (app health endpoint succeeds)',
);
assert.match(
src,
/status:\s*'ok'/,
"the health payload must report a healthy application ({\"status\":\"ok\"})",
);
assert.match(
src,
/server\.listen\(/,
'the server entrypoint must start listening (server.listen)',
);
assert.match(
src,
/3000/,
'the server must default to the application port 3000 (Dockerfile EXPOSE / compose :3000)',
);
}
// ---------------------------------------------------------------------------
// Boot helpers for the HTTP smoke test (Node type stripping, no build step)
// ---------------------------------------------------------------------------
/**
* How the current Node executes TypeScript sources: `default` (>= 23.6, type
* stripping on by default), `strip-types-flag` (>= 22.6 via
* `--experimental-strip-types`) or `null` (cannot run .ts at all). The
* workspace pins engines.node to 24.x, where type stripping is stable.
*/
function tsExecMode() {
const [major, minor] = process.versions.node.split('.').map(Number);
if (major > 23 || (major === 23 && minor >= 6)) return 'default';
if (major === 22 && minor >= 6) return 'strip-types-flag';
return null;
}
/** Reserves an ephemeral TCP port, then releases it for the child to bind. */
function reservePort() {
return new Promise((resolve, reject) => {
const probe = createNetServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
probe.close(() => resolve(port));
});
});
}
/**
* Boots the committed server source on `port`. Returns `{ child, stderr }`;
* the child writes its stderr into the `stderr()` closure for diagnostics.
*/
function bootServer(port) {
const args =
tsExecMode() === 'strip-types-flag'
? ['--experimental-strip-types', SERVER_SRC]
: [SERVER_SRC];
const child = spawn(process.execPath, args, {
cwd: REPO_ROOT,
env: { ...process.env, PORT: String(port) },
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
return { child, stderr: () => stderr };
}
/**
* Polls `GET /health` until it answers or the child exits / the deadline
* passes (poll with timeout — no flaky sleeps).
*/
async function waitForHealth(port, child, stderr) {
const deadline = Date.now() + 10_000;
let lastError = '';
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`the server exited before answering GET /health (code ${child.exitCode}): ${stderr().trim()}`,
);
}
try {
return await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(1_000),
});
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
await delay(100);
}
}
throw new Error(
`GET /health did not answer within 10s (last error: ${lastError}; server stderr: ${stderr().trim()})`,
);
}
// ---------------------------------------------------------------------------
// Criterion tests
// ---------------------------------------------------------------------------
test('the app health endpoint succeeds (committed entrypoint answers GET /health with HTTP 200)', () => {
assertHealthEndpointSource(read(SERVER_SRC));
});
test('the endpoint reports a healthy application (committed health payload is {"status":"ok"})', () => {
const src = read(SERVER_SRC);
assert.match(
src,
/status:\s*'ok'/,
"the health payload must report a healthy application ({\"status\":\"ok\"})",
);
});
test('an HTTP smoke test against the booted server succeeds for GET /health (200 + healthy body)', async (t) => {
if (!tsExecMode()) {
t.skip(
`Node ${process.versions.node} cannot execute TypeScript sources; the workspace pins engines.node to 24.x (type stripping is stable there)`,
);
return;
}
const port = await reservePort();
const { child, stderr } = bootServer(port);
try {
const response = await waitForHealth(port, child, stderr);
assert.equal(
response.status,
200,
`GET /health must succeed with HTTP 200 (got ${response.status})`,
);
const body = await response.json();
assert.equal(
body.status,
'ok',
'the health endpoint must report a healthy application ({"status":"ok"})',
);
} finally {
child.kill('SIGTERM');
await Promise.race([once(child, 'exit'), delay(2_000)]);
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}
});
// ---------------------------------------------------------------------------
// Non-vacuous probes — the assertions above really do fail on violations
// ---------------------------------------------------------------------------
test('removing the /health route makes the health-endpoint criterion fail (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutRoute = src.replace(/'\/health'/, "'/nope'");
assert.notEqual(withoutRoute, src, 'the mutation must actually replace the /health route');
assert.throws(() => assertHealthEndpointSource(withoutRoute), /\/health/);
});
test('removing the HTTP 200 makes the health-endpoint criterion fail (mutation probe)', () => {
const src = read(SERVER_SRC);
const without200 = src.replace(/sendJson\(res, 200/, 'sendJson(res, 500');
assert.notEqual(without200, src, 'the mutation must actually change the health status code');
assert.throws(() => assertHealthEndpointSource(without200), /HTTP 200/);
});
test('removing the healthy report makes the healthy-report criterion fail (mutation probe)', () => {
const src = read(SERVER_SRC);
const withoutOk = src.replace(/status:\s*'ok'/, "status: 'nope'");
assert.notEqual(withoutOk, src, 'the mutation must actually change the health payload');
assert.throws(() => assertHealthEndpointSource(withoutOk), /healthy application/);
});
test('a placeholder entrypoint (no server at all) fails the health-endpoint criterion (mutation probe)', () => {
assert.throws(() => assertHealthEndpointSource('export {};\n'), /createServer/);
});