'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"); });