BillTracker/tests/categoryReorder.test.js

130 lines
3.7 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-category-reorder-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
function createUser(db, suffix) {
return db
.prepare(
`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
`,
)
.run(`category-reorder-${suffix}`, `category-reorder-${suffix}@local`).lastInsertRowid;
}
function createCategory(db, userId, name) {
return db
.prepare(
`
INSERT INTO categories (user_id, name)
VALUES (?, ?)
`,
)
.run(userId, name).lastInsertRowid;
}
function callCategoriesRoute(routePath, method, { userId, params = {}, body = {} }) {
const categoriesRouter = require('../routes/categories.cts');
const layer = categoriesRouter.stack.find(
(item) => item.route?.path === routePath && item.route.methods[method],
);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = {
body,
params,
user: { id: userId, role: 'user' },
};
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(data) {
resolve({ status: this.statusCode, data });
},
};
// Simulate the terminal error handler: a thrown ApiError becomes a formatted
// response (mirrors utils/apiError formatError + server.cts).
const onError = (err) => {
resolve({
status: err.status || 500,
data: {
error: err.code || 'INTERNAL_ERROR',
message: err.message,
code: err.code || 'INTERNAL_ERROR',
field: err.field ?? null,
},
});
};
try {
const maybe = handler(req, res);
if (maybe && typeof maybe.then === 'function') maybe.catch(onError);
} catch (err) {
onError(err);
}
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('category reorder endpoint persists category order for the current user', async () => {
const db = getDb();
const userId = createUser(db, 'owner');
const food = createCategory(db, userId, 'Food');
const loans = createCategory(db, userId, 'Loans');
const utilities = createCategory(db, userId, 'Utilities');
const response = await callCategoriesRoute('/reorder', 'put', {
userId,
body: {
[utilities]: 0,
[food]: 1,
[loans]: 2,
},
});
assert.equal(response.status, 200);
assert.equal(response.data.success, true);
const ordered = await callCategoriesRoute('/', 'get', { userId });
assert.deepEqual(
ordered.data.filter((cat) => [food, loans, utilities].includes(cat.id)).map((cat) => cat.id),
[utilities, food, loans],
);
});
test('category reorder rejects categories outside the current user scope', async () => {
const db = getDb();
const ownerId = createUser(db, 'scoped-owner');
const otherId = createUser(db, 'scoped-other');
const ownerCategory = createCategory(db, ownerId, 'Owner Category');
const otherCategory = createCategory(db, otherId, 'Other Category');
const response = await callCategoriesRoute('/reorder', 'put', {
userId: ownerId,
body: {
[ownerCategory]: 0,
[otherCategory]: 1,
},
});
assert.equal(response.status, 404);
});