Files
finance-app/src/__tests__/integration/helpers.ts
T
siddharthd 91586ce4a4
ci / lint-test (push) Successful in 45s
test(intake): the second review pass, and CLAUDE.md for the rules it set
Six of the nine fixes have a test that fails against the previous tree: the
metadata row's currency, the Create-path hold, two concurrent batches booking
once, mrkdwn escaping including plain_text, the escaped currency, and
candidate-row currency.

Three do not, and the reason is the same each time — the window needs a crash
or a lost race that a test cannot force. Asserted instead: a stale metadataId
falls back to the live row rather than reporting a phantom write; two writes
racing to create a metadata row both resolve to one; the create path records a
baseline at all. All three passed before as well.
2026-08-21 18:23:52 +10:00

148 lines
5.3 KiB
TypeScript

import { Pool } from "pg";
import { vi } from "vitest";
export function createPool() {
return new Pool({ connectionString: process.env.DATABASE_URL });
}
// Replace the app's Prisma-based queryRaw with a direct pg call so that
// tests don't depend on Prisma's singleton picking up the right DATABASE_URL.
// Must be called BEFORE dynamically importing any module that uses @/lib/db.
// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`.
export function mockDbWithPool(p: Pool) {
vi.resetModules(); // clear module cache so fresh imports pick up the mock
vi.doMock("@/lib/db", () => ({
queryRaw: async (sql: string, params: unknown[] = []) => {
const result = await p.query(sql, params);
return result.rows;
},
// Mirrors the real module: a mock that omits an export makes it `undefined`
// at the call site, so any route using queryRow fails with a confusing
// "not a function" rather than a query error.
queryRow: async (sql: string, params: unknown[] = []) => {
const result = await p.query(sql, params);
return result.rows[0] ?? null;
},
// Mirrors withTransaction: one connection, BEGIN/COMMIT around the callback,
// so pg_advisory_xact_lock actually holds across the statements inside it.
withTransaction: async (fn: (q: unknown) => Promise<unknown>) => {
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn({
queryRaw: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows,
queryRow: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows[0] ?? null,
});
await client.query("COMMIT");
return result;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
},
prisma: p,
}));
}
/**
* Refuse to truncate anything that is not the test database.
*
* `.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.
*/
let targetVerified = false;
async function assertTestDatabase(pool: Pool) {
if (targetVerified) return;
const { rows } = await pool.query<{ db: string }>(`SELECT current_database() AS db`);
const db = rows[0]?.db;
if (db !== "personal_test") {
throw new Error(
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
`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;
}
/** Wipe all data tables and restart sequences between tests. */
export async function resetDB(pool: Pool) {
await assertTestDatabase(pool);
await pool.query(`
TRUNCATE
split_payments,
transaction_splits,
transaction_tags,
transaction_overrides,
rule_apply_runs,
rules,
budgets,
account_owner_mappings,
transactions,
statements,
tags,
trips,
participants
RESTART IDENTITY CASCADE
`);
}
/** Seed two participants and return their IDs. */
export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) {
const r1 = await pool.query(
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
[names[0]]
);
const r2 = await pool.query(
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
[names[1]]
);
return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number };
}
/** Insert a manual transaction (no statement) and return its id. */
export async function insertTransaction(
pool: Pool,
ownerId: number,
overrides: {
description?: string;
amount?: number;
category?: string;
transaction_type?: string;
transaction_date?: string;
merchant_normalized?: string;
} = {}
): Promise<number> {
const r = await pool.query(
`INSERT INTO transactions
(owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
[
ownerId,
// Post-cutover by default: a split on an older transaction never counts
// towards a balance (ACTIVE_OBLIGATION), so a pre-cutover default would
// make every balance fixture silently read zero.
overrides.transaction_date ?? "2026-06-15",
overrides.description ?? "Test transaction",
overrides.amount ?? 100,
overrides.transaction_type ?? "debit",
overrides.category ?? "other",
]
);
return r.rows[0].id as number;
}