diff --git a/tests/billMerchantRuleService.test.js b/tests/billMerchantRuleService.test.js new file mode 100644 index 0000000..85ef9f5 --- /dev/null +++ b/tests/billMerchantRuleService.test.js @@ -0,0 +1,67 @@ +'use strict'; + +// Phase 1 — billMerchantRuleService.syncBillPaymentsFromSimplefin is a money- +// CREATING path: it turns matching unmatched bank transactions into payments +// (payment_source='provider_sync'). Untested until now. Pins: a rule-matched +// transaction becomes exactly one payment (amount in cents, tx marked matched), +// it's idempotent (re-running creates no duplicate — match-status filter + the +// UNIQUE(transaction_id) index), and a non-matching transaction is left alone. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-merchantrule-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService'); + +let db, userId, billId; +const insertTx = (payee, amountCents, postedDate) => db.prepare(` + INSERT INTO transactions (user_id, source_type, amount, payee, posted_date, match_status, ignored, pending) + VALUES (?, 'simplefin', ?, ?, ?, 'unmatched', 0, 0) +`).run(userId, amountCents, payee, postedDate).lastInsertRowid; +const activePayments = () => db.prepare("SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL").all(billId); + +test.before(() => { + db = getDb(); + userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('sync-user','x','user',1)").run().lastInsertRowid; + billId = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Netflix', 15, 1500, 50000, 0, 1)").run(userId).lastInsertRowid; + addMerchantRule(db, userId, billId, 'netflix'); +}); +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} } +}); + +test('a rule-matched unmatched transaction becomes exactly one provider_sync payment', () => { + const txId = insertTx('NETFLIX', -1500, '2026-07-15'); // $15 charge (cents, negative = money out) + const result = syncBillPaymentsFromSimplefin(db, userId, billId); + assert.equal(result.added, 1, 'one payment created'); + + const pays = activePayments(); + assert.equal(pays.length, 1); + assert.equal(pays[0].amount, 1500, 'amount stored in cents'); + assert.equal(pays[0].payment_source, 'provider_sync'); + assert.equal(pays[0].transaction_id, txId, 'linked to the source transaction'); + + const tx = db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(txId); + assert.equal(tx.match_status, 'matched', 'transaction marked matched'); + assert.equal(tx.matched_bill_id, billId); +}); + +test('re-running is idempotent — no duplicate payment', () => { + const result = syncBillPaymentsFromSimplefin(db, userId, billId); + assert.equal(result.added, 0, 'nothing new to add on a second sync'); + assert.equal(activePayments().length, 1, 'still exactly one payment'); +}); + +test('a non-matching transaction is left untouched', () => { + const spotifyTx = insertTx('SPOTIFY USA', -999, '2026-07-16'); + const result = syncBillPaymentsFromSimplefin(db, userId, billId); + assert.equal(result.added, 0, 'Spotify does not match the netflix rule'); + assert.equal(activePayments().length, 1); + assert.equal(db.prepare('SELECT match_status FROM transactions WHERE id = ?').get(spotifyTx).match_status, 'unmatched', 'left unmatched'); +}); diff --git a/tests/spendingService.test.js b/tests/spendingService.test.js new file mode 100644 index 0000000..01598f5 --- /dev/null +++ b/tests/spendingService.test.js @@ -0,0 +1,51 @@ +'use strict'; + +// Phase 1 — spendingService budgets were untested. Pins the dollars↔cents +// round-trip and the set/update/clear semantics of setSpendingBudget / +// getSpendingBudgets (getSpendingSummary's gating is separately reconciled +// against the Tracker in tests/reconciliation.test.js). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-spendsvc-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { setSpendingBudget, getSpendingBudgets } = require('../services/spendingService'); + +let db, userId, catId; +const rawCents = () => db.prepare('SELECT amount FROM spending_budgets WHERE user_id=? AND category_id=? AND year=2026 AND month=7').get(userId, catId)?.amount; + +test.before(() => { + db = getDb(); + userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('spend-user','x','user',1)").run().lastInsertRowid; + catId = db.prepare("INSERT INTO categories (user_id, name, spending_enabled) VALUES (?, 'Groceries', 1)").run(userId).lastInsertRowid; +}); +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} } +}); + +test('setSpendingBudget stores cents and getSpendingBudgets returns dollars', () => { + setSpendingBudget(db, userId, catId, 2026, 7, 200); + assert.equal(rawCents(), 20000, 'stored as integer cents'); + const budgets = getSpendingBudgets(db, userId, 2026, 7); + assert.deepEqual(budgets, [{ category_id: catId, amount: 200, category_name: 'Groceries' }], 'returned in dollars'); +}); + +test('setting the same category again updates in place (ON CONFLICT), no duplicate row', () => { + setSpendingBudget(db, userId, catId, 2026, 7, 350.50); + assert.equal(rawCents(), 35050); + const budgets = getSpendingBudgets(db, userId, 2026, 7); + assert.equal(budgets.length, 1, 'still one budget row for the category/month'); + assert.equal(budgets[0].amount, 350.5); +}); + +test('setting null clears the budget', () => { + setSpendingBudget(db, userId, catId, 2026, 7, null); + assert.equal(rawCents(), undefined, 'row deleted'); + assert.deepEqual(getSpendingBudgets(db, userId, 2026, 7), []); +});