bmc_hub/tests/test_smart_time_queue.js

77 lines
8.4 KiB
JavaScript

const {test}=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const fs=require('node:fs');
const html=fs.readFileSync('app/economy/frontend/time_queue.html','utf8');
function fixture(rows=[]){
const controls={};for(const id of ['approveBtn','stickyApproveBtn','settlementMethod','prepaidCard','agreementId','workflowLineMode','workflowLineModeHost','setMethodBtn','previewBtn','stickyPreviewBtn','selectionText','previewPanel','settleBtn','filterMethod','filterCustomer','filterStatus','filterCaseState','filterBillable','filterQuery'])controls[id]={value:'',disabled:false,classList:{add(){},toggle(){}}};
controls.workflowLineMode.value='separate';
const state={scope:'active',items:rows,selected:new Set(rows.map(x=>x.id)),triage:'all',preview:{valid:true},previewPayload:{ids:[1]},previewFingerprint:'old'};
const ctx={state,$:id=>controls[id],norm:v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[v]||v||'draft',ids:()=>[...state.selected],selectedRows:()=>rows,confirm:()=>true,Number,JSON};
vm.createContext(ctx);vm.runInContext(html.slice(html.indexOf(' function actionable('),html.indexOf(' function status(')),ctx);
return {ctx,controls,state};
}
test('workflow stale, absent or manual review never inherits invoice fallback',()=>{
const {ctx}=fixture();
assert.equal(ctx.effectiveMethod({workflow_active:true,billing_method:'invoice'}),'draft');
assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_stale:true,workflow_decision:{method:'invoice',status:'decided'}}),'draft');
assert.equal(ctx.effectiveMethod({workflow_active:true,workflow_decision:{method:'invoice',status:'manual_review'}}),'manual_review');
assert.match(ctx.reviewIssue({workflow_active:true,workflow_stale:true}),/Genberegn/);
});
test('readiness flags missing card/agreement and accepts concrete workflow selection',()=>{
const {ctx}=fixture();const base={original_hours:1,customer_id:9,status:'pending'};
assert.match(ctx.reviewIssue({...base,billing_method:'prepaid'}),/klippekort/);
assert.match(ctx.reviewIssue({...base,billing_method:'fixed_price'}),/aftale/);
assert.equal(ctx.reviewIssue({...base,workflow_active:true,workflow_decision:{method:'subscription',status:'decided'},fixed_price_agreement_id:2}),'');
assert.match(ctx.reviewIssue({...base,billing_method:'invoice',billable:false}),/ikke-fakturerbar/);
});
test('mixed selection disables bulk overrides and preview instead of leaking configuration',()=>{
const {ctx,controls}=fixture([{id:1,workflow_active:true},{id:2,workflow_active:false}]);
controls.settlementMethod.value='invoice';controls.prepaidCard.value='7';ctx.syncWorkflowControls();
assert.equal(controls.setMethodBtn.disabled,true);assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.settlementMethod.value,'');assert.equal(controls.prepaidCard.value,'');assert.match(controls.selectionText.textContent,/hver for sig/);
});
test('preview fingerprint tracks billing inputs, filters, selection and line grouping',()=>{
const {ctx,controls,state}=fixture([{id:1,workflow_active:true}]);const initial=ctx.fingerprint();
controls.workflowLineMode.value='combined';assert.notEqual(ctx.fingerprint(),initial);
const grouped=ctx.fingerprint();controls.filterQuery.value='ABA';assert.notEqual(ctx.fingerprint(),grouped);
const filtered=ctx.fingerprint();state.selected.add(2);assert.notEqual(ctx.fingerprint(),filtered);
const selected=ctx.fingerprint();controls.agreementId.value='42';assert.notEqual(ctx.fingerprint(),selected);
ctx.invalidatePreview();assert.equal(state.preview,null);assert.equal(state.previewPayload,null);assert.equal(controls.settleBtn.disabled,true);
});
test('settlement sends frozen preview request, never a mutated billing configuration',async()=>{
const {ctx,controls,state}=fixture([{id:1}]);let sent=null,sentUrl=null;ctx.message=()=>{};ctx.api=async(url,options)=>{sentUrl=url;sent=JSON.parse(options.body);return {}};ctx.loadCustomers=async()=>{};ctx.load=async()=>{};
state.preview={valid:true};state.previewPayload={ids:[1],line_mode:'separate',snapshots:[{id:1,updated_at:'reviewed'}]};state.previewFingerprint=ctx.fingerprint();
vm.runInContext(html.slice(html.indexOf(' async function settle(){'),html.indexOf(' function selectionChanged()')),ctx);
await ctx.settle();assert.match(sentUrl,/send-reviewed$/);assert.equal(sent.snapshots[0].updated_at,'reviewed');assert.equal(sent.line_mode,'separate');assert.deepEqual(sent.ids,[1]);
state.preview={valid:true};state.previewPayload={ids:[1],billing_method:'invoice'};state.previewFingerprint=ctx.fingerprint();controls.agreementId.value='99';sent=null;
await ctx.settle();assert.equal(sent,null);assert.equal(state.preview,null);
});
test('triage and select-ready respect the visible billing filter',()=>{
const rows=[{id:1,can_approve:true,billing_method:'invoice',original_hours:1,customer_id:9},{id:2,can_approve:true,billing_method:'draft',original_hours:1},{id:3,can_approve:true,billing_method:'prepaid',original_hours:1,prepaid_card_id:8}];
const {ctx,controls,state}=fixture(rows);vm.runInContext(html.slice(html.indexOf(' function methodFilteredItems('),html.indexOf(' function render(){')),ctx);
state.triage='action';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[2]);state.triage='ready';assert.deepEqual(Array.from(ctx.visibleItems(),x=>x.id),[1,3]);
controls.filterMethod.value='prepaid';ctx.selectionChanged=()=>{};vm.runInContext(html.slice(html.indexOf("$('selectReadyBtn').onclick="),html.indexOf(";['workflowLineMode'",html.indexOf("$('selectReadyBtn').onclick="))),Object.assign(ctx,{ $:id=>id==='selectReadyBtn'?(controls.selectReadyBtn??={}):controls[id]}));controls.selectReadyBtn.onclick();assert.deepEqual([...state.selected],[3]);
});
test('case tags escape names without inserting customer-controlled styles',()=>{
const {ctx}=fixture();ctx.esc=v=>String(v).replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
const out=ctx.caseTags({case_tags:[{name:'ABA <script>',color:'red;position:fixed'}]});assert.match(out,/ABA &lt;script&gt;/);assert.ok(!out.includes('position:fixed'));assert.match(out,/bi-tag/);
});
test('history is read-only even when approved entries can otherwise settle',()=>{
const {ctx,state}=fixture([{id:1,can_settle:true,can_approve:false}]);assert.equal(ctx.actionable(state.items[0]),true);state.scope='history';assert.equal(ctx.actionable(state.items[0]),false);
});
test('approval submits reviewed snapshots without settlement',async()=>{
const row={id:1,can_approve:true,updated_at:'2026-10-09',entry_fingerprint:'canonical-reviewed-row',billing_method:'draft',workflow_revision:4};const {ctx,state,controls}=fixture([row]);let calls=[];ctx.api=async(url,options)=>{calls.push({url,body:JSON.parse(options.body)});return {}};ctx.message=()=>{};ctx.load=async()=>{};
vm.runInContext(html.slice(html.indexOf(' async function approveSelected(){'),html.indexOf(' async function changeBilling(')),ctx);await ctx.approveSelected();assert.equal(calls.length,1);assert.match(calls[0].url,/approve-reviewed$/);assert.equal(calls[0].body.snapshots[0].workflow_revision,4);assert.equal(calls[0].body.snapshots[0].billing_method,'draft');assert.equal(calls[0].body.snapshots[0].entry_fingerprint,'canonical-reviewed-row');assert.equal(state.selected.size,0);
});
test('new send flow previews saved choices and ignores obsolete bulk inputs',()=>{
const {ctx,controls}=fixture([{id:1}]);controls.settlementMethod.value='invoice';controls.prepaidCard.value='9';controls.agreementId.value='3';assert.deepEqual(JSON.parse(JSON.stringify(ctx.payload())),{ids:[1],line_mode:'separate'});
});
test('permission booleans disable send and approved rows cannot change',()=>{
const {ctx,controls}=fixture([{id:1,can_approve:false,can_settle:false,can_change_billing:false,lifecycle_status:'approved'}]);ctx.syncWorkflowControls();assert.equal(controls.previewBtn.disabled,true);assert.equal(controls.stickyPreviewBtn.disabled,true);assert.equal(controls.approveBtn.disabled,true);assert.equal(controls.setMethodBtn.disabled,true);
});
test('review snapshots bind decision identity and terminal history shows saved method',()=>{
const {ctx}=fixture();assert.equal(ctx.approvalSnapshot({id:1,workflow_decision:{id:15,revision:2}}).workflow_decision_id,15);assert.equal(ctx.effectiveMethod({lifecycle_status:'settled',billing_method:'fixed_price',workflow_active:true,workflow_decision:{method:'invoice',status:'overridden'}}),'subscription');
});