From b51af02d0670c75af446c95b732251120d0d64d8 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 00:32:15 +0000 Subject: [PATCH] test: lock in DB volume persistence criteria (E00-S02-T04) compose-config: assert the db service mounts the named db-data volume at the PostgreSQL data directory and the top-level volumes map declares it; non-vacuous mutation probes (missing mount, missing volume declaration, wrong mount target all fail); Docker-gated real-stack probe writes a fixture row and asserts it survives `docker compose restart` (restart) and `docker compose down` + `up -d` (recreate), cleaned up with `docker compose down -v`. --- tests/compose-config.test.mjs | 195 +++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 3 deletions(-) diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index e72ff19..8a62ae3 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/T03] `docker - * compose up -d` DB + app baseline, the PostgreSQL health gate and the app - * health endpoint for the workspace. + * Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03/T04] + * `docker compose up -d` DB + app baseline, the PostgreSQL health gate, the + * app health endpoint and the DB volume persistence for the workspace. * * Acceptance criteria covered (each test fails without the committed state): * - "docker compose up -d starts the database" → the committed @@ -31,6 +31,16 @@ * depends on `db` with `condition: service_healthy`, so Compose only * starts the application once PostgreSQL reports healthy (the real-stack * probe asserts the `db` container is healthy when Docker reports it). + * - "database volume persists across restart" and "database volume + * persists across recreate" → the committed `compose.yaml` declares a + * named `db-data` volume and mounts it at PostgreSQL's data directory + * (`/var/lib/postgresql/data`), so the database files survive + * `docker compose restart` (same containers) and `docker compose down` → + * `docker compose up -d` (containers recreated — the container + * filesystem is discarded, so only a named volume carries the data). + * When Docker is available, the real-stack probe writes a fixture row, + * then asserts it survives both operations; data is reset with + * `docker compose down -v` (the issue's rollback note). * * Run: `node --test tests/compose-config.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) @@ -249,6 +259,33 @@ function assertDbHealthcheck(compose) { ); } +/** + * Asserts the committed compose.yaml makes the database volume persist: the + * `db` service mounts the named `db-data` volume at PostgreSQL's data + * directory (`/var/lib/postgresql/data`) and the top-level `volumes` map + * declares that volume. That is what lets the database survive `docker + * compose restart` (containers restarted in place) and `docker compose down` + * → `docker compose up -d` (containers recreated — the container filesystem + * is discarded, so only a named volume carries the data across). Removing + * either half breaks the criterion (see the mutation probes below). + */ +function assertDbVolume(compose) { + const db = compose.services?.db; + assert.ok(db, 'compose.yaml must declare a "db" service'); + assert.ok( + Array.isArray(db.volumes) && + db.volumes.includes('db-data:/var/lib/postgresql/data'), + "the \"db\" service must mount the named db-data volume at PostgreSQL's data directory " + + '(a "db-data:/var/lib/postgresql/data" entry) so the database survives restart and recreate', + ); + const volumes = compose.volumes ?? {}; + assert.ok( + Object.prototype.hasOwnProperty.call(volumes, 'db-data'), + 'compose.yaml must declare the named "db-data" volume (top-level "volumes: db-data:") ' + + 'so the mount target exists and is preserved across recreate', + ); +} + // --------------------------------------------------------------------------- // Docker probe helpers (integration tests skip cleanly without Docker) // --------------------------------------------------------------------------- @@ -303,6 +340,29 @@ function field(container, ...names) { return undefined; } +/** + * Polls `docker compose ps` until the db container reports healthy (or the + * deadline passes). Used by the T04 persistence probe after `up`, `restart` + * and `down` + `up` so fixture queries never race a still-booting database. + */ +function waitForDbHealthy(deadlineMs = 60_000) { + const deadline = Date.now() + deadlineMs; + let last = ''; + while (Date.now() < deadline) { + const ps = run('docker', ['compose', 'ps', '--format', 'json'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + if (ps.status === 0) { + last = ps.stdout; + const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db'); + if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return; + } + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry + } + throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`); +} + const DOCKER_COMPOSE = dockerComposeAvailable(); const DOCKER_DAEMON = dockerDaemonAvailable(); @@ -342,6 +402,10 @@ test('the app waits for the database before starting (depends_on db with conditi ); }); +test('the database volume persists across restart and recreate (db mounts the named db-data volume)', () => { + assertDbVolume(parseYaml(read(COMPOSE_PATH))); +}); + test('the application image is defined by a committed multi-stage Dockerfile', () => { assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); const dockerfile = read(DOCKERFILE_PATH); @@ -478,6 +542,101 @@ test('docker compose up -d starts the database and application containers', { sk } }); +test('a database fixture survives docker compose restart and recreate (named db-data volume)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { + // T04 acceptance criteria: the database volume persists across restart and + // across recreate. The probe writes a fixture row through the running db + // service, then asserts it is still there after `docker compose restart` + // (containers restarted in place) and after `docker compose down` + + // `docker compose up -d` (containers **recreated** — the container + // filesystem is discarded, so only the named `db-data` volume can carry + // the data across). `docker compose down -v` (the issue's rollback note) + // cleans up in `finally` so runs stay isolated. Credentials use the + // committed defaults, the same ones the app's DATABASE_URL hardcodes. + const execPsql = (args) => + run('docker', ['compose', 'exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], { + cwd: REPO_ROOT, + timeout: 60_000, + }); + const createFixture = () => + execPsql([ + '-v', 'ON_ERROR_STOP=1', '-c', + "CREATE TABLE t04_persist_probe (note text); INSERT INTO t04_persist_probe VALUES ('t04-fixture');", + ]); + const countFixture = () => + execPsql(['-tA', '-c', 'SELECT count(*) FROM t04_persist_probe;']); + + const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT }); + assert.equal( + up.status, + 0, + `"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), + ); + try { + waitForDbHealthy(); + + const created = createFixture(); + assert.equal( + created.status, + 0, + `fixture creation via psql must succeed:\n${(created.stdout || '')}\n${(created.stderr || '')}`.trim(), + ); + + // Acceptance 1 — "database volume persists across restart": `docker + // compose restart` stops and starts the same containers; the fixture + // must still be queryable afterwards. + const restart = run('docker', ['compose', 'restart'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + restart.status, + 0, + `"docker compose restart" must exit 0:\n${(restart.stdout || '')}\n${(restart.stderr || '')}`.trim(), + ); + waitForDbHealthy(); + const afterRestart = countFixture(); + assert.equal( + afterRestart.status, + 0, + `fixture query after restart must succeed:\n${(afterRestart.stdout || '')}\n${(afterRestart.stderr || '')}`.trim(), + ); + assert.match( + afterRestart.stdout.trim(), + /^1$/m, + `the database fixture must survive "docker compose restart" (volume persists across restart; got: "${afterRestart.stdout.trim()}")`, + ); + + // Acceptance 2 — "database volume persists across recreate": `docker + // compose down` (without -v, so named volumes survive) removes the + // containers, then `docker compose up -d` recreates them from scratch — + // the only thing that can carry the fixture across is the named volume. + const down = run('docker', ['compose', 'down'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + down.status, + 0, + `"docker compose down" must exit 0:\n${(down.stdout || '')}\n${(down.stderr || '')}`.trim(), + ); + const upAgain = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT, timeout: 120_000 }); + assert.equal( + upAgain.status, + 0, + `"docker compose up -d" after down must exit 0:\n${(upAgain.stdout || '')}\n${(upAgain.stderr || '')}`.trim(), + ); + waitForDbHealthy(); + const afterRecreate = countFixture(); + assert.equal( + afterRecreate.status, + 0, + `fixture query after recreate must succeed:\n${(afterRecreate.stdout || '')}\n${(afterRecreate.stderr || '')}`.trim(), + ); + assert.match( + afterRecreate.stdout.trim(), + /^1$/m, + `the database fixture must survive "docker compose down" + "docker compose up -d" (volume persists across recreate; got: "${afterRecreate.stdout.trim()}")`, + ); + } finally { + // Rollback note from the issue: `docker compose down -v` resets the data. + run('docker', ['compose', 'down', '-v'], { cwd: REPO_ROOT, timeout: 120_000 }); + } +}); + // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- @@ -563,3 +722,33 @@ test('a Dockerfile without the runtime entrypoint fails the image criterion (mut }; assert.throws(asserts, /compiled entrypoint/); }); + +test('removing the db volume mount makes the persistence criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutMount = text.replace( + ' volumes:\n - db-data:/var/lib/postgresql/data\n', + '', + ); + assert.notEqual(withoutMount, text, 'the mutation must actually remove the db volume mount'); + const compose = parseYaml(withoutMount); + assert.throws(() => assertDbVolume(compose), /db-data/); +}); + +test('removing the named db-data volume declaration makes the persistence criterion fail (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const withoutVolume = text.replace('volumes:\n db-data:\n', ''); + assert.notEqual(withoutVolume, text, 'the mutation must actually remove the top-level volumes declaration'); + const compose = parseYaml(withoutVolume); + assert.throws(() => assertDbVolume(compose), /db-data/); +}); + +test('mounting the volume at the wrong path fails the persistence criterion (mutation probe)', () => { + const text = read(COMPOSE_PATH); + const wrongPath = text.replace( + 'db-data:/var/lib/postgresql/data', + 'db-data:/var/lib/postgresql', + ); + assert.notEqual(wrongPath, text, 'the mutation must actually change the mount target'); + const compose = parseYaml(wrongPath); + assert.throws(() => assertDbVolume(compose), /data directory/); +});