// 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(//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,/]+(?: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:'.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,/<old>.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/); }); }