90 lines
3.9 KiB
JavaScript
90 lines
3.9 KiB
JavaScript
'use strict';
|
||
|
||
// X1 — POST /payments/quick must be idempotent under a double-clicked / retried
|
||
// "pay" and must apply the balance delta atomically with the INSERT. Before the
|
||
// fix, a second identical request created a duplicate payment AND double-applied
|
||
// the balance drop.
|
||
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-quickpay-${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);
|
||
});
|
||
}
|
||
|
||
let userId, billId;
|
||
test.before(() => {
|
||
const db = getDb();
|
||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('qp-user','x','user',1)").run().lastInsertRowid;
|
||
// $100 expected, $1,000 balance so we can observe the balance drop.
|
||
billId = db.prepare(
|
||
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Card', 1, 10000, 100000, 5000, 0, 1)",
|
||
).run(userId).lastInsertRowid;
|
||
});
|
||
test.after(() => {
|
||
closeDb();
|
||
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
|
||
});
|
||
|
||
test('first quick pay creates the payment (201) and drops the balance once', async () => {
|
||
const { status, data } = await call('post', '/quick', {
|
||
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
||
});
|
||
assert.equal(status, 201);
|
||
assert.equal(data.amount, 100, 'payment amount in dollars');
|
||
|
||
const db = getDb();
|
||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||
assert.equal(count, 1);
|
||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||
assert.equal(bal, 90000, '1000.00 − 100.00 = 900.00 (cents)');
|
||
});
|
||
|
||
test('a duplicate quick pay (same bill+date+amount) is idempotent — no second row, balance unchanged', async () => {
|
||
const { status, data } = await call('post', '/quick', {
|
||
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
||
});
|
||
assert.equal(status, 200, 'returns the existing payment, not a new 201');
|
||
assert.equal(data.amount, 100);
|
||
|
||
const db = getDb();
|
||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||
assert.equal(count, 1, 'still exactly one payment');
|
||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||
assert.equal(bal, 90000, 'balance NOT dropped a second time');
|
||
});
|
||
|
||
test('a different amount on the same day is a legitimate new payment', async () => {
|
||
const { status } = await call('post', '/quick', {
|
||
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
|
||
});
|
||
assert.equal(status, 201);
|
||
const db = getDb();
|
||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||
assert.equal(count, 2);
|
||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||
assert.equal(bal, 85000, '900.00 − 50.00 = 850.00 (cents)');
|
||
});
|