From f7257f92586582a34792a6e4b2153ce89457381c Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:21:34 +0000 Subject: [PATCH] test: lock in app health endpoint criteria (E00-S02-T03) --- tests/compose-config.test.mjs | 63 ++++++--- tests/health-endpoint.test.mjs | 232 +++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 tests/health-endpoint.test.mjs diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index a4cca8a..e72ff19 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -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 }); } diff --git a/tests/health-endpoint.test.mjs b/tests/health-endpoint.test.mjs new file mode 100644 index 0000000..746d286 --- /dev/null +++ b/tests/health-endpoint.test.mjs @@ -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/); +});