test(db): resolve the integration DSN at run time instead of storing an IP (#161)
ci / lint-test (push) Successful in 1m5s

setup-test-db.sh wrote .env.test once with the Postgres container's bridge IP
baked in, and Docker reassigns those on recreation. The file rotted silently:
its stored 172.22.0.18 belongs to **gitea** as of today, and an earlier drift
had the suite pointing its TRUNCATE at **postgres-pantry** — which it would
have wiped had the credentials happened to match.

Root-cause fix: stop persisting a host at all.

- scripts/resolve-test-db.sh re-derives the DSN from the live container and
  rewrites .env.test. Resolution order is TEST_DATABASE_URL, then the
  container's published host port, then the bridge IP. `docker port
  postgres-personal` reports 127.0.0.1:5432 since the 2026-08-17 PG17
  migration, so loopback is what gets used here — and a loopback address
  cannot be reassigned to another container, which is the whole point.
  (Note this supersedes the smarthome CLAUDE.md's "postgres-personal publishes
  no host port"; that is now stale.) The bridge-IP fallback is kept for a host
  where the port is not published, and the script never prints the password.
- Wired as `pretest:integration`, so npm runs it before every integration run.
  .env.test becomes a generated artifact rather than stored configuration.
- setup-test-db.sh now only creates the database and copies the schema, then
  delegates to the resolver.
- helpers.ts::assertTestDatabase stays and its message now points at the
  resolver rather than at `docker inspect`. It still earns its place: running
  vitest directly bypasses the pretest hook, and a stale .env.test would then
  still be read.

Docs: CLAUDE.md gains a Testing section carrying this, the prisma-generate
trap for a fresh checkout or worktree, and the frozen-clock rule for tests
whose subject is defined relative to "now". AGENTS.md updated for the same, and
for lint now being blocking.
This commit is contained in:
2026-08-21 00:52:54 +10:00
parent eaf5d5ac66
commit b958d05287
6 changed files with 143 additions and 30 deletions
+14 -8
View File
@@ -30,12 +30,17 @@ export function mockDbWithPool(p: Pool) {
/**
* Refuse to truncate anything that is not the test database.
*
* `DATABASE_URL` in `.env.test` names the Postgres container by IP, and
* container IPs move on recreation: 172.22.0.47 stopped being
* `postgres-personal` and became `postgres-pantry`, so the suite spent a while
* pointing its TRUNCATE at another app's database. It only failed safe because
* the credentials happened not to match — had they matched, this would have
* wiped pantry-app.
* `.env.test` used to name the Postgres container by bridge IP, and Docker
* reassigns those on recreation: 172.22.0.47 stopped being `postgres-personal`
* and became `postgres-pantry`, so the suite spent a while pointing its
* TRUNCATE at another app's database. It only failed safe because the
* credentials happened not to match — had they matched, this would have wiped
* pantry-app.
*
* `scripts/resolve-test-db.sh` (wired as `pretest:integration`) now re-derives
* the DSN from the live container before every run, preferring the published
* loopback port. This check stays anyway: running vitest directly bypasses the
* pretest hook, and a stale `.env.test` would still be read.
*
* Checked once per process, before the first truncate.
*/
@@ -47,8 +52,9 @@ async function assertTestDatabase(pool: Pool) {
if (db !== "personal_test") {
throw new Error(
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
`Check DATABASE_URL in .env.test — the Postgres container IP may have changed ` +
`(docker inspect postgres-personal --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}').`
`Re-resolve the DSN with \`bash scripts/resolve-test-db.sh\` (or just run ` +
`\`npm run test:integration\`, which does it for you), and run ` +
`\`npm run test:setup\` if personal_test does not exist yet.`
);
}
targetVerified = true;