test: add unit and integration test suites
- 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
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it.
|
||||
// vi.doMock is NOT hoisted so it can close over the pool instance.
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── getTransactions ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getTransactions — owner scoping", () => {
|
||||
it("returns only the owner's transactions", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Alice groceries" });
|
||||
await insertTransaction(pool, otherId, { description: "Bob petrol" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Alice groceries");
|
||||
});
|
||||
|
||||
it("includes transactions where owner is a split participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" });
|
||||
|
||||
// Add Alice as a split participant
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data.some((t) => t.description === "Shared dinner")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns correct total count", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "tx1" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx2" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx3" });
|
||||
|
||||
const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — date filters", () => {
|
||||
it("filters by from date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("new tx");
|
||||
});
|
||||
|
||||
it("filters by to date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("old tx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — category filter", () => {
|
||||
it("filters by category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
||||
await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Grocery run");
|
||||
});
|
||||
|
||||
it("category override takes precedence over raw category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { category: "dining" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`,
|
||||
[txId]
|
||||
);
|
||||
|
||||
const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 });
|
||||
const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(dining).toHaveLength(0); // override hides original
|
||||
expect(groceries).toHaveLength(1); // override exposes new category
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — search filter", () => {
|
||||
it("searches description case-insensitively", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" });
|
||||
await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("COLES WYNDHAM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — amount filters", () => {
|
||||
it("filters by amount_min", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(200);
|
||||
});
|
||||
|
||||
it("filters by amount_max", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — pagination", () => {
|
||||
it("respects limit and offset", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` });
|
||||
}
|
||||
|
||||
const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 });
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page1.data[0].description).not.toBe(page2.data[0].description);
|
||||
expect(page1.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — splits and tags attached", () => {
|
||||
it("attaches empty arrays when no splits or tags", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toEqual([]);
|
||||
expect(data[0].tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("attaches split participants", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toHaveLength(1);
|
||||
expect(data[0].splits[0].name).toBe("Bob");
|
||||
expect(Number(data[0].splits[0].share_percent)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getParticipantBalances ────────────────────────────────────────────────────
|
||||
|
||||
describe("getParticipantBalances", () => {
|
||||
it("shows zero balance when no splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
void otherId;
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("calculates positive balance when participant owes owner", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Alice pays $100, Bob owes 50%
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance).toBeDefined();
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("reduces balance after recording a payment", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
// Bob pays Alice $30
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date)
|
||||
VALUES ($1, $2, 30, '2024-06-20')`,
|
||||
[otherId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it("shows negative balance when owner owes participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Bob pays $100 for a shared expense, Alice owes 50%
|
||||
const txId = await insertTransaction(pool, otherId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50);
|
||||
});
|
||||
|
||||
it("unsettled_count reflects open splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tx1 = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const tx2 = await insertTransaction(pool, ownerId, { amount: 80 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50), ($3, $4, 50)`,
|
||||
[tx1, otherId, tx2, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance!.unsettled_count).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user