From e9c5e4d1d3c6f297683779893bbe9aedd616b7bd Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 18:28:12 -0500 Subject: [PATCH] test(tracker): cover paymentAccounting + analytics money services (X2) Added tests for two untested money-critical services: - paymentAccountingService: bank-backed predicate, accounting-active SQL, and the manual-vs-bank override invariant (bank overrides provisional manual so it isn't double-counted; balance restored; manual reactivates + re-applies balance when the override is removed). - analyticsService: month-window math (year-boundary + leap edges) and validateSummaryQuery defaults/range errors. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 4 ++ tests/analyticsService.test.js | 68 ++++++++++++++++++++ tests/paymentAccountingService.test.js | 89 ++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 tests/analyticsService.test.js create mode 100644 tests/paymentAccountingService.test.js diff --git a/HISTORY.md b/HISTORY.md index 4492a2d..d1b99cb 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,10 @@ # Bill Tracker โ€” Changelog ## v0.41.0 +### ๐Ÿงช Money-service test coverage + +- **[Tests] Covered two untested money-critical services** โ€” the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2) + ### โšก Tracker performance - **[Tracker] Killed the getTracker N+1 (was ~2โ€“3 DB round-trips ร— N bills every home-page load)** โ€” inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months ร— 2) โ€” roughly 70โ€“450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving โ€” `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1) diff --git a/tests/analyticsService.test.js b/tests/analyticsService.test.js new file mode 100644 index 0000000..fe8ed78 --- /dev/null +++ b/tests/analyticsService.test.js @@ -0,0 +1,68 @@ +'use strict'; + +// X2 โ€” analyticsService's month-window math (which drives the expected-vs-actual +// reconciliation, the QA-B5-03 area) and query validation had no unit tests. +// These pin the pure helpers with hand-calculated examples, including the +// year-boundary and leap-year edges that off-by-one bugs hide in. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery, +} = require('../services/analyticsService'); + +test('monthKey zero-pads the month', () => { + assert.equal(monthKey(2026, 6), '2026-06'); + assert.equal(monthKey(2026, 12), '2026-12'); +}); + +test('monthLabel is a UTC short-month + 2-digit year (no TZ drift)', () => { + assert.equal(monthLabel(2026, 1), 'Jan 26'); + assert.equal(monthLabel(2026, 6), 'Jun 26'); + assert.equal(monthLabel(2025, 12), 'Dec 25'); +}); + +test('addMonths crosses year boundaries both ways', () => { + assert.deepEqual(addMonths(2026, 1, -1), { year: 2025, month: 12 }); + assert.deepEqual(addMonths(2026, 12, 1), { year: 2027, month: 1 }); + assert.deepEqual(addMonths(2026, 6, 0), { year: 2026, month: 6 }); + assert.deepEqual(addMonths(2026, 3, -6), { year: 2025, month: 9 }); +}); + +test('monthEndDate handles 30/31-day months and leap Februaries', () => { + assert.equal(monthEndDate(2026, 6), '2026-06-30'); + assert.equal(monthEndDate(2026, 7), '2026-07-31'); + assert.equal(monthEndDate(2026, 2), '2026-02-28'); // not a leap year + assert.equal(monthEndDate(2024, 2), '2024-02-29'); // leap year +}); + +test('buildMonths yields `count` contiguous months ending at (endYear, endMonth)', () => { + const months = buildMonths(2026, 3, 3); // Jan, Feb, Mar 2026 + assert.equal(months.length, 3); + assert.deepEqual(months.map(m => m.key), ['2026-01', '2026-02', '2026-03']); + assert.equal(months[0].start, '2026-01-01'); + assert.equal(months[2].end, '2026-03-31'); + assert.equal(months[2].label, 'Mar 26'); +}); + +test('buildMonths spanning a year boundary', () => { + const months = buildMonths(2026, 1, 3); // Nov 25, Dec 25, Jan 26 + assert.deepEqual(months.map(m => m.key), ['2025-11', '2025-12', '2026-01']); +}); + +test('validateSummaryQuery: defaults, parsing, and range errors', () => { + const ok = validateSummaryQuery({ year: '2026', month: '6', months: '12' }); + assert.equal(ok.error, undefined); + assert.equal(ok.year, 2026); + assert.equal(ok.month, 6); + assert.equal(ok.months, 12); + assert.equal(ok.includeSkipped, true, 'skipped included unless explicitly false'); + + assert.match(validateSummaryQuery({ month: '13' }, new Date('2026-06-15')).error, /month/); + assert.match(validateSummaryQuery({ months: '37' }, new Date('2026-06-15')).error, /months/); + assert.match(validateSummaryQuery({ year: '1999' }, new Date('2026-06-15')).error, /year/); + assert.match(validateSummaryQuery({ category_id: '-1' }, new Date('2026-06-15')).error, /category_id/); + + // include_skipped=false flips the flag + assert.equal(validateSummaryQuery({ include_skipped: 'false' }, new Date('2026-06-15')).includeSkipped, false); +}); diff --git a/tests/paymentAccountingService.test.js b/tests/paymentAccountingService.test.js new file mode 100644 index 0000000..1ce9e9d --- /dev/null +++ b/tests/paymentAccountingService.test.js @@ -0,0 +1,89 @@ +'use strict'; + +// X2 โ€” the payment-accounting source-of-truth layer (manual vs. bank) had no +// dedicated tests. Covers the bank-backed predicate, the accounting-active SQL +// fragment, and the core money-integrity invariant: when a bank payment lands it +// overrides a provisional manual payment (so it isn't double-counted) and the +// manual one counts again โ€” with the balance restored/re-applied โ€” if the bank +// override is later removed. +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-payacct-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { + isBankBackedPayment, + accountingActiveSql, + markProvisionalManualPaymentsOverridden, + reactivatePaymentsOverriddenBy, +} = require('../services/paymentAccountingService'); + +test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => { + assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true); + assert.equal(isBankBackedPayment({ payment_source: 'transaction_match' }), true); + assert.equal(isBankBackedPayment({ payment_source: 'auto_match' }), true); + assert.equal(isBankBackedPayment({ payment_source: 'manual', transaction_id: 42 }), true); + assert.equal(isBankBackedPayment({ payment_source: 'manual' }), false); + assert.equal(isBankBackedPayment({}), false); +}); + +test('accountingActiveSql fragment, with and without a table alias', () => { + assert.equal(accountingActiveSql(), 'COALESCE(accounting_excluded, 0) = 0'); + assert.equal(accountingActiveSql('p'), 'COALESCE(p.accounting_excluded, 0) = 0'); +}); + +test('bank payment overrides a provisional manual payment, then reactivates on removal', () => { + const db = getDb(); + const userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pa-user','x','user',1)").run().lastInsertRowid; + // $500 balance; a manual $100 payment already dropped it to $400. + const billId = db.prepare( + "INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Card', 10, 'monthly', 'monthly', 10000, 40000, 0, 1)", + ).run(userId).lastInsertRowid; + const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId); + + const manualId = db.prepare( + "INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, balance_delta) VALUES (?, 10000, '2026-06-10', 'manual', 'manual', -10000)", + ).run(billId).lastInsertRowid; + // Bank payment for the same bill/cycle (a matched SimpleFIN transaction). + const bankId = db.prepare( + "INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, transaction_id) VALUES (?, 10000, '2026-06-12', 'bank', 'transaction_match', 777)", + ).run(billId).lastInsertRowid; + const bankPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(bankId); + + const activeCount = () => db.prepare( + `SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()}`, + ).get(billId).c; + + // Both currently count โ€” the double-count we must resolve. + assert.equal(activeCount(), 2); + + const { overridden } = markProvisionalManualPaymentsOverridden(db, bill, bankPayment); + assert.equal(overridden, 1, 'the one provisional manual payment is overridden'); + + const manualAfter = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId); + assert.equal(manualAfter.accounting_excluded, 1); + assert.equal(manualAfter.overridden_by_payment_id, bankId); + assert.equal(manualAfter.balance_delta, null, 'overridden payment no longer holds a balance delta'); + assert.equal(activeCount(), 1, 'only the bank payment counts now โ€” no double count'); + // Its balance effect was reversed: $400 โ†’ $500. + assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 50000); + + // Bank override removed โ†’ the manual payment counts again and re-applies its balance. + const { reactivated } = reactivatePaymentsOverriddenBy(db, bankId); + assert.equal(reactivated, 1); + const manualReactivated = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId); + assert.equal(manualReactivated.accounting_excluded, 0); + assert.equal(manualReactivated.overridden_by_payment_id, null); + assert.equal(manualReactivated.balance_delta, -10000, 'balance delta re-applied'); + assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 40000, '$500 โ†’ $400 again'); +}); + +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} } +});