242 lines
13 KiB
JavaScript
242 lines
13 KiB
JavaScript
const {test} = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const vm = require('node:vm');
|
|
const flush = async () => { for(let i=0;i<12;i++) await Promise.resolve(); };
|
|
|
|
function page(){
|
|
class Element {
|
|
constructor(){ this.children=[]; this.attributes={}; this.hidden=false; this.dataset={}; }
|
|
set textContent(value){ this.text=value; this.children=[]; }
|
|
get textContent(){ return (this.text || '')+this.children.map(x=>x.textContent).join(''); }
|
|
set innerHTML(value){ this.children=[]; this.text=value; }
|
|
append(...nodes){ this.children.push(...nodes); }
|
|
appendChild(node){ this.append(node); }
|
|
setAttribute(key,value){ this.attributes[key]=String(value); }
|
|
focus(){ this.focused=true; }
|
|
}
|
|
const ids={}, requests=[], timers=new Map();
|
|
let timerId=0;
|
|
const el=id=>ids[id] ||= new Element();
|
|
const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController,
|
|
setTimeout:callback=>{const id=++timerId;timers.set(id,callback);return id;},
|
|
clearTimeout:id=>timers.delete(id)});
|
|
vm.runInContext(fs.readFileSync('static/js/customer_orders.js','utf8'),context);
|
|
const orders=context.CustomerOrders.create({text:key=>key === 'ordersProgress' ? 'Loaded {loaded} of {total}' : key,
|
|
fetch:(url,options)=>new Promise((resolve,reject)=>requests.push({url,options,resolve,reject})),
|
|
renderCard:(element,card)=>{element.textContent=card.no;},
|
|
onUnauthorized:()=>{page.expired=true;}});
|
|
return {el,orders,requests,timeout:()=>{for(const callback of Array.from(timers.values())) callback();},reply:async (i,data,status=200)=>{
|
|
requests[i].resolve({ok:status===200,status,json:async()=>data}); await flush();
|
|
}};
|
|
}
|
|
const row=(id=1)=>({id,order_no:'ORDER'+id,inbound_code:'INBOUND'+id,status:'PROCESSING'});
|
|
const counts={total:26,pending:4,processing:20,completed:1,cancelled:0,other:1};
|
|
const filter=(p,name)=>p.el('customer-orders-summary').children.find(button=>button.dataset.filter===name);
|
|
|
|
test('overview uses full server totals; a status click filters from the first page',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
|
|
assert.match(p.el('customer-orders-toggle').textContent,/26/);
|
|
assert.match(filter(p,'processing').textContent,/20/);
|
|
filter(p,'completed').onclick();
|
|
assert.equal(p.requests[1].url,'/api/customer/orders?status=completed');
|
|
assert.equal(p.el('customer-orders-list').textContent,'');
|
|
await p.reply(1,{orders:[row(9)],next_before:null,summary:counts});
|
|
assert.equal(filter(p,'completed').attributes['aria-pressed'],'true');
|
|
assert.equal(filter(p,'all').attributes['aria-pressed'],'false');
|
|
assert.match(p.el('customer-orders-progress').textContent,/1/);
|
|
filter(p,'processing').onclick();
|
|
await p.reply(2,{orders:[row()],next_before:1,summary:counts});
|
|
p.el('customer-orders-more').onclick();
|
|
assert.equal(p.requests[3].url,'/api/customer/orders?status=processing&before=1');
|
|
await p.reply(3,{orders:[row(2)],next_before:null,summary:counts});
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/);
|
|
});
|
|
|
|
test('network failure and timeout show different recoverable messages',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
p.requests[0].reject(new TypeError('Failed to fetch'));await flush();
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersNetworkError');
|
|
assert.equal(p.el('customer-orders-retry').hidden,false);
|
|
p.el('customer-orders-retry').onclick();p.timeout();
|
|
assert.equal(p.requests[1].options.signal.aborted,true);
|
|
p.requests[1].reject(Object.assign(new Error('abort'),{name:'AbortError'}));await flush();
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersTimeout');
|
|
assert.equal(p.el('customer-orders-retry').hidden,false);
|
|
p.el('customer-orders-retry').onclick();
|
|
await p.reply(2,{orders:[row()],next_before:null});
|
|
assert.equal(p.el('customer-orders-message').hidden,true);
|
|
});
|
|
|
|
test('HTTP errors are classified without showing raw server output',async()=>{
|
|
for(const [status,key,retryable] of [[400,'ordersInvalidRequest',false],[403,'ordersForbidden',false],
|
|
[429,'ordersRateLimited',true],[500,'ordersServerError',true],[502,'ordersServerError',true],
|
|
[503,'ordersServerError',true],[408,'ordersTimeout',true],[504,'ordersTimeout',true],[418,'ordersFailed',true]]){
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{error:'private traceback with secret'},status);
|
|
assert.equal(p.el('customer-orders-message').textContent,key,'status '+status);
|
|
assert.equal(p.el('customer-orders-retry').hidden,!retryable,'status '+status);
|
|
assert.doesNotMatch(p.el('customer-orders-message').textContent,/private|secret/);
|
|
}
|
|
});
|
|
|
|
test('missing order directs the customer back to the list instead of retrying a missing record',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
|
|
p.el('customer-orders-list').children[0].onclick();
|
|
await p.reply(1,{error:'not found or forbidden'},404);
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersUnavailable');
|
|
assert.equal(p.el('customer-orders-back').hidden,false);
|
|
assert.equal(p.el('customer-orders-retry').hidden,true);
|
|
p.el('customer-orders-back').onclick();
|
|
assert.equal(p.el('customer-orders-message').hidden,true);
|
|
assert.equal(p.el('customer-orders-list').hidden,false);
|
|
});
|
|
|
|
test('malformed successful responses are not reported as a network outage',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
p.requests[0].resolve({ok:true,status:200,json:async()=>{throw new SyntaxError('Unexpected <');}});await flush();
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
|
|
p.el('customer-orders-retry').onclick();
|
|
await p.reply(1,{orders:'invalid'});
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse');
|
|
});
|
|
|
|
test('an error page from a proxy uses HTTP status even when the body is not JSON',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
let parsed=false;
|
|
p.requests[0].resolve({ok:false,status:502,json:async()=>{parsed=true;throw new SyntaxError('html');}});
|
|
await flush();
|
|
assert.equal(parsed,false);
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersServerError');
|
|
});
|
|
|
|
test('switching filters ignores late results; logout erases counts and selection',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
|
|
filter(p,'completed').onclick();filter(p,'pending').onclick();
|
|
await p.reply(2,{orders:[],next_before:null,summary:counts});
|
|
await p.reply(1,{orders:[row(9)],next_before:null,summary:{...counts,total:999}});
|
|
assert.doesNotMatch(p.el('customer-orders-toggle').textContent,/999/);
|
|
assert.equal(p.el('customer-orders-list').textContent,'');
|
|
assert.equal(p.el('customer-orders-message').textContent,'ordersFilterEmpty');
|
|
p.orders.setUser(null);
|
|
assert.equal(p.el('customer-orders-summary').hidden,true);
|
|
assert.doesNotMatch(p.el('customer-orders-summary').textContent,/26|20/);
|
|
});
|
|
|
|
test('login automatically opens list; collapse/reopen and paging keep drafts outside panel untouched',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
assert.equal(p.el('customer-orders').hidden,false);
|
|
assert.equal(p.el('customer-orders-toggle').attributes['aria-expanded'],'true');
|
|
await p.reply(0,{orders:[row()],next_before:1});
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND1/);
|
|
p.el('customer-orders-toggle').onclick();
|
|
assert.equal(p.el('customer-orders-body').hidden,true);
|
|
p.el('customer-orders-toggle').onclick();
|
|
p.el('customer-orders-more').onclick();
|
|
assert.equal(p.requests[1].url,'/api/customer/orders?before=1');
|
|
await p.reply(1,{orders:[row(2)],next_before:null});
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/);
|
|
assert.equal(p.el('customer-orders-more').hidden,true);
|
|
});
|
|
|
|
test('logout clears list and ignores delayed list/detail responses from a previous account',async()=>{
|
|
const p=page();p.orders.setUser({id:1});p.orders.setUser(null);
|
|
await p.reply(0,{orders:[row()],next_before:null});
|
|
assert.equal(p.el('customer-orders').hidden,true);
|
|
assert.equal(p.el('customer-orders-list').textContent,'');
|
|
p.orders.setUser({id:2});await p.reply(1,{orders:[row(2)],next_before:null});
|
|
p.el('customer-orders-list').children[0].onclick();
|
|
p.orders.setUser(null);p.orders.setUser({id:3});
|
|
await p.reply(2,{card:{no:'private-old-account'}});
|
|
assert.doesNotMatch(p.el('customer-orders-detail').textContent,/private/);
|
|
p.orders.setUser(null);
|
|
await p.reply(3,{orders:[],next_before:null});
|
|
});
|
|
|
|
test('exact order detail and back preserve list without a new AI request',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row(9)],next_before:null});
|
|
p.el('customer-orders-list').children[0].onclick();
|
|
assert.equal(p.requests[1].url,'/api/customer/orders/9');
|
|
await p.reply(1,{card:{no:'ORDER9'}});
|
|
assert.equal(p.el('customer-orders-detail').textContent,'ORDER9');
|
|
p.el('customer-orders-back').onclick();
|
|
assert.equal(p.el('customer-orders-list').hidden,false);
|
|
assert.equal(p.requests.length,2);
|
|
});
|
|
|
|
test('failed request offers retry; expired login immediately clears protected data',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
p.requests[0].reject(new Error('offline'));await flush();
|
|
assert.equal(p.el('customer-orders-retry').hidden,false);
|
|
p.el('customer-orders-retry').onclick();
|
|
await p.reply(1,{orders:[row()],next_before:null});
|
|
p.el('customer-orders-refresh').onclick();
|
|
await p.reply(2,{error:'customer_login_required'},401);
|
|
assert.equal(p.el('customer-orders').hidden,true);
|
|
assert.equal(p.el('customer-orders-list').textContent,'');
|
|
});
|
|
|
|
test('refresh cancels pagination; stale errors cannot replace the refreshed list',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:1});
|
|
p.el('customer-orders-more').onclick();
|
|
p.el('customer-orders-refresh').onclick();
|
|
await p.reply(2,{orders:[row(3)],next_before:null});
|
|
p.requests[1].reject(new Error('late error'));await flush();
|
|
assert.equal(p.el('customer-orders-retry').hidden,true);
|
|
assert.doesNotMatch(p.el('customer-orders-list').textContent,/INBOUND1/);
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
|
|
});
|
|
|
|
test('automatic refresh keeps the filter, old rows, scroll and collapsed state until success',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
|
|
filter(p,'processing').onclick();
|
|
await p.reply(1,{orders:[row(2)],next_before:2,summary:counts});
|
|
p.el('customer-orders-body').scrollTop=85;
|
|
p.el('customer-orders-toggle').onclick();
|
|
p.orders.refresh();p.orders.refresh();
|
|
assert.equal(p.requests.length,3);
|
|
assert.equal(p.requests[2].url,'/api/customer/orders?status=processing');
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
|
|
await p.reply(2,{orders:[row(3)],next_before:null,summary:{...counts,total:27,processing:21}});
|
|
assert.match(p.el('customer-orders-toggle').textContent,/27/);
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND3/);
|
|
assert.equal(p.el('customer-orders-body').scrollTop,85);
|
|
assert.equal(p.el('customer-orders-body').hidden,true);
|
|
assert.equal(filter(p,'processing').attributes['aria-pressed'],'true');
|
|
});
|
|
|
|
test('failed automatic refresh retains previous data and retry can update it',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:1,summary:counts});
|
|
p.orders.refresh();p.requests[1].reject(new Error('offline'));await flush();
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND1/);
|
|
assert.match(p.el('customer-orders-toggle').textContent,/26/);
|
|
assert.equal(p.el('customer-orders-retry').hidden,false);
|
|
p.el('customer-orders-retry').onclick();
|
|
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
|
|
});
|
|
|
|
test('automatic refresh keeps the open tracking detail and logout invalidates late responses',async()=>{
|
|
const p=page();p.orders.setUser({id:1});
|
|
await p.reply(0,{orders:[row()],next_before:null,summary:counts});
|
|
p.el('customer-orders-list').children[0].onclick();
|
|
await p.reply(1,{card:{no:'ORDER1'}});
|
|
p.orders.refresh();
|
|
await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}});
|
|
assert.equal(p.el('customer-orders-detail').textContent,'ORDER1');
|
|
assert.equal(p.el('customer-orders-list').hidden,true);
|
|
p.el('customer-orders-back').onclick();
|
|
assert.match(p.el('customer-orders-list').textContent,/INBOUND2/);
|
|
p.orders.refresh();p.orders.setUser(null);
|
|
await p.reply(3,{orders:[row(99)],summary:counts});
|
|
assert.equal(p.el('customer-orders-list').textContent,'');
|
|
assert.equal(p.el('customer-orders').hidden,true);
|
|
});
|