The ledger already knew we had ordered from a place; it did not know the food was bad. Orders got repeated from places we disliked because nobody remembered by the time the next one went in. That is what the receipt ingestion was for (ING-9) and the last piece was missing: order_reviews existed as a table with no API, no UI and no writes. Four levels, not three. "Loved" and "liked" are both "would order again" but only one is worth a detour, and "ok" is not a recommendation. A verdict belongs to a person, not to an order. A shared meal produces two opinions and they routinely disagree — that disagreement is the useful part, and the old UNIQUE on transaction_id alone could not hold it. Now UNIQUE (transaction_id, participant_id), and the default is the signed-in user rather than the owner: Sonu authenticates through the same Traefik OAuth as participant 4, so an owner default would have filed her verdict under his name. Per-item opinions key on the item DESCRIPTION, not its index. An index is meaningless across orders; "the Pad Thai here is good" is the signal that has to survive into the next order from the same merchant. Only the two poles are offered — a per-item "ok" answers neither of the questions you ask at order time. Sharing is recorded as a real 50/50 split, not a decorative flag. The split already IS the record that an order was shared, and two records of one fact drift apart. An ABSENT item_verdicts means "leave them alone"; an empty array clears them. Without that distinction a note-only save silently wipes every per-item opinion — the same shape as the bug that reset `settled` on split rewrites, and just as invisible on screen. Mutation-tested: making keepItems a no-op fails exactly one test. mockDbWithPool gained queryRow. Omitting an export from the mock makes it undefined at the call site, which fails as "not a function" and reads like a code bug rather than a test-harness gap.
95 lines
3.1 KiB
TypeScript
95 lines
3.1 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;
|
|
},
|
|
prisma: p,
|
|
}));
|
|
}
|
|
|
|
/** Wipe all data tables and restart sequences between tests. */
|
|
export async function resetDB(pool: 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;
|
|
}
|