ci / lint-test (push) Successful in 45s
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.
148 lines
5.3 KiB
TypeScript
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;
|
|
}
|