75 lines
3.4 KiB
JavaScript
75 lines
3.4 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
// P1 — computeAmountSuggestionsBatch replaces up to 12 per-bill queries with two
|
||
|
|
// batched queries in getTracker. This pins that the batched form is byte-identical
|
||
|
|
// to calling the single-bill computeAmountSuggestion per bill (the behavior we
|
||
|
|
// rely on for the N+1 fix to be safe).
|
||
|
|
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-amountsug-${process.pid}.sqlite`);
|
||
|
|
process.env.DB_PATH = dbPath;
|
||
|
|
|
||
|
|
const { getDb, closeDb } = require('../db/database');
|
||
|
|
const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService');
|
||
|
|
|
||
|
|
let db, userId, bills;
|
||
|
|
test.before(() => {
|
||
|
|
db = getDb();
|
||
|
|
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('as-user','x','user',1)").run().lastInsertRowid;
|
||
|
|
const insBill = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 10000, 1)");
|
||
|
|
bills = {
|
||
|
|
payments: insBill.run(userId, 'Payments only').lastInsertRowid, // suggestion from payment sums
|
||
|
|
mbs: insBill.run(userId, 'Corrected').lastInsertRowid, // user-corrected actual_amount wins
|
||
|
|
mixed: insBill.run(userId, 'Mixed').lastInsertRowid, // some months mbs, some payments
|
||
|
|
empty: insBill.run(userId, 'No history').lastInsertRowid, // → null suggestion
|
||
|
|
};
|
||
|
|
|
||
|
|
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
|
||
|
|
// For query month = 2026-06: prior months are May, Apr, Mar, Feb, Jan 2026, Dec 2025.
|
||
|
|
pay.run(bills.payments, 10000, '2026-05-15');
|
||
|
|
pay.run(bills.payments, 12000, '2026-04-15');
|
||
|
|
pay.run(bills.payments, 11000, '2026-03-15');
|
||
|
|
// Two payments in one month should sum.
|
||
|
|
pay.run(bills.mixed, 3000, '2026-05-10');
|
||
|
|
pay.run(bills.mixed, 2000, '2026-05-20'); // May total 5000
|
||
|
|
pay.run(bills.mixed, 9000, '2026-04-10');
|
||
|
|
|
||
|
|
const mbs = db.prepare("INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)");
|
||
|
|
mbs.run(bills.mbs, 2026, 5, 8000);
|
||
|
|
mbs.run(bills.mbs, 2026, 4, 8500);
|
||
|
|
mbs.run(bills.mbs, 2026, 3, 7500);
|
||
|
|
// Mixed: March has a corrected amount that should win over any payment sum.
|
||
|
|
mbs.run(bills.mixed, 2026, 3, 4200);
|
||
|
|
});
|
||
|
|
test.after(() => {
|
||
|
|
closeDb();
|
||
|
|
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||
|
|
});
|
||
|
|
|
||
|
|
test('batch output equals per-bill computeAmountSuggestion for every bill', () => {
|
||
|
|
const ids = Object.values(bills);
|
||
|
|
const batch = computeAmountSuggestionsBatch(db, ids, 2026, 6);
|
||
|
|
for (const id of ids) {
|
||
|
|
const single = computeAmountSuggestion(db, id, 2026, 6);
|
||
|
|
assert.deepEqual(batch.get(id), single, `bill ${id} batch matches single`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('empty bill list returns an empty map; no-history bill → null', () => {
|
||
|
|
assert.equal(computeAmountSuggestionsBatch(db, [], 2026, 6).size, 0);
|
||
|
|
const batch = computeAmountSuggestionsBatch(db, [bills.empty], 2026, 6);
|
||
|
|
assert.equal(batch.get(bills.empty), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('corrected monthly amount wins over payment sums (median of mbs values)', () => {
|
||
|
|
const s = computeAmountSuggestion(db, bills.mbs, 2026, 6);
|
||
|
|
// median of [8000, 8500, 7500] = 8000 → $80
|
||
|
|
assert.equal(s.suggestion, 80);
|
||
|
|
assert.equal(s.months_used, 3);
|
||
|
|
assert.equal(s.confidence, 'high');
|
||
|
|
});
|