test(qa): export→import round-trip preserves money (B9 data integrity)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
c31d8cbe9e
commit
5ffe2db85a
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue