Four defects found reviewing a month of overseas wallet rows:
- Race: n8n posts a poll's items concurrently and both halves of a tap
usually share a poll, so each ran findPairedRow before the other inserted.
11 taps became 22 rows (A$3,841.40), identical created_at on every pair.
One advisory lock now covers re-check -> pair -> adopt -> insert, on the
transaction's own connection (a second pooled one per request deadlocks a
burst). Mutation-tested: without the lock the concurrent test fails 3/3.
- Pair window: amount within +/-1 DAY folded the UNICEF $50 donation and an
unrelated $50 Amex charge 17h later into one row and renamed it. A pair is
now minutes apart AND from two different apps. Needs when/where a
notification arrived: migration 0039 adds expense_metadata.notified_at /
notif_app, backfilled from capture.wallet_events (144 rows). A merged half
now records wallet_evt:<id>, so a replay cannot merge it twice.
- Not purchases: a CommBank overdraft notice became a $21.21 purchase named
"To avoid fees and interest," (the Westpac fixed-width parser ran on a
CommBank body). Account notices and transit-card balance drawdowns are
skipped; the descriptor parser only runs on Westpac.
- FX: with no ledger history (Hong Kong) every tap stored A$0.00. The ECB
rate via Frankfurter is the fallback, flagged rate_ecb; the ledger median
stays first because it carries the card's FX fee.
Also: dates in Australia/Melbourne like the statements (UTC slicing filed 28
of 134 rows a day early), and test:setup now actually rebuilds personal_test
-- replaying the dump over it never applied a migration.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton