Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70ae7ee939 | |||
| 36a0e2c42c | |||
| 41f3fee83c |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vseebox-stockroom",
|
"name": "vseebox-stockroom",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": { "node": ">=22.13.0" },
|
"engines": { "node": ">=22.13.0" },
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const esc = (v = "") => String(v).replace(/[&<>'"]/g, c => ({ "&":"&", "<":"
|
|||||||
const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
|
const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
|
||||||
const primaryId = p => ids(p)[0]?.[1] || "No identifier";
|
const primaryId = p => ids(p)[0]?.[1] || "No identifier";
|
||||||
function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; }
|
function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; }
|
||||||
|
const systemTheme=window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
function savedTheme(){try{return localStorage.getItem("stockroom-theme")||"system"}catch{return "system"}}
|
||||||
|
function applyTheme(choice=savedTheme()){const resolved=choice==="system"?(systemTheme.matches?"dark":"light"):choice;document.documentElement.dataset.theme=resolved;document.querySelector('meta[name="theme-color"]').content=resolved==="dark"?"#0d1320":"#f5f7fb";if($("#themeSelect"))$("#themeSelect").value=choice;}
|
||||||
|
function decorateResponsiveTable(){const labels=[...document.querySelectorAll("#thead th")].map(th=>th.textContent.trim()||"Actions");document.querySelectorAll("#rows tr").forEach(row=>[...row.children].forEach((cell,index)=>cell.dataset.label=labels[index]||"Details"));}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const writing=options.method&&options.method!=="GET"; if(writing)storageStatus("saving","Saving to database");
|
const writing=options.method&&options.method!=="GET"; if(writing)storageStatus("saving","Saving to database");
|
||||||
@@ -52,6 +56,7 @@ function render() {
|
|||||||
const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE);
|
const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE);
|
||||||
$("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>UID / SN / MAC</th><th>Sold</th><th>Payment</th><th>Sale price</th><th></th></tr>";
|
$("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>UID / SN / MAC</th><th>Sold</th><th>Payment</th><th>Sale price</th><th></th></tr>";
|
||||||
$("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join("");
|
$("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join("");
|
||||||
|
decorateResponsiveTable();
|
||||||
$("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded.";
|
$("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded.";
|
||||||
$("#pagination").innerHTML=all.length?`<span>Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:"";
|
$("#pagination").innerHTML=all.length?`<span>Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:"";
|
||||||
}
|
}
|
||||||
@@ -121,5 +126,8 @@ $("#pagination").onclick=e=>{if(!e.target.dataset.page)return;state.page+=e.targ
|
|||||||
$("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");};
|
$("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");};
|
||||||
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
|
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
|
||||||
$("#logout").onclick=logout;
|
$("#logout").onclick=logout;
|
||||||
|
$("#themeSelect").onchange=e=>{try{localStorage.setItem("stockroom-theme",e.target.value)}catch{}applyTheme(e.target.value);};
|
||||||
|
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
|
||||||
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
||||||
|
applyTheme();
|
||||||
initialize();
|
initialize();
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>vSeeBox Stockroom</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"></head>
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><title>vSeeBox Stockroom</title><script>try{const t=localStorage.getItem("stockroom-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
|
||||||
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><b>S</b><span>Stockroom</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><b>S</b><span>Stockroom</span></div><input id="search" type="search" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive">+ Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><button class="secondary" id="logout">Log out</button></div></header>
|
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><b>S</b><span>Stockroom</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><b>S</b><span>Stockroom</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive">+ Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button class="secondary" id="logout">Log out</button></div></header>
|
||||||
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
|
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
|
||||||
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
|
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
|
||||||
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
|
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
:root{
|
||||||
|
color-scheme:light;
|
||||||
|
--page:#f5f7fb;--surface:#fff;--surface-soft:#f8fafc;--surface-table:#fbfcfe;
|
||||||
|
--text:#172033;--text-strong:#172033;--muted:#68758b;--muted-2:#7b8799;
|
||||||
|
--border:#e3e7ef;--border-strong:#d7deea;--blue:#2563eb;--blue-soft:#edf3ff;
|
||||||
|
--blue-text:#1d4ed8;--green:#16a34a;--green-soft:#dcfce7;--danger:#c62828;
|
||||||
|
--danger-soft:#fff0f0;--warning:#9a3412;--warning-soft:#fff7ed;--shadow:#17203324;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"]{
|
||||||
|
color-scheme:dark;
|
||||||
|
--page:#0d1320;--surface:#151d2c;--surface-soft:#111827;--surface-table:#121a28;
|
||||||
|
--text:#dce5f4;--text-strong:#f5f8ff;--muted:#a7b3c7;--muted-2:#929fb4;
|
||||||
|
--border:#2b374b;--border-strong:#3a4961;--blue:#4f83ff;--blue-soft:#1c315d;
|
||||||
|
--blue-text:#91b5ff;--green:#44cf7b;--green-soft:#153b2a;--danger:#ff8585;
|
||||||
|
--danger-soft:#4a2027;--warning:#f3b166;--warning-soft:#472f1e;--shadow:#0008;
|
||||||
|
}
|
||||||
|
html[data-theme],body{background:var(--page);color:var(--text)}body,header,.records,.stats article,.auth-card,dialog{transition:background-color .18s ease,color .18s ease,border-color .18s ease}
|
||||||
|
header,.records,.stats article,.auth-card,dialog{background:var(--surface);border-color:var(--border);color:var(--text)}
|
||||||
|
.brand+input,#search,label input,label select,label textarea,.secondary,.upload-backup,.backup-list a.button,.user-menu select{background:var(--surface-soft);border-color:var(--border-strong);color:var(--text)}
|
||||||
|
.intro>span{background:var(--surface);border-color:var(--border);color:var(--muted)}
|
||||||
|
.stats span,.stats small,.records nav>span,dialog>div>p,.admin-page p,.auth-card>div>p,.muted,td small,.backup-list article small,.user-list small,.audit-list small{color:var(--muted)}
|
||||||
|
th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.backup-list article,.user-list article,.audit-list article{border-color:var(--border)}
|
||||||
|
.records nav .tab,.customer-link{color:var(--muted)}.records nav .tab.active{color:var(--blue);border-color:var(--blue)}.tab i{background:var(--surface-soft)}
|
||||||
|
.row-actions button,.customer-purchases button{background:var(--blue-soft);color:var(--blue-text)}.row-actions .danger,.customer-notes article>button,.delete-backup,.backup-list .delete-backup{background:var(--danger-soft);color:var(--danger)}
|
||||||
|
.pill{background:var(--green-soft);color:var(--green)}.detail-card{background:var(--surface-soft);border-color:var(--border)}
|
||||||
|
.customer-notes article,.backup-list,.user-list,.audit-list{border-color:var(--border)}
|
||||||
|
.note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)}
|
||||||
|
.auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)}
|
||||||
|
dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)}
|
||||||
|
#toast{background:var(--text-strong);color:var(--surface);box-shadow:0 12px 30px var(--shadow)}
|
||||||
|
.user-menu select{width:118px;border:1px solid var(--border-strong);border-radius:9px;padding:9px 30px 9px 10px;font-size:12px;font-weight:650}
|
||||||
|
button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid color-mix(in srgb,var(--blue) 45%,transparent);outline-offset:2px}
|
||||||
|
@media (prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
|
||||||
|
@media (pointer:coarse){button,.button,.upload-backup,.user-menu select{min-height:44px}}
|
||||||
|
@media (max-width:1050px){
|
||||||
|
header{gap:14px;padding-inline:3vw}.brand span{display:none}.actions{gap:6px}.actions button{padding-inline:10px}.user-menu{padding-left:10px;gap:7px}.user-menu select{width:105px}
|
||||||
|
.inline-user-form{grid-template-columns:1fr 1fr}.audit-list article{grid-template-columns:120px 160px 1fr}
|
||||||
|
}
|
||||||
|
@media (max-width:820px){
|
||||||
|
header{position:relative;display:grid;grid-template-columns:auto 1fr auto;height:auto;padding:12px max(14px,env(safe-area-inset-right)) 12px max(14px,env(safe-area-inset-left));gap:10px}
|
||||||
|
header .brand{grid-column:1}.user-menu{grid-column:2/4;grid-row:1;margin-left:auto!important;min-width:0!important;padding-left:0;border-left:0}.user-menu #currentUser{display:grid!important;width:auto!important;min-width:70px}
|
||||||
|
header #search{grid-column:1/-1;grid-row:2;order:initial}.actions{grid-column:1/-1;grid-row:3;width:100%;margin:0}.actions button{font-size:14px!important;flex:1}.actions button::first-letter{font-size:inherit!important}
|
||||||
|
main{width:min(100% - 24px,1400px);margin:14px auto 28px}.intro h1{font-size:23px}.intro>span{font-size:12px}.stats{gap:10px;margin:14px 0}.stats article{padding:16px}.stats strong{font-size:26px}
|
||||||
|
.records{overflow:visible}.records nav{display:grid;grid-template-columns:1fr 1fr;overflow:visible;height:auto;padding:0 10px;gap:0}.records nav .tab{width:100%;height:50px;white-space:nowrap;padding-inline:6px;font-size:12px}.storage-status{grid-column:1/-1;min-width:100%;margin:0!important;padding:10px 4px!important;border-top:1px solid var(--border)}
|
||||||
|
.table-wrap{overflow:visible}table{display:block;min-width:0}thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}tbody{display:grid;gap:10px;padding:10px;background:var(--page)}tr{display:grid;background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}td{display:grid;grid-template-columns:minmax(92px,34%) 1fr;gap:10px;align-items:start;padding:11px 13px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}td:last-child{border-bottom:0}td::before{content:attr(data-label);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-2)}td:empty{display:none}.row-actions{flex-wrap:wrap}.row-actions button{flex:1;min-width:95px}
|
||||||
|
#empty{background:var(--surface);padding:45px 20px}#pagination{background:var(--surface);padding:14px 12px}.address-grid{grid-template-columns:1fr}.form-row,.scan{grid-template-columns:1fr}
|
||||||
|
dialog{width:calc(100vw - 20px);max-height:calc(100dvh - 20px);padding:22px 18px;margin:10px}.form-actions{flex-wrap:wrap}.form-actions button{min-width:110px}
|
||||||
|
#adminPanel{padding:15px 12px}.admin-page{gap:26px}.admin-section{gap:13px}.inline-user-form{grid-template-columns:1fr}.user-list article,.backup-list article{align-items:stretch}.user-list article>div:last-child,.backup-list article>div:last-child{display:grid;grid-template-columns:1fr 1fr}.user-list button,.backup-list button,.backup-list a.button{justify-content:center;text-align:center}.audit-list article{grid-template-columns:1fr;gap:3px}.audit-list{max-height:55vh}
|
||||||
|
.auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline}
|
||||||
|
}
|
||||||
|
@media (max-width:430px){
|
||||||
|
.user-menu{width:100%;justify-content:flex-end!important}.user-menu select{width:88px;max-width:88px;padding-inline:8px}.user-menu #currentUser small{font-size:10px}.user-menu #logout{padding-inline:10px}
|
||||||
|
.intro{align-items:flex-start}.intro h1{margin:8px 0}.intro>span{padding:7px 8px}.stats{grid-template-columns:1fr}.stats article{grid-template-columns:1fr auto}.stats article strong{grid-column:2;grid-row:1/3}.stats article small{grid-column:1}
|
||||||
|
td{grid-template-columns:82px 1fr}.backup-list article>div:last-child,.user-list article>div:last-child{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
|
@media (forced-colors:active){button,.button,input,select,textarea,.records,.stats article{border:1px solid CanvasText}.storage-status i{forced-color-adjust:none}}
|
||||||
+38
-2
@@ -1,2 +1,38 @@
|
|||||||
import test from "node:test";import assert from "node:assert/strict";import {spawn} from "node:child_process";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {join} from "node:path";
|
import test from "node:test";
|
||||||
test("health, seed, receive, sell and restock",async t=>{const data=await mkdtemp(join(tmpdir(),"stockroom-")),port=31991,p=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});t.after(()=>p.kill());await new Promise((ok,no)=>{p.stdout.on("data",d=>String(d).includes("listening")&&ok());p.on("error",no)});let r=await fetch(`http://127.0.0.1:${port}/api/products`),items=await r.json();assert.equal(items.length,10);r=await fetch(`http://127.0.0.1:${port}/api/products`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({uid:"U1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(r.status,201);const item=await r.json();r=await fetch(`http://127.0.0.1:${port}/api/products/${item.id}/sell`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({customerName:"Test",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123"})});const sale=await r.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");r=await fetch(`http://127.0.0.1:${port}/api/products/${item.id}/restock`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});assert.equal((await r.json()).status,"available")});
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
test("authentication, roles, inventory, sale, and restock", async t => {
|
||||||
|
const data=await mkdtemp(join(tmpdir(),"stockroom-")),port=31991;
|
||||||
|
const processHandle=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});
|
||||||
|
t.after(()=>processHandle.kill());
|
||||||
|
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
|
||||||
|
const base=`http://127.0.0.1:${port}`;
|
||||||
|
const request=async(path,options={})=>fetch(base+path,options);
|
||||||
|
const login=async(username,password)=>{const response=await request("/api/auth/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username,password})});return{response,cookie:response.headers.get("set-cookie").split(";")[0]}};
|
||||||
|
const jsonHeaders=cookie=>({"content-type":"application/json",cookie});
|
||||||
|
|
||||||
|
let response=await request("/api/health");assert.equal(response.status,200);
|
||||||
|
response=await request("/api/products");assert.equal(response.status,401);
|
||||||
|
|
||||||
|
const bootstrap=await login("admin","admin");assert.equal(bootstrap.response.status,200);assert.equal((await bootstrap.response.json()).mustChangePassword,true);
|
||||||
|
response=await request("/api/products",{headers:{cookie:bootstrap.cookie}});assert.equal(response.status,403);
|
||||||
|
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(bootstrap.cookie),body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})});assert.equal(response.status,200);
|
||||||
|
const adminCookie=response.headers.get("set-cookie").split(";")[0];
|
||||||
|
|
||||||
|
response=await request("/api/products",{headers:{cookie:adminCookie}});let items=await response.json();assert.equal(items.length,10);
|
||||||
|
response=await request("/api/admin/users",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({username:"viewer",password:"viewer123",role:"readonly"})});assert.equal(response.status,201);
|
||||||
|
const viewerLogin=await login("viewer","viewer123");
|
||||||
|
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(viewerLogin.cookie),body:JSON.stringify({currentPassword:"viewer123",newPassword:"viewer456",confirmPassword:"viewer456"})});
|
||||||
|
const viewerCookie=response.headers.get("set-cookie").split(";")[0];
|
||||||
|
response=await request("/api/products",{headers:{cookie:viewerCookie}});assert.equal(response.status,200);
|
||||||
|
response=await request("/api/products",{method:"POST",headers:jsonHeaders(viewerCookie),body:"{}"});assert.equal(response.status,403);
|
||||||
|
response=await request("/api/admin/users",{headers:{cookie:viewerCookie}});assert.equal(response.status,403);
|
||||||
|
|
||||||
|
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,201);const item=await response.json();
|
||||||
|
response=await request(`/api/products/${item.id}/sell`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123"})});const sale=await response.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");
|
||||||
|
response=await request(`/api/products/${item.id}/restock`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});assert.equal((await response.json()).status,"available");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user