BillTracker/tests/paymentsRoute.test.js

124 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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)');
});
test('manual POST of a same bill+date+amount is a suspected duplicate (409, no new row, balance unchanged)', async () => {
const before = balanceOf(billId);
const { status, data } = await call('post', '/', {
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
});
assert.equal(status, 409);
assert.equal(data.code, 'DUPLICATE_SUSPECTED');
assert.ok(data.existing?.id, 'returns the existing payment so the client can show it');
assert.equal(balanceOf(billId), before, 'balance not touched by a rejected duplicate');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 1, 'still one payment');
});
test('manual POST with allow_duplicate bypasses the guard and records the second payment', async () => {
const { status } = await call('post', '/', {
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01', allow_duplicate: true },
});
assert.equal(status, 201);
assert.equal(balanceOf(billId), 90000, '950.00 50.00 = 900.00 (cents)');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 2, 'a legitimately-identical second payment is recorded');
});