'use strict'; // Track A — the manual create / delete / restore / edit payment paths must apply // their balance mutation atomically with the row write (each is now wrapped in a // single db.transaction()). These tests pin the *observable* invariant the wrap // guarantees: the bill balance and the payment state stay consistent across a // create → delete → restore → edit round-trip, and a double-restore is a no-op. 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-payments-${process.pid}.sqlite`); process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); const router = require('../routes/payments'); function handler(method, routePath) { const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]); assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`); return layer.route.stack[layer.route.stack.length - 1].handle; } function call(method, routePath, { userId, params = {}, body = {} } = {}) { const h = handler(method, routePath); return new Promise((resolve) => { const req = { params, body, query: {}, user: { id: userId, role: 'user' } }; const res = { statusCode: 200, status(c) { this.statusCode = c; return this; }, json(d) { resolve({ status: this.statusCode, data: d }); }, }; h(req, res); }); } function balanceOf(billId) { return getDb().prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance; } let userId, billId; test.before(() => { const db = getDb(); userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pay-user','x','user',1)").run().lastInsertRowid; // $100 expected, $1,000 balance (cents), 0% interest so deltas are pure principal. billId = db.prepare( "INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Loan', 1, 10000, 100000, 5000, 0, 1)", ).run(userId).lastInsertRowid; }); test.after(() => { closeDb(); for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} } }); let paymentId; test('manual POST /payments creates the payment (201) and drops the balance once', async () => { const { status, data } = await call('post', '/', { userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' }, }); assert.equal(status, 201); assert.equal(data.amount, 100, 'serialized amount in dollars'); paymentId = data.id; assert.equal(balanceOf(billId), 90000, '1000.00 − 100.00 = 900.00 (cents)'); const count = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c; assert.equal(count, 1); }); test('DELETE reverses the balance and soft-deletes atomically', async () => { const { status, data } = await call('delete', '/:id', { userId, params: { id: paymentId } }); assert.equal(status, 200); assert.equal(data.success, true); assert.equal(balanceOf(billId), 100000, 'balance restored to 1000.00'); const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c; assert.equal(active, 0, 'payment is soft-deleted'); }); test('restore re-applies the balance drop atomically', async () => { const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } }); assert.equal(status, 200); assert.equal(balanceOf(billId), 90000, 'balance dropped to 900.00 again'); const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c; assert.equal(active, 1, 'payment is active again'); }); test('double-restore is a no-op (404, balance unchanged)', async () => { const before = balanceOf(billId); const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } }); assert.equal(status, 404, 'already-active payment cannot be restored again'); assert.equal(balanceOf(billId), before, 'balance not double-applied'); }); test('PUT edit reverses the old amount and applies the new one atomically', async () => { const { status, data } = await call('put', '/:id', { userId, params: { id: paymentId }, body: { amount: 50 }, }); assert.equal(status, 200); assert.equal(data.amount, 50, 'amount updated to $50'); // reverse $100 (→ 1000.00) then apply $50 (→ 950.00) assert.equal(balanceOf(billId), 95000, '1000.00 − 50.00 = 950.00 (cents)'); });