From 5ffe2db85a9351ccc098a7714cc1c68619e039c5 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 2 Jul 2026 22:26:49 -0500 Subject: [PATCH] =?UTF-8?q?test(qa):=20export=E2=86=92import=20round-trip?= =?UTF-8?q?=20preserves=20money=20(B9=20data=20integrity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract buildUserDbExportFile() from routes/export.js so the SQLite user-DB export is testable (route behavior unchanged) - tests/exportImportRoundTrip.test.js: export user A (bill/payment/override) → import into fresh user B → assert all money survives exactly in cents. Confirms the export(fromCents)/import(toCents) conversion is symmetric — no 100x drift — and guards it from regressing. Co-Authored-By: Claude Opus 4.8 --- routes/export.js | 14 +++++-- tests/exportImportRoundTrip.test.js | 63 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/exportImportRoundTrip.test.js diff --git a/routes/export.js b/routes/export.js index e02808c..e8e5423 100644 --- a/routes/export.js +++ b/routes/export.js @@ -172,9 +172,11 @@ router.get('/user-excel', (req, res) => { res.send(buffer); }); -router.get('/user-db', (req, res) => { - const data = getUserExportData(req.user.id); - const file = path.join(os.tmpdir(), `bill-tracker-user-${req.user.id}-${Date.now()}.sqlite`); +// Build the SQLite user-DB export to a temp file and return its path. Extracted +// so the export→import round-trip can be regression-tested (exportImportRoundTrip). +function buildUserDbExportFile(userId) { + const data = getUserExportData(userId); + const file = path.join(os.tmpdir(), `bill-tracker-user-${userId}-${Date.now()}.sqlite`); const out = new Database(file); try { out.exec(` @@ -214,9 +216,15 @@ router.get('/user-db', (req, res) => { } finally { out.close(); } + return file; +} + +router.get('/user-db', (req, res) => { + const file = buildUserDbExportFile(req.user.id); res.download(file, 'bill-tracker-user-export.sqlite', () => { try { fs.unlinkSync(file); } catch {} }); }); module.exports = router; +module.exports.buildUserDbExportFile = buildUserDbExportFile; diff --git a/tests/exportImportRoundTrip.test.js b/tests/exportImportRoundTrip.test.js new file mode 100644 index 0000000..06504e5 --- /dev/null +++ b/tests/exportImportRoundTrip.test.js @@ -0,0 +1,63 @@ +'use strict'; + +// B9: export → import round-trip must preserve money exactly. The export writes +// dollars (fromCents) into REAL columns and has no schema_migrations table, so the +// import treats it as dollars and applies toCents — a symmetric conversion. This +// guards against that symmetry breaking (which would 100x amounts on import). +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-roundtrip-test-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { buildUserDbExportFile } = require('../routes/export'); +const { previewUserDbImport, applyUserDbImport } = require('../services/userDbImportService'); + +const mkUser = (db, name) => + db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)").run(name).lastInsertRowid; + +test('export → import round-trip preserves money in cents (no 100x drift)', async () => { + const db = getDb(); + const userA = mkUser(db, 'rt-a'); + const userB = mkUser(db, 'rt-b'); + + const catA = db.prepare("INSERT INTO categories (user_id, name) VALUES (?, 'Utilities')").run(userA).lastInsertRowid; + const billA = db.prepare(` + INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, cycle_type, expected_amount, active) + VALUES (?, 'Electric Company', ?, 15, 'monthly', 'monthly', 8500, 1) + `).run(userA, catA).lastInsertRowid; // $85.00 + db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 8500, '2026-06-15', 'manual')").run(billA); + db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, 2026, 7, 5000)').run(billA); // $50 override + + // Export user A, import into fresh user B. + const file = buildUserDbExportFile(userA); + try { + const buffer = fs.readFileSync(file); + const preview = await previewUserDbImport(userB, buffer, {}); + assert.ok(preview.import_session_id, 'preview returns an import session id'); + await applyUserDbImport(userB, preview.import_session_id, {}); + } finally { + try { fs.unlinkSync(file); } catch { /* ignore */ } + } + + const billB = db.prepare("SELECT expected_amount FROM bills WHERE user_id = ? AND name = 'Electric Company'").get(userB); + assert.ok(billB, 'bill imported for user B'); + assert.equal(billB.expected_amount, 8500, 'bill expected_amount preserved as cents'); + + const payB = db.prepare('SELECT p.amount FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ?').get(userB); + assert.equal(payB.amount, 8500, 'payment amount preserved as cents'); + + const stateB = db.prepare('SELECT m.actual_amount FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ?').get(userB); + assert.equal(stateB.actual_amount, 5000, 'per-month override preserved as cents'); +}); + +test.after(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ } + } +});