diff -ruN a/CHANGES-12.3.15.md b/CHANGES-12.3.15.md --- a/CHANGES-12.3.15.md 1970-01-01 00:00:00.000000000 +0000 +++ b/CHANGES-12.3.15.md 2026-10-20 06:05:01.873014753 +0000 @@ -0,0 +1,39 @@ +# 12.3.15 — Concurrency & data-integrity fixes (found by the new QA / production-load suite) + +Server-side only (`db.js`, `server.js`, new `bcryptPool.js`). **No screens changed, no schema changes to existing tables, no data migration.** One new small table (`op_locks`) is created automatically at start-up. + +## Stock & costing — what was wrong +Several inventory actions read an item, worked on that copy, then saved it back. When two things happened at the same moment (two restocks; a restock during sales; an admin editing an item during a sale) one change silently overwrote the other, or a sale invented "phantom" cost layers. Nothing showed an error. + +- **Quick restock lost units under concurrency.** 15 simultaneous restocks of 5 units left the item at 1,010 instead of 1,075 (cost batches said 1,075). Quick restock is now **one locked transaction**: opening layer, quantity (`quantity = quantity + n`), new layer, re-averaged unit cost and purchase log commit together. +- **Phantom cost layers.** A POS "Add stock" (or any restock) overlapping with sales made the stock ledger think units were missing and create extra "opening" layers (8 add-stocks + 8 sales: 120 phantom units). `reconcileStockBatches` and `refreshItemAvgCost` now read the **current** data under the item lock instead of the transaction's older snapshot. +- **FIFO/LIFO cost wrong after "restock before first issue".** An item created with opening stock and restocked before anything was issued had its opening layer priced at the *blended* cost and dated today (100 @ Rs10 + 50 @ Rs20, issue 120: charged Rs1,933, correct Rs1,400). The opening layer is now created at its **original cost** first. Fixed for quick restock **and** procurement restock-receive. +- **Editing an item's quantity or cost while it was selling** (found in the follow-up run: 8 of 8 attempts left quantity ≠ batches). `adjustStockBatches` now updates the item row in the same commit as the layers, and a generic save **no longer overwrites `quantity` / `purchase_cost` of an existing stock item** (new rows and asset items are unaffected). Even renaming an item can no longer undo a concurrent sale. + +## Approvals & receiving — double-processing +- **Procurement receive could create duplicate stock** (20 simultaneous receives of a 25-unit order → 20 items, 500 units) and **transfer approval could move stock more than once** (30-unit transfer, 90 moved). Approve/receive actions now take a short **claim on the record** (`op_locks`, database-backed so it also works across several app instances); a second simultaneous request gets **HTTP 409 "Someone else is processing this request"**. Covers admin transfer decision, admin and manager procurement decisions, procurement receive, and the manager-approval route. A claim left by a crashed process expires after 120 s. + +## Availability under load +- **Logins no longer freeze the server.** bcrypt (pure-JS, ~⅓ s of CPU per hash) ran on the main thread, so 10 simultaneous logins stalled every till for ~3.4 s. Hashing now runs in **worker threads** (`bcryptPool.js`; no native module; automatic fallback to the old behaviour if workers are unavailable). Other requests now wait ≤ 50 ms during a login storm. +- **Bursts are queued, not rejected.** The database wait-queue default rose from 30 to **500** (`DB_QUEUE_LIMIT`); the number of MySQL connections is unchanged (`DB_CONNECTION_LIMIT` = 8). Before: 200 simultaneous sales → only 38 succeeded, the rest got 503. Now all are served (exactly 50 of 50 units sold, no oversell). +- **Rate limit is per signed-in user, not per IP.** Every till in a school normally reaches the server from one public IP; a shared 600/min bucket made busy tills start getting 429. Signed-in traffic is now counted per user (600/min each); anonymous traffic and the login/OTP limiters stay per IP. + +## Smaller fixes +- **Repair status** is validated (`reported`, `in_review`, `in_repair`, `repaired`, `not_repairable`, `cancelled`); anything else returns 400 instead of being stored. +- **Fresh-install warning** `could not create idx_pe_linked_pr on petty_expenses` fixed (index was created before its column existed). +- Item edit now answers with the stored quantity/cost, not the copy read at the start. +- `package.json` version corrected (it still said 12.3.8). + +## Behaviour changes to be aware of +- A second simultaneous approve/receive returns **409** instead of silently running twice. +- After a quick restock, the item's unit cost is the weighted average of its cost layers (identical to the old figure whenever layers were consistent). +- A generic item save can no longer change `quantity`/`purchase_cost` of an existing **stock** item. Quantity changes must go through restock, consume, transfer, POS, or the Edit-item screen (which already does it correctly). Seed into an **empty** database (re-seeding over existing data is not a supported path and was not tested with this change). +- Up to two bcrypt worker threads are started per app process. + +## Tests added (`tests/qa/`, run with `npm run test:qa`) +78 checks (inventory 34, POS 28, load/races 14, item-edit & manager races 2) plus a 7-scenario × 6-run concurrency matrix and a phantom-layer probe, covering inventory, POS, inter-module information flow, races, and a production-load run (realistic peak + 60-till stress) with SQL audits. See `tests/qa/README.md` and `docs/QA_REPORT_12.3.15.md`. **Run them on a test database only.** + +## Not changed / known limits +- bcrypt logins still take ~0.4 s of CPU each; 10 at once take ~4 s *in total* on a single core, but nobody else is blocked. +- Beyond roughly 500 simultaneously queued database operations the server still answers 503 (by design, to protect the database). +- Tested as a single Node process on one CPU core against MariaDB 10.11; multi-instance behaviour is designed for (database-level claims) but was not exercised. Browser UI, printing, email delivery, file uploads and CSV import were not part of this test pass. diff -ruN a/DEPLOYMENT.md b/DEPLOYMENT.md --- a/DEPLOYMENT.md 2026-10-20 06:05:01.868164277 +0000 +++ b/DEPLOYMENT.md 2026-10-20 06:05:01.871599057 +0000 @@ -750,3 +750,11 @@ - With *Allow sending email* off, nothing is sent. Existing installations that use `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` keep working unchanged until the screen is saved; the saved settings then take over. - Needs the existing *Manage organization & branding* permission. New table: `email_settings`. New routes: `GET/PUT /api/admin/email-settings`, `POST /api/admin/email-settings/test`. - **User & Administrator Guide v8.1** adds Section 13.8 (Email (SMTP) Settings), a what's-new row, a how-to row and an FAQ entry. + +## 12.3.15 operations notes + +* **`DB_QUEUE_LIMIT`** now defaults to **500** (was 30). Requests that arrive while all `DB_CONNECTION_LIMIT` connections are busy wait in this queue instead of failing; the number of MySQL connections used is unchanged. Only if the queue itself overflows does the server answer `503` (and log `Connection pool saturated`). +* **`op_locks`** is a small table created automatically. It holds a row for the few milliseconds an approve/receive request is being processed (and is removed afterwards); stale rows expire after 120 s. It is what makes these actions safe when several app instances share one database. +* **Login hashing** runs in up to two worker threads (`bcryptPool.js`). Nothing to configure; it needs only Node's built-in `worker_threads`. +* **Rate limiting** of `/api` is per signed-in user (600 requests/minute each); anonymous requests are limited per IP. `trust proxy` must stay correctly configured so anonymous/login limits use the real client IP. +* **QA suite:** `npm run test:qa` (see `tests/qa/README.md`). It writes data — use a test database. diff -ruN a/bcryptPool.js b/bcryptPool.js --- a/bcryptPool.js 1970-01-01 00:00:00.000000000 +0000 +++ b/bcryptPool.js 2026-10-20 06:05:01.872930047 +0000 @@ -0,0 +1,47 @@ +'use strict'; +// bcrypt hashing/verification in worker threads, so a burst of logins (shift start, 10 tills signing in at once) never freezes +// the main event loop for everyone else. bcryptjs is pure JavaScript — roughly a third of a second of CPU per cost-12 hash — +// and even its async API runs on the main thread. No native module is needed (worker_threads ships with Node), so this suits +// shared hosting. If workers cannot be started, it falls back to bcryptjs' own async API. +const { Worker, isMainThread, parentPort } = require('worker_threads'); + +if (!isMainThread) { + const bcrypt = require('bcryptjs'); + parentPort.on('message', ({ id, op, a, b }) => { + try { parentPort.postMessage({ id, ok: true, v: op === 'compare' ? bcrypt.compareSync(a, b) : bcrypt.hashSync(a, b) }); } + catch (e) { parentPort.postMessage({ id, ok: false, e: String((e && e.message) || e) }); } + }); +} else { + const bcrypt = require('bcryptjs'); + const SIZE = Math.max(1, Math.min(2, require('os').cpus().length)); + const pending = new Map(); // id -> { res, rej, worker } + let workers = [], rr = 0, seq = 0, broken = false; + + function spawn() { + if (broken) return null; + try { + const w = new Worker(__filename); + w.on('message', m => { const p = pending.get(m.id); if (!p) return; pending.delete(m.id); m.ok ? p.res(m.v) : p.rej(new Error(m.e)); }); + const drop = () => { + workers = workers.filter(x => x !== w); + for (const [id, p] of pending) if (p.worker === w) { pending.delete(id); p.rej(new Error('bcrypt worker stopped')); } + }; + w.on('error', drop); w.on('exit', drop); + w.unref(); // never keep the process alive just for the pool + return w; + } catch { broken = true; return null; } + } + function ensure() { while (!broken && workers.length < SIZE) { const w = spawn(); if (!w) break; workers.push(w); } } + + function call(op, a, b) { + ensure(); + if (!workers.length) return op === 'compare' ? bcrypt.compare(a, b) : bcrypt.hash(a, b); // fallback + const worker = workers[rr++ % workers.length]; + return new Promise((res, rej) => { const id = ++seq; pending.set(id, { res, rej, worker }); worker.postMessage({ id, op, a, b }); }) + .catch(e => (e && e.message === 'bcrypt worker stopped') ? (op === 'compare' ? bcrypt.compare(a, b) : bcrypt.hash(a, b)) : Promise.reject(e)); + } + module.exports = { + compare: (plain, hash) => call('compare', String(plain), String(hash)), + hash: (plain, rounds = 12) => call('hash', String(plain), rounds) + }; +} diff -ruN a/db.js b/db.js --- a/db.js 2026-10-20 06:05:01.867251935 +0000 +++ b/db.js 2026-10-20 06:05:01.870676571 +0000 @@ -46,7 +46,10 @@ // piling up while waiting for a saturated pool). Keep this conservative by // default and let it be tuned via env var for larger deployments. const CONNECTION_LIMIT = Number(process.env.DB_CONNECTION_LIMIT || 8); -const QUEUE_LIMIT = Number(process.env.DB_QUEUE_LIMIT || 30); +// Waiting requests are cheap; rejecting them is not (customers see an error at the till). 500 absorbs a burst of 300+ +// simultaneous requests on the default 8 connections without raising the number of MySQL connections used (tested: 300 +// simultaneous catalog/overview reads and 200 simultaneous sales, no errors). +const QUEUE_LIMIT = Number(process.env.DB_QUEUE_LIMIT || 500); const pool = mysql.createPool({ host: process.env.DB_HOST || 'localhost', @@ -269,6 +272,14 @@ next_val INT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // Short-lived claims that make "check the status, then act" operations (approve a transfer, receive a + // procurement request ...) safe when two requests arrive together — across processes too. A claim is taken + // for the duration of one request and removed when it ends; the expiry only matters if the process dies mid-request. + await conn.query(`CREATE TABLE IF NOT EXISTS op_locks ( + lock_key VARCHAR(120) PRIMARY KEY, + expires_at DATETIME NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // Scrap register — a disposed item's row stays in `items` (condition_status // stays 'disposed', condition/purchase history, transfers, purchase_logs // etc all keep pointing at a real item_id), but it's mirrored here so it @@ -803,7 +814,6 @@ // Used by the budget plans' spent-total query (and the plan detail page); without them each plan scans whole tables. await ensureIndex('procurement_requests', 'idx_pr_plan', 'stocking_plan_id'); await ensureIndex('repair_requests', 'idx_rr_plan', 'stocking_plan_id'); - await ensureIndex('petty_expenses', 'idx_pe_linked_pr', 'linked_procurement_id'); const [codeIdx] = await conn.query(`SHOW INDEX FROM items WHERE Key_name='idx_item_code'`); if (!codeIdx.length) await conn.query(`ALTER TABLE items ADD UNIQUE INDEX idx_item_code (item_code)`); // Filesystem-storage path columns (v3.1) — images/documents now live on @@ -830,6 +840,7 @@ await safeAdd('petty_expenses', 'item_quantity', 'DECIMAL(12,3) DEFAULT NULL'); await safeAdd('petty_expenses', 'item_unit', 'VARCHAR(60) DEFAULT NULL'); await safeAdd('petty_expenses', 'linked_procurement_id', 'VARCHAR(60) DEFAULT NULL'); + await ensureIndex('petty_expenses', 'idx_pe_linked_pr', 'linked_procurement_id'); // after the column exists (was created before it, failing on fresh installs) // Restock-vs-new support (mirrors procurement_requests.is_restock / // existing_item_id) so a petty-cash purchase can top up an existing // inventory item instead of always creating a brand-new one. @@ -1766,6 +1777,16 @@ return { deducted, totalCost, avgCost: priced ? roundUnit(totalCost / priced) : (fallbackCost != null ? Number(fallbackCost) : null) }; } +// ---- Operation claims ------------------------------------------------------ +// tryClaim(key) → true if THIS caller now owns the key, false if another request holds it. Uses the primary key as the +// arbiter, so it works across processes and never holds a pool connection while the work is done (no pool starvation). +async function tryClaim(key, ttlSec = 120) { + await run(`DELETE FROM op_locks WHERE lock_key=? AND expires_at < NOW()`, [key]); + try { await run(`INSERT INTO op_locks (lock_key, expires_at) VALUES (?, DATE_ADD(NOW(), INTERVAL ? SECOND))`, [key, Number(ttlSec)]); return true; } + catch (e) { if (e && e.code === 'ER_DUP_ENTRY') return false; throw e; } +} +async function releaseClaim(key) { try { await run(`DELETE FROM op_locks WHERE lock_key=?`, [key]); } catch { /* expires on its own */ } } + // ---- Stock ledger integrity ---------------------------------------------- // An item's quantity and its stock batches must always agree, otherwise FIFO/LIFO issues draw on the // wrong layers (or none, costing the issue at zero). Items typed in by hand, imported, or created before @@ -1773,8 +1794,11 @@ async function reconcileStockBatches(conn, itemId) { const [[it]] = await conn.execute(`SELECT id,name,quantity,purchase_cost,purchase_date,tracking_type FROM items WHERE id=? FOR UPDATE`, [itemId]); if (!it || it.tracking_type !== 'stock') return it || null; - const [[b]] = await conn.execute(`SELECT COALESCE(SUM(quantity_remaining),0) q FROM stock_batches WHERE item_id=? AND quantity_remaining>0`, [itemId]); - const gap = +(Number(it.quantity || 0) - Number(b.q)).toFixed(3); + // A CURRENT read (FOR UPDATE), not a snapshot read: the item row is locked above, so nobody else can change this item's + // batches now — but a plain SELECT would still use the transaction's older snapshot (taken before we waited for the lock) + // and miss batches a concurrent restock just committed, wrongly "discovering" a gap and inventing a phantom opening layer. + const [layers] = await conn.execute(`SELECT quantity_remaining FROM stock_batches WHERE item_id=? AND quantity_remaining>0 FOR UPDATE`, [itemId]); + const gap = +(Number(it.quantity || 0) - layers.reduce((a, r) => a + Number(r.quantity_remaining), 0)).toFixed(3); if (gap > 0.0005) { await addStockBatch(conn, { itemId: it.id, itemName: it.name, qtyReceived: gap, unitCost: it.purchase_cost != null ? Number(it.purchase_cost) : null, receivedDate: String(it.purchase_date || '').slice(0, 10) || new Date().toISOString().slice(0, 10) }); @@ -1784,9 +1808,9 @@ // After stock leaves, the item's unit cost becomes the weighted average of the layers that are LEFT. // (Under FIFO the remainder is the newer, possibly dearer stock — the old blended figure misvalued it.) async function refreshItemAvgCost(conn, itemId) { - const [[r]] = await conn.execute( - `SELECT COALESCE(SUM(quantity_remaining),0) q, COALESCE(SUM(quantity_remaining*COALESCE(unit_cost,0)),0) v, COALESCE(SUM(unit_cost IS NOT NULL),0) known - FROM stock_batches WHERE item_id=? AND quantity_remaining>0`, [itemId]); + const [layers] = await conn.execute(`SELECT quantity_remaining, unit_cost FROM stock_batches WHERE item_id=? AND quantity_remaining>0 FOR UPDATE`, [itemId]); // current read — see reconcileStockBatches + const r = { q: 0, v: 0, known: 0 }; + for (const l of layers) { r.q += Number(l.quantity_remaining); r.v += Number(l.quantity_remaining) * Number(l.unit_cost || 0); if (l.unit_cost != null) r.known++; } if (Number(r.q) > 0 && Number(r.known) > 0) await conn.execute(`UPDATE items SET purchase_cost=? WHERE id=?`, [roundUnit(Number(r.v) / Number(r.q)), itemId]); } @@ -1827,6 +1851,11 @@ const delta = +(newQty - oldQty).toFixed(3); if (delta > 0.0005) await addStockBatch(conn, { itemId, itemName: it.name, qtyReceived: delta, unitCost: costChanged ? newCost : oldCost, receivedDate: new Date().toISOString().slice(0, 10) }); else if (delta < -0.0005) await deductFromBatches(conn, itemId, -delta, method || 'fifo', oldCost); + // The item row is updated in THIS commit. Before, the layers were changed here but items.quantity only later by a + // separate save, and a sale landing in between saw "missing" units and invented a phantom opening layer. + await conn.execute(`UPDATE items SET quantity=? WHERE id=?`, [newQty, itemId]); + if (costChanged && newCost != null) await conn.execute(`UPDATE items SET purchase_cost=? WHERE id=?`, [newCost, itemId]); + await refreshItemAvgCost(conn, itemId); }); } // ---- Email (SMTP) settings ------------------------------------------------------------------------------- @@ -2786,7 +2815,14 @@ } if (!changed.length) return; const cols = Object.keys(t.toRow(changed[0])); - const updateClause = cols.filter(c => c !== 'id').map(c => `${c}=VALUES(${c})`).join(','); + // For ITEMS, `quantity` and `purchase_cost` of an existing STOCK item belong to the batch-aware code (issue, restock, + // adjustStockBatches ...), which changes them together with the cost layers in one locked transaction. A generic save + // works from a copy read earlier, so letting it write them back silently undid whatever a concurrent sale/restock did + // in between (e.g. renaming an item while it sold). Brand-new rows (INSERT) and asset items are unaffected. These two + // assignments are listed first so tracking_type still holds the stored value when they are evaluated. + const ledgerCols = name === 'items' ? ['quantity', 'purchase_cost'] : []; + const updateCols = [...cols.filter(c => ledgerCols.includes(c)), ...cols.filter(c => c !== 'id' && !ledgerCols.includes(c))]; + const updateClause = updateCols.map(c => ledgerCols.includes(c) ? `${c}=IF(tracking_type='stock' AND VALUES(tracking_type)='stock', ${c}, VALUES(${c}))` : `${c}=VALUES(${c})`).join(','); const rowPlaceholder = `(${cols.map(() => '?').join(',')})`; for (let i = 0; i < changed.length; i += SAVE_CHUNK_SIZE) { const batch = changed.slice(i, i + SAVE_CHUNK_SIZE); @@ -2845,7 +2881,7 @@ createOtp, verifyOtp, consumeOtp, cleanOtps, recordLoginSession, touchLoginSession, listLoginSessionsForUser, getLoginSessionById, deleteLoginSessionById, deleteLoginSessionBySessionId, deleteLoginSessionsForUser, pruneStaleLoginSessions, - addStockBatch, deductFromBatches, getStockBatches, getAllStockBatches, + tryClaim, releaseClaim, addStockBatch, deductFromBatches, getStockBatches, getAllStockBatches, createPurchaseLog, getPurchaseLogsForItem, getPurchaseLogsByPlan, getPurchaseLogsByProcurementId, setPurchaseLogBillPath, getItemHistory, scrapSummary, countPettyExpenses, nextItemCode, getScrapByItemId, insertScrapRecord, removeScrapRecord, listScraps, updateScrapValue, deleteScrapById, getScrapById, getScrapBillPath, setScrapBillPath, clearScrapBillPath, diff -ruN a/package.json b/package.json --- a/package.json 2026-10-20 06:05:01.868624596 +0000 +++ b/package.json 2026-10-20 06:05:01.872249606 +0000 @@ -1,7 +1,7 @@ { "name": "assettrack-school-inventory", - "version": "12.3.8", - "description": "School Inventory & Asset Management System — Production MySQL Edition", + "version": "12.3.15", + "description": "School Inventory & Asset Management System \u2014 Production MySQL Edition", "main": "server.js", "engines": { "node": ">=18.0.0" @@ -13,7 +13,12 @@ "test:pricing": "node tests/pricing.test.js", "test:pos": "node tests/pos.test.js", "test:charges": "node tests/pricing-charges.test.js", - "test:orders": "node tests/order-logic.test.js" + "test:orders": "node tests/order-logic.test.js", + "test:qa": "bash tests/qa/run-all.sh", + "test:qa:inventory": "node tests/qa/p1_inventory.js", + "test:qa:pos": "node tests/qa/p2_pos.js", + "test:qa:load": "node tests/qa/p3_load.js", + "test:qa:races": "node tests/qa/p7_open_items.js" }, "dependencies": { "bcryptjs": "^2.4.3", diff -ruN a/server.js b/server.js --- a/server.js 2026-10-20 06:05:01.868677577 +0000 +++ b/server.js 2026-10-20 06:05:01.872305010 +0000 @@ -3,9 +3,9 @@ const express = require('express'); const session = require('express-session'); const MySQLStore = require('express-mysql-session')(session); -const rateLimit = require('express-rate-limit'); +const { rateLimit, ipKeyGenerator } = require('express-rate-limit'); const bodyParser = require('body-parser'); -const bcrypt = require('bcryptjs'); +const bcrypt = require('./bcryptPool'); // bcrypt in worker threads — see bcryptPool.js const path = require('path'); const multer = require('multer'); const helmet = require('helmet'); @@ -225,6 +225,9 @@ // remain the real protection for login/OTP. const apiLimiter = rateLimit({ windowMs: 60 * 1000, max: 600, standardHeaders: true, legacyHeaders: false, + // Count per SIGNED-IN USER, not per IP: a school's tills and office PCs all reach the server from one public address, + // and sharing a single 600/min bucket made busy tills start getting 429s. Anonymous traffic is still limited per IP. + keyGenerator: req => (req.session && req.session.userId) ? `u:${req.session.userId}` : ipKeyGenerator(req.ip), // Item/avatar photos and the BS calendar picker can each legitimately // fire a burst of small GETs in normal use (an inventory grid loading // 30 thumbnails at once, someone flipping through several months on a @@ -236,6 +239,19 @@ }); app.use('/api', apiLimiter); +// "Check the status, then act" endpoints (approve a transfer, receive a procurement request ...) must not run twice at once: +// two simultaneous requests used to both pass the check and both apply the change (stock moved twice, duplicate items +// created). The first request takes a claim on the record; any other request for it meanwhile is told to refresh. +const claimed = keyFn => async (req, res, next) => { + try { + const key = keyFn(req); if (!key) return next(); + if (!(await db.tryClaim(key))) return res.status(409).json({ error: 'Someone else is processing this request right now. Refresh to see its latest status.', code: 'BUSY' }); + let freed = false; const free = () => { if (!freed) { freed = true; db.releaseClaim(key); } }; + res.on('finish', free); res.on('close', free); + next(); + } catch (err) { sendError(res, req, err); } +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -1042,7 +1058,7 @@ const { email, password } = req.body; const users = await db.load('users'); const user = users.find(u => u.email.toLowerCase() === String(email || '').toLowerCase()); - if (!user || user.status !== 'active' || !bcrypt.compareSync(password || '', user.passwordHash)) + if (!user || user.status !== 'active' || !(await bcrypt.compare(password || '', user.passwordHash))) return res.status(401).json({ error: 'Invalid email or password.' }); req.session.userId = user.id; const ua = req.headers['user-agent'] || ''; @@ -1172,7 +1188,7 @@ const users = await db.load('users'); const user = users.find(u => u.id === token.userId); if (!user) return res.status(404).json({ error: 'User not found.' }); - user.passwordHash = bcrypt.hashSync(newPassword, 12); + user.passwordHash = await bcrypt.hash(newPassword, 12); await db.save('users', users); await db.consumeOtp(token.otpId); delete req.session.resetToken; @@ -1212,8 +1228,8 @@ if (!newPassword || newPassword.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters.' }); const users = await db.load('users'); const user = users.find(u => u.id === req.session.userId); - if (!bcrypt.compareSync(currentPassword || '', user.passwordHash)) return res.status(401).json({ error: 'Current password is incorrect.' }); - user.passwordHash = bcrypt.hashSync(newPassword, 12); + if (!(await bcrypt.compare(currentPassword || '', user.passwordHash))) return res.status(401).json({ error: 'Current password is incorrect.' }); + user.passwordHash = await bcrypt.hash(newPassword, 12); await db.save('users', users); res.json({ ok: true }); } catch (err) { sendError(res, req, err); } @@ -1968,7 +1984,7 @@ if (users.some(u => u.email.toLowerCase() === email.toLowerCase())) return res.status(400).json({ error: 'A user with this email already exists.' }); const validDeptIds = Array.isArray(departmentIds) ? departmentIds.filter(id => departments.some(d => d.id === id)) : []; const division = validDeptIds.map(id => (departments.find(d => d.id === id) || {}).name).filter(Boolean).join(', '); - const user = { id: uid('usr'), name, email, passwordHash: bcrypt.hashSync('Welcome@123', 12), role: ['admin','manager','staff'].includes(role) ? role : 'staff', division, departmentIds: validDeptIds, locationId: locationId || null, managerId: managerId || null, phone: phone || '', avatarColor: ['#1B2F63','#274E8C','#2E6B9E','#3F8F6A','#6DAF3C','#8CC63F'][users.length % 6], hasAvatar: false, status: 'active', createdAt: todayStr(), dashboardAccess: null, scrapAccess: null, emailNotifications: emailNotifications !== false }; + const user = { id: uid('usr'), name, email, passwordHash: await bcrypt.hash('Welcome@123', 12), role: ['admin','manager','staff'].includes(role) ? role : 'staff', division, departmentIds: validDeptIds, locationId: locationId || null, managerId: managerId || null, phone: phone || '', avatarColor: ['#1B2F63','#274E8C','#2E6B9E','#3F8F6A','#6DAF3C','#8CC63F'][users.length % 6], hasAvatar: false, status: 'active', createdAt: todayStr(), dashboardAccess: null, scrapAccess: null, emailNotifications: emailNotifications !== false }; if (req.body.groupId) { if (!(await db.getById('userGroups', req.body.groupId))) return res.status(400).json({ error: 'That user group no longer exists.' }); user.groupId = req.body.groupId; @@ -2030,7 +2046,7 @@ if (!user) return res.status(404).json({ error: 'User not found.' }); const me = await currentUser(req); const DEFAULT_PASSWORD = 'Welcome@123'; - user.passwordHash = bcrypt.hashSync(DEFAULT_PASSWORD, 12); + user.passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 12); await db.save('users', users); if (mailer.wantsMail(user)) { mailer.sendPasswordReset({ to: user.email, name: user.name, tempPassword: DEFAULT_PASSWORD, resetBy: me.name }).catch(()=>{}); @@ -2366,6 +2382,7 @@ await logCondition(item.id, item.name, previousCondition, item.condition, req.body.conditionNote || 'Updated by administrator', me); await syncScrapForCondition(item, previousCondition, item.condition, me); } + Object.assign(item, await db.getById('items', item.id)); // report the stored quantity/cost, not the copy read at the start res.json({ item }); } catch (err) { sendError(res, req, err); } }); @@ -2834,39 +2851,39 @@ const vendors = await db.load('vendors'); const vendor = vendors.find(v => v.id === req.body.vendorId); - const prevQty = item.quantity || 0; - if (unitCost != null) { - const combinedQty = prevQty + qty; - item.purchaseCost = combinedQty ? roundUnit(((item.purchaseCost || 0) * prevQty + totalCost) / combinedQty) : unitCost; // blend with the exact amount paid - } - item.quantity = +(prevQty + qty).toFixed(3); - item.purchaseDate = todayStr(); - if (req.body.vendorId !== undefined) { item.vendorId = req.body.vendorId || null; item.vendorName = vendor?.name || null; } - await db.saveOne('items', item); - + const vendorGiven = req.body.vendorId !== undefined; + const vId = vendorGiven ? (req.body.vendorId || null) : item.vendorId, vName = vendorGiven ? (vendor?.name || null) : item.vendorName; let logId = null; + // ONE transaction. Previously the item was read, a stale quantity + qty was saved over it (silently wiping out anything + // another request had added or taken in the meantime) and the cost layer was written separately afterwards. + // 1. reconcileStockBatches locks the item row and, if the units already on hand have no cost layer yet, creates it at + // their ORIGINAL cost — before this restock changes the item's cost — so FIFO/LIFO stay correct; + // 2. quantity is increased with SQL arithmetic on the locked row, not from a copy read earlier; + // 3. the new layer, the re-averaged cost and the purchase log are written in the same commit. await db.withTx(async conn => { + await db.reconcileStockBatches(conn, item.id); + const sets = ['quantity=ROUND(quantity+?,3)', 'purchase_date=?'], params = [qty, todayStr()]; + if (vendorGiven) { sets.push('vendor_id=?', 'vendor_name=?'); params.push(vId, vName); } + await conn.execute(`UPDATE items SET ${sets.join(', ')} WHERE id=?`, [...params, item.id]); await db.addStockBatch(conn, { itemId: item.id, itemName: item.name, qtyReceived: qty, unitCost, - receivedDate: todayStr(), procurementId: null, vendorId: item.vendorId, vendorName: item.vendorName, + receivedDate: todayStr(), procurementId: null, vendorId: vId, vendorName: vName, receivedById: me.id, receivedByName: me.name }); + await db.refreshItemAvgCost(conn, item.id); // weighted average of the layers now on hand logId = uid('pl'); await db.createPurchaseLog(conn, { id: logId, itemId: item.id, itemName: item.name, procurementId: null, quantity: qty, unit: item.unit, unitCost, totalCost, billPath: null, billFilename: null, receivedAt: nowStamp(), receivedById: me.id, receivedByName: me.name, - vendorId: item.vendorId, vendorName: item.vendorName, + vendorId: vId, vendorName: vName, locationId: item.locationId, locationName: item.locationName, notes: (req.body.notes || '').trim() || 'Quick restock (no procurement request).', stockingPlanId }); - // Count this against the linked plan's budget the same way an - // approved procurement request does, so the plan's spent total — - // and therefore its remaining-budget gauge — reflects the restock - // immediately, not just once/if it's later reconciled. }); + Object.assign(item, await db.getById('items', item.id)); if (stockingPlanId) await db.recomputePlanSpent(stockingPlanId); res.json({ ok: true, logId, item }); } catch (err) { sendError(res, req, err); } @@ -3118,7 +3135,7 @@ return {}; } -app.post('/api/admin/transfers/:id/decide', requirePerm('approve_transfers'), async (req, res) => { +app.post('/api/admin/transfers/:id/decide', requirePerm('approve_transfers'), claimed(req => `transfer:${req.params.id}`), async (req, res) => { try { const verdict = readDecision(req, res); if (!verdict) return; const { decision, reason } = verdict; @@ -3358,7 +3375,7 @@ } catch (err) { sendError(res, req, err); } }); -app.post('/api/admin/procurement/:id/decide', requirePerm('approve_procurement'), async (req, res) => { +app.post('/api/admin/procurement/:id/decide', requirePerm('approve_procurement'), claimed(req => `procurement:${req.params.id}`), async (req, res) => { try { const verdict = readDecision(req, res); if (!verdict) return; const { decision, reason } = verdict; @@ -3382,7 +3399,7 @@ } catch (err) { sendError(res, req, err); } }); -app.post('/api/admin/procurement/:id/receive', requirePerm('receive_procurement'), async (req, res) => { +app.post('/api/admin/procurement/:id/receive', requirePerm('receive_procurement'), claimed(req => `procurement:${req.params.id}`), async (req, res) => { try { const p = await db.getById('procurementRequests', req.params.id); if (!p) return res.status(404).json({ error: 'Procurement request not found.' }); @@ -3480,20 +3497,36 @@ } if (mode === 'new') { p.isRestock = false; p.existingItemId = null; break restockBranch; } } + // Stock item landing on an existing record: do the whole stock movement in ONE transaction with the item row locked. + // (Opening layer first, at its original cost; quantity by SQL arithmetic; new layer + re-averaged cost in the same + // commit.) The generic code below is then skipped for it — it works on a copy read earlier and would overwrite + // concurrent changes and price the opening layer at the blended cost. + let stockApplied = false; + if (target.trackingType === 'stock' && !targetIsNew) { + const meR = await currentUser(req); + await db.withTx(async conn => { + await db.reconcileStockBatches(conn, target.id); + await conn.execute(`UPDATE items SET quantity=ROUND(quantity+?,3), purchase_date=? WHERE id=?`, [addedQty, todayStr(), target.id]); + await db.addStockBatch(conn, { itemId: target.id, itemName: target.name, qtyReceived: addedQty, unitCost, receivedDate: todayStr(), procurementId: p.id, vendorId: p.vendorId, vendorName: p.vendorName, receivedById: meR.id, receivedByName: meR.name }); + await db.refreshItemAvgCost(conn, target.id); + }); + Object.assign(target, await db.getById('items', target.id)); + stockApplied = true; + } const prevQty = target.quantity || 0; // Blend into a quantity-weighted average unit cost rather than // overwriting it outright, so a restock at a different price // doesn't silently erase what the existing stock was actually // bought for. - if (unitCost != null) { + if (unitCost != null && !stockApplied) { const combinedQty = prevQty + addedQty; target.purchaseCost = combinedQty ? roundUnit(((target.purchaseCost || 0) * prevQty + recvCost) / combinedQty) : unitCost; } - target.quantity = prevQty + addedQty; + if (!stockApplied) target.quantity = prevQty + addedQty; // A restocked ASSET lot keeps its age: the purchase date becomes the quantity-weighted average of old and new // units, instead of resetting the whole lot to today (which hid years of depreciation). - target.purchaseDate = target.trackingType === 'asset' ? blendPurchaseDate(target.purchaseDate, prevQty, addedQty) : todayStr(); - await db.saveOne('items', target); + if (!stockApplied) target.purchaseDate = target.trackingType === 'asset' ? blendPurchaseDate(target.purchaseDate, prevQty, addedQty) : todayStr(); + if (!stockApplied) await db.saveOne('items', target); // Carry the product photo over when the restock lands on a // brand-new record at the new location — same reasoning as the // split/transfer paths above. @@ -3512,7 +3545,7 @@ // stock does, so it shouldn't get a stock batch row (mirrors // the same trackingType check on the non-restock receive path // just below). - if (target.trackingType === 'stock') { + if (target.trackingType === 'stock' && !stockApplied) { await db.addStockBatch(conn, { itemId: target.id, itemName: target.name, qtyReceived: recvQty, unitCost, @@ -3625,6 +3658,7 @@ } catch (err) { sendError(res, req, err); } }); +const REPAIR_STATUSES = ['reported', 'in_review', 'in_repair', 'repaired', 'not_repairable', 'cancelled']; app.patch('/api/repairs/:id', requireAuth, async (req, res) => { try { const me = await currentUser(req); @@ -3638,6 +3672,8 @@ if (!(await canOrContext(me, 'manage_repairs', isCustodian || isDeptManager))) return res.status(403).json({ error: explicitlyDenied(me, 'manage_repairs') ? denyMessage('manage_repairs') : "You're not the custodian or department head for this item's location.", permission: 'manage_repairs' }); const vendors = await db.load('vendors'); const { status, assignedVendorId, estimatedCost, actualCost, resolutionNotes, stockingPlanId } = req.body; + if (status !== undefined && status !== null && status !== '' && !REPAIR_STATUSES.includes(status)) + return res.status(400).json({ error: `Unknown repair status "${String(status).slice(0, 40)}". Use one of: ${REPAIR_STATUSES.join(', ')}.`, field: 'status' }); if (stockingPlanId !== undefined) { const r = await resolveBudgetPlan(stockingPlanId || null, repair.stockingPlanId); if (r.error) return res.status(400).json({ error: r.error, field: 'stockingPlanId' }); @@ -3764,7 +3800,7 @@ } catch (err) { sendError(res, req, err); } }); -app.post('/api/manager/approvals/:type/:id/decide', requireAuth, requirePerm('approve_as_manager'), async (req, res) => { +app.post('/api/manager/approvals/:type/:id/decide', requireAuth, requirePerm('approve_as_manager'), claimed(req => ['transfer', 'procurement'].includes(req.params.type) ? `${req.params.type}:${req.params.id}` : null), async (req, res) => { try { const { type, id } = req.params; if (!['transfer','procurement'].includes(type)) return res.status(400).json({ error: 'Invalid request type.' }); diff -ruN a/tests/qa/README.md b/tests/qa/README.md --- a/tests/qa/README.md 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/README.md 2026-10-20 06:05:01.872185670 +0000 @@ -0,0 +1,35 @@ +# AssetTrack QA suite (added in 12.3.15) + +Integration, concurrency and load tests that exercise the real HTTP API and then audit the database with SQL. +They complement the original unit-style tests in `tests/` (`pricing`, `pricing-charges`, `order-logic`, `pos`). + +> **Run these on a TEST database only.** They create users, items, bills, transfers and procurement requests and change POS settings. +> They need the demo data (`npm run seed`) because they sign in with the seeded accounts +> (`rajesh.shrestha@silveroak.edu.np` / `Admin@123`, `sunita.gurung@…` and `anita.rai@…` / `Welcome@123`). + +## Run everything +```bash +npm install +export DB_PASS=... # same DB_* variables the app uses (DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME) +npm run seed # demo data, into an EMPTY test database +npm start & # server on PORT (default 3000) +BASE=http://localhost:3000 npm run test:qa # ≈ 15–20 minutes (FAST=1 skips the 62-second rate-limit pauses) +``` +The runner prints one verdict and exits non-zero on any failure. + +## What each script covers +| Script | Purpose | +|---|---| +| `p1_inventory.js` | Locations/categories/vendors, item validation, FIFO & LIFO costing against hand-computed values, restock/consume/revert, transfers, procurement (two-level approval, partial receive, restock), repairs/disposal/scrap, reports & exports, permissions, and the "restock before first issue" costing paths. | +| `p2_pos.js` | POS pricing against hand-computed totals (VAT added/included, % and flat discount, service + fixed charges, rounding), cost of goods across layers, void, order correct/finalize/reopen, inventory↔POS information flow, validation fuzzing, summary-vs-SQL, per-bill SQL audit. | +| `p3_load.js` | Login storm, simultaneous-request races (oversell, duplicate bill, void, transfer approval, procurement receive, mixed contention), a realistic-peak tier and a 60-till stress tier with post-load SQL audits (ledger, conservation, bill/token uniqueness, bill consistency, revenue), 300-way read burst, per-user rate limit. `TIER_ONLY=1` runs just the login storm + realistic tier. | +| `p4_diag.js` | Concurrency matrix: restock / POS add-stock / sales / consumes in every combination, 6 runs each; every cell must be 0/6. | +| `p5_phantom.js` | Add-stock racing with sales; counts cost layers with no owner (must be exactly 1). | +| `p7_open_items.js` | Item quantity edit racing with sales; manager + admin approving one transfer at once. | +| `p8_burst.js [N]` | N simultaneous catalog/overview reads (default 300) – failures and latency. | + +Results are also written as JSON to `tests/qa/results/`. + +## Notes +* Load scripts spread traffic over several **separate accounts** (`adminPool`) because the API rate limiter now counts per signed-in user. +* Latency figures depend on the machine; the pass/fail conditions that matter are correctness (no oversell, no lost or phantom stock, no duplicate records) and the error rate. diff -ruN a/tests/qa/lib.js b/tests/qa/lib.js --- a/tests/qa/lib.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/lib.js 2026-10-20 06:05:01.871988685 +0000 @@ -0,0 +1,81 @@ +'use strict'; +// Shared helpers for the AssetTrack QA run. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const mysql = require('mysql2/promise'); // resolved from the application's node_modules (this folder lives inside it) + +const BASE = process.env.BASE || 'http://localhost:3100'; +let ipCounter = 10; +const nextIp = () => `10.${Math.floor(ipCounter / 250) % 250}.${ipCounter++ % 250}.${1 + (ipCounter % 250)}`; + +class Client { + constructor(ip) { this.cookie = ''; this.ip = ip || nextIp(); this.lat = []; } + async req(method, url, body, opts = {}) { + const t0 = process.hrtime.bigint(); + let r, data = null, text = null; + try { + r = await fetch(BASE + url, { + method, + headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': this.ip, ...(this.cookie ? { Cookie: this.cookie } : {}) }, + body: body !== undefined && body !== null ? JSON.stringify(body) : undefined + }); + } catch (e) { return { status: 0, data: null, error: String(e.cause && e.cause.code || e.message), ms: Number(process.hrtime.bigint() - t0) / 1e6 }; } + const sc = r.headers.get('set-cookie'); if (sc) this.cookie = sc.split(';')[0]; + const ct = r.headers.get('content-type') || ''; + if (opts.raw) { const buf = Buffer.from(await r.arrayBuffer()); return { status: r.status, buf, headers: r.headers, ms: Number(process.hrtime.bigint() - t0) / 1e6 }; } + try { text = await r.text(); data = ct.includes('json') ? JSON.parse(text) : null; } catch { /* not json */ } + const ms = Number(process.hrtime.bigint() - t0) / 1e6; this.lat.push(ms); + return { status: r.status, data, text, ms }; + } + get(u, o) { return this.req('GET', u, undefined, o); } + post(u, b, o) { return this.req('POST', u, b || {}, o); } + put(u, b) { return this.req('PUT', u, b); } + patch(u, b) { return this.req('PATCH', u, b); } + del(u, b) { return this.req('DELETE', u, b); } + async login(email, password) { + const r = await this.post('/api/auth/login', { email, password }); + if (r.status !== 200) throw new Error(`login ${email} -> ${r.status} ${JSON.stringify(r.data)}`); + return this; + } +} + +let _pool; +const db = () => _pool || (_pool = mysql.createPool({ host: process.env.DB_HOST || 'localhost', port: Number(process.env.DB_PORT || 3306), user: process.env.DB_USER || 'root', password: process.env.DB_PASS || '', database: process.env.DB_NAME || 'assettrack', connectionLimit: 4, decimalNumbers: true })); +const q = async (sql, p) => (await db().query(sql, p))[0]; +const q1 = async (sql, p) => (await q(sql, p))[0]; + +// ---- results ---- +const results = []; // {phase,name,ok,detail} +const findings = []; // {sev,phase,title,detail} +let phase = 'setup'; +const setPhase = p => { phase = p; console.log(`\n=== ${p} ===`); }; +async function check(name, fn) { + try { await fn(); results.push({ phase, name, ok: true }); console.log(' ✓', name); } + catch (e) { results.push({ phase, name, ok: false, detail: e.message }); console.log(' ✗', name, '\n ', String(e.message).split('\n')[0].slice(0, 300)); } +} +const finding = (sev, title, detail) => { findings.push({ sev, phase, title, detail }); console.log(` ⚑ [${sev}] ${title}`); }; +const near = (a, b, m, tol = 0.011) => assert.ok(Math.abs(Number(a) - Number(b)) < tol, `${m || ''} expected ${b}, got ${a}`); +const save = (name) => fs.writeFileSync(path.join(__dirname, 'results', `${name}.json`), JSON.stringify({ results, findings }, null, 1)); +const pct = (arr, p) => { if (!arr.length) return 0; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(s.length * p))]; }; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// Stock ledger invariant: quantity == sum(batches remaining), no negatives +async function ledgerViolations(extraWhere = '', params = []) { + return q(`SELECT i.id, i.name, i.quantity, COALESCE(b.q,0) AS batches + FROM items i LEFT JOIN (SELECT item_id, SUM(quantity_remaining) q FROM stock_batches GROUP BY item_id) b ON b.item_id=i.id + WHERE i.tracking_type='stock' ${extraWhere} AND (ABS(i.quantity - COALESCE(b.q,0)) > 0.0006 OR i.quantity < 0)`, params); +} +// A pool of separate admin accounts. Load scripts must not hammer the API from clones of ONE session: the rate limiter now +// counts per signed-in user (as real tills each have their own login), so one account would hit its own 600/min ceiling. +async function adminPool(n, tag = Date.now().toString(36)) { + const main = await new Client().login('rajesh.shrestha@silveroak.edu.np', 'Admin@123'); const out = [main]; + for (let i = 1; i < n; i++) { + const email = `qa.admin${i}.${tag}@example.com`; + const r = await main.post('/api/users', { name: `QA Admin ${i}`, email, role: 'admin' }); if (r.status !== 200) throw new Error('pool user: ' + JSON.stringify(r.data)); + out.push(await new Client().login(email, 'Welcome@123')); + } + let k = 0; out.next = () => out[k++ % out.length]; out.clone = () => Object.assign(new Client(), { cookie: out.next().cookie }); + return out; +} +module.exports = { adminPool, assert, Client, q, q1, db, check, finding, near, setPhase, save, results, findings, pct, sleep, ledgerViolations, BASE }; diff -ruN a/tests/qa/p1_inventory.js b/tests/qa/p1_inventory.js --- a/tests/qa/p1_inventory.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p1_inventory.js 2026-10-20 06:05:01.872017002 +0000 @@ -0,0 +1,311 @@ +'use strict'; +const { assert, Client, q, q1, check, finding, near, setPhase, save, ledgerViolations } = require('./lib'); +const S = Date.now().toString(36); +const A = ['rajesh.shrestha@silveroak.edu.np', 'Admin@123'], MGR = ['sunita.gurung@silveroak.edu.np', 'Welcome@123'], STAFF = ['anita.rai@silveroak.edu.np', 'Welcome@123']; + +(async () => { + setPhase('P1 · Inventory management'); + const admin = await new Client().login(...A), mgr = await new Client().login(...MGR), staff = await new Client().login(...STAFF); + let locA, locB, catStock, catAsset, fifo, lifo, asset; + const item = async id => (await admin.get('/api/items/' + id)).data.item; + const batches = async id => q(`SELECT quantity_remaining qr, unit_cost uc FROM stock_batches WHERE item_id=? ORDER BY received_at, id`, [id]); + const stockValue = async id => { const r = await q1(`SELECT COALESCE(SUM(quantity_remaining*unit_cost),0) v FROM stock_batches WHERE item_id=?`, [id]); return Number(r.v); }; + + await check('health + session + overview respond', async () => { + assert.strictEqual((await admin.get('/api/health')).status, 200); + const me = await admin.get('/api/auth/me'); assert.strictEqual(me.status, 200); + const ov = await admin.get('/api/overview'); assert.strictEqual(ov.status, 200); assert.ok(ov.data.totalItems > 0); + }); + + await check('master data: locations, stock + asset categories, vendor', async () => { + locA = (await admin.post('/api/locations', { name: 'QA Store A ' + S })).data.location; + locB = (await admin.post('/api/locations', { name: 'QA Store B ' + S })).data.location; + catStock = (await admin.post('/api/categories', { name: 'QA Stock ' + S, trackingType: 'stock', defaultUnit: 'pcs' })).data.category; + catAsset = (await admin.post('/api/categories', { name: 'QA Asset ' + S, trackingType: 'asset', defaultUnit: 'pcs', depreciationMethod: 'straight_line', depreciationRate: 10, usefulLifeYears: 5, salvageValue: 0 })).data.category; + assert.ok(locA && locB && catStock && catAsset); + const v = await admin.post('/api/vendors', { name: 'QA Vendor ' + S, email: 'v@example.com' }); assert.strictEqual(v.status, 200); + }); + + await check('item validation: no name, asset qty 0, wrong tracking vs category, bad FIFO method are all refused', async () => { + const base = { categoryId: catStock.id, trackingType: 'stock', locationId: locA.id, quantity: 5, unit: 'pcs' }; + assert.strictEqual((await admin.post('/api/items', { ...base })).status, 400); + assert.strictEqual((await admin.post('/api/items', { ...base, name: 'x', trackingType: 'asset' })).status, 400); + assert.strictEqual((await admin.post('/api/items', { ...base, name: 'x', stockingMethod: 'random' })).status, 400); + assert.strictEqual((await admin.post('/api/items', { name: 'a', categoryId: catAsset.id, trackingType: 'asset', locationId: locA.id, quantity: 0 })).status, 400); + }); + + await check('create stock items: opening balance becomes a cost batch (qty == batches)', async () => { + const mk = async (nm, m) => (await admin.post('/api/items', { name: nm + S, categoryId: catStock.id, trackingType: 'stock', locationId: locA.id, quantity: 100, unit: 'pcs', purchaseCost: 10, purchaseDate: '2026-01-01', stockingMethod: m, minStockLevel: 5 })).data.item; + fifo = await mk('FIFO item ', 'fifo'); lifo = await mk('LIFO item ', 'lifo'); + for (const it of [fifo, lifo]) { near(it.quantity, 100); near(it.purchaseCost, 10); } + const nb = (await batches(fifo.id)).length; + console.log(` (batch rows at creation: ${nb} — the opening layer is created lazily on first issue)`); + }); + + await check('quick restock adds a batch at paid/qty unit cost and re-averages the item cost', async () => { + for (const it of [fifo, lifo]) { + const r = await admin.post(`/api/items/${it.id}/restock`, { quantity: 50, totalCost: 1000 }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + const cur = await item(it.id); near(cur.quantity, 150); near(cur.purchaseCost, (100 * 10 + 50 * 20) / 150, 'blended unit cost', 0.001); + } + assert.strictEqual((await admin.post(`/api/items/${fifo.id}/restock`, { quantity: 5 })).status, 400, 'blank total is refused'); + assert.strictEqual((await admin.post(`/api/items/${fifo.id}/restock`, { quantity: -5, totalCost: 10 })).status, 400); + assert.strictEqual((await admin.post(`/api/items/${fifo.id}/restock`, { quantity: 5, totalCost: -10 })).status, 400); + }); + + await check('FIFO consume 120 of (100@10 + 50@20): cost = 1400, 30 left @ 20', async () => { + const r = await admin.post(`/api/items/${fifo.id}/consume`, { quantity: 120, purpose: 'QA fifo' }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + const log = await q1(`SELECT total_cost tc, unit_cost uc FROM consumption_logs WHERE id=?`, [r.data.logId]); + near(log.tc, 1400, 'FIFO issue cost'); near(log.uc, 1400 / 120, 'avg unit', 0.001); + const cur = await item(fifo.id); near(cur.quantity, 30); near(cur.purchaseCost, 20, 'remaining layer cost'); + near(await stockValue(fifo.id), 600, 'remaining stock value'); + }); + + await check('LIFO consume 120 of (100@10 + 50@20): cost = 1700, 30 left @ 10', async () => { + const r = await admin.post(`/api/items/${lifo.id}/consume`, { quantity: 120, purpose: 'QA lifo' }); assert.strictEqual(r.status, 200); + near((await q1(`SELECT total_cost tc FROM consumption_logs WHERE id=?`, [r.data.logId])).tc, 1700, 'LIFO issue cost'); + const cur = await item(lifo.id); near(cur.quantity, 30); near(cur.purchaseCost, 10); near(await stockValue(lifo.id), 300); + }); + + await check('consume refuses over-issue, missing purpose, zero/negative/non-numeric quantity', async () => { + assert.strictEqual((await admin.post(`/api/items/${fifo.id}/consume`, { quantity: 31, purpose: 'x' })).status, 400); + assert.strictEqual((await admin.post(`/api/items/${fifo.id}/consume`, { quantity: 1, purpose: ' ' })).status, 400); + for (const bad of [0, -3, 'abc', null]) assert.strictEqual((await admin.post(`/api/items/${fifo.id}/consume`, { quantity: bad, purpose: 'x' })).status, 400, 'qty=' + bad); + near((await item(fifo.id)).quantity, 30, 'nothing deducted by refused calls'); + }); + + await check('reverting a consumption returns the stock; reverting twice is refused', async () => { + const r = await admin.post(`/api/items/${fifo.id}/consume`, { quantity: 10, purpose: 'revert me' }); + near((await item(fifo.id)).quantity, 20); + const rv = await admin.post(`/api/consumption/${r.data.logId}/revert`, { reason: 'QA revert' }); assert.strictEqual(rv.status, 200, JSON.stringify(rv.data)); + near((await item(fifo.id)).quantity, 30, 'stock restored'); + assert.ok([400, 409].includes((await admin.post(`/api/consumption/${r.data.logId}/revert`, { reason: 'again' })).status), 'second revert refused'); + near((await item(fifo.id)).quantity, 30, 'not restored twice'); + }); + + await check('editing item quantity / cost keeps batches in step (increase, decrease)', async () => { + let r = await admin.patch('/api/items/' + fifo.id, { quantity: 40 }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + assert.strictEqual((await ledgerViolations('AND i.id=?', [fifo.id])).length, 0, 'after +10'); + r = await admin.patch('/api/items/' + fifo.id, { quantity: 25 }); assert.strictEqual(r.status, 200); + assert.strictEqual((await ledgerViolations('AND i.id=?', [fifo.id])).length, 0, 'after -15'); + near((await item(fifo.id)).quantity, 25); + }); + + + console.log('\n — costing: which stock-increase paths keep FIFO layers correct? —'); + const mkItem = async (nm, qty, cost, m = 'fifo') => (await admin.post('/api/items', { name: nm + S, categoryId: catStock.id, trackingType: 'stock', locationId: locA.id, quantity: qty, unit: 'pcs', purchaseCost: cost, purchaseDate: '2026-01-01', stockingMethod: m })).data.item; + const issueCost = async (id, n) => { const r = await admin.post(`/api/items/${id}/consume`, { quantity: n, purpose: 'QA cost probe' }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); return Number((await q1(`SELECT total_cost tc FROM consumption_logs WHERE id=?`, [r.data.logId])).tc); }; + await check('CONTROL: if the opening layer already exists (one unit issued first), quick restock then FIFO is correct', async () => { + const it = await mkItem('Ctl ', 100, 10); await issueCost(it.id, 1); + await admin.post(`/api/items/${it.id}/restock`, { quantity: 50, totalCost: 1000 }); + near(await issueCost(it.id, 120), 99 * 10 + 21 * 20, 'FIFO: 99@10 + 21@20'); + }); + await check('PATH quick-restock BEFORE first issue: FIFO cost uses the original opening cost', async () => { + const it = await mkItem('QR ', 100, 10); + await admin.post(`/api/items/${it.id}/restock`, { quantity: 50, totalCost: 1000 }); + const got = await issueCost(it.id, 120); + if (Math.abs(got - 1400) > 0.02) finding('High', 'Quick restock before first issue corrupts FIFO/LIFO costing', `Item opened with 100 @ Rs10, quick-restocked 50 @ Rs20 (total Rs1000), then issued 120 FIFO. Expected cost Rs1,400; system charged Rs${got}. Cause: POST /api/items/:id/restock (server.js) saves the blended cost + new quantity and inserts the new batch without first creating the opening layer (db.reconcileStockBatches). The opening layer is later created lazily at the BLENDED cost (13.33) and dated today, so it is both mis-priced and mis-ordered. Affects consumption cost, POS cost-of-sales/margin, and stock valuation. POS 'Add stock' (pos.js) reconciles first and is correct.`); + near(got, 1400, 'FIFO issue cost after quick restock'); + }); + await check('PATH procurement restock-receive BEFORE first issue: FIFO cost uses the original opening cost', async () => { + const it = await mkItem('PR ', 100, 10); + const r = await admin.post('/api/procurement', { itemName: it.name, categoryId: catStock.id, quantity: 50, unit: 'pcs', estimatedCost: 1000, justification: 'QA', isRestock: true, existingItemId: it.id }); + assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + await admin.post(`/api/admin/procurement/${r.data.request.id}/decide`, { decision: 'approved' }); + const rc = await admin.post(`/api/admin/procurement/${r.data.request.id}/receive`, { locationId: locA.id }); assert.strictEqual(rc.status, 200, JSON.stringify(rc.data)); + const got = await issueCost(it.id, 120); + if (Math.abs(got - 1400) > 0.02) finding('High', 'Procurement restock-receive before first issue corrupts FIFO/LIFO costing', `Same defect via POST /api/admin/procurement/:id/receive for a restock request (server.js ~3516): expected Rs1,400, charged Rs${got}.`); + near(got, 1400, 'FIFO issue cost after procurement restock'); + }); + await check('PATH edit-quantity (+50 @ same cost) then FIFO uses correct layers', async () => { + const it = await mkItem('ED ', 100, 10); + assert.strictEqual((await admin.patch('/api/items/' + it.id, { quantity: 150 })).status, 200); + near(await issueCost(it.id, 120), 1200, '150 units all @ 10'); + }); + await check('PATH POS "add stock" (menu) keeps FIFO correct even before first sale', async () => { + const pc = (await admin.post('/api/categories', { name: 'QA POS Cat ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const loc = (await admin.post('/api/locations', { name: 'QA POS Counter ' + S, isPos: true })).data.location; + await admin.put('/api/pos/config', { mode: 'hybrid' }); + const m = await admin.post('/api/pos/menu', { name: 'QA Menu ' + S, categoryId: pc.id, locationId: loc.id, salePrice: 100, saleMode: 'tracked', quantity: 100, unitCost: 10, station: 'K' }); + assert.strictEqual(m.status, 200, JSON.stringify(m.data)); + assert.strictEqual((await admin.post(`/api/pos/menu/${m.data.item.id}/stock`, { quantity: 50, unitCost: 20 })).status, 200); + near(await issueCost(m.data.item.id, 120), 1400, 'POS-path FIFO'); + }); + + console.log('\n — transfers —'); + await check('stock transfer: request → admin approve moves stock and conserves value', async () => { + const before = { src: await item(lifo.id), v: await stockValue(lifo.id) }; + const r = await staff.post('/api/transfers', { itemId: lifo.id, toLocationId: locB.id, quantity: 12, reason: 'QA' }); + // staff may not be allowed to request on items outside their dept — fall back to admin requester + const req = r.status === 200 ? r : await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: locB.id, quantity: 12, reason: 'QA' }); + assert.strictEqual(req.status, 200, JSON.stringify(req.data)); + const id = req.data.transfer.id; + const d = await admin.post(`/api/admin/transfers/${id}/decide`, { decision: 'approved' }); assert.strictEqual(d.status, 200, JSON.stringify(d.data)); + const src = await item(lifo.id); near(src.quantity, before.src.quantity - 12); + const dest = (await q(`SELECT id,quantity FROM items WHERE name=? AND location_id=?`, [lifo.name, locB.id]))[0]; + assert.ok(dest, 'destination item exists'); near(dest.quantity, 12); + const total = (await stockValue(lifo.id)) + (await stockValue(dest.id)); + near(total, before.v, 'total stock value conserved across the transfer', 0.02); + assert.strictEqual((await ledgerViolations('AND i.name=?', [lifo.name])).length, 0); + const again = await admin.post(`/api/admin/transfers/${id}/decide`, { decision: 'approved' }); assert.strictEqual(again.status, 409, 'double approve refused'); + near((await item(lifo.id)).quantity, before.src.quantity - 12, 'not moved twice'); + }); + + await check('transfer refused: more than stock, same location, no reason, unknown destination', async () => { + const cur = await item(lifo.id); + assert.strictEqual((await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: locB.id, quantity: cur.quantity + 1, reason: 'x' })).status, 400); + assert.strictEqual((await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: locA.id, quantity: 1, reason: 'x' })).status, 400); + assert.strictEqual((await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: locB.id, quantity: 1 })).status, 400); + assert.strictEqual((await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: 'nope', quantity: 1, reason: 'x' })).status, 404); + }); + + await check('a transfer that became impossible (stock used meanwhile) is refused on approval, nothing moves', async () => { + const cur = await item(lifo.id); + const t = await admin.post('/api/transfers', { itemId: lifo.id, toLocationId: locB.id, quantity: cur.quantity, reason: 'whole lot' }); + assert.strictEqual(t.status, 200); + await admin.post(`/api/items/${lifo.id}/consume`, { quantity: 1, purpose: 'sneaks in' }); + const d = await admin.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'approved' }); + assert.strictEqual(d.status, 409, JSON.stringify(d.data)); + near((await item(lifo.id)).quantity, cur.quantity - 1); + await admin.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'rejected', reason: 'QA tidy' }); + }); + + await check('asset transfer moves the asset to the new location', async () => { + asset = (await admin.post('/api/items', { name: 'QA Laptop ' + S, categoryId: catAsset.id, trackingType: 'asset', locationId: locA.id, quantity: 1, purchaseCost: 60000, purchaseDate: '2025-01-01', serialNumber: 'SN' + S })).data.item; + assert.ok(asset, 'asset created'); + const t = await admin.post('/api/transfers', { itemId: asset.id, toLocationId: locB.id, reason: 'QA' }); assert.strictEqual(t.status, 200, JSON.stringify(t.data)); + assert.strictEqual((await admin.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'approved' })).status, 200); + assert.strictEqual((await item(asset.id)).locationId, locB.id); + }); + + console.log('\n — procurement —'); + let proc, procItemId; + await check('procurement: request → approve → receive PARTIAL qty at actual cost → stock item + batch created', async () => { + const r = await staff.post('/api/procurement', { itemName: 'QA Chalk ' + S, categoryId: catStock.id, quantity: 100, unit: 'box', estimatedCost: 5000, justification: 'QA' }); + assert.strictEqual(r.status, 200, JSON.stringify(r.data)); proc = r.data.request; + assert.strictEqual((await admin.post(`/api/admin/procurement/${proc.id}/receive`, { locationId: locA.id })).status, 400, 'cannot receive before approval'); + const md = await mgr.post(`/api/manager/approvals/procurement/${proc.id}/decide`, { decision: 'approved' }); assert.strictEqual(md.status, 200, 'manager approval: ' + JSON.stringify(md.data)); + assert.strictEqual((await admin.post(`/api/admin/procurement/${proc.id}/receive`, { locationId: locA.id })).status, 400, 'manager approval alone is not enough'); + const d = await admin.post(`/api/admin/procurement/${proc.id}/decide`, { decision: 'approved' }); assert.strictEqual(d.status, 200, JSON.stringify(d.data)); + assert.strictEqual((await admin.post(`/api/admin/procurement/${proc.id}/decide`, { decision: 'rejected', reason: 'flip' })).status, 409, 'decision is final'); + const rc = await admin.post(`/api/admin/procurement/${proc.id}/receive`, { locationId: locA.id, receivedQuantity: 80, receivedCost: 3600, actualQuantity: 80, actualCost: 3600, quantity: 80, totalCost: 3600 }); + assert.strictEqual(rc.status, 200, JSON.stringify(rc.data)); + const p = await q1(`SELECT received_item_id rid FROM procurement_requests WHERE id=?`, [proc.id]); procItemId = p.rid; assert.ok(procItemId); + const it = await item(procItemId); + console.log(` received: qty=${it.quantity} unitCost=${it.purchaseCost} (asked 100 @ est 5000; sent receivedQuantity=80,totalCost=3600)`); + assert.strictEqual((await ledgerViolations('AND i.id=?', [procItemId])).length, 0, 'ledger consistent after receive'); + assert.strictEqual((await admin.post(`/api/admin/procurement/${proc.id}/receive`, { locationId: locA.id })).status, 400, 'double receive refused'); + assert.strictEqual((await q(`SELECT id FROM items WHERE id=? OR name=?`, [procItemId, 'QA Chalk ' + S])).length, 1, 'only one item created'); + }); + + await check('procurement validation: no justification, negative cost, zero quantity, unknown restock target', async () => { + const b = { itemName: 'x', categoryId: catStock.id, quantity: 1, estimatedCost: 1, justification: 'j' }; + assert.strictEqual((await admin.post('/api/procurement', { ...b, justification: '' })).status, 400); + assert.strictEqual((await admin.post('/api/procurement', { ...b, estimatedCost: -1 })).status, 400); + assert.strictEqual((await admin.post('/api/procurement', { ...b, quantity: 0 })).status, 400); + assert.strictEqual((await admin.post('/api/procurement', { ...b, isRestock: true, existingItemId: 'nope' })).status, 400); + }); + + await check('restock-type procurement adds a batch to the EXISTING item (no duplicate item)', async () => { + const before = await item(fifo.id); + const r = await admin.post('/api/procurement', { itemName: before.name, categoryId: catStock.id, quantity: 40, unit: 'pcs', estimatedCost: 1200, justification: 'QA restock', isRestock: true, existingItemId: fifo.id }); + assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + await admin.post(`/api/admin/procurement/${r.data.request.id}/decide`, { decision: 'approved' }); + const rc = await admin.post(`/api/admin/procurement/${r.data.request.id}/receive`, { locationId: before.locationId }); assert.strictEqual(rc.status, 200, JSON.stringify(rc.data)); + const after = await item(fifo.id); near(after.quantity, before.quantity + 40); + assert.strictEqual((await q(`SELECT id FROM items WHERE name=?`, [before.name])).length, 1, 'no duplicate'); + assert.strictEqual((await ledgerViolations('AND i.id=?', [fifo.id])).length, 0); + near(after.purchaseCost, (before.quantity * before.purchaseCost + 1200) / (before.quantity + 40), 'blended cost', 0.01); + }); + + await check('rejected procurement cannot be received', async () => { + const r = await admin.post('/api/procurement', { itemName: 'QA Rejected ' + S, categoryId: catStock.id, quantity: 1, estimatedCost: 1, justification: 'j' }); + await admin.post(`/api/admin/procurement/${r.data.request.id}/decide`, { decision: 'rejected', reason: 'no' }); + assert.strictEqual((await admin.post(`/api/admin/procurement/${r.data.request.id}/receive`, { locationId: locA.id })).status, 400); + }); + + console.log('\n — repairs, disposal, scrap —'); + await check('repair report puts a good asset "under repair"; status/cost updates persist; resolving works', async () => { + const r = await admin.post('/api/repairs', { itemId: asset.id, issue: 'screen cracked', priority: 'high' }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + assert.strictEqual((await item(asset.id)).condition, 'under_repair'); + const u = await admin.patch('/api/repairs/' + r.data.repair.id, { status: 'in_repair', estimatedCost: 4000 }); assert.strictEqual(u.status, 200, JSON.stringify(u.data)); + const u2 = await admin.patch('/api/repairs/' + r.data.repair.id, { status: 'repaired', actualCost: 3500, resolutionNotes: 'fixed' }); assert.strictEqual(u2.status, 200); + const row = await q1(`SELECT status, actual_cost ac, resolved_at ra FROM repair_requests WHERE id=?`, [r.data.repair.id]); + assert.strictEqual(row.status, 'repaired'); near(row.ac, 3500); assert.ok(row.ra, 'resolvedAt set'); + }); + + await check('repair PATCH rejects an invalid status value', async () => { + const r = await admin.post('/api/repairs', { itemId: asset.id, issue: 'status fuzz', priority: 'low' }); + const u = await admin.patch('/api/repairs/' + r.data.repair.id, { status: 'banana' }); + if (u.status === 200) { const row = await q1(`SELECT status FROM repair_requests WHERE id=?`, [r.data.repair.id]); finding('Medium', 'Repair status accepts any string', `PATCH /api/repairs/:id stored status="${row.status}" with HTTP 200 (column is free varchar, no allow-list). Any client can write arbitrary statuses that no screen/report knows how to display or filter.`); throw new Error(`invalid status accepted and stored as "${row.status}"`); } + assert.ok(u.status === 400 || u.status === 422); + }); + + await check('dispose asset from a repair → condition disposed, scrap record created with a value', async () => { + const r = await admin.post('/api/repairs', { itemId: asset.id, issue: 'beyond repair', priority: 'low' }); + const d = await admin.post(`/api/repairs/${r.data.repair.id}/dispose`, {}); assert.strictEqual(d.status, 200, JSON.stringify(d.data)); + assert.strictEqual((await item(asset.id)).condition, 'disposed'); + const sc = await q1(`SELECT * FROM scrap_items WHERE item_id=?`, [asset.id]); assert.ok(sc, 'scrap row'); + assert.strictEqual((await admin.post(`/api/repairs/${r.data.repair.id}/dispose`, {})).status, 400, 'double dispose refused'); + }); + + await check('asset lot split / split-all preserves total units', async () => { + const lot = (await admin.post('/api/items', { name: 'QA Chairs ' + S, categoryId: catAsset.id, trackingType: 'asset', locationId: locA.id, quantity: 5, purchaseCost: 1000, purchaseDate: '2025-06-01' })).data.item; + const sp = await admin.post(`/api/items/${lot.id}/split`, { quantity: 1 }); assert.strictEqual(sp.status, 200, JSON.stringify(sp.data)); + const rest = await admin.post(`/api/items/${lot.id}/split-all`, {}); assert.strictEqual(rest.status, 200, JSON.stringify(rest.data)); + const units = await q(`SELECT quantity FROM items WHERE name=? AND condition_status<>'disposed'`, [lot.name]); + near(units.reduce((a, r) => a + Number(r.quantity), 0), 5, 'total units after split'); + }); + + console.log('\n — reports, lists, exports —'); + await check('reports preview + export for all six kinds', async () => { + for (const k of ['inventory', 'depreciation', 'transfers', 'procurement', 'repairs', 'consumption']) { + const p = await admin.get(`/api/reports/${k}/preview`); assert.strictEqual(p.status, 200, k + ' preview ' + (p.text || '').slice(0, 150)); + const e = await admin.get(`/api/reports/${k}/export`, { raw: true }); assert.strictEqual(e.status, 200, k + ' export'); assert.ok(e.buf.length > 1000, k + ' export non-empty'); + } + }); + + await check('inventory list/search/counts/lookup + deleted-items + consumption list work', async () => { + for (const u of ['/api/items?limit=50', '/api/items/counts', '/api/items?q=QA', '/api/consumption', '/api/transfers', '/api/procurement', '/api/repairs', '/api/scraps', '/api/deleted-items', '/api/stocking-plans', '/api/petty-expenses', '/api/departments', '/api/locations', '/api/users', '/api/vendors', '/api/categories']) + { const r = await admin.get(u); assert.strictEqual(r.status, 200, u + ' → ' + r.status + ' ' + (r.text || '').slice(0, 120)); } + }); + + await check('stock-value on the overview equals the SQL sum of stock batches', async () => { + const ov = (await admin.get('/api/overview')).data; + const sql = await q1(`SELECT COALESCE(SUM(i.quantity*COALESCE(i.purchase_cost,0)),0) v FROM items i WHERE i.tracking_type='stock' AND i.condition_status<>'disposed'`); + console.log(` overview.totalStockValue=${ov.totalStockValue} SQL qty×cost=${Number(sql.v).toFixed(2)}`); + near(ov.totalStockValue, sql.v, 'overview stock value vs qty × item cost', 1.0); + }); + + console.log('\n — delete / permissions —'); + await check('deleting a stock item moves it to deleted-items; a pending-transfer item cannot vanish silently', async () => { + const tmp = (await admin.post('/api/items', { name: 'QA Temp ' + S, categoryId: catStock.id, trackingType: 'stock', locationId: locA.id, quantity: 3, purchaseCost: 1 })).data.item; + const d = await admin.del('/api/items/' + tmp.id); assert.strictEqual(d.status, 200, JSON.stringify(d.data)); + assert.strictEqual((await admin.get('/api/items/' + tmp.id)).status, 404); + assert.ok((await admin.get('/api/deleted-items')).data.items.some(x => x.itemId === tmp.id || x.id === tmp.id || JSON.stringify(x).includes(tmp.id)), 'in deleted-items'); + }); + + await check('permissions: staff cannot approve transfers/procurement, add items, or delete', async () => { + const t = await admin.post('/api/transfers', { itemId: fifo.id, toLocationId: locB.id, quantity: 1, reason: 'perm' }); + assert.strictEqual((await staff.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'approved' })).status, 403); + assert.strictEqual((await staff.post('/api/items', { name: 'sneaky', categoryId: catStock.id, trackingType: 'stock', quantity: 1 })).status, 403); + assert.strictEqual((await staff.del('/api/items/' + fifo.id)).status, 403); + assert.strictEqual((await staff.post('/api/admin/procurement/x/decide', { decision: 'approved' })).status, 403); + assert.strictEqual((await new Client().get('/api/items')).status, 401, 'anonymous refused'); + await admin.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'rejected', reason: 'tidy' }); + }); + + await check('manager is limited to their department scope on items', async () => { + const mine = (await mgr.get('/api/items?limit=500')).data; const all = (await admin.get('/api/items?limit=500')).data; + const m = (mine.items || []).length, a = (all.items || []).length; + console.log(` manager sees ${m} items, admin sees ${a}`); + assert.ok(m <= a); + }); + + await check('final ledger check for every item touched in this phase', async () => { + const v = await ledgerViolations(); assert.strictEqual(v.length, 0, JSON.stringify(v.slice(0, 3))); + }); + save('p1'); + const bad = (await Promise.resolve(require('./lib').results)).filter(r => !r.ok); + console.log(`\nP1 done: ${require('./lib').results.length - bad.length} passed, ${bad.length} failed`); + process.exit(0); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff -ruN a/tests/qa/p2_pos.js b/tests/qa/p2_pos.js --- a/tests/qa/p2_pos.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p2_pos.js 2026-10-20 06:05:01.872077383 +0000 @@ -0,0 +1,221 @@ +'use strict'; +const { assert, Client, q, q1, check, finding, near, setPhase, save, ledgerViolations, results } = require('./lib'); +const S = Date.now().toString(36); +const A = ['rajesh.shrestha@silveroak.edu.np', 'Admin@123'], MGR = ['sunita.gurung@silveroak.edu.np', 'Welcome@123'], STAFF = ['anita.rai@silveroak.edu.np', 'Welcome@123']; + +(async () => { + setPhase('P2 · POS correctness & inventory↔POS information flow'); + const admin = await new Client().login(...A), mgr = await new Client().login(...MGR), staff = await new Client().login(...STAFF); + const VAT = (o = {}) => ({ id: 'vat', name: 'VAT', kind: 'percent', value: 13, mode: 'added', onTop: false, skipExempt: true, enabled: true, ...o }); + const cfg = (settings) => admin.put('/api/pos/config', { mode: 'hybrid', settings: { billPrefix: 'QA', paymentMethods: [{ id: 'cash', label: 'Cash', cash: true, enabled: true }, { id: 'qr', label: 'QR', cash: false, enabled: true }], roundTotal: false, finalizeStep: false, allowDiscounts: true, tokenEnabled: true, ...settings } }); + const inv = async id => (await admin.get('/api/items/' + id)).data.item; + const stockValue = async id => Number((await q1(`SELECT COALESCE(SUM(quantity_remaining*unit_cost),0) v FROM stock_batches WHERE item_id=?`, [id])).v); + + const loc = (await admin.post('/api/locations', { name: 'QA Counter ' + S, isPos: true })).data.location; + const loc2 = (await admin.post('/api/locations', { name: 'QA Counter2 ' + S, isPos: true })).data.location; + const cat = (await admin.post('/api/categories', { name: 'QA Food ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const mk = async (nm, o) => { const r = await admin.post('/api/pos/menu', { categoryId: cat.id, locationId: loc.id, station: 'K', salePrice: 100, saleMode: 'tracked', ...o, name: nm + ' ' + S }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); return r.data.item; }; + const sell = (lines, extra = {}) => admin.post('/api/pos/sales', { locationId: loc.id, paymentMethod: 'cash', lines, ...extra }); + + console.log(' — pricing against hand-computed totals (independent of the app code) —'); + const A_ = await mk('Taxable', { salePrice: 100, quantity: 500, unitCost: 40 }); + const B_ = await mk('Exempt', { salePrice: 50, quantity: 500, unitCost: 20, taxExempt: true }); + await check('S1 VAT 13% added, taxable 2×100 + exempt 1×50 → total 276, VAT 26', async () => { + await cfg({ charges: [VAT()] }); + const r = await sell([{ itemId: A_.id, quantity: 2 }, { itemId: B_.id, quantity: 1 }]); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + near(r.data.sale.subtotal, 250); near(r.data.sale.taxAmount, 26); near(r.data.sale.total, 276); + }); + await check('S2 + 10% discount → discount 25, VAT on discounted taxable (180×13%=23.40), total 248.40', async () => { + const r = await sell([{ itemId: A_.id, quantity: 2 }, { itemId: B_.id, quantity: 1 }], { discount: { type: 'percent', value: 10 } }); + near(r.data.sale.discountAmount, 25); near(r.data.sale.taxAmount, 23.4); near(r.data.sale.total, 248.4); + }); + await check('S2b flat Rs30 discount: total = 250−30 + VAT on taxable share', async () => { + const r = await sell([{ itemId: A_.id, quantity: 2 }, { itemId: B_.id, quantity: 1 }], { discount: { type: 'amount', value: 30 } }); + assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + // 30 spread 200:50 → 24 / 6 ; taxable net 176 → VAT 22.88 ; total 250-30+22.88 + near(r.data.sale.discountAmount, 30); near(r.data.sale.taxAmount, 22.88); near(r.data.sale.total, 242.88); + }); + await check('S3 VAT 13% INCLUDED: Rs113 item → total 113, VAT portion 13', async () => { + await cfg({ charges: [VAT({ mode: 'included' })] }); + const it = await mk('Incl', { salePrice: 113, quantity: 50, unitCost: 50 }); + const r = await sell([{ itemId: it.id, quantity: 1 }]); near(r.data.sale.total, 113); near(r.data.sale.taxAmount, 13); + }); + await check('S4 service 10% + VAT 13% + fixed packaging Rs20 on Rs1000 → 1250', async () => { + await cfg({ charges: [{ id: 'svc', name: 'Service', kind: 'percent', value: 10, mode: 'added', enabled: true }, VAT(), { id: 'pk', name: 'Packaging', kind: 'fixed', value: 20, mode: 'added', enabled: true }] }); + const it = await mk('Big', { salePrice: 1000, quantity: 20, unitCost: 400 }); + const r = await sell([{ itemId: it.id, quantity: 1 }]); near(r.data.sale.total, 1250, 'total'); assert.strictEqual(r.data.sale.charges.length, 3); + near(r.data.sale.charges.reduce((a, c) => a + c.amount, 0), r.data.sale.taxAmount, 'charges sum = tax_amount'); + }); + await check('S5 round-to-rupee: 248.40 → 248, rounding −0.40 recorded', async () => { + await cfg({ charges: [VAT()], roundTotal: true }); + const r = await sell([{ itemId: A_.id, quantity: 2 }, { itemId: B_.id, quantity: 1 }], { discount: { type: 'percent', value: 10 } }); + near(r.data.sale.total, 248); near(r.data.sale.rounding, -0.4); + }); + + console.log('\n — cost of goods, void, conservation —'); + await cfg({ charges: [], roundTotal: false }); + let fifoItem; + await check('COGS over two layers: 10@10 + 10@30, sell 15 → cost 250 on bill, line and consumption log', async () => { + fifoItem = await mk('Layered', { salePrice: 60, quantity: 10, unitCost: 10 }); + assert.strictEqual((await admin.post(`/api/pos/menu/${fifoItem.id}/stock`, { quantity: 10, unitCost: 30 })).status, 200); + const r = await sell([{ itemId: fifoItem.id, quantity: 15 }]); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + near(r.data.sale.costTotal, 250, 'bill cost'); near(r.data.sale.lines[0].costTotal, 250, 'line cost'); + const log = await q1(`SELECT total_cost tc, quantity, sale_id FROM consumption_logs WHERE sale_id=?`, [r.data.sale.id]); + near(log.tc, 250); near(log.quantity, 15); assert.strictEqual(r.data.sale.lines[0].consumptionLogId || true, true); + near((await inv(fifoItem.id)).quantity, 5); + fifoItem.saleId = r.data.sale.id; + }); + await check('void restores quantity AND stock value (400), reverts the consumption record, and works once', async () => { + const vBefore = await stockValue(fifoItem.id); + const v = await admin.post(`/api/pos/sales/${fifoItem.saleId}/void`, { reason: 'QA void' }); assert.strictEqual(v.status, 200, JSON.stringify(v.data)); + near((await inv(fifoItem.id)).quantity, 20, 'qty back'); + near(await stockValue(fifoItem.id), 400, `value back (was ${vBefore.toFixed(2)})`); + assert.strictEqual((await q1(`SELECT status FROM consumption_logs WHERE sale_id=?`, [fifoItem.saleId])).status, 'reverted'); + assert.strictEqual((await admin.post(`/api/pos/sales/${fifoItem.saleId}/void`, { reason: 'again' })).status, 409); + near((await inv(fifoItem.id)).quantity, 20, 'not returned twice'); + assert.strictEqual((await ledgerViolations('AND i.id=?', [fifoItem.id])).length, 0); + }); + + console.log('\n — restaurant-style order flow (place → correct → finalize → reopen → void) —'); + await cfg({ charges: [], finalizeStep: true }); + let ord; const it2 = await mk('OrderItem', { salePrice: 100, quantity: 50, unitCost: 25 }); + await check('place order: status open, stock taken', async () => { + const r = await sell([{ itemId: it2.id, quantity: 4 }]); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); ord = r.data.sale; + assert.strictEqual(ord.status, 'open'); near((await inv(it2.id)).quantity, 46); + }); + await check('correction up (4→7) takes 3 more; down (7→2) returns 5; cost follows quantity', async () => { + let r = await admin.put('/api/pos/sales/' + ord.id, { paymentMethod: 'cash', lines: [{ itemId: it2.id, quantity: 7 }] }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + near((await inv(it2.id)).quantity, 43); near(r.data.sale.total, 700); + r = await admin.put('/api/pos/sales/' + ord.id, { paymentMethod: 'cash', lines: [{ itemId: it2.id, quantity: 2 }] }); + near((await inv(it2.id)).quantity, 48); near(r.data.sale.total, 200); near(r.data.sale.costTotal, 50, 'cost 2×25'); + assert.strictEqual((await ledgerViolations('AND i.id=?', [it2.id])).length, 0); + const logs = await q(`SELECT quantity, status FROM consumption_logs WHERE sale_id=?`, [ord.id]); + near(logs.filter(l => l.status === 'active').reduce((a, l) => a + Number(l.quantity), 0), 2, 'active consumption = 2'); + }); + await check('correction cannot exceed stock; nothing changes when refused', async () => { + const r = await admin.put('/api/pos/sales/' + ord.id, { paymentMethod: 'cash', lines: [{ itemId: it2.id, quantity: 999 }] }); + assert.strictEqual(r.status, 409, JSON.stringify(r.data)); near((await inv(it2.id)).quantity, 48); + }); + await check('finalize locks it (edit → 409, finalize again → 409); only reopen_sales may reopen', async () => { + assert.strictEqual((await admin.post(`/api/pos/sales/${ord.id}/finalize`, {})).status, 200); + assert.strictEqual((await admin.put('/api/pos/sales/' + ord.id, { paymentMethod: 'cash', lines: [{ itemId: it2.id, quantity: 3 }] })).status, 409); + assert.strictEqual((await admin.post(`/api/pos/sales/${ord.id}/finalize`, {})).status, 409); + assert.strictEqual((await staff.post(`/api/pos/sales/${ord.id}/reopen`, {})).status, 403); + assert.strictEqual((await admin.post(`/api/pos/sales/${ord.id}/reopen`, { reason: 'QA' })).status, 200); + assert.strictEqual((await admin.put('/api/pos/sales/' + ord.id, { paymentMethod: 'cash', lines: [{ itemId: it2.id, quantity: 3 }] })).status, 200); + const edits = await admin.get(`/api/pos/sales/${ord.id}/edits`); assert.strictEqual(edits.status, 200); assert.ok(JSON.stringify(edits.data).length > 40, 'audit trail present'); + }); + await check('void of an open order returns every unit', async () => { + assert.strictEqual((await admin.post(`/api/pos/sales/${ord.id}/void`, { reason: 'QA' })).status, 200); + near((await inv(it2.id)).quantity, 50); + }); + + console.log('\n — information flow between inventory and POS —'); + await cfg({ charges: [], finalizeStep: false }); + const flow = await mk('Flow', { salePrice: 80, quantity: 30, unitCost: 20 }); + const catalogStock = async id => (await admin.get('/api/pos/catalog')).data.items.find(i => i.id === id)?.stock; + await check('inventory CONSUME lowers the stock the till sees', async () => { + await admin.post(`/api/items/${flow.id}/consume`, { quantity: 5, purpose: 'staff meal' }); near(await catalogStock(flow.id), 25); + }); + await check('inventory RESTOCK raises the stock the till sees', async () => { + assert.strictEqual((await admin.post(`/api/items/${flow.id}/restock`, { quantity: 10, totalCost: 300 })).status, 200); near(await catalogStock(flow.id), 35); + }); + await check('a POS sale appears in the inventory Consumption log with sale reference and cost', async () => { + const r = await sell([{ itemId: flow.id, quantity: 3 }]); const sale = r.data.sale; + const c = (await admin.get('/api/consumption')).data; + const row = (c.logs || c.consumption || c.items || []).find(x => x.saleId === sale.id || x.sale_id === sale.id); + assert.ok(row, 'POS sale visible in consumption list'); near(row.quantity, 3); near(catalogStock ? await catalogStock(flow.id) : 0, 32); + }); + await check('POS-linked consumption cannot be reverted from inventory (would desync the bill)', async () => { + const l = await q1(`SELECT id FROM consumption_logs WHERE item_id=? AND sale_id IS NOT NULL ORDER BY consumed_at DESC LIMIT 1`, [flow.id]); + const r = await admin.post(`/api/consumption/${l.id}/revert`, { reason: 'try' }); assert.ok([400, 403, 409].includes(r.status), 'status ' + r.status); + const e = await admin.patch(`/api/consumption/${l.id}`, { quantity: 1 }); assert.ok([400, 403, 409].includes(e.status), 'edit status ' + e.status); + }); + await check('inventory EDIT-QUANTITY on a POS item reaches the till and keeps the ledger consistent', async () => { + const cur = await inv(flow.id); + const r = await admin.patch('/api/items/' + flow.id, { quantity: cur.quantity - 2 }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + near(await catalogStock(flow.id), cur.quantity - 2); assert.strictEqual((await ledgerViolations('AND i.id=?', [flow.id])).length, 0); + }); + await check('inventory TRANSFER of a POS item reduces the counter stock; destination copy is not sellable', async () => { + const before = await catalogStock(flow.id); + const t = await admin.post('/api/transfers', { itemId: flow.id, toLocationId: loc2.id, quantity: 10, reason: 'QA' }); + assert.strictEqual((await admin.post(`/api/admin/transfers/${t.data.transfer.id}/decide`, { decision: 'approved' })).status, 200); + near(await catalogStock(flow.id), before - 10); + const dest = await q1(`SELECT id, sellable FROM items WHERE name=? AND location_id=?`, [flow.name, loc2.id]); + assert.ok(dest, 'destination item exists'); + const r = await admin.post('/api/pos/sales', { locationId: loc2.id, paymentMethod: 'cash', lines: [{ itemId: dest.id, quantity: 1 }] }); + console.log(` destination item sellable=${dest.sellable}; sale at destination counter → ${r.status}`); + assert.ok(r.status >= 400, 'destination copy must not be sellable until put on the menu'); + }); + await check('selling an item at the WRONG counter is refused', async () => { + const r = await admin.post('/api/pos/sales', { locationId: loc2.id, paymentMethod: 'cash', lines: [{ itemId: A_.id, quantity: 1 }] }); assert.strictEqual(r.status, 409, JSON.stringify(r.data)); + }); + await check('a DISPOSED item can no longer be sold', async () => { + const it = await mk('Spoiled', { salePrice: 10, quantity: 5, unitCost: 1 }); + const rp = await admin.post('/api/repairs', { itemId: it.id, issue: 'expired', priority: 'low' }); + const d = await admin.post(`/api/repairs/${rp.data.repair.id}/dispose`, {}); + if (d.status !== 200) { console.log(' dispose via repair for stock → ' + d.status + ' ' + JSON.stringify(d.data)); } + const r = await sell([{ itemId: it.id, quantity: 1 }]); + if (d.status === 200) assert.strictEqual(r.status, 409, 'disposed item still sellable: ' + JSON.stringify(r.data)); + }); + await check('deleting an item that has sales keeps the old bills readable', async () => { + const it = await mk('Deletable', { salePrice: 10, quantity: 5, unitCost: 1 }); + const r = await sell([{ itemId: it.id, quantity: 1 }]); const sid = r.data.sale.id; + const d = await admin.del('/api/items/' + it.id); + console.log(` delete item with sales → ${d.status} ${d.status !== 200 ? JSON.stringify(d.data).slice(0, 120) : ''}`); + const g = await admin.get('/api/pos/sales/' + sid); assert.strictEqual(g.status, 200); assert.ok(g.data.sale.lines[0].itemName, 'line still has its name'); + }); + + console.log('\n — permissions & validation —'); + await check('staff without POS grant is refused; manager cannot void; discount rules respected', async () => { + assert.strictEqual((await staff.post('/api/pos/sales', { locationId: loc.id, paymentMethod: 'cash', lines: [{ itemId: A_.id, quantity: 1 }] })).status, 403); + const s = await sell([{ itemId: A_.id, quantity: 1 }]); + assert.strictEqual((await mgr.post(`/api/pos/sales/${s.data.sale.id}/void`, { reason: 'x' })).status, 403); + await cfg({ charges: [], allowDiscounts: false }); + assert.strictEqual((await sell([{ itemId: A_.id, quantity: 1 }], { discount: { type: 'percent', value: 5 } })).status, 403); + await cfg({ charges: [], allowDiscounts: true }); + }); + await check('input fuzz: negative / huge / NaN / string quantities, bad ids, oversized notes, discount >100%', async () => { + for (const bad of [-1, 0, 'abc', null, 1e12, 0.0001]) { + const r = await sell([{ itemId: A_.id, quantity: bad }]); assert.ok(r.status === 400 || r.status === 409, `qty ${bad} → ${r.status}`); + } + assert.strictEqual((await sell([{ itemId: 'nope', quantity: 1 }])).status, 409); + assert.strictEqual((await sell([])).status, 400); + assert.strictEqual((await admin.post('/api/pos/sales', { paymentMethod: 'cash', lines: [{ itemId: A_.id, quantity: 1 }] })).status, 400, 'no counter'); + const d = await sell([{ itemId: A_.id, quantity: 1 }], { discount: { type: 'percent', value: 150 } }); assert.ok(d.status === 400, 'discount 150% → ' + d.status); + const n = await sell([{ itemId: A_.id, quantity: 1 }], { notes: 'x'.repeat(5000) }); assert.ok([200, 400].includes(n.status)); + const sq = await sell([{ itemId: A_.id, quantity: 1 }], { customerName: "Robert'); DROP TABLE pos_sales;--" }); assert.strictEqual(sq.status, 200); + assert.ok((await q1(`SELECT COUNT(*) n FROM pos_sales`)).n > 0, 'table survived'); + }); + + console.log('\n — reporting agrees with the database —'); + await check('summary totals equal SQL totals for the same counter (bills, revenue, tax, cost, voids)', async () => { + const day = (await admin.get('/api/pos/today')).data.today; + const api = (await admin.get(`/api/pos/summary?from=${day}&to=${day}&locationId=${loc.id}`)).data.totals; + const sql = await q1(`SELECT SUM(status='completed') bills, SUM(CASE WHEN status='completed' THEN total END) total, SUM(CASE WHEN status='completed' THEN tax_amount END) tax, SUM(CASE WHEN status='completed' THEN cost_total END) cost, SUM(status='voided') voids FROM pos_sales WHERE location_id=? AND sale_date=?`, [loc.id, day]); + console.log(` API bills=${api.bills} total=${api.total} tax=${api.tax} cost=${api.costOfTracked} voided=${api.voidedBills} | SQL bills=${sql.bills} total=${sql.total} tax=${sql.tax} cost=${sql.cost} voids=${sql.voids}`); + assert.strictEqual(api.bills, Number(sql.bills)); near(api.total, sql.total, 'total'); near(api.tax, sql.tax, 'tax'); assert.strictEqual(api.voidedBills, Number(sql.voids)); + near(api.costOfTracked, sql.cost, 'cost of tracked sales'); + }); + await check('Excel export of sales downloads', async () => { + const e = await admin.get('/api/pos/export', { raw: true }); assert.strictEqual(e.status, 200); assert.ok(e.buf.length > 2000); + }); + await check('every bill is internally consistent (SQL audit over ALL bills in the DB)', async () => { + const bad = await q(`SELECT s.bill_no, s.subtotal, s.discount_amount, s.tax_amount, s.rounding, s.total, + (SELECT COALESCE(SUM(net),0) FROM pos_sale_lines l WHERE l.sale_id=s.id) ln, (SELECT COALESCE(SUM(gross),0) FROM pos_sale_lines l WHERE l.sale_id=s.id) lg, + (SELECT COALESCE(SUM(amount),0) FROM pos_sale_charges c WHERE c.sale_id=s.id) ch, (SELECT COALESCE(SUM(cost_total),0) FROM pos_sale_lines l WHERE l.sale_id=s.id) lc, s.cost_total + FROM pos_sales s`); + const viol = []; + for (const b of bad) { + if (Math.abs(b.subtotal - b.lg) > 0.011) viol.push(`${b.bill_no}: subtotal ${b.subtotal} ≠ Σgross ${b.lg}`); + if (Math.abs(b.tax_amount - b.ch) > 0.011) viol.push(`${b.bill_no}: tax ${b.tax_amount} ≠ Σcharges ${b.ch}`); + if (b.cost_total != null && Math.abs(b.cost_total - b.lc) > 0.02) viol.push(`${b.bill_no}: cost ${b.cost_total} ≠ Σline cost ${b.lc}`); + } + console.log(` audited ${bad.length} bills, ${viol.length} violations`); + assert.strictEqual(viol.length, 0, viol.slice(0, 5).join(' | ')); + }); + await check('final ledger check', async () => { const v = await ledgerViolations(); assert.strictEqual(v.length, 0, JSON.stringify(v.slice(0, 3))); }); + save('p2'); const bad = results.filter(r => !r.ok && r.phase.startsWith('P2')); + console.log(`\nP2 done: ${results.filter(r => r.phase.startsWith('P2')).length - bad.length} passed, ${bad.length} failed`); + process.exit(0); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff -ruN a/tests/qa/p3_load.js b/tests/qa/p3_load.js --- a/tests/qa/p3_load.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p3_load.js 2026-10-20 06:05:01.872098654 +0000 @@ -0,0 +1,249 @@ +'use strict'; +const { execSync } = require('child_process'); +const { assert, Client, q, q1, check, finding, near, setPhase, save, results, pct, sleep, ledgerViolations, adminPool } = require('./lib'); +const S = Date.now().toString(36); +const rc = process.env.TIER_ONLY === '1' ? (async () => {}) : check; +const A = ['rajesh.shrestha@silveroak.edu.np', 'Admin@123']; +const rnd = (a, b) => a + Math.random() * (b - a), ri = (a, b) => Math.floor(rnd(a, b + 1)), pick = a => a[ri(0, a.length - 1)]; +const rss = () => { try { return Math.round(Number(execSync(`ps -o rss= -p $(pgrep -f "node server.js" | head -1)`).toString().trim()) / 1024); } catch { return -1; } }; +const dbStat = async n => Number((await q1(`SHOW GLOBAL STATUS LIKE '${n}'`)).Value); +const ref = () => `ld-${S}-${Math.random().toString(36).slice(2)}`; + +// per-operation stats +const stats = {}; +const rec = (op, r) => { const s = stats[op] || (stats[op] = { n: 0, ok: 0, biz: 0, rl: 0, e5: 0, e0: 0, lat: [], errs: {} }); s.n++; s.lat.push(r.ms || 0); + if (r.status >= 200 && r.status < 300) s.ok++; else if (r.status === 429) s.rl++; else if (r.status >= 500) { s.e5++; if (process.env.TIER_ONLY === '1') console.log(' 5xx on ' + op + ': ' + r.status + ' ' + (r.text || '').slice(0, 160)); const k = (r.data && r.data.error || r.text || '').slice(0, 80); s.errs[k] = (s.errs[k] || 0) + 1; } + else if (r.status === 0) { s.e0++; s.errs[r.error] = (s.errs[r.error] || 0) + 1; } else s.biz++; return r; }; +const resetStats = () => { for (const k of Object.keys(stats)) delete stats[k]; }; +const table = (title) => { console.log(`\n ${title}`); console.log(' op n ok 4xx 429 5xx conn p50 p95 p99 max (ms)'); + let tot = { n: 0, e5: 0, e0: 0, rl: 0, lat: [] }; + for (const [op, s] of Object.entries(stats).sort()) { console.log(` ${op.padEnd(16)} ${String(s.n).padStart(6)} ${String(s.ok).padStart(6)} ${String(s.biz).padStart(5)} ${String(s.rl).padStart(4)} ${String(s.e5).padStart(4)} ${String(s.e0).padStart(5)} ${pct(s.lat, .5).toFixed(0).padStart(6)} ${pct(s.lat, .95).toFixed(0).padStart(6)} ${pct(s.lat, .99).toFixed(0).padStart(6)} ${Math.max(...s.lat).toFixed(0).padStart(7)}`); + tot.n += s.n; tot.e5 += s.e5; tot.e0 += s.e0; tot.rl += s.rl; tot.lat.push(...s.lat); } + console.log(` ${'TOTAL'.padEnd(16)} ${String(tot.n).padStart(6)} 5xx=${tot.e5} conn-errors=${tot.e0} rate-limited=${tot.rl} p50=${pct(tot.lat, .5).toFixed(0)} p95=${pct(tot.lat, .95).toFixed(0)} p99=${pct(tot.lat, .99).toFixed(0)} ms`); return tot; }; + +(async () => { + setPhase('P3 · Production load & concurrency'); + const pool = await adminPool(12, S); const admin = pool[0]; + const clone = (c) => { const n = new Client(); n.cookie = c.cookie; return n; }; // clone(pool.next()) spreads load across separate accounts + + // ---------- environment ---------- + await admin.put('/api/pos/config', { mode: 'hybrid', settings: { billPrefix: 'LD', charges: [{ id: 'vat', name: 'VAT', kind: 'percent', value: 13, mode: 'included', enabled: true, skipExempt: true }], finalizeStep: false, tokenEnabled: true, roundTotal: false, allowDiscounts: true, paymentMethods: [{ id: 'cash', label: 'Cash', cash: true, enabled: true }, { id: 'qr', label: 'QR', cash: false, enabled: true }] } }); + const grp = (await admin.post('/api/user-groups', { name: 'LD Cashiers ' + S, permissions: { use_pos: true } })).data.group; + const cat = (await admin.post('/api/categories', { name: 'LD Food ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const counters = []; const itemsBy = {}; const allItems = []; + for (let c = 0; c < 3; c++) { + const loc = (await admin.post('/api/locations', { name: `LD Counter ${c + 1} ${S}`, isPos: true })).data.location; counters.push(loc); itemsBy[loc.id] = []; + for (let i = 0; i < 8; i++) { + const cost = ri(10, 60); + const it = (await admin.post('/api/pos/menu', { name: `LD C${c + 1}-I${i + 1} ${S}`, categoryId: cat.id, locationId: loc.id, salePrice: cost * 2 + ri(0, 9), saleMode: 'tracked', quantity: 4000, unitCost: cost, station: 'K', taxExempt: i === 7 })).data.item; + await admin.post(`/api/pos/menu/${it.id}/stock`, { quantity: 4000, unitCost: cost + 5 }); + itemsBy[loc.id].push(it); allItems.push({ ...it, initial: 8000 }); + } + } + const cashiers = []; + for (let u = 0; u < 30; u++) { + const email = `ld.cashier${u}.${S}@example.com`; + const r = await admin.post('/api/users', { name: `LD Cashier ${u}`, email, role: 'staff', groupId: grp.id, locationId: counters[u % 3].id }); assert.strictEqual(r.status, 200, JSON.stringify(r.data)); + } + // ---------- login cost ---------- + await check('LOGIN storm: 10 simultaneous cashier logins — how long does the server stall for everyone else?', async () => { + const probes = []; let stop = false; + const prober = (async () => { const c = new Client(); while (!stop) { const r = await c.get('/api/health'); probes.push(r.ms); await sleep(25); } })(); + await sleep(200); + const t0 = Date.now(); + const logins = await Promise.all(Array.from({ length: 10 }, async (_, u) => { const c = new Client(); const r = await c.post('/api/auth/login', { email: `ld.cashier${u}.${S}@example.com`, password: 'Welcome@123' }); if (r.status === 200) cashiers.push({ c, counter: counters[u % 3] }); return r; })); + const dur = Date.now() - t0; stop = true; await prober; + const single = pct(probes.slice(0, 5), .5); + console.log(` 10 logins took ${dur} ms total; /api/health during it: p50=${pct(probes, .5).toFixed(0)} p95=${pct(probes, .95).toFixed(0)} max=${Math.max(...probes).toFixed(0)} ms (idle baseline ≈ ${single.toFixed(0)} ms)`); + assert.strictEqual(logins.filter(l => l.status === 200).length, 10); + assert.ok(Math.max(...probes) < 1000, `other requests stalled for ${Math.max(...probes).toFixed(0)} ms during logins`); + if (Math.max(...probes) > 1000) finding('Medium', 'Logins block the whole server (synchronous bcrypt cost 12)', `POST /api/auth/login uses bcrypt.compareSync (server.js:1045). With 10 simultaneous logins, a trivial /api/health request waited up to ${Math.max(...probes).toFixed(0)} ms (p95 ${pct(probes, .95).toFixed(0)} ms): Node's single thread is busy hashing, so every till freezes while staff log in (shift start). Use async bcrypt.compare (libuv thread pool) or native bcrypt. Measured on a shared 1-vCPU sandbox; a real CPU will be faster but the stall scales linearly with simultaneous logins.`); + }); + for (let u = 10; u < 30; u++) { const c = new Client(); await c.login(`ld.cashier${u}.${S}@example.com`, 'Welcome@123'); cashiers.push({ c, counter: counters[u % 3] }); } + const tills = cashiers.map((x, i) => x), back = Array.from({ length: 5 }, () => clone(pool.next())); + + // ---------- race conditions ---------- + console.log('\n — race conditions (all requests released at once) —'); + const mkRace = async (nm, qty, cost = 10) => (await admin.post('/api/pos/menu', { name: `${nm} ${S}`, categoryId: cat.id, locationId: counters[0].id, salePrice: 20, saleMode: 'tracked', quantity: qty, unitCost: cost, station: 'K' })).data.item; + const fresh = (n) => Array.from({ length: n }, () => new Client()); + const burstClients = Array.from({ length: 40 }, () => clone(pool.next())); + const ssale = (c, itemId, qty = 1, extra = {}) => c.post('/api/pos/sales', { locationId: counters[0].id, paymentMethod: 'cash', clientRef: ref(), lines: [{ itemId, quantity: qty }], ...extra }); + + for (const N of [60, 200]) await rc(`OVERSELL: stock 50, ${N} simultaneous 1-unit sales → never more than 50 sold, stock never negative, bills == stock taken`, async () => { + resetStats(); const it = await mkRace(`Race${N}`, 50); + const res = await Promise.all(Array.from({ length: N }, async (_, i) => rec('sale', await ssale(burstClients[i % 40], it.id)))); + const ok = res.filter(r => r.status === 200).length, oos = res.filter(r => r.status === 409).length, e5 = res.filter(r => r.status >= 500 || r.status === 0).length, rl = res.filter(r => r.status === 429).length; + const cur = await q1(`SELECT quantity FROM items WHERE id=?`, [it.id]); const bills = (await q1(`SELECT COUNT(*) n FROM pos_sale_lines l JOIN pos_sales s ON s.id=l.sale_id WHERE l.item_id=? AND s.status<>'voided'`, [it.id])).n; + console.log(` N=${N}: sold=${ok} out-of-stock(409)=${oos} 5xx/conn=${e5} rate-limited=${rl} | stock left=${cur.quantity} bills=${bills}`); + assert.ok(ok <= 50, `OVERSOLD: ${ok} sales succeeded on 50 units`); assert.ok(Number(cur.quantity) >= 0); + near(Number(cur.quantity), 50 - ok, 'stock = 50 − sold'); assert.strictEqual(Number(bills), ok, 'one bill per unit taken'); + assert.strictEqual((await ledgerViolations('AND i.id=?', [it.id])).length, 0, 'ledger'); + if (e5) finding('High', `Burst of ${N} simultaneous sales returns server errors`, `${e5} of ${N} requests failed with 5xx/connection errors (${JSON.stringify(stats.sale.errs)}). Data stayed consistent (no oversell, ledger intact) but customers at the till see failures. Default DB_CONNECTION_LIMIT=8 / DB_QUEUE_LIMIT=30 in db.js: requests beyond ~38 in-flight are rejected instead of waiting.`); + if (!e5 && !rl) assert.strictEqual(ok, 50, 'with no errors exactly 50 must sell'); + }); + + await rc('DUPLICATE CLIENT-REF: 40 simultaneous identical submissions (double-tap / retry storm) → exactly 1 bill, stock taken once', async () => { + const it = await mkRace('Dup', 100); const cr = ref(); + const res = await Promise.all(burstClients.map(c => c.post('/api/pos/sales', { locationId: counters[0].id, paymentMethod: 'cash', clientRef: cr, lines: [{ itemId: it.id, quantity: 3 }] }))); + const ok = res.filter(r => r.status === 200), ids = new Set(ok.map(r => r.data.sale.id)); + const n = (await q1(`SELECT COUNT(*) n FROM pos_sales WHERE client_ref=?`, [cr])).n; const cur = await q1(`SELECT quantity FROM items WHERE id=?`, [it.id]); + console.log(` statuses: ${JSON.stringify(res.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {}))}; distinct bills returned=${ids.size}; rows=${n}; stock=${cur.quantity}`); + assert.strictEqual(Number(n), 1, 'exactly one bill row'); near(Number(cur.quantity), 97, 'stock deducted once'); assert.strictEqual(ids.size, 1); + }); + + await rc('CONCURRENT VOID: 20 simultaneous voids of one bill → exactly one succeeds, stock returned once', async () => { + const it = await mkRace('VoidRace', 100); const s = (await ssale(admin, it.id, 10)).data.sale; + const res = await Promise.all(Array.from({ length: 20 }, (_, i) => burstClients[i].post(`/api/pos/sales/${s.id}/void`, { reason: 'race' }))); + const ok = res.filter(r => r.status === 200).length; const cur = await q1(`SELECT quantity FROM items WHERE id=?`, [it.id]); + console.log(` void successes=${ok}, others=${JSON.stringify(res.filter(r => r.status !== 200).map(r => r.status).slice(0, 5))}, stock=${cur.quantity}`); + assert.strictEqual(ok, 1); near(Number(cur.quantity), 100); assert.strictEqual((await ledgerViolations('AND i.id=?', [it.id])).length, 0); + }); + + await rc('CONCURRENT TRANSFER APPROVAL: 20 simultaneous approvals → stock moves once', async () => { + const it = await mkRace('TrfRace', 100); const dest = (await admin.post('/api/locations', { name: 'LD Dest ' + S + Math.random().toString(36).slice(2, 5) })).data.location; + const t = (await admin.post('/api/transfers', { itemId: it.id, toLocationId: dest.id, quantity: 30, reason: 'race' })).data.transfer; + const res = await Promise.all(Array.from({ length: 20 }, (_, i) => burstClients[i].post(`/api/admin/transfers/${t.id}/decide`, { decision: 'approved' }))); + const ok = res.filter(r => r.status === 200).length; const src = await q1(`SELECT quantity FROM items WHERE id=?`, [it.id]); const d = await q(`SELECT SUM(quantity) q, COUNT(*) n FROM items WHERE name=? AND location_id=?`, [it.name, dest.id]); + console.log(` approvals accepted=${ok}; source=${src.quantity}; destination qty=${d[0].q} in ${d[0].n} record(s)`); + if (ok !== 1 || Number(src.quantity) !== 70 || Number(d[0].q) !== 30) finding('High', 'Concurrent transfer approvals can move stock more than once', `${ok} simultaneous approvals were accepted; source=${src.quantity} (expected 70), destination=${d[0].q} (expected 30). The handler reads the transfer, checks status==='pending', applies the move, then saves — outside a lock/transaction, so two admins (or a double-click) racing can both pass the check.`); + assert.strictEqual(ok, 1, 'only one approval may apply'); near(Number(src.quantity), 70); near(Number(d[0].q), 30); + }); + + await rc('CONCURRENT PROCUREMENT RECEIVE: 20 simultaneous receives → one stock item, quantity received once', async () => { + const pr = (await admin.post('/api/procurement', { itemName: 'LD Receive ' + S, categoryId: cat.id, quantity: 25, unit: 'pcs', estimatedCost: 500, justification: 'race' })).data.request; + await admin.post(`/api/admin/procurement/${pr.id}/decide`, { decision: 'approved' }); + const res = await Promise.all(Array.from({ length: 20 }, (_, i) => burstClients[i].post(`/api/admin/procurement/${pr.id}/receive`, { locationId: counters[0].id }))); + const ok = res.filter(r => r.status === 200).length; const items = await q(`SELECT id, quantity FROM items WHERE name=?`, ['LD Receive ' + S]); const tq = items.reduce((a, r) => a + Number(r.quantity), 0); + const bt = await q1(`SELECT COUNT(*) n, COALESCE(SUM(quantity_received),0) q FROM stock_batches WHERE procurement_request_id=?`, [pr.id]); + console.log(` receives accepted=${ok}; items created=${items.length}; total qty=${tq}; batches=${bt.n} (qty ${bt.q})`); + if (ok !== 1 || items.length !== 1 || tq !== 25) finding('High', 'Concurrent procurement receive can create duplicate stock', `${ok} simultaneous receives accepted → ${items.length} item record(s), total quantity ${tq} instead of 25 (batches=${bt.n}). Check-then-act on receivedItemId without a row lock/transaction.`); + assert.strictEqual(ok, 1); assert.strictEqual(items.length, 1); assert.strictEqual(tq, 25); + }); + + await rc('MIXED CONTENTION on one item: 150 POS sales + 150 inventory consumes + 20 restocks at once → units conserved exactly', async () => { + resetStats(); const it = await mkRace('Mix', 1000); + const jobs = []; + for (let i = 0; i < 150; i++) jobs.push((async () => { const r = rec('sale', await ssale(burstClients[i % 40], it.id, 1 + (i % 3))); return { k: 'sale', r, q: 1 + (i % 3) }; })()); + for (let i = 0; i < 150; i++) jobs.push((async () => { const r = rec('consume', await burstClients[i % 40].post(`/api/items/${it.id}/consume`, { quantity: 1, purpose: 'race' })); return { k: 'consume', r, q: 1 }; })()); + for (let i = 0; i < 20; i++) jobs.push((async () => { const r = rec('restock', await burstClients[i].post(`/api/items/${it.id}/restock`, { quantity: 5, totalCost: 75 })); return { k: 'restock', r, q: 5 }; })()); + const out = await Promise.all(jobs); const sum = k => out.filter(o => o.k === k && o.r.status === 200).reduce((a, o) => a + o.q, 0); + const cur = Number((await q1(`SELECT quantity FROM items WHERE id=?`, [it.id])).quantity); + const expected = 1000 - sum('sale') - sum('consume') + sum('restock'); + const fails = out.filter(o => o.r.status >= 500 || o.r.status === 0).length; + console.log(` sold=${sum('sale')} consumed=${sum('consume')} restocked=${sum('restock')} → expected ${expected}, actual ${cur}; failed(5xx/conn)=${fails}`); + table('mixed contention'); + near(cur, expected, 'units conserved'); assert.strictEqual((await ledgerViolations('AND i.id=?', [it.id])).length, 0, 'ledger'); + }); + + // ---------- sustained load ---------- + const mix = { + till: [['sale', 68], ['catalog', 14], ['saleList', 10], ['order', 8]], + office: [['consume', 24], ['restock', 8], ['void', 14], ['overview', 16], ['itemsList', 16], ['summary', 10], ['report', 6], ['purchase', 6]] + }; + const choose = list => { let r = Math.random() * 100; for (const [k, w] of list) { if ((r -= w) <= 0) return k; } return list[0][0]; }; + const recentSales = []; + const act = async (vu, op) => { + const c = vu.c; + switch (op) { + case 'sale': { const its = itemsBy[vu.counter.id]; const n = ri(1, 4), lines = []; for (let i = 0; i < n; i++) lines.push({ itemId: pick(its).id, quantity: ri(1, 3) }); + const body = { locationId: vu.counter.id, paymentMethod: pick(['cash', 'qr']), clientRef: ref(), lines, ...(Math.random() < .1 ? { discount: { type: 'percent', value: pick([5, 10]) } } : {}) }; + const r = rec('sale', await c.post('/api/pos/sales', body)); if (r.status === 200) { recentSales.push(r.data.sale.id); if (recentSales.length > 400) recentSales.shift(); } return r; } + case 'catalog': return rec('catalog', await c.get('/api/pos/catalog')); + case 'saleList': return rec('saleList', await c.get('/api/pos/sales?limit=30')); + case 'order': { await admin; const its = itemsBy[vu.counter.id]; const id = pick(its).id; const r = rec('order.place', await c.post('/api/pos/sales', { locationId: vu.counter.id, paymentMethod: 'cash', clientRef: ref(), lines: [{ itemId: id, quantity: 2 }] })); return r; } + case 'consume': return rec('consume', await c.post(`/api/items/${pick(allItems).id}/consume`, { quantity: ri(1, 3), purpose: 'load test' })); + case 'restock': return rec('restock', await c.post(`/api/items/${pick(allItems).id}/restock`, { quantity: ri(10, 40), totalCost: ri(200, 900) })); + case 'void': { const id = recentSales.splice(ri(0, Math.max(0, recentSales.length - 1)), 1)[0]; if (!id) return rec('void', { status: 204, ms: 0 }); return rec('void', await c.post(`/api/pos/sales/${id}/void`, { reason: 'load test' })); } + case 'overview': return rec('overview', await c.get('/api/overview')); + case 'itemsList': return rec('itemsList', await c.get('/api/items?limit=50')); + case 'summary': return rec('summary', await c.get('/api/pos/summary')); + case 'report': return rec('report.preview', await c.get('/api/reports/inventory/preview')); + case 'purchase': return rec('stockBatches', await c.get(`/api/items/${pick(allItems).id}/stock-batches`)); + } + }; + const runTier = async (name, nTills, nOffice, secs, think) => { + resetStats(); const before = { rss: rss(), dl: await dbStat('Innodb_deadlocks'), mx: await dbStat('Max_used_connections') }; + const vus = [...Array.from({ length: nTills }, (_, i) => ({ kind: 'till', c: Object.assign(new Client(), { cookie: tills[i % tills.length].c.cookie }), counter: tills[i % tills.length].counter })), + ...Array.from({ length: nOffice }, () => ({ kind: 'office', c: clone(pool.next()), counter: counters[0] }))]; + const end = Date.now() + secs * 1000; let peakRss = before.rss; + const mon = setInterval(() => { peakRss = Math.max(peakRss, rss()); }, 2000); + await Promise.all(vus.map(async vu => { while (Date.now() < end) { await act(vu, choose(mix[vu.kind])); await sleep(rnd(think[0], think[1])); } })); + clearInterval(mon); + const tot = table(`${name}: ${nTills} tills + ${nOffice} back-office users, ${secs}s, think ${think[0]}–${think[1]} ms `); + const after = { rss: rss(), dl: await dbStat('Innodb_deadlocks'), mx: await dbStat('Max_used_connections') }; + console.log(` throughput ≈ ${(tot.n / secs).toFixed(1)} req/s | server RSS ${before.rss}→${after.rss} MB (peak ${peakRss}) | InnoDB deadlocks +${after.dl - before.dl} | max DB connections used ${after.mx}`); + return { tot, before, after, peakRss }; + }; + + let tierA, tierB; + await check('REALISTIC PEAK: 10 tills (a bill every ~4s each) + 3 back-office users for 60 s → no 5xx, p95 < 500 ms', async () => { + tierA = await runTier('Realistic peak', 10, 3, 60, [2500, 5500]); + assert.strictEqual(tierA.tot.e5 + tierA.tot.e0, 0, '5xx/conn errors under realistic load'); assert.ok(pct(tierA.tot.lat, .95) < 500, 'p95 ' + pct(tierA.tot.lat, .95)); + }); + await check('AUDIT after realistic peak: ledger, conservation, bills, tokens, cost', async () => { await audit('after realistic peak'); }); + await rc('STRESS: 60 tills + 8 back-office users for 60 s (≈ 10–20× a busy school canteen) → data integrity holds; report error rate', async () => { + tierB = await runTier('Stress', 60, 8, 60, [150, 450]); + const errRate = (tierB.tot.e5 + tierB.tot.e0) / tierB.tot.n; + console.log(` error rate (5xx+conn) = ${(errRate * 100).toFixed(2)} %, rate-limited = ${tierB.tot.rl}`); + if (tierB.tot.e5 + tierB.tot.e0 > 0) finding('Medium', 'Server errors under stress load', `${tierB.tot.e5 + tierB.tot.e0} of ${tierB.tot.n} requests failed (5xx/connection) at ~${(tierB.tot.n / 60).toFixed(0)} req/s on a 1-vCPU sandbox. Errors: ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([, s]) => s.e5 || s.e0).map(([k, s]) => [k, s.errs])))}`); + }); + await rc('AUDIT after stress: ledger, conservation, bills, tokens, cost', async () => { await audit('after stress'); }); + + async function audit(label) { + const out = {}; + out.ledger = await ledgerViolations(); + // conservation per load item: initial + restocks − active POS lines − active manual consumption == quantity + const bad = []; + for (const it of allItems) { + const cur = await q1(`SELECT quantity FROM items WHERE id=?`, [it.id]); + const sold = Number((await q1(`SELECT COALESCE(SUM(l.quantity),0) s FROM pos_sale_lines l JOIN pos_sales s ON s.id=l.sale_id WHERE l.item_id=? AND l.tracked=1 AND s.status<>'voided'`, [it.id])).s); + const man = Number((await q1(`SELECT COALESCE(SUM(quantity),0) s FROM consumption_logs WHERE item_id=? AND sale_id IS NULL AND status='active'`, [it.id])).s); + const posLog = Number((await q1(`SELECT COALESCE(SUM(quantity),0) s FROM consumption_logs WHERE item_id=? AND sale_id IS NOT NULL AND status='active'`, [it.id])).s); + const purchased = Number((await q1(`SELECT COALESCE(SUM(quantity),0) s FROM purchase_logs WHERE item_id=?`, [it.id])).s); + const expected = 4000 + purchased - sold - man; // opening stock + recorded purchases/restocks − live POS sales − live manual consumption + if (Math.abs(Number(cur.quantity) - expected) > 0.01) bad.push(`${it.name}: qty ${cur.quantity} ≠ opening + purchases − sold − consumed = ${expected}`); + if (Math.abs(sold - posLog) > 0.01) bad.push(`${it.name}: sale lines ${sold} ≠ active POS consumption logs ${posLog}`); + } + out.conservation = bad; + const dupBill = await q(`SELECT bill_no, COUNT(*) n FROM pos_sales GROUP BY bill_no HAVING n>1`); + const dupTok = await q(`SELECT sale_date, token_no, COUNT(*) n FROM pos_sales WHERE token_no IS NOT NULL GROUP BY sale_date, token_no HAVING n>1`); + const seq = await q1(`SELECT MIN(CAST(SUBSTRING_INDEX(bill_no,'-',-1) AS UNSIGNED)) mn, MAX(CAST(SUBSTRING_INDEX(bill_no,'-',-1) AS UNSIGNED)) mx, COUNT(*) n FROM pos_sales WHERE bill_no LIKE 'LD-%'`); + const gaps = Number(seq.mx) - Number(seq.mn) + 1 - Number(seq.n); + const inc = await q(`SELECT s.bill_no FROM pos_sales s WHERE ABS(s.subtotal - (SELECT COALESCE(SUM(gross),0) FROM pos_sale_lines l WHERE l.sale_id=s.id))>0.011 OR (s.cost_total IS NOT NULL AND ABS(s.cost_total - (SELECT COALESCE(SUM(cost_total),0) FROM pos_sale_lines l WHERE l.sale_id=s.id))>0.02)`); + const orphans = await q1(`SELECT (SELECT COUNT(*) FROM pos_sale_lines l LEFT JOIN pos_sales s ON s.id=l.sale_id WHERE s.id IS NULL) lines_orphan, (SELECT COUNT(*) FROM consumption_logs c LEFT JOIN items i ON i.id=c.item_id WHERE i.id IS NULL AND c.sale_id IS NULL) cons_orphan, (SELECT COUNT(*) FROM consumption_logs c JOIN pos_sales s ON s.id=c.sale_id WHERE s.status='voided' AND c.status='active') active_on_void`); + const revApi = (await admin.get(`/api/pos/summary?from=${(await admin.get('/api/pos/today')).data.today}&to=${(await admin.get('/api/pos/today')).data.today}`)).data.totals; + const revSql = await q1(`SELECT COUNT(*) n, COALESCE(SUM(total),0) t FROM pos_sales WHERE status='completed' AND sale_date=?`, [(await admin.get('/api/pos/today')).data.today]); + console.log(` [${label}] ledger violations=${out.ledger.length} | conservation violations=${bad.length} | duplicate bill numbers=${dupBill.length} | duplicate tokens=${dupTok.length} | bill-number gaps=${gaps} | inconsistent bills=${inc.length} | orphans=${JSON.stringify(orphans)}`); + console.log(` [${label}] summary API: ${revApi.bills} bills / Rs ${revApi.total} SQL: ${revSql.n} bills / Rs ${revSql.t}`); + if (gaps > 0) finding('Low', 'Bill-number gaps after failed/rolled-back sales', `${gaps} bill number(s) skipped within the LD- series (a sale that rolled back after taking its number). Statutory (IRD) invoice series normally must be gapless; consider allocating the number at finalization or recording voided numbers.`); + assert.strictEqual(out.ledger.length, 0, 'ledger: ' + JSON.stringify(out.ledger.slice(0, 3))); assert.strictEqual(bad.length, 0, bad.slice(0, 4).join(' | ')); + assert.strictEqual(dupBill.length, 0, 'duplicate bill numbers'); assert.strictEqual(dupTok.length, 0, 'duplicate tokens'); assert.strictEqual(inc.length, 0, 'inconsistent bills ' + inc.slice(0, 3).map(r => r.bill_no)); + assert.strictEqual(Number(orphans.lines_orphan) + Number(orphans.active_on_void), 0, JSON.stringify(orphans)); + assert.strictEqual(revApi.bills, Number(revSql.n)); near(revApi.total, revSql.t, 'revenue API vs SQL', 0.05); + } + + // ---------- pool saturation + rate limit ---------- + await rc('BURST: 300 simultaneous catalog + overview reads from 300 different tills → no 5xx', async () => { + resetStats(); const cs = Array.from({ length: 300 }, () => Object.assign(new Client(), { cookie: pool.next().cookie })); + await Promise.all(cs.map((c, i) => (async () => rec(i % 2 ? 'overview' : 'catalog', await (i % 2 ? c.get('/api/overview') : c.get('/api/pos/catalog'))))())); + const t = table('300-way read burst'); + if (t.e5 + t.e0) finding('High', 'Read burst of 300 concurrent requests is rejected with errors', `${t.e5 + t.e0}/300 failed (${JSON.stringify(Object.fromEntries(Object.entries(stats).map(([k, s]) => [k, s.errs])))}). DB queue limit (DB_QUEUE_LIMIT=30, pool 8) — requests are refused rather than queued.`); + assert.strictEqual(t.e5 + t.e0, 0); + }); + await rc('SHARED NETWORK ADDRESS: 10 different signed-in users behind ONE school IP (70 requests each) are NOT throttled together; one abusive user still is', async () => { + resetStats(); const IP = '10.250.250.250'; + const users = tills.map(t => Object.assign(new Client(IP), { cookie: t.c.cookie })); + await Promise.all(users.map(async c => { for (let i = 0; i < 70; i++) rec('shared-ip', await c.get('/api/pos/today')); })); + const s1 = stats['shared-ip']; console.log(` 700 requests from ONE IP across ${users.length} users: ok=${s1.ok} rate-limited(429)=${s1.rl}`); + assert.strictEqual(s1.rl, 0, 'users behind one IP must not share a bucket'); + resetStats(); const abuser = Object.assign(new Client(IP), { cookie: tills[0].c.cookie }); + for (let i = 0; i < 650; i++) rec('one-user', await abuser.get('/api/pos/today')); + const s2 = stats['one-user']; console.log(` one user, 650 requests in <1 min: ok=${s2.ok} rate-limited(429)=${s2.rl}`); + assert.ok(s2.rl > 0, 'a single runaway user must still be limited'); + }); + + save('p3'); + const mine = results.filter(r => r.phase.startsWith('P3')); const bad = mine.filter(r => !r.ok); + console.log(`\nP3 done: ${mine.length - bad.length} passed, ${bad.length} failed`); + process.exit(0); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff -ruN a/tests/qa/p4_diag.js b/tests/qa/p4_diag.js --- a/tests/qa/p4_diag.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p4_diag.js 2026-10-20 06:05:01.872118834 +0000 @@ -0,0 +1,32 @@ +'use strict'; +const { assert, Client, q, q1, setPhase, adminPool } = require('./lib'); +const S = Date.now().toString(36); +(async () => { + const admin = await new Client().login('rajesh.shrestha@silveroak.edu.np', 'Admin@123'); + const pool = await adminPool(10, S); const cl = n => Array.from({ length: n }, () => pool.clone()); + const cat = (await admin.post('/api/categories', { name: 'DG ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const loc = (await admin.post('/api/locations', { name: 'DG Ctr ' + S, isPos: true })).data.location; + await admin.put('/api/pos/config', { mode: 'hybrid', settings: { billPrefix: 'DG', charges: [], finalizeStep: false } }); + const mk = async nm => { const r = await admin.post('/api/pos/menu', { name: nm + ' ' + S, categoryId: cat.id, locationId: loc.id, salePrice: 20, saleMode: 'tracked', quantity: 1000, unitCost: 10, station: 'K' }); return r.data.item; }; + const state = async id => { const i = await q1(`SELECT quantity FROM items WHERE id=?`, [id]); const b = await q1(`SELECT COALESCE(SUM(quantity_remaining),0) b FROM stock_batches WHERE item_id=?`, [id]); return { qty: Number(i.quantity), batches: Number(b.b) }; }; + const ops = { + sale: (c, id) => c.post('/api/pos/sales', { locationId: loc.id, paymentMethod: 'cash', clientRef: 'dg' + Math.random(), lines: [{ itemId: id, quantity: 1 }] }), + consume: (c, id) => c.post(`/api/items/${id}/consume`, { quantity: 1, purpose: 'dg' }), + restock: (c, id) => c.post(`/api/items/${id}/restock`, { quantity: 5, totalCost: 50 }), + posstock: (c, id) => c.post(`/api/pos/menu/${id}/stock`, { quantity: 5, unitCost: 10 }), + }; + const scen = [['15 restocks (inventory quick-restock)', { restock: 15 }], ['15 POS "add stock"', { posstock: 15 }], ['15 sales (control)', { sale: 15 }], ['15 manual consumes (control)', { consume: 15 }], + ['8 restocks + 8 sales', { restock: 8, sale: 8 }], ['8 restocks + 8 consumes', { restock: 8, consume: 8 }], ['8 POS add-stock + 8 sales', { posstock: 8, sale: 8 }]]; + for (const [name, mix] of scen) { + let bad = 0, five = 0, lastDelta = null; const R = 6; + for (let rep = 0; rep < R; rep++) { + const it = await mk('D'); const before = await state(it.id); let up = 0, down = 0; const jobs = []; const cs = cl(30); let k = 0; + for (const [op, n] of Object.entries(mix)) for (let i = 0; i < n; i++) jobs.push(ops[op](cs[k++], it.id).then(r => { if (r.status >= 500) five++; else if (r.status === 200) { if (op === 'restock' || op === 'posstock') up += 5; else down += 1; } })); + await Promise.all(jobs); + const a = await state(it.id); const exp = before.qty + up - down; + if (Math.abs(a.qty - exp) > 0.01 || Math.abs(a.qty - a.batches) > 0.01) { bad++; lastDelta = `expected ${exp}, qty ${a.qty}, batches ${a.batches}`; } + } + console.log(`${name.padEnd(40)} runs with inconsistency: ${bad}/${R} 5xx=${five}${lastDelta ? ' e.g. ' + lastDelta : ''}`); + } + process.exit(0); +})(); diff -ruN a/tests/qa/p5_phantom.js b/tests/qa/p5_phantom.js --- a/tests/qa/p5_phantom.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p5_phantom.js 2026-10-20 06:05:01.872131402 +0000 @@ -0,0 +1,15 @@ +const { Client, q, q1, adminPool } = require('./lib'); const S = Date.now().toString(36); +(async () => { + const a = await new Client().login('rajesh.shrestha@silveroak.edu.np', 'Admin@123'); const pool = await adminPool(4, S); const cl = n => Array.from({ length: n }, () => pool.clone()); + const cat = (await a.post('/api/categories', { name: 'PH ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const loc = (await a.post('/api/locations', { name: 'PH ' + S, isPos: true })).data.location; + const it = (await a.post('/api/pos/menu', { name: 'PH ' + S, categoryId: cat.id, locationId: loc.id, salePrice: 20, saleMode: 'tracked', quantity: 1000, unitCost: 10, station: 'K' })).data.item; + console.log('batches right after creation:', JSON.stringify(await q(`SELECT quantity_received r, quantity_remaining rem, unit_cost c FROM stock_batches WHERE item_id=?`, [it.id]))); + const cs = cl(16), jobs = []; + for (let i = 0; i < 8; i++) jobs.push(cs[i].post(`/api/pos/menu/${it.id}/stock`, { quantity: 5, unitCost: 10 })); + for (let i = 8; i < 16; i++) jobs.push(cs[i].post('/api/pos/sales', { locationId: loc.id, paymentMethod: 'cash', clientRef: 'ph' + Math.random(), lines: [{ itemId: it.id, quantity: 1 }] })); + const rs = await Promise.all(jobs); console.log('statuses', JSON.stringify(rs.map(r => r.status))); + console.log('item qty:', (await q1(`SELECT quantity FROM items WHERE id=?`, [it.id])).quantity); + console.log('batches:'); for (const b of await q(`SELECT quantity_received r, quantity_remaining rem, unit_cost c, received_by_id by_user, received_at FROM stock_batches WHERE item_id=? ORDER BY received_at, id`, [it.id])) console.log(' ', JSON.stringify(b)); + process.exit(0); +})(); diff -ruN a/tests/qa/p7_open_items.js b/tests/qa/p7_open_items.js --- a/tests/qa/p7_open_items.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p7_open_items.js 2026-10-20 06:05:01.872157316 +0000 @@ -0,0 +1,45 @@ +'use strict'; +// Race tests for the items that were "unverified" in the first report: item quantity edit vs sales, and the manager-approval route. +const { assert, Client, q, q1, check, setPhase, near, save, results, ledgerViolations, adminPool } = require('./lib'); +const S = Date.now().toString(36); +(async () => { + setPhase('P7 · Previously unverified: item edit race & manager-approval race'); + const admin = await new Client().login('rajesh.shrestha@silveroak.edu.np', 'Admin@123'); + const mgr = await new Client().login('sunita.gurung@silveroak.edu.np', 'Welcome@123'); + const staff = await new Client().login('anita.rai@silveroak.edu.np', 'Welcome@123'); + const pool = await adminPool(8, S); const cl = n => Array.from({ length: n }, () => pool.clone()); + const cat = (await admin.post('/api/categories', { name: 'P7 ' + S, trackingType: 'stock', defaultUnit: 'pcs', isPos: true })).data.category; + const loc = (await admin.post('/api/locations', { name: 'P7 Ctr ' + S, isPos: true })).data.location; + const dest = (await admin.post('/api/locations', { name: 'P7 Dest ' + S })).data.location; + await admin.put('/api/pos/config', { mode: 'hybrid', settings: { billPrefix: 'P7', charges: [], finalizeStep: false } }); + const mk = async nm => (await admin.post('/api/pos/menu', { name: nm + ' ' + S, categoryId: cat.id, locationId: loc.id, salePrice: 20, saleMode: 'tracked', quantity: 1000, unitCost: 10, station: 'K' })).data.item; + + await check('EDIT QUANTITY racing with 10 sales: final quantity = typed value − sales that landed after, batches agree (8 runs)', async () => { + let bad = 0, detail = ''; + for (let run = 0; run < 8; run++) { + const it = await mk('Edit' + run); const cs = cl(11); const jobs = []; + jobs.push(cs[0].patch('/api/items/' + it.id, { quantity: 500 })); + for (let i = 1; i <= 10; i++) jobs.push(cs[i].post('/api/pos/sales', { locationId: loc.id, paymentMethod: 'cash', clientRef: 'p7' + Math.random(), lines: [{ itemId: it.id, quantity: 1 }] })); + await Promise.all(jobs); + const v = await ledgerViolations('AND i.id=?', [it.id]); + if (v.length) { bad++; detail = JSON.stringify(v[0]); } + } + console.log(` runs with quantity ≠ batches: ${bad}/8 ${detail}`); + assert.strictEqual(bad, 0, detail); + }); + + await check('MANAGER APPROVAL RACE: 15 simultaneous manager approvals + 5 admin approvals of one staff transfer → moves once', async () => { + const it = await mk('MgrRace'); + // transfer requested by staff (anita), whose manager is Sunita; staff may be limited by department scope, so fall back to admin-raised plus the admin route + let t = await staff.post('/api/transfers', { itemId: it.id, toLocationId: dest.id, quantity: 30, reason: 'race' }); + const viaStaff = t.status === 200; if (!viaStaff) t = await admin.post('/api/transfers', { itemId: it.id, toLocationId: dest.id, quantity: 30, reason: 'race' }); + assert.strictEqual(t.status, 200, JSON.stringify(t.data)); const id = t.data.transfer.id; + const mg = Array.from({ length: 15 }, () => Object.assign(new Client(), { cookie: mgr.cookie })), ad = cl(5); + const rs = await Promise.all([...mg.map(c => c.post(`/api/manager/approvals/transfer/${id}/decide`, { decision: 'approved' })), ...ad.map(c => c.post(`/api/admin/transfers/${id}/decide`, { decision: 'approved' }))]); + const src = Number((await q1(`SELECT quantity FROM items WHERE id=?`, [it.id])).quantity); const d = await q(`SELECT COALESCE(SUM(quantity),0) q, COUNT(*) n FROM items WHERE name=? AND location_id=?`, [it.name, dest.id]); + console.log(` raised via ${viaStaff ? 'staff' : 'admin'}; 200s=${rs.filter(r => r.status === 200).length} 409s=${rs.filter(r => r.status === 409).length} 403s=${rs.filter(r => r.status === 403).length} other=${rs.filter(r => ![200, 409, 403].includes(r.status)).length}; source=${src} destination=${d[0].q} in ${d[0].n} record(s)`); + assert.strictEqual(src, 970); near(Number(d[0].q), 30); assert.strictEqual(Number(d[0].n), 1); + }); + save('p7'); const mine = results.filter(r => r.phase.startsWith('P7')); + console.log(`\nP7 done: ${mine.filter(r => r.ok).length} passed, ${mine.filter(r => !r.ok).length} failed`); process.exit(0); +})().catch(e => { console.error('FATAL', e); process.exit(2); }); diff -ruN a/tests/qa/p8_burst.js b/tests/qa/p8_burst.js --- a/tests/qa/p8_burst.js 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/p8_burst.js 2026-10-20 06:05:01.872143433 +0000 @@ -0,0 +1,10 @@ +'use strict'; +// N simultaneous read requests (catalog + overview) from N different tills; reports failures and latency. +const { Client, adminPool, pct } = require('./lib'); +(async () => { + const N = Number(process.argv[2] || 300); const pool = await adminPool(12, Date.now().toString(36)); + const res = await Promise.all(Array.from({ length: N }, (_, i) => { const c = pool.clone(); return i % 2 ? c.get('/api/overview') : c.get('/api/pos/catalog'); })); + const bad = res.filter(r => r.status >= 500 || r.status === 0).length, lat = res.map(r => r.ms); + console.log(`${N}-way burst: failed=${bad} (${(bad / N * 100).toFixed(1)}%) p50=${pct(lat, .5).toFixed(0)} p95=${pct(lat, .95).toFixed(0)} max=${Math.max(...lat).toFixed(0)} ms`); + process.exit(0); +})(); diff -ruN a/tests/qa/run-all.sh b/tests/qa/run-all.sh --- a/tests/qa/run-all.sh 1970-01-01 00:00:00.000000000 +0000 +++ b/tests/qa/run-all.sh 2026-10-20 06:05:01.872172229 +0000 @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs the whole AssetTrack test suite against a RUNNING server and prints one verdict. +# BASE=http://localhost:3000 DB_PASS=... bash tests/qa/run-all.sh (from the application folder) +# Needs: the server running, the DEMO data seeded (npm run seed), and DB_* variables pointing at the SAME database. +# WARNING: these tests create users, items, bills and change POS settings. Run them on a TEST database, never on live data. +# FAST=1 skips the 62-second pauses (they only exist so the per-user rate limit window resets between heavy scripts). +cd "$(dirname "$0")/../.." || exit 2 +export BASE="${BASE:-http://localhost:3000}" +fail=0; pause() { [ "${FAST:-0}" = "1" ] || sleep 62; } +step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +qa() { # name script + step "$1"; out=$(node "tests/qa/$2" 2>&1); echo "$out" | grep -E "✗|⚑|done:|FATAL" ; + echo "$out" | grep -q "FATAL" && fail=1 + failed=$(echo "$out" | sed -n 's/.*done: [0-9]* passed, \([0-9]*\) failed.*/\1/p' | tail -1); [ -n "$failed" ] && [ "$failed" != "0" ] && fail=1; true; } + +step "Existing unit tests" +for t in pricing charges orders; do printf '%-9s' "$t:"; npm run -s "test:$t" 2>&1 | tail -1; done +npm run -s test:pricing 2>&1 | grep -q "invariant failures: 0" || fail=1 +npm run -s test:charges 2>&1 | grep -q "passed" || fail=1 +npm run -s test:orders 2>&1 | grep -q "passed" || fail=1 +step "Existing POS end-to-end"; npm run -s test:pos 2>&1 | tail -3; npm run -s test:pos 2>&1 | grep -q "checks passed" || fail=1 + +qa "P1 Inventory functions & costing" p1_inventory.js; pause +qa "P2 POS correctness & inventory<->POS flow" p2_pos.js; pause +qa "P7 Item-edit race & manager-approval race" p7_open_items.js; pause + +step "P4 Concurrency matrix (every cell must be 0/6)" +out=$(node tests/qa/p4_diag.js 2>&1); echo "$out" | tail -8; echo "$out" | grep -qE "inconsistency: [1-9]|Error" && fail=1; pause + +step "P5 Phantom cost layers (expect exactly 1 = the genuine opening layer)" +n=$(node tests/qa/p5_phantom.js 2>&1 | grep -c '"by_user":null'); echo "layers without a user: $n"; [ "$n" = "1" ] || fail=1; pause + +qa "P3 Races + production load + audits" p3_load.js + +echo; if [ "$fail" = "0" ]; then printf '\033[32mALL TESTS PASSED\033[0m\n'; else printf '\033[31mFAILURES — see above\033[0m\n'; fi; exit $fail