2026-07-05 11:44:45 -05:00
|
|
|
|
'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;
|
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 11:34:08 -05:00
|
|
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
2026-07-06 11:26:50 -05:00
|
|
|
|
const router = require('../routes/payments.cts');
|
2026-07-05 11:44:45 -05:00
|
|
|
|
|
|
|
|
|
|
function handler(method, routePath) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const layer = router.stack.find((l) => l.route?.path === routePath && l.route.methods[method]);
|
2026-07-05 11:44:45 -05:00
|
|
|
|
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,
|
2026-07-06 14:23:53 -05:00
|
|
|
|
status(c) {
|
|
|
|
|
|
this.statusCode = c;
|
|
|
|
|
|
return this;
|
|
|
|
|
|
},
|
|
|
|
|
|
json(d) {
|
|
|
|
|
|
resolve({ status: this.statusCode, data: d });
|
|
|
|
|
|
},
|
2026-07-05 11:44:45 -05:00
|
|
|
|
};
|
|
|
|
|
|
h(req, res);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
function balanceOf(billId) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return getDb().prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId)
|
|
|
|
|
|
.current_balance;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let userId, billId;
|
|
|
|
|
|
test.before(() => {
|
|
|
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
|
userId = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('pay-user','x','user',1)",
|
|
|
|
|
|
)
|
|
|
|
|
|
.run().lastInsertRowid;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
// $100 expected, $1,000 balance (cents), 0% interest so deltas are pure principal.
|
2026-07-06 14:23:53 -05:00
|
|
|
|
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;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
});
|
|
|
|
|
|
test.after(() => {
|
|
|
|
|
|
closeDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
|
for (const s of ['', '-wal', '-shm']) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
fs.unlinkSync(dbPath + s);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
2026-07-05 11:44:45 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let paymentId;
|
|
|
|
|
|
test('manual POST /payments creates the payment (201) and drops the balance once', async () => {
|
|
|
|
|
|
const { status, data } = await call('post', '/', {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
userId,
|
|
|
|
|
|
body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
2026-07-05 11:44:45 -05:00
|
|
|
|
});
|
|
|
|
|
|
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)');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const count = getDb()
|
|
|
|
|
|
.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
|
|
|
|
|
|
.get(billId).c;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
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');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const active = getDb()
|
|
|
|
|
|
.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
|
|
|
|
|
|
.get(billId).c;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
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');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const active = getDb()
|
|
|
|
|
|
.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
|
|
|
|
|
|
.get(billId).c;
|
2026-07-05 11:44:45 -05:00
|
|
|
|
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', {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
userId,
|
|
|
|
|
|
params: { id: paymentId },
|
|
|
|
|
|
body: { amount: 50 },
|
2026-07-05 11:44:45 -05:00
|
|
|
|
});
|
|
|
|
|
|
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)');
|
|
|
|
|
|
});
|
2026-07-05 11:51:06 -05:00
|
|
|
|
|
|
|
|
|
|
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', '/', {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
userId,
|
|
|
|
|
|
body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
|
2026-07-05 11:51:06 -05:00
|
|
|
|
});
|
|
|
|
|
|
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');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const active = getDb()
|
|
|
|
|
|
.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
|
|
|
|
|
|
.get(billId).c;
|
2026-07-05 11:51:06 -05:00
|
|
|
|
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', '/', {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
userId,
|
|
|
|
|
|
body: { bill_id: billId, amount: 50, paid_date: '2026-07-01', allow_duplicate: true },
|
2026-07-05 11:51:06 -05:00
|
|
|
|
});
|
|
|
|
|
|
assert.equal(status, 201);
|
|
|
|
|
|
assert.equal(balanceOf(billId), 90000, '950.00 − 50.00 = 900.00 (cents)');
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const active = getDb()
|
|
|
|
|
|
.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
|
|
|
|
|
|
.get(billId).c;
|
2026-07-05 11:51:06 -05:00
|
|
|
|
assert.equal(active, 2, 'a legitimately-identical second payment is recorded');
|
|
|
|
|
|
});
|