Files
ARR-2.0-0918/tests/javascript/daily_report_lifecycle.cjs
T

287 lines
14 KiB
JavaScript

// Date identity, replacement and recoverable deletion against a synthetic API only.
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 {harness,response} = require('./helpers/arr_ui_harness.cjs');
const date='2026-09-16';
const job=(id,extra={})=>({job_id:id,arrival_date:date,status:'succeeded',filename:`${id}.XML`,
no_of_rooms:150,created_at:'2026-10-08T01:00:00Z',finished_at:'2026-10-08T01:00:52Z',
daily_report_sha256:'f'.repeat(64),is_current:true,is_retired:false,can_delete:true,...extra});
const day=(current=job('current'),latest=current,extra={})=>({business_date:date,current_job:current,latest_job:latest,history_count:1,...extra});
const page=(items,total=items.length)=>({status:200,ok:true,json:async()=>({ok:true,data:items,pagination:{total}})});
const button=(id='current',current=true)=>({dataset:{dailyDelete:id,dailyDate:date,dailyCurrent:String(current)},isConnected:true,focus(){}});
const plain=value=>JSON.parse(JSON.stringify(value));
function setup(respond=()=>{throw new Error('Unexpected request');}) {
const h=harness(respond);
h.state.health.download_ready=true;
h.state.dailyOverviewDate=date;
h.state.selectedJobId='current';
h.element('#arr-download-date').value=date;
return h;
}
test('overview follows the selected incomplete date and never borrows another successful date',()=>{
const h=setup();
h.state.dailyReports=[day(job('september17',{arrival_date:'2026-09-17'}),undefined,{business_date:'2026-09-17'})];
h.state.arrDownloadTask={request_id:'a'.repeat(32),report_date:date,status:'needs_data_review'};
h.renderDailyOverview();
assert.equal(h.element('#metric-arrival').textContent,date);
assert.equal(h.element('#metric-arrival-note').textContent,'daily.overview_review');
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#metric-current-version').hidden,true);
assert.equal(h.calls.length,0);
});
test('a same-date pending replacement preserves the accepted day and hides ungenerated metrics',()=>{
const h=setup();
const pending=job('new',{status:'needs_review',is_current:false,no_of_rooms:0,daily_report_sha256:null,review_case_id:'case',review_total_items:1});
h.state.dailyReports=[day(job('old'),pending)];
h.renderJobs();
const html=h.element('#jobs-body').innerHTML;
assert.equal((html.match(/<tr>/g)||[]).length,1);
assert.match(html,/daily.current/);
assert.match(html,/daily.replacement/);
assert.match(html,/data-open-price-review="new"/);
assert.match(html,/job_id=old/);
assert.doesNotMatch(html,/job_id=new/);
assert.equal(h.element('#metric-arrival').textContent,date);
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#metric-current-version').hidden,false);
assert.doesNotMatch(html,/<tr[^>]+(?:tabindex|onclick|role="button")/);
});
test('a successful replacement displays only the accepted new report for the same day',()=>{
const h=setup();
h.state.dailyReports=[day(job('new',{no_of_rooms:43}))];
h.renderJobs();
assert.equal(h.element('#metric-rooms').textContent,'43');
assert.equal(h.element('#metric-current-version').hidden,true);
assert.match(h.element('#jobs-body').innerHTML,/job_id=new/);
assert.doesNotMatch(h.element('#jobs-body').innerHTML,/job_id=old/);
});
test('a failed replacement never removes the valid old report',()=>{
const h=setup();
h.state.dailyReports=[day(job('old'),job('new',{status:'failed',is_current:false,daily_report_sha256:null}))];
h.renderJobs();
assert.equal(h.element('#metric-rooms').textContent,'150');
assert.equal(h.element('#metric-current-version').hidden,false);
assert.match(h.element('#jobs-body').innerHTML,/job_id=old/);
assert.match(h.element('#jobs-body').innerHTML,/status.failed/);
});
test('deleting the current report leaves older successful versions explicitly historical instead of completed current reports',()=>{
const h=setup();
const historical=job('superseded',{is_current:false});
h.state.dailyReports=[day(null,historical,{history_count:2})];
h.renderJobs();
const html=h.element('#jobs-body').innerHTML;
assert.match(html,/daily.no_current/);
assert.match(html,/daily.history_only/);
assert.doesNotMatch(html,/status\.completed|daily\.current|download-link/);
assert.match(html,/data-daily-history="2026-09-16"/);
assert.match(html,/data-daily-delete="superseded"[^>]+data-daily-current="false"/);
assert.equal(h.element('#metric-arrival-note').textContent,'daily.no_current');
assert.equal(h.element('#metric-rooms').textContent,'—');
});
test('an out-of-page date reads its own overview and ignores stale and cross-context replies',async()=>{
let resolve;
const h=setup(()=>new Promise(r=>{resolve=r;}));
const pending=h.loadDailyOverview(date);
h.state.dailyOverviewDate='2026-10-07';
resolve(response(200,day()));
await pending;
assert.equal(h.state.dailyOverview,null);
h.state.dailyOverviewDate=date;
const other=h.loadDailyOverview(date);
h.state.arrDownloadContextId='another-hotel';
resolve(response(200,day()));
await other;
assert.equal(h.state.dailyOverview,null);
const current=h.loadDailyOverview(date);
resolve(response(200,day()));
await current;
assert.equal(h.element('#metric-arrival').textContent,date);
assert.equal(h.element('#metric-rooms').textContent,'150');
assert.equal(h.calls.every(c=>c.method==='GET'),true);
});
test('date pagination uses grouped API results and preserves job objects for traces',async()=>{
const h=setup(url=>{
assert.equal(url,'/api/daily-reports?month=2026-09&limit=50&offset=0');
return page([day(job('old'),job('new',{status:'needs_review',is_current:false}))],31);
});
h.state.jobsMonth='2026-09';
await h.loadJobs(false,false);
assert.equal(h.state.jobsTotal,31);
assert.equal(h.state.dailyReports.length,1);
assert.deepEqual(plain(h.state.jobs.map(j=>j.job_id)),['old','new']);
assert.equal(h.calls.length,1);
});
test('history distinguishes old, current and deleted records without exposing missing filenames',async()=>{
const h=setup(url=>{
assert.equal(url,`/api/daily-reports/${date}/history?limit=50&offset=0`);
return page([job('current'),job('old',{is_current:false,filename:'<old>.XML'}),
job('deleted',{is_current:false,is_retired:true,status:'deleted',can_delete:false,filename:null})]);
});
await h.openDailyHistory(date,button());
const html=h.element('#daily-history-body').innerHTML;
assert.match(html,/daily.current/);
assert.match(html,/daily.historical/);
assert.match(html,/daily.deleted/);
assert.match(html,/&lt;old&gt;.XML/);
assert.match(html,/data-daily-delete="old"/);
assert.doesNotMatch(html,/data-daily-delete="deleted"|job_id=deleted/);
assert.doesNotMatch(html,/deleted\.XML|null|undefined/);
});
function deletionSetup({removed=true,fail=false,wait=false}={}) {
let release;
const next=removed?day(null,null,{history_count:2}):day(job('new'));
const h=setup((url,options)=>{
if(options.method==='DELETE') {
assert.equal(url,'/api/jobs/current');
assert.deepEqual(JSON.parse(options.body),{business_date:date});
if(fail) return response(409,null,'DAILY_JOB_BUSY');
const value=response(200,{job_id:'current',business_date:date,status:'deleted',current_removed:removed});
return wait?new Promise(resolve=>{release=()=>resolve(value);}):value;
}
if(url.startsWith('/api/daily-reports?')) return page([next]);
if(url===`/api/daily-reports/${date}`) return response(200,next);
if(url==='/api/history-months') return response(200,[]);
throw new Error('Unexpected request '+url);
});
h.state.dailyReports=[day()];
h.state.jobsMonth='2026-09';
return {h,release:()=>release()};
}
test('deleting an exact current job clears only its pending review and refreshes the day without resurrecting history',async()=>{
const {h}=deletionSetup();
const task={request_id:'a'.repeat(32),report_date:date,status:'needs_review',job_id:'current',can_retry:false};
h.state.arrDownloadTask=task;
h.state.arrDownloadPendingReviews=[task,{request_id:'b'.repeat(32),report_date:'2026-10-07',status:'needs_data_review'}];
h.rememberARRIntent({request_id:task.request_id,report_date:date});
h.state.dailyPriceReviewJobId='current';
h.state.dailyPriceReview={case_status:'open',items:[]};
h.openDailyDelete(button());
assert.equal(h.element('#daily-delete-description').textContent,'daily.delete_current_description');
await h.confirmDailyDelete();
assert.equal(h.state.arrDownloadTask.status,'deleted');
assert.equal(h.state.arrDownloadIntent,null);
assert.equal(h.storage.has('test'),false);
assert.equal(h.state.dailyPriceReviewJobId,'');
assert.equal(h.state.arrDownloadPendingReviews.length,1);
assert.equal(h.state.arrDownloadPendingReviews[0].report_date,'2026-10-07');
assert.equal(h.state.dailyReports[0].current_job,null);
assert.equal(h.element('#metric-rooms').textContent,'—');
assert.equal(h.element('#toast').textContent,'daily.deleted_current');
const deletes=h.calls.filter(c=>c.method==='DELETE');
assert.equal(deletes.length,1);
assert.equal(deletes[0].csrf,'fixture');
assert.equal(h.calls.every(c=>['GET','DELETE'].includes(c.method)),true);
});
test('when a newer report wins during deletion, the old target alone is deleted and the actual outcome is shown',async()=>{
const {h}=deletionSetup({removed:false});
h.openDailyDelete(button());
await h.confirmDailyDelete();
assert.equal(h.state.dailyReports[0].current_job.job_id,'new');
assert.equal(h.element('#toast').textContent,'daily.deleted_history');
assert.match(h.element('#jobs-body').innerHTML,/job_id=new/);
});
test('failed deletion keeps the current report, same review and confirmation available for retry',async()=>{
const {h}=deletionSetup({fail:true});
h.openDailyDelete(button());
await h.confirmDailyDelete();
assert.equal(h.state.dailyDeleteRequest.jobId,'current');
assert.equal(h.state.dailyDeleting,false);
assert.equal(h.state.dailyReports[0].current_job.job_id,'current');
assert.equal(h.element('#daily-delete-error').hidden,false);
assert.equal(h.element('#daily-delete-error').textContent,'DAILY_JOB_BUSY');
assert.equal(h.calls.length,1);
});
test('double confirmation while deletion is pending cannot send another mutation',async()=>{
const {h,release}=deletionSetup({wait:true});
h.openDailyDelete(button());
const first=h.confirmDailyDelete();
await h.confirmDailyDelete();
h.closeDailyDelete();
assert.equal(h.state.dailyDeleteRequest.jobId,'current');
assert.equal(h.calls.filter(c=>c.method==='DELETE').length,1);
release();
await first;
assert.equal(h.state.dailyDeleteRequest,null);
});
test('unsaved price edits prevent date changes and deletion instead of discarding human work',async()=>{
const h=setup();
h.state.dailyPriceReview={case_status:'open',items:[{item_id:1,real_price:null}]};
h.element('#daily-price-review-body').querySelectorAll=()=>[{value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})}];
h.openDailyDelete(button());
assert.equal(h.state.dailyDeleteRequest,null);
await h.selectDailyReportDate('2026-10-07');
assert.equal(h.element('#arr-download-date').value,date);
assert.equal(h.element('#toast').textContent,'daily_review.save_before_switch');
assert.equal(h.calls.length,0);
});
test('deleted OHIP intent becomes terminal and permits a fresh explicit same-date request',async()=>{
const h=setup((url,options)=>{
assert.equal(url,'/api/arr-downloads');
assert.equal(options.method,'POST');
return response(202,{...JSON.parse(options.body),status:'queued',can_retry:false});
});
const old={request_id:'a'.repeat(32),report_date:date,status:'deleted',job_id:'current',can_retry:false,is_retired:true};
await h.acceptARRDownloadTask(old,{sync:false});
assert.equal(h.state.arrDownloadIntent,null);
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.equal(h.element('#arr-download-review').hidden,true);
await h.submit();
assert.equal(h.calls.length,1);
assert.notEqual(JSON.parse(h.calls[0].body).request_id,old.request_id);
assert.equal(JSON.parse(h.calls[0].body).report_date,date);
});
test('all new lifecycle labels and warnings are available in Chinese, English and Thai',()=>{
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
const catalog=context.window.ARRI18n.CATALOG;
for(const key of ['daily.current','daily.no_current','daily.history_only','daily.current_preserved','daily.overview_review',
'daily.overview_fetching','daily.overview_processing','daily.after_generation','daily.replacement','daily.history_versions',
'daily.history_title','daily.history_dialog_note','daily.history_region','daily.history_pagination','daily.delete_title',
'daily.delete_current_description','daily.delete_history_description','daily.delete_note','daily.deleted_current',
'daily.deleted_history','arr_download.deleted','error.daily_delete_busy']) {
assert.equal(catalog[key]?.length,3,key);
assert.equal(catalog[key].every(Boolean),true,key);
assert.match(catalog[key][2],/[\u0e00-\u0e7f]/,key);
}
});
for(const scope of ['month','context']) {
test(`a late daily list response after changing ${scope} is ignored and the current selection is loaded`,async()=>{
let resolveOld;
const nextDate='2026-10-07';
const h=setup((url,options,calls)=>calls.length===1 ? new Promise(resolve=>{resolveOld=resolve;})
: page([day(job('fresh',{arrival_date:nextDate}),undefined,{business_date:nextDate})]));
h.state.jobsMonth='2026-09';
const renders=[];
Object.defineProperty(h.element('#jobs-body'),'innerHTML',{get(){return renders.at(-1)||'';},set(value){renders.push(value);}});
const loading=h.loadJobs(false,false);
if(scope==='month') h.state.jobsMonth='2026-10';
else h.state.arrDownloadContextId='another-hotel';
resolveOld(page([day(job('stale'))]));
await loading;
assert.equal(h.calls.length,2);
assert.equal(h.state.dailyReports[0].current_job.job_id,'fresh');
assert.equal(renders.some(html=>html.includes('stale.XML')),false);
if(scope==='month') assert.match(h.calls[1].url,/month=2026-10/);
});
}