feat: implement full backend API for Pantree Phase 1 MVP

This commit is contained in:
Azriel
2026-05-10 04:57:26 +00:00
parent d755eea792
commit 03a2cfb748
32 changed files with 4139 additions and 0 deletions

305
tests/helpers/testDb.js Normal file
View File

@@ -0,0 +1,305 @@
'use strict';
/**
* In-memory database stub for tests.
* Replaces the real Knex/PostgreSQL connection with a lightweight
* object that simulates table operations using plain JS Maps.
*
* Usage: const db = createTestDb(); setDb(db);
*/
function makeTable(rows = []) {
return [...rows];
}
/**
* Creates a minimal Knex-like query builder stub backed by in-memory arrays.
* Supports: insert, where, whereRaw, whereIn, whereNull, whereNotNull,
* first, select, update, delete, returning, orderBy,
* leftJoin, groupBy, raw, transaction.
*/
function createTestDb(initialData = {}) {
const tables = {};
function getTable(name) {
if (!tables[name]) tables[name] = [];
return tables[name];
}
function seedTable(name, rows) {
tables[name] = [...rows];
}
// Seed initial data
for (const [name, rows] of Object.entries(initialData)) {
seedTable(name, rows);
}
// ── Query Builder ────────────────────────────────────────────────────────────
function queryBuilder(tableName) {
let _rows = null; // lazy — resolved on terminal op
let _filters = [];
let _insertData = null;
let _updateData = null;
let _doDelete = false;
let _returning = false;
let _orderByField = null;
let _orderByDir = 'asc';
let _selectFields = null;
let _limit1 = false;
let _joins = [];
let _groupByField = null;
let _rawSelects = [];
function getRows() {
if (_rows !== null) return _rows;
return getTable(tableName);
}
function applyFilters(rows) {
return rows.filter((row) => {
for (const f of _filters) {
if (!f(row)) return false;
}
return true;
});
}
const qb = {
where(condOrField, val) {
if (typeof condOrField === 'object') {
for (const [k, v] of Object.entries(condOrField)) {
_filters.push((row) => row[k] === v);
}
} else {
_filters.push((row) => row[condOrField] === val);
}
return qb;
},
whereRaw(expr, params) {
// Support: 'LOWER(email) = ?' and 'item_name_lower = LOWER(?)'
const lowerEmailMatch = expr.match(/LOWER\((\w+)\)\s*=\s*\?/i);
const lowerFieldMatch = expr.match(/(\w+)\s*=\s*LOWER\(\?\)/i);
if (lowerEmailMatch) {
const field = lowerEmailMatch[1];
const val = params[0].toLowerCase();
_filters.push((row) => (row[field] || '').toLowerCase() === val);
} else if (lowerFieldMatch) {
const field = lowerFieldMatch[1];
const val = params[0].toLowerCase();
_filters.push((row) => (row[field] || '') === val);
}
return qb;
},
whereIn(field, vals) {
_filters.push((row) => vals.includes(row[field]));
return qb;
},
whereNull(field) {
_filters.push((row) => row[field] == null);
return qb;
},
whereNotNull(field) {
_filters.push((row) => row[field] != null);
return qb;
},
select(...fields) {
_selectFields = fields.flat();
return qb;
},
orderBy(field, dir = 'asc') {
_orderByField = field;
_orderByDir = dir;
return qb;
},
groupBy() {
_groupByField = true;
return qb;
},
leftJoin(otherTable, leftKey, rightKey) {
_joins.push({ otherTable, leftKey, rightKey });
return qb;
},
raw(sql) {
_rawSelects.push(sql);
return sql; // knex.raw returns the string in our stub
},
insert(data) {
_insertData = Array.isArray(data) ? data : [data];
return qb;
},
update(data) {
_updateData = data;
return qb;
},
delete() {
_doDelete = true;
return qb;
},
returning() {
_returning = true;
return qb;
},
first() {
_limit1 = true;
return qb;
},
// Terminal: await qb
then(resolve, reject) {
try {
resolve(execute());
} catch (e) {
reject(e);
}
},
};
function execute() {
const table = getTable(tableName);
if (_insertData) {
const inserted = _insertData.map((d) => {
const row = {
id: d.id || require('uuid').v4(),
...d,
created_at: d.created_at || new Date().toISOString(),
updated_at: d.updated_at || new Date().toISOString(),
last_modified: d.last_modified || new Date().toISOString(),
};
// Compute generated columns
if (row.item_name !== undefined) {
row.item_name_lower = row.item_name.toLowerCase();
}
table.push(row);
return row;
});
return _returning ? inserted : inserted.length;
}
if (_doDelete) {
const before = table.length;
const toDelete = applyFilters(table);
for (const row of toDelete) {
const idx = table.indexOf(row);
if (idx !== -1) table.splice(idx, 1);
}
return _returning ? toDelete : before - table.length;
}
if (_updateData) {
const matched = applyFilters(table);
const updated = matched.map((row) => {
Object.assign(row, _updateData, {
updated_at: new Date().toISOString(),
});
if (row.item_name !== undefined) {
row.item_name_lower = row.item_name.toLowerCase();
}
return row;
});
return _returning ? updated : updated.length;
}
// SELECT
let rows = applyFilters(table);
// Apply joins (simplified — just attach joined fields)
for (const join of _joins) {
const otherRows = getTable(join.otherTable);
const [leftTable, leftField] = join.leftKey.split('.');
const [, rightField] = join.rightKey.split('.');
rows = rows.map((row) => {
const matches = otherRows.filter((o) => o[rightField] === row[leftField || 'id']);
if (matches.length === 0) return { ...row, _joined: [] };
return matches.map((m) => ({ ...row, ...m, _joined: true }));
}).flat();
}
if (_orderByField) {
rows = [...rows].sort((a, b) => {
const av = a[_orderByField] || '';
const bv = b[_orderByField] || '';
const cmp = String(av).localeCompare(String(bv));
return _orderByDir === 'desc' ? -cmp : cmp;
});
}
// Aggregate for groupBy (count)
if (_groupByField) {
const grouped = {};
for (const row of rows) {
const key = row.id;
if (!grouped[key]) {
grouped[key] = { ...row, item_count: 0, checked_count: 0 };
}
if (row._joined) {
grouped[key].item_count++;
if (row.checked_off) grouped[key].checked_count++;
}
}
rows = Object.values(grouped);
}
if (_limit1) return rows[0] || null;
return rows;
}
return qb;
}
// ── Transaction stub ─────────────────────────────────────────────────────────
async function transaction(fn) {
// Pass the same db proxy as the transaction context
return fn(proxy);
}
// ── Raw stub ─────────────────────────────────────────────────────────────────
function raw(sql) {
return sql;
}
const proxy = new Proxy(
{ transaction, raw, _tables: tables, seedTable, getTable },
{
get(target, prop) {
if (prop in target) return target[prop];
return (tableName) => queryBuilder(tableName || prop);
},
apply(target, thisArg, args) {
return queryBuilder(args[0]);
},
}
);
// Make proxy callable as a function: db('tableName')
return new Proxy(function () {}, {
apply(target, thisArg, args) {
return queryBuilder(args[0]);
},
get(target, prop) {
if (prop === 'transaction') return transaction;
if (prop === 'raw') return raw;
if (prop === '_tables') return tables;
if (prop === 'seedTable') return seedTable;
if (prop === 'getTable') return getTable;
return (tableName) => queryBuilder(tableName || prop);
},
});
}
module.exports = { createTestDb };