22 lines
10 KiB
JavaScript
22 lines
10 KiB
JavaScript
import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn} from 'node:child_process';
|
|
const read=p=>JSON.parse(fs.readFileSync(p,'utf8')),state=read('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),tag=process.argv[2];if(state.port!==55439||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned correction fixture required');const root=path.join(state.root,'private/ledger-'+tag),g=read(path.join(root,'cost-fixture.json')),i=read(path.join(root,'industrial-fixture.json')),cg=read(path.join(root,'industrial-correction-fixture.json')),out=path.join(root,'industrial-correction-http.json');if(fs.existsSync(out))throw Error('Completed correction HTTP proof preserved');
|
|
await new Promise((resolve,reject)=>{const s=net.createServer();s.once('error',reject);s.listen(8047,'127.0.0.1',()=>s.close(resolve));});const log=fs.openSync(path.join(root,'cost-correction-http-server.log'),'a'),server=spawn('C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe',['-S','127.0.0.1:8047',path.resolve('scripts/ledger-cost-http-router.php')],{windowsHide:true,stdio:['ignore',log,log],env:{...process.env,LEDGER_QA_SETTINGS:path.join(root,'settings.json')}});
|
|
let checks=0;const ok=(v,m)=>{checks++;assert(v,m);};const client=()=>{const jar=new Map();let auth;const request=async(url,body,key=crypto.randomUUID(),extra={})=>{const r=await fetch('http://127.0.0.1:8047'+url,{method:body?'POST':'GET',headers:{Accept:'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-SGF-Context':auth?.context??'',...(body?{'Content-Type':'application/json','X-CSRF-TOKEN':auth?.csrf??'','Idempotency-Key':key}:{}),...extra},body:body?JSON.stringify(body):undefined,redirect:'manual',signal:AbortSignal.timeout(15000)});for(const c of r.headers.getSetCookie()){const p=c.split(';')[0],i=p.indexOf('=');jar.set(p.slice(0,i),p.slice(i+1));}const text=await r.text();let data;try{data=JSON.parse(text);}catch{}return{status:r.status,data,text,headers:r.headers};};return{request,enter:async role=>{const r=await request('/qa/cost-enter/'+role);ok(r.status===200,'Owned '+role+' entry');auth=r.data;ok(jar.has('sgf_cost_'+tag)&&!jar.has('XSRF-TOKEN'),'Separate session cookie and shared CSRF preserved');},module:async module=>{const old=auth.context,r=await request('/api/context',{...g.scope,module_id:module});ok(r.status===200,'Explicit module transition');auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
|
|
|
|
const api='/api/general-ledger/industrial?lot_id='+i.lot_id,html='/general-ledger/industrial?lot_id='+i.lot_id;const command=async(c,a,b,key)=>{const r=await c.request('/api/general-ledger/'+a,b,key);ok(r.status===200,a+' '+r.status+' '+(r.data?.message??''));return r.data;};
|
|
try{
|
|
const p=client(),v=client(),r=client(),tech=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await r.request(api)).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}ok(ready,'Unauthenticated correction read refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');await tech.enter('technician');ok((await tech.request(api)).status===403,'Technical rights do not grant accounting');
|
|
let w=await r.request(api,undefined,undefined,{'X-Cost-QA-Proof':'read'});ok(w.status===200&&w.data.industrial.source.principal_minor===182000&&w.data.industrial.journal.debit_minor===180000&&w.data.industrial.accounting_state==='requires_future_correction'&&w.data.corrections.length===0&&w.data.book.journals.length===1,'Actual original275/current295 requires explicit correction');const initialSource=JSON.stringify(w.data.industrial.facts);const mode=read(path.join(root,'cost-http-read.json'));ok(mode.transaction.isolation==='repeatable read'&&mode.transaction.read_only==='on'&&mode.writer_locks===0,'Source, group and book reads coherent without writer locks');
|
|
let view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-prepare"')&&view.text.includes('20,00 MZN')&&!view.text.includes('data-action="effect-prepare"'),'Individual pair capture with explicit difference20');fs.writeFileSync(path.join(root,'correction-preparer.html'),view.text);view=await r.request(html);ok(view.status===200&&!view.text.includes('data-action='),'Reader cannot decide');
|
|
const body={journal_id:cg.original_journal_id,journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Explicit original275/current295 pair, preserving original source evidence.'};ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-CSRF-TOKEN':''})).status===419,'Real CSRF');ok((await r.request('/api/general-ledger/correct-prepare',body)).status===403,'No implicit correction from view');ok((await p.request('/api/general-ledger/correct-prepare',{...body,journal_id:999999})).status===409,'Foreign original refused');ok((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Unresolved changed cost blocks closing.'})).status===409,'Changed cost cannot close before correction');
|
|
for(const q of ['lot_id=999999','lot_id=0','lot_id%5Bx%5D=1','lot_id='+i.lot_id+'&tenant_id=9'])ok([403,422].includes((await r.request('/api/general-ledger/industrial?'+q)).status),'Invalid/foreign source query refused');
|
|
const old=await p.module('finance');ok((await p.request(api)).status===409,'Wrong module refuses correction workspace');await p.module('accounting');ok((await p.request('/api/general-ledger/correct-prepare',body,undefined,{'X-SGF-Context':old})).status===409,'Stale generation refuses correction');
|
|
const prepareKey=crypto.randomUUID(),proposal=await command(p,'correct-prepare',body,prepareKey);ok((await command(p,'correct-prepare',body,prepareKey)).replayed,'Capture retry receipt');w=await r.request(api);const group=w.data.corrections[0],part=w.data.book.proposals.find(x=>x.id===group.reverse_proposal_id);ok(JSON.parse(group.reverse_snapshot).impact.debit_minor===180000&&JSON.parse(group.replacement_snapshot).impact.debit_minor===182000,'Group has exact asymmetric impacts');view=await v.request(html);ok(view.status===200&&view.text.includes('data-action="correct-validate"')&&!view.text.includes('data-action="validate"'),'Reviewer sees only joint decision, no individual part');fs.writeFileSync(path.join(root,'correction-reviewer.html'),view.text);view=await v.request('/general-ledger');ok(view.status===200&&!view.text.includes('data-action="validate"'),'Generic diary also hides isolated part decision');
|
|
const review={correction_id:proposal.correction_id,revision:proposal.revision,decision:'approved',reason:'Independent conference of four lines, original275/latest295/difference20.'};ok((await p.request('/api/general-ledger/correct-validate',review)).status===403,'Preparer has no independent correction review');ok((await v.request('/api/general-ledger/correct-validate',{...review,revision:'0'.repeat(64)})).status===409,'Old group revision refused');ok((await v.request('/api/general-ledger/validate',{proposal_id:part.id,revision:part.revision,decision:'approved',reason:'Isolated part review forbidden.'})).status===409,'Public individual review refused');
|
|
const post={correction_id:proposal.correction_id,revision:proposal.revision,reason:'Apply reviewed four-line cost pair atomically once.'};ok((await p.request('/api/general-ledger/correct-post',post)).status===409,'Pair cannot apply before distinct approval');await command(v,'correct-validate',review);view=await p.request(html);ok(view.status===200&&view.text.includes('data-action="correct-post"')&&!view.text.includes('data-action="post"'),'Only joint application exposed');fs.writeFileSync(path.join(root,'correction-post.html'),view.text);ok((await p.request('/api/general-ledger/post',{proposal_id:part.id,revision:part.revision,reason:'Isolated balanced reversal still forbidden.'})).status===409,'Public individual application refused');
|
|
const key=crypto.randomUUID(),result=await command(p,'correct-post',post,key);ok((await command(p,'correct-post',post,key)).replayed,'Pair retry receipt');ok((await command(p,'correct-post',post)).result_id===result.result_id,'Other key recovers same pair');ok((await p.request('/api/general-ledger/correct-prepare',body)).status===409,'No second correction chain');w=await r.request(api);ok(w.data.book.journals.length===3&&w.data.book.lines.length===6&&w.data.correction_results.length===1&&w.data.industrial.accounting_state==='current'&&JSON.stringify(w.data.industrial.facts)===initialSource,'Three journals/six lines resolve current cost without mutating source');ok(w.data.book.events.length===9,'All proposal/review/post history retained');
|
|
const dates='from_on=2026-10-01&to_on=2026-10-31';let report=await r.request('/api/general-ledger/trial-balance?'+dates);ok(report.status===200&&report.data.totals.debit_minor===569500&&report.data.totals.credit_minor===569500,'Gross trial balance845 each side');report=await r.request('/api/general-ledger/account-ledger?'+dates+'&account_id='+i.accounts['QA-CUSTO-INDUSTRIAL']);ok(report.status===200&&report.data.closing_net_minor===182000&&report.data.rows.length===3,'Account ledger exact295 after reversal and replacement');view=await r.request('/general-ledger/account-ledger?'+dates+'&account_id='+i.accounts['QA-CUSTO-INDUSTRIAL']);ok(view.status===200&&view.text.includes('/general-ledger/industrial?lot_id='+i.lot_id),'Report traces corrected cost source');
|
|
ok((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Other composed sources remain unresolved after industrial pair.'})).status===409,'Industrial correction alone cannot close whole composition');
|
|
fs.writeFileSync(out,JSON.stringify({passed:true,checks,actual_http:true,csrf_and_context:true,indivisible_four_line_impact:true,isolated_public_parts_refused:true,original_minor:180000,replacement_minor:182000,delta_minor:2000,journals:3,lines:6,applied_groups:1,industrial_gross_minor:542000,company_gross_minor:569500,balance_minor:182000,source_snapshot_unchanged:true,joint_close_requires_other_sources:true,read_only_workspace:true,writer_locks:0,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,journals:3,lines:6}));
|
|
}finally{server.kill();fs.closeSync(log);}
|