Fix production ARR reads and add audited source field review
This commit is contained in:
1 parent
2417b1a41c
commit
fe8735aac2
26 files changed
+2417
-157
No files matched your search
@@ -0,0 +1,167 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {harness,response} = require('./helpers/arr_ui_harness.cjs');
|
||||
const requestId='a'.repeat(32);
|
||||
const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false};
|
||||
const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SYNTHETIC-ROOM',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'BLOCK_CODE',field_label:'团队代码',reason_code:'MISSING',can_be_empty:true,value:null,confirmed:false,...extra});
|
||||
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
|
||||
const plain=value=>JSON.parse(JSON.stringify(value));
|
||||
|
||||
test('source review opens before a job exists and choosing another date retains the original request',async()=>{
|
||||
const h=harness(()=>response(200,review([item()])));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
assert.equal(h.element('#arr-download-data-review').hidden,false);
|
||||
assert.equal(h.element('#arr-data-review-panel').hidden,false);
|
||||
assert.equal(h.element('#arr-download-review').hidden,true);
|
||||
assert.equal(h.element('#arr-data-review-finalize').disabled,true);
|
||||
h.element('#arr-download-date').value='2026-10-08';
|
||||
h.handleARRDownloadDateChange();
|
||||
await h.submit();
|
||||
assert.equal(h.state.arrDownloadIntent.request_id,requestId);
|
||||
assert.equal(h.state.arrDownloadTask.report_date,'2026-10-07');
|
||||
assert.equal(h.element('#arr-download-date').value,'2026-10-08');
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true,'review never creates a replacement download');
|
||||
});
|
||||
|
||||
test('incomplete review cannot finalize even when server flag says it can',async()=>{
|
||||
const h=harness(()=>response(200,{...review([item()]),can_finalize:true,pending_count:0}));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
await h.finalizeARRDataReview();
|
||||
assert.equal(h.arrDataReviewCanFinalize(),false);
|
||||
assert.equal(h.calls.length,1);
|
||||
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.complete_first');
|
||||
});
|
||||
|
||||
test('blank text is not silently confirmed; explicit empty saves its revision and reloads the review',async()=>{
|
||||
let current=review([item()]);
|
||||
const h=harness((url,options)=>{
|
||||
if(options.method==='POST') {
|
||||
assert.equal(url,`/api/arr-downloads/${requestId}/data-review/items/1%3ABLOCK_CODE`);
|
||||
assert.deepEqual(JSON.parse(options.body),{revision:1,value:''});
|
||||
current=review([item({confirmed:true,value:''})],2);
|
||||
return response(200,{revision:2});
|
||||
}
|
||||
return response(200,current);
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
const row=h.row('1:BLOCK_CODE','');
|
||||
await h.saveARRDataReviewItem(row.button);
|
||||
assert.equal(h.calls.length,1);
|
||||
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.use_empty');
|
||||
await h.saveARRDataReviewItem(row.button,{empty:true});
|
||||
assert.equal(h.calls.length,3);
|
||||
assert.equal(h.state.arrDataReview.revision,2);
|
||||
assert.equal(h.arrDataReviewCanFinalize(),true);
|
||||
assert.equal(h.element('#arr-data-review-finalize').disabled,false);
|
||||
});
|
||||
|
||||
test('products are an ordered code array and explicit empty products are an empty array',()=>{
|
||||
const h=harness(()=>{throw new Error('no request');});
|
||||
const product=item({field:'PRODUCTS'});
|
||||
assert.deepEqual(plain(h.parseARRDataReviewValue(product,'BREAKFAST\nDINNER\nBREAKFAST',false)),['BREAKFAST','DINNER','BREAKFAST']);
|
||||
assert.deepEqual(plain(h.parseARRDataReviewValue(product,'ignored',true)),[]);
|
||||
assert.equal(h.parseARRDataReviewValue(item({field:'EFFECTIVE_RATE_AMOUNT'}),'900.000000000001',false),'900.000000000001');
|
||||
assert.equal(h.parseARRDataReviewValue(item({field:'ADULTS'}),'0',false),'0');
|
||||
assert.throws(()=>h.parseARRDataReviewValue(item({field:'ADULTS'}),'1.5',false));
|
||||
assert.throws(()=>h.parseARRDataReviewValue(item({field:'ARRIVAL'}),'2026-02-30',false));
|
||||
});
|
||||
|
||||
test('revision conflict reloads the current revision before any next save',async()=>{
|
||||
let reads=0;
|
||||
const h=harness((url,options)=>options.method==='POST'
|
||||
? response(409,null,'ARR_DATA_REVIEW_CONFLICT')
|
||||
: response(200,review([item()],++reads===1 ? 1 : 5)));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
const row=h.row('1:BLOCK_CODE','VERIFIED');
|
||||
h.trackARRDataReviewDraft(row.input);
|
||||
await h.saveARRDataReviewItem(row.button);
|
||||
assert.equal(h.state.arrDataReview.revision,5);
|
||||
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'VERIFIED');
|
||||
assert.equal(h.element('#arr-data-review-message').textContent,'data_review.conflict');
|
||||
assert.equal(h.state.arrDataReviewMutating,false);
|
||||
});
|
||||
|
||||
test('unconfirmed source values are never prefilled and all labels and stored values are escaped',async()=>{
|
||||
const h=harness(()=>response(200,review([
|
||||
item({company_name:'<img src=x onerror=alert(1)>',value:'GUESSED-BLOCK'}),
|
||||
item({item_id:'2:RES_COMMENT',field:'RES_COMMENT',confirmed:true,value:'</textarea><script>bad()</script>'}),
|
||||
])));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
const markup=h.element('#arr-data-review-body').innerHTML;
|
||||
assert.equal(markup.includes('GUESSED-BLOCK'),false);
|
||||
assert.equal(markup.includes('<img'),false);
|
||||
assert.equal(markup.includes('<script>'),false);
|
||||
assert.match(markup,/<img/);
|
||||
assert.match(markup,/<\/textarea>/);
|
||||
});
|
||||
|
||||
test('unsaved edits of confirmed fields block finalization until explicitly saved',async()=>{
|
||||
const h=harness(()=>response(200,review([item({confirmed:true,value:'FIRST'})])));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
assert.equal(h.arrDataReviewCanFinalize(),true);
|
||||
const row=h.row('1:BLOCK_CODE','SECOND');
|
||||
h.trackARRDataReviewDraft(row.input);
|
||||
await h.finalizeARRDataReview();
|
||||
assert.equal(h.arrDataReviewCanFinalize(),false);
|
||||
assert.equal(h.calls.length,1);
|
||||
row.input.value='FIRST';
|
||||
h.trackARRDataReviewDraft(row.input);
|
||||
assert.equal(h.arrDataReviewCanFinalize(),true);
|
||||
});
|
||||
|
||||
test('lost save response re-reads durable confirmation rather than resubmitting the item',async()=>{
|
||||
let current=review([item()]);
|
||||
const h=harness((url,options)=>{
|
||||
if(options.method==='POST') {
|
||||
current=review([item({value:'VERIFIED',confirmed:true})],2);
|
||||
throw new TypeError('lost response');
|
||||
}
|
||||
return response(200,current);
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
const row=h.row('1:BLOCK_CODE','VERIFIED');
|
||||
h.trackARRDataReviewDraft(row.input);
|
||||
await h.saveARRDataReviewItem(row.button);
|
||||
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
||||
assert.equal(h.state.arrDataReview.revision,2);
|
||||
assert.equal(h.arrDataReviewCanFinalize(),true);
|
||||
});
|
||||
|
||||
test('finalize uses the same request and a lost response reconciles processing without a new download',async()=>{
|
||||
let finalized=false;
|
||||
const h=harness((url,options)=>{
|
||||
if(options.method==='POST') {
|
||||
assert.equal(url,`/api/arr-downloads/${requestId}/data-review/finalize`);
|
||||
assert.deepEqual(JSON.parse(options.body),{revision:7});
|
||||
finalized=true;
|
||||
throw new TypeError('lost finalize response');
|
||||
}
|
||||
if(url===`/api/arr-downloads/${requestId}`) return response(200,{...task,status:'processing'});
|
||||
return response(200,review([item({confirmed:true,value:''})],7));
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
await h.finalizeARRDataReview();
|
||||
assert.equal(finalized,true);
|
||||
assert.equal(h.state.arrDownloadTask.status,'processing');
|
||||
assert.equal(h.state.arrDownloadIntent.request_id,requestId);
|
||||
assert.equal(h.state.arrDataReview,null);
|
||||
assert.equal(h.state.arrDataReviewFinalizing,false);
|
||||
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
||||
assert.equal(h.calls.some(call=>call.url==='/api/arr-downloads'),false);
|
||||
});
|
||||
|
||||
test('an uncertain frozen finalize stays locked when the task and review cannot be reconciled',async()=>{
|
||||
let finalizing=false;
|
||||
const h=harness((url,options)=>{
|
||||
if(options.method==='POST') {finalizing=true;throw new TypeError('offline');}
|
||||
if(finalizing) throw new TypeError('offline');
|
||||
return response(200,review([item({confirmed:true,value:''})]));
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
await h.finalizeARRDataReview();
|
||||
assert.equal(h.state.arrDataReviewFinalizing,true);
|
||||
assert.equal(h.state.arrDataReviewDisconnected,true);
|
||||
assert.equal(h.element('#arr-data-review-finalize').disabled,true);
|
||||
await h.submit();
|
||||
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
|
||||
});
|
||||
@@ -1,34 +1,7 @@
|
||||
// Runs actual submission code in a JS sandbox; no browser, network or database.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const {webcrypto} = require('node:crypto');
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../arr_web/static/app.js'), 'utf8');
|
||||
const boot = ' boot();\n})();';
|
||||
assert(source.endsWith(boot+'\n') || source.endsWith(boot));
|
||||
function harness(respond) {
|
||||
const elements=new Map(), storage=new Map(), calls=[];
|
||||
const element=selector => {
|
||||
if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'',
|
||||
classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){}});
|
||||
return elements.get(selector);
|
||||
};
|
||||
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
|
||||
document:{hidden:false,querySelector:element},
|
||||
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code},crypto:webcrypto,
|
||||
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
|
||||
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>storage.get(k),removeItem:k=>storage.delete(k)},
|
||||
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);},
|
||||
});
|
||||
vm.runInContext(source.replace(boot,' globalThis.subject = {state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload};\n})();'),context);
|
||||
const subject=context.subject;
|
||||
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',csrf:'fixture'});
|
||||
element('#arr-download-date').value='2026-09-16';
|
||||
return {...subject,calls,storage,element,submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
}
|
||||
const response=(status,data,code) => ({status,ok:status<400,json:async()=>code?{ok:false,error:{code}}:{ok:true,data}});
|
||||
const {harness,response,scopedKey} = require('./helpers/arr_ui_harness.cjs');
|
||||
const refusal=()=>response(409,null,'REPLAY_DATE_NOT_AVAILABLE');
|
||||
|
||||
test('definite unavailable source date clears only the refused intent and permits correction',async()=>{
|
||||
@@ -143,7 +116,7 @@ for (const status of ['succeeded','failed']) {
|
||||
|
||||
test('editing before status loads still restores the running task and retains the selected date',async()=>{
|
||||
const task={request_id:'e'.repeat(32),report_date:'2026-09-15',status:'downloading',can_retry:false};
|
||||
const h=harness(()=>response(200,{ready:true,default_date:'2026-09-17',latest_task:task}));
|
||||
const h=harness(()=>response(200,{context_id:'production',ready:true,default_date:'2026-09-17',latest_task:task}));
|
||||
h.handleARRDownloadDateChange();
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.state.arrDownloadTask.request_id,task.request_id);
|
||||
@@ -152,3 +125,82 @@ test('editing before status loads still restores the running task and retains th
|
||||
assert.equal(h.calls.length,1);
|
||||
assert.equal(h.calls[0].method,'GET');
|
||||
});
|
||||
|
||||
test('config is fetched first and only this instance and user may restore an intent',async()=>{
|
||||
const intent={request_id:'f'.repeat(32),report_date:'2026-10-07'};
|
||||
const h=harness((url)=>{
|
||||
if(url==='/api/arr-downloads') {
|
||||
assert.equal(h.storageReads.length,0,'identity must be fetched before storage is read');
|
||||
return response(200,{context_id:'production',ready:true,default_date:'2026-10-06'});
|
||||
}
|
||||
assert.equal(url,`/api/arr-downloads/${intent.request_id}`);
|
||||
return response(200,{...intent,status:'downloading',can_retry:false});
|
||||
});
|
||||
h.storage.set(scopedKey(),JSON.stringify(intent));
|
||||
h.storage.set(scopedKey('sandbox'),JSON.stringify({...intent,request_id:'a'.repeat(32)}));
|
||||
h.storage.set(scopedKey('production','another-user'),JSON.stringify({...intent,request_id:'b'.repeat(32)}));
|
||||
h.storage.set('arr:last-download:operator',JSON.stringify({...intent,report_date:'2026-09-15'}));
|
||||
await h.initARRDownload();
|
||||
assert.deepEqual(h.storageReads,[scopedKey()]);
|
||||
assert.equal(h.state.arrDownloadTask.request_id,intent.request_id);
|
||||
assert.equal(h.element('#arr-download-date').value,intent.report_date);
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
});
|
||||
|
||||
test('legacy or another context cannot replace default date with an old failed task',async()=>{
|
||||
const old={request_id:'a'.repeat(32),report_date:'2026-09-15',status:'failed',can_retry:true};
|
||||
const h=harness(()=>response(200,{context_id:'production',ready:true,default_date:'2026-10-07',latest_task:old}));
|
||||
h.storage.set('arr:last-download:operator',JSON.stringify(old));
|
||||
h.storage.set(scopedKey('sandbox'),JSON.stringify(old));
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.state.arrDownloadTask,null);
|
||||
assert.equal(h.state.arrDownloadIntent,null);
|
||||
assert.equal(h.element('#arr-download-date').value,'2026-10-07');
|
||||
assert.deepEqual(h.storageReads,[scopedKey()]);
|
||||
assert.equal(h.calls.length,1);
|
||||
});
|
||||
|
||||
test('config failure does not read any saved request before identity is known',async()=>{
|
||||
const h=harness(()=>{throw new TypeError('offline');});
|
||||
h.storage.set('arr:last-download:operator',JSON.stringify({request_id:'a'.repeat(32),report_date:'2026-09-15'}));
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.storageReads.length,0);
|
||||
assert.equal(h.state.arrDownloadIntent,null);
|
||||
assert.equal(h.state.arrDownloadRestored,false);
|
||||
});
|
||||
|
||||
test('an unavailable instance without an identity stays unavailable without restoring storage',async()=>{
|
||||
const h=harness(()=>response(200,{ready:false,context_id:null,default_date:'2026-10-07'}));
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.storageReads.length,0);
|
||||
assert.equal(h.state.arrDownloadReady,false);
|
||||
assert.equal(h.state.arrDownloadConfigDisconnected,false);
|
||||
assert.equal(h.element('#arr-download-date').value,'2026-10-07');
|
||||
assert.equal(h.element('#arr-download-button').disabled,true);
|
||||
});
|
||||
|
||||
test('a running task from config preselects its date and only scoped terminal intent restores later',async()=>{
|
||||
const task={request_id:'a'.repeat(32),report_date:'2026-10-07',status:'processing',can_retry:false};
|
||||
const h=harness(url=>response(200,url==='/api/arr-downloads'
|
||||
? {context_id:'production',ready:true,default_date:'2026-10-06',latest_task:task} : {...task,status:'failed',can_retry:true}));
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.element('#arr-download-date').value,task.report_date);
|
||||
assert.equal(h.element('#arr-download-button').disabled,true);
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.state.arrDownloadTask.status,'failed');
|
||||
assert.equal(h.state.arrDownloadIntent.request_id,task.request_id);
|
||||
assert.equal(h.element('#arr-download-date').value,task.report_date);
|
||||
});
|
||||
|
||||
test('changing source context clears the previous in-memory intent before restoring',async()=>{
|
||||
let context='sandbox';
|
||||
const h=harness(()=>response(200,{context_id:context,ready:true,default_date:'2026-10-07'}));
|
||||
await h.initARRDownload();
|
||||
h.rememberARRIntent({request_id:'a'.repeat(32),report_date:'2026-09-15'});
|
||||
context='production';
|
||||
await h.initARRDownload();
|
||||
assert.equal(h.state.arrDownloadIntent,null);
|
||||
assert.equal(h.state.arrDownloadTask,null);
|
||||
assert.equal(h.element('#arr-download-date').value,'2026-10-07');
|
||||
assert.equal(h.calls.length,2);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Exercise the actual UI functions with a synthetic API, without booting a browser.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const {webcrypto} = require('node:crypto');
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../arr_web/static/app.js'), 'utf8');
|
||||
const boot = ' boot();\n})();';
|
||||
assert(source.endsWith(boot+'\n') || source.endsWith(boot));
|
||||
|
||||
function harness(respond) {
|
||||
const elements=new Map(), storage=new Map(), storageReads=[], calls=[];
|
||||
const element=selector => {
|
||||
if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',
|
||||
classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){},scrollIntoView(){},focus(){}});
|
||||
return elements.get(selector);
|
||||
};
|
||||
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
|
||||
document:{hidden:false,querySelector:element},
|
||||
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code},crypto:webcrypto,
|
||||
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
|
||||
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
|
||||
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);},
|
||||
});
|
||||
const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue';
|
||||
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
|
||||
const subject=context.subject;
|
||||
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});
|
||||
element('#arr-download-date').value='2026-09-16';
|
||||
const row=(itemId,value='')=>{
|
||||
const input={value,focus(){},closest:()=>node};
|
||||
const node={dataset:{arrDataReviewItemId:itemId},querySelector:()=>input};
|
||||
return {input,button:{closest:()=>node}};
|
||||
};
|
||||
return {...subject,calls,storage,storageReads,element,row,submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
}
|
||||
const response=(status,data,code) => ({status,ok:status<400,json:async()=>code?{ok:false,error:{code}}:{ok:true,data}});
|
||||
const scopedKey=(context='production',username='operator')=>`arr:last-download:v2:${encodeURIComponent(context)}:${encodeURIComponent(username)}`;
|
||||
module.exports={harness,response,scopedKey};
|
||||
Reference in new issue
Block a user