BillTracker/tests/transactionMatchState.test.js

81 lines
3.7 KiB
JavaScript
Raw Normal View History

'use strict';
// IMP-CODE-03: the canonical match-state writers keep match_status,
// matched_bill_id and ignored moving together, so no path can leave the columns
// inconsistent (the QA-B5-04 class of bug).
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-match-state-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState');
let db, userId, otherId, billId;
function newTxn(uid, { status = 'unmatched', bill = null, ignored = 0 } = {}) {
return db.prepare(
'INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, ignored) VALUES (?, ?, ?, -1000, ?, ?, ?)',
).run(uid, 'manual', `ms-${Math.random().toString(36).slice(2)}`, status, bill, ignored).lastInsertRowid;
}
const row = (id) => db.prepare('SELECT match_status, matched_bill_id, ignored FROM transactions WHERE id = ?').get(id);
test.before(() => {
db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-user','x','user',1)").run().lastInsertRowid;
otherId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ms-other','x','user',1)").run().lastInsertRowid;
billId = db.prepare('INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, 1)').run(userId, 'Bill').lastInsertRowid;
});
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch {} }
});
test('markMatched sets status + bill together', () => {
const id = newTxn(userId);
const changes = markMatched(db, userId, id, billId);
assert.equal(changes, 1);
assert.deepEqual(row(id), { match_status: 'matched', matched_bill_id: billId, ignored: 0 });
});
test('markMatched leaves the ignored flag unless resetIgnored is set', () => {
const kept = newTxn(userId, { ignored: 1, status: 'ignored' });
markMatched(db, userId, kept, billId);
assert.equal(row(kept).ignored, 1, 'ignored preserved by default');
const cleared = newTxn(userId, { ignored: 1, status: 'ignored' });
markMatched(db, userId, cleared, billId, { resetIgnored: true });
assert.equal(row(cleared).ignored, 0, 'resetIgnored clears it');
});
test('markUnmatched clears the bill and status (never leaves matched+NULL)', () => {
const id = newTxn(userId, { status: 'matched', bill: billId });
markUnmatched(db, userId, id);
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
});
test('markUnmatched with resetIgnored un-ignores', () => {
const id = newTxn(userId, { status: 'ignored', ignored: 1 });
markUnmatched(db, userId, id, { resetIgnored: true });
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
});
test('markIgnored sets ignored + status, clears bill', () => {
const id = newTxn(userId, { status: 'matched', bill: billId });
markIgnored(db, userId, id);
assert.deepEqual(row(id), { match_status: 'ignored', matched_bill_id: null, ignored: 1 });
});
test('all writers are ownership-scoped — never touch another user\'s transaction', () => {
const foreign = newTxn(otherId, { status: 'matched', bill: null });
assert.equal(markMatched(db, userId, foreign, billId), 0);
assert.equal(markUnmatched(db, userId, foreign), 0);
assert.equal(markIgnored(db, userId, foreign), 0);
assert.equal(row(foreign).match_status, 'matched', 'foreign row untouched');
});