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:
2026-04-01 19:59:29 +11:00
parent 7491e70a15
commit 1296555f17
12 changed files with 2036 additions and 74 deletions
+1249 -5
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -6,7 +6,12 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "vitest run --config vitest.config.ts",
"test:watch": "vitest --config vitest.config.ts",
"test:setup": "bash scripts/setup-test-db.sh",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:all": "npm test && npm run test:integration"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
@@ -25,9 +30,12 @@
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.2"
} }
} }
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Creates the personal_test database and writes .env.test
# Run once before integration tests: npm run test:setup
set -e
PG_CONTAINER=postgres-personal
PG_USER=personal
PG_PASS=personalpassword123
TEST_DB=personal_test
# Discover the container's bridge IP (accessible from the host on Linux)
PG_HOST=$(docker inspect "$PG_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
if [ -z "$PG_HOST" ]; then
echo "ERROR: Could not find container '$PG_CONTAINER'"
exit 1
fi
echo "Postgres container at $PG_HOST:5432"
# Create test database (ignore error if it already exists)
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
# Wipe and rebuild schema from production (schema only, no data)
echo "Copying schema from $PG_USER to $TEST_DB..."
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
| docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q
echo "Schema ready."
# Write .env.test
cat > "$(dirname "$0")/../.env.test" << EOF
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
EOF
echo ".env.test written — integration tests can now run with: npm run test:integration"
+83
View File
@@ -0,0 +1,83 @@
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;
},
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,
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,
overrides.transaction_date ?? "2024-06-15",
overrides.description ?? "Test transaction",
overrides.amount ?? 100,
overrides.transaction_type ?? "debit",
overrides.category ?? "other",
]
);
return r.rows[0].id as number;
}
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { createPool, resetDB } from "./helpers";
const pool = createPool();
beforeEach(async () => {
await resetDB(pool);
});
afterAll(async () => {
await pool.end();
});
// This tests the name-substitution logic applied in /api/participants GET.
// The rule: the participant matching the current user's ID gets name "Me";
// everyone else keeps their real name.
function substituteMe(
participants: { id: number; name: string }[],
currentUserId: number
) {
return participants.map((p) =>
p.id === currentUserId ? { ...p, name: "Me" } : p
);
}
describe("participant Me substitution", () => {
it("replaces the current user's name with Me", async () => {
const r = await pool.query(
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
);
const userId = r.rows[0].id;
const participants = [
{ id: userId, name: "Siddharth" },
{ id: userId + 1, name: "Sonu" },
];
const result = substituteMe(participants, userId);
expect(result.find((p) => p.id === userId)?.name).toBe("Me");
expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu");
});
it("leaves all names unchanged when currentUserId does not match", () => {
const participants = [
{ id: 1, name: "Siddharth" },
{ id: 2, name: "Sonu" },
];
const result = substituteMe(participants, 999);
expect(result).toEqual(participants);
});
it("Sonu sees Me for herself and Siddharth for the primary user", async () => {
const r1 = await pool.query(
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
);
const r2 = await pool.query(
`INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id`
);
const siddharthId = r1.rows[0].id;
const sonuId = r2.rows[0].id;
const rawParticipants = [
{ id: siddharthId, name: "Siddharth" },
{ id: sonuId, name: "Sonu" },
];
// Siddharth's view
const siddharthView = substituteMe(rawParticipants, siddharthId);
expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me");
expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu");
// Sonu's view
const sonuView = substituteMe(rawParticipants, sonuId);
expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth");
expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me");
});
});
+255
View File
@@ -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);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { CATEGORIES, formatCategory } from "@/lib/categories";
describe("formatCategory", () => {
it("capitalises single word", () => {
expect(formatCategory("groceries")).toBe("Groceries");
});
it("capitalises and spaces underscore-separated words", () => {
expect(formatCategory("home_goods")).toBe("Home Goods");
});
it("handles three-word categories", () => {
expect(formatCategory("home_maintenance")).toBe("Home Maintenance");
});
it("handles cash_advance", () => {
expect(formatCategory("cash_advance")).toBe("Cash Advance");
});
it("handles personal_care", () => {
expect(formatCategory("personal_care")).toBe("Personal Care");
});
it("handles single-word categories without underscores", () => {
expect(formatCategory("travel")).toBe("Travel");
expect(formatCategory("fees")).toBe("Fees");
expect(formatCategory("other")).toBe("Other");
});
});
describe("CATEGORIES", () => {
it("contains expected core categories", () => {
const cats = CATEGORIES as readonly string[];
expect(cats).toContain("groceries");
expect(cats).toContain("dining");
expect(cats).toContain("transport");
expect(cats).toContain("health");
expect(cats).toContain("other");
});
it("has no duplicates", () => {
const cats = CATEGORIES as readonly string[];
expect(new Set(cats).size).toBe(cats.length);
});
it("all entries are lowercase with only letters and underscores", () => {
for (const cat of CATEGORIES) {
expect(cat).toMatch(/^[a-z_]+$/);
}
});
it("formatCategory produces unique display names", () => {
const formatted = CATEGORIES.map(formatCategory);
expect(new Set(formatted).size).toBe(formatted.length);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { evaluateCondition, type Condition, type TxFields } from "@/lib/rules";
function tx(overrides: Partial<TxFields> = {}): TxFields {
return {
effective_category: "groceries",
effective_merchant: "Coles",
description: "COLES WYNDHAM VALE",
bank_name: "ANZ",
amount: 42.5,
transaction_type: "debit",
tags: [],
...overrides,
};
}
function cond(field: Condition["field"], operator: Condition["operator"], value: string): Condition {
return { field, operator, value };
}
// ── String fields ─────────────────────────────────────────────────────────────
describe("merchant_normalized", () => {
it("contains — matches substring", () => {
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx())).toBe(true);
});
it("contains — case-insensitive", () => {
expect(evaluateCondition(cond("merchant_normalized", "contains", "COLES"), tx())).toBe(true);
});
it("contains — no match", () => {
expect(evaluateCondition(cond("merchant_normalized", "contains", "woolworths"), tx())).toBe(false);
});
it("equals — exact match (case-insensitive)", () => {
expect(evaluateCondition(cond("merchant_normalized", "equals", "coles"), tx())).toBe(true);
});
it("equals — no match", () => {
expect(evaluateCondition(cond("merchant_normalized", "equals", "cole"), tx())).toBe(false);
});
it("starts_with — matches prefix", () => {
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "col"), tx())).toBe(true);
});
it("starts_with — no match", () => {
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "oles"), tx())).toBe(false);
});
it("not_equals — different value", () => {
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "woolworths"), tx())).toBe(true);
});
it("not_equals — same value", () => {
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "coles"), tx())).toBe(false);
});
it("empty merchant falls back to empty string", () => {
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx({ effective_merchant: "" }))).toBe(false);
});
});
describe("description", () => {
it("contains — matches", () => {
expect(evaluateCondition(cond("description", "contains", "wyndham"), tx())).toBe(true);
});
it("equals — exact (case-insensitive)", () => {
expect(evaluateCondition(cond("description", "equals", "coles wyndham vale"), tx())).toBe(true);
});
it("starts_with", () => {
expect(evaluateCondition(cond("description", "starts_with", "coles"), tx())).toBe(true);
});
});
describe("category", () => {
it("equals category", () => {
expect(evaluateCondition(cond("category", "equals", "groceries"), tx())).toBe(true);
});
it("not_equals different category", () => {
expect(evaluateCondition(cond("category", "not_equals", "dining"), tx())).toBe(true);
});
it("contains partial", () => {
expect(evaluateCondition(cond("category", "contains", "grocer"), tx())).toBe(true);
});
});
describe("bank_name", () => {
it("equals bank", () => {
expect(evaluateCondition(cond("bank_name", "equals", "anz"), tx())).toBe(true);
});
it("not_equals different bank", () => {
expect(evaluateCondition(cond("bank_name", "not_equals", "nab"), tx())).toBe(true);
});
});
describe("transaction_type", () => {
it("equals debit", () => {
expect(evaluateCondition(cond("transaction_type", "equals", "debit"), tx())).toBe(true);
});
it("not_equals credit", () => {
expect(evaluateCondition(cond("transaction_type", "not_equals", "credit"), tx())).toBe(true);
});
it("equals credit — no match on debit tx", () => {
expect(evaluateCondition(cond("transaction_type", "equals", "credit"), tx())).toBe(false);
});
});
// ── Amount field ──────────────────────────────────────────────────────────────
describe("amount", () => {
it("equals exact amount", () => {
expect(evaluateCondition(cond("amount", "equals", "42.5"), tx())).toBe(true);
});
it("equals wrong amount", () => {
expect(evaluateCondition(cond("amount", "equals", "42"), tx())).toBe(false);
});
it("not_equals different amount", () => {
expect(evaluateCondition(cond("amount", "not_equals", "100"), tx())).toBe(true);
});
it("gt — amount is greater", () => {
expect(evaluateCondition(cond("amount", "gt", "40"), tx())).toBe(true);
});
it("gt — amount is equal (not strictly greater)", () => {
expect(evaluateCondition(cond("amount", "gt", "42.5"), tx())).toBe(false);
});
it("gt — amount is less", () => {
expect(evaluateCondition(cond("amount", "gt", "50"), tx())).toBe(false);
});
it("lt — amount is less", () => {
expect(evaluateCondition(cond("amount", "lt", "50"), tx())).toBe(true);
});
it("lt — amount is equal (not strictly less)", () => {
expect(evaluateCondition(cond("amount", "lt", "42.5"), tx())).toBe(false);
});
it("lt — amount is greater", () => {
expect(evaluateCondition(cond("amount", "lt", "40"), tx())).toBe(false);
});
it("unsupported operator (contains) returns false", () => {
expect(evaluateCondition(cond("amount", "contains", "42"), tx())).toBe(false);
});
});
// ── Tag field ────────────────────────────────────────────────────────────────
describe("tag", () => {
it("equals — tag present", () => {
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(true);
});
it("equals — tag absent", () => {
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [] }))).toBe(false);
});
it("equals — different tag", () => {
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 7 }] }))).toBe(false);
});
it("not_equals — tag absent", () => {
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [] }))).toBe(true);
});
it("not_equals — tag present", () => {
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(false);
});
it("matches one of multiple tags", () => {
expect(evaluateCondition(cond("tag", "equals", "3"), tx({ tags: [{ id: 1 }, { id: 3 }] }))).toBe(true);
});
});
// ── Unknown field ─────────────────────────────────────────────────────────────
describe("unknown field", () => {
it("returns false for unrecognised field", () => {
// @ts-expect-error intentional invalid field for regression guard
expect(evaluateCondition({ field: "nonexistent", operator: "equals", value: "x" }, tx())).toBe(false);
});
});
+1 -67
View File
@@ -2,24 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries"; import { getTransactions } from "@/lib/queries";
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
interface Condition {
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type" | "tag";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
interface SplitEntry {
participant_id: number;
share_percent: number;
}
interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
apply_split?: SplitEntry[];
}
interface SnapshotEntry { interface SnapshotEntry {
transaction_id: number; transaction_id: number;
@@ -30,55 +13,6 @@ interface SnapshotEntry {
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[]; prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
} }
interface TxFields {
effective_category: string;
effective_merchant: string;
description: string;
bank_name: string;
amount: number;
transaction_type: string;
tags: { id: number }[];
}
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
if (cond.field === "amount") {
const numVal = Number(tx.amount);
const numCond = Number(cond.value);
switch (cond.operator) {
case "equals": return numVal === numCond;
case "not_equals": return numVal !== numCond;
case "gt": return numVal > numCond;
case "lt": return numVal < numCond;
default: return false;
}
}
if (cond.field === "tag") {
const tagId = Number(cond.value);
const hasTag = tx.tags.some((t) => t.id === tagId);
return cond.operator === "not_equals" ? !hasTag : hasTag;
}
let fieldVal: string;
switch (cond.field) {
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
case "description": fieldVal = tx.description || ""; break;
case "category": fieldVal = tx.effective_category || ""; break;
case "bank_name": fieldVal = tx.bank_name || ""; break;
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
default: return false;
}
const strVal = fieldVal.toLowerCase();
const strCond = cond.value.toLowerCase();
switch (cond.operator) {
case "contains": return strVal.includes(strCond);
case "equals": return strVal === strCond;
case "starts_with": return strVal.startsWith(strCond);
case "not_equals": return strVal !== strCond;
default: return false;
}
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
+74
View File
@@ -0,0 +1,74 @@
export interface Condition {
field:
| "merchant_normalized"
| "description"
| "category"
| "bank_name"
| "amount"
| "transaction_type"
| "tag";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
export interface SplitEntry {
participant_id: number;
share_percent: number;
}
export interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
apply_split?: SplitEntry[];
}
export interface TxFields {
effective_category: string;
effective_merchant: string;
description: string;
bank_name: string;
amount: number;
transaction_type: string;
tags: { id: number }[];
}
export function evaluateCondition(cond: Condition, tx: TxFields): boolean {
if (cond.field === "amount") {
const numVal = Number(tx.amount);
const numCond = Number(cond.value);
switch (cond.operator) {
case "equals": return numVal === numCond;
case "not_equals": return numVal !== numCond;
case "gt": return numVal > numCond;
case "lt": return numVal < numCond;
default: return false;
}
}
if (cond.field === "tag") {
const tagId = Number(cond.value);
const hasTag = tx.tags.some((t) => t.id === tagId);
return cond.operator === "not_equals" ? !hasTag : hasTag;
}
let fieldVal: string;
switch (cond.field) {
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
case "description": fieldVal = tx.description || ""; break;
case "category": fieldVal = tx.effective_category || ""; break;
case "bank_name": fieldVal = tx.bank_name || ""; break;
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
default: return false;
}
const strVal = fieldVal.toLowerCase();
const strCond = cond.value.toLowerCase();
switch (cond.operator) {
case "contains": return strVal.includes(strCond);
case "equals": return strVal === strCond;
case "starts_with": return strVal.startsWith(strCond);
case "not_equals": return strVal !== strCond;
default: return false;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["src/__tests__/unit/**/*.test.ts"],
},
});
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
// Load .env.test into process.env HERE, at config-load time.
// Forked workers inherit the parent process env, so Prisma's singleton
// will see the test DATABASE_URL when db.ts is first imported.
const envTestPath = resolve(__dirname, ".env.test");
if (existsSync(envTestPath)) {
for (const line of readFileSync(envTestPath, "utf-8").split("\n")) {
const m = line.match(/^([^#=]+)=(.*)/);
if (m) process.env[m[1].trim()] = m[2].trim();
}
}
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["src/__tests__/integration/**/*.test.ts"],
pool: "forks",
},
});