BillTracker/tests/billsDeletedRoute.test.js

93 lines
3.6 KiB
JavaScript
Raw Normal View History

'use strict';
// IMP-UX-01: GET /api/bills/deleted lists soft-deleted bills still inside the
// 30-day recovery window (newest first, with days_left), so the "Recently
// deleted" view can offer a restore beyond the transient undo toast. Bills
// purged past the window (or another user's) must not appear.
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-bills-deleted-route-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function createUser(db, suffix) {
return db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(`deleted-bills-${suffix}`).lastInsertRowid;
}
// daysAgo === null → an active (not deleted) bill; otherwise soft-deleted N days ago.
function insertBill(db, userId, name, daysAgo) {
const id = db.prepare(
'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, ?)',
).run(userId, name, daysAgo == null ? 1 : 0).lastInsertRowid;
if (daysAgo != null) {
db.prepare("UPDATE bills SET deleted_at = datetime('now', ?) WHERE id = ?").run(`-${daysAgo} days`, id);
}
return id;
}
function callGetDeleted(userId) {
const router = require('../routes/bills');
const layer = router.stack.find(item => item.route?.path === '/deleted' && item.route.methods.get);
assert.ok(layer, 'GET /deleted route should exist');
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = { query: {}, params: {}, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(data) { resolve({ status: this.statusCode, data }); },
};
try { handler(req, res); } catch (err) { reject(err); }
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
test('GET /bills/deleted returns only recoverable soft-deleted bills, newest first', async () => {
const db = getDb();
const userId = createUser(db, 'a');
insertBill(db, userId, 'Active Bill', null); // not deleted
insertBill(db, userId, 'Recently Deleted', 2); // 2 days ago
insertBill(db, userId, 'Older Deleted', 20); // 20 days ago
insertBill(db, userId, 'Purgeable', 40); // past the 30-day window
const { status, data } = await callGetDeleted(userId);
assert.equal(status, 200);
const names = data.map(b => b.name);
assert.deepEqual(names, ['Recently Deleted', 'Older Deleted'], 'only in-window bills, newest first');
assert.ok(!names.includes('Active Bill'), 'active bill excluded');
assert.ok(!names.includes('Purgeable'), 'past-window bill excluded');
const recentRow = data.find(b => b.name === 'Recently Deleted');
assert.ok(recentRow.days_left >= 27 && recentRow.days_left <= 28, `~28 days left, got ${recentRow.days_left}`);
assert.ok(recentRow.deleted_at, 'exposes deleted_at');
assert.equal(recentRow.expected_amount, 10, 'money serialized to dollars (cents/100)');
});
test('GET /bills/deleted isolates by user', async () => {
const db = getDb();
const me = createUser(db, 'me');
const other = createUser(db, 'other');
insertBill(db, me, 'Mine', 1);
insertBill(db, other, 'Theirs', 1);
const { data } = await callGetDeleted(me);
const names = data.map(b => b.name);
assert.ok(names.includes('Mine'));
assert.ok(!names.includes('Theirs'), "never leaks another user's deleted bills");
});