Files
SGF/scripts/ledger-cycle-http.mjs
T
2026-11-04 09:37:20 +02:00

42 lines
12 KiB
JavaScript

import fs from 'node:fs';import path from 'node:path';import net from 'node:net';import crypto from 'node:crypto';import assert from 'node:assert/strict';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'),mode=process.argv[2],tag=process.argv[3];
if(state.port!==55439||!['sgf','native'].includes(mode)||!/^[a-f0-9]{12}$/.test(tag??''))throw Error('Owned cycle composition required');
const native=mode==='native',root=path.join(state.root,'private/ledger-'+(native?'native-':'')+tag),settings=path.join(root,'settings.json'),owned=read(settings),f=read(path.join(root,'fixture.json')),g=read(path.join(root,'ledger-fixture.json')),output=path.join(root,'ledger-cycle-http.json');if(!g.cycle_enabled||fs.existsSync(output))throw Error('Unfinished cycle HTTP proof required');
await new Promise((resolve,reject)=>{const p=net.createServer();p.once('error',reject);p.listen(8047,'127.0.0.1',()=>p.close(resolve));});
const log=fs.openSync(path.join(root,'cycle-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(native?'scripts/ledger-cycle-native-http-router.php':'scripts/ledger-http-router.php')],{cwd:process.cwd(),env:{...process.env,LEDGER_QA_SETTINGS:settings},windowsHide:true,stdio:['ignore',log,log]});
let checks=0;const check=(v,s)=>{checks++;assert(v,s);};const client=()=>{let auth;const jar=new Map();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(12000)});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/ledger-enter/'+role);check(r.status===200,'Fixture identity '+role);auth=r.data;check(jar.has((native?'sgc':'sgf')+'_ledger_'+tag)&&!jar.has('XSRF-TOKEN'),'Isolated session cookie');},module:async module=>{if(native)return;const old=auth.context,r=await request('/api/context',{...f.scope,module_id:module});check(r.status===200,'Explicit existing context switch '+module);auth={csrf:r.data.csrf,context:r.data.context.token};return old;}};};
const ledger=async(c,action,body,key)=>{const r=await c.request('/api/general-ledger/'+action,body,key);check(r.status===200,'Ledger '+action+' succeeds: '+r.status+' '+(r.data?.message??''));return r.data;};
const finance=async(c,action,body)=>{const r=await c.request('/api/supplier-payments/'+action,body);check(r.status===200,'Financial '+action+' succeeds: '+r.status+' '+(r.data?.message??''));return r.data;};
try{
const p=client(),v=client(),r=client();let ready=false;for(let n=0;n<50&&!ready;n++){try{ready=(await p.request('/api/general-ledger/cycle')).status===401;}catch{}if(!ready)await new Promise(res=>setTimeout(res,100));}check(ready,'Unauthenticated cycle refused');await p.enter('prepare');await v.enter('validate');await r.enter('read');
let data=await r.request('/api/general-ledger/cycle');check(data.status===200&&data.data.book.journals.length===0&&data.data.allocations.length===0,'Starts with no accounting or allocation effect');
let html=await p.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('data-action="correct-prepare"')&&html.text.includes('data-action="period-prepare"'),'Common functional cycle interface rendered');
html=await r.request('/general-ledger/cycle');check(html.status===200&&!html.text.includes('data-action='),'Reader has no command forms');
for(const q of ['page=0','tenant_id=99','page%5Bx%5D=1'])check((await r.request('/api/general-ledger/cycle?'+q)).status===422,'Unknown/invalid page query refused');
check((await r.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Reader cannot close period.'})).status===403,'Reader cannot prepare close');
check((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Missing token must refuse.'},undefined,{'X-CSRF-TOKEN':''})).status===419,'Actual CSRF validation retained');
check((await p.request('/api/general-ledger/period-prepare',{period_key:'QA-2026-10',reason:'Missing recognition journal.'})).status===409,'Unaccounted recognition prevents closing');
const prepared=await ledger(p,'prepare',g.body);await ledger(v,'validate',{proposal_id:prepared.proposal_id,revision:prepared.revision,decision:'approved',reason:'Independent original recognition review.'});const original=await ledger(p,'post',{proposal_id:prepared.proposal_id,revision:prepared.revision,reason:'Apply original recognition once.'});
await p.module('purchasing');await v.module('purchasing');const focus='/api/supplier-payables?'+(native?'invoice=':'source=')+g.local_source_id;data=await p.request(focus);check(data.status===200&&data.data.obligations.length===1,'Financial source accessible in own module');const sourceRevision=data.data.obligations[0].source_revision;
const payment=await finance(p,'payment-prepare',{obligation_id:g.obligation_id,source_revision:sourceRevision,amount:'20.00',currency:'MZN',paid_on:'2026-10-29',fact_reference:'SIM-HTTP-CYCLE-20',evidence_reference:'SIM-HTTP-EVIDENCE-20',reason:'Synthetic evidence only; no money execution.'});await finance(v,'payment-decide',{payment_id:payment.payment_id,decision:'confirmed',evidence_reference:'SIM-HTTP-CONFIRMED-20',reason:'Independent synthetic payment fact confirmation.'});
const plan=await finance(p,'allocation-prepare',{payment_id:payment.payment_id,reference:'SIM-HTTP-PLAN-20',allocations:[{obligation_id:g.obligation_id,source_revision:sourceRevision,amount:'20.00'}],reason:'Explicit partial allocation twenty.'});await finance(v,'allocation-decide',{proposal_id:plan.proposal_id,decision:'confirmed',reason:'Independent exact partial allocation review.'});
data=await p.request(focus);check(data.data.obligations[0].principal_minor===6000&&data.data.obligations[0].balance_minor===4000,'Financial partial balance40 principal60');await p.module('accounting');await v.module('accounting');
data=await p.request('/api/general-ledger/cycle');const allocation=data.data.allocations[0];check(allocation.amount_minor===2000,'Confirmed allocation is available as source');
const effectBody=(kind,id,rule)=>({effect_type:kind,source_id:id,journal_on:'2026-10-29',period_key:'QA-2026-10',rule_key:rule,reason:'Capture exact confirmed synthetic financial fact.'});
check((await p.request('/api/general-ledger/effect-prepare',effectBody('allocation.confirmed',999999,'QA-ALLOCATION'))).status===409,'Foreign or unavailable financial effect refused');
check((await p.request('/api/general-ledger/effect-prepare',effectBody('journal.reversal',original.journal_id,'QA-CORRECTION-REVERSE'))).status===422,'Corrective effect cannot bypass grouped route');
const a=await ledger(p,'effect-prepare',effectBody('allocation.confirmed',allocation.id,'QA-ALLOCATION'));await ledger(v,'validate',{proposal_id:a.proposal_id,revision:a.revision,decision:'approved',reason:'Independent review of twenty allocation impact.'});const allocationPost={proposal_id:a.proposal_id,revision:a.revision,reason:'Apply allocation accounting without second deduction.'},postKey=crypto.randomUUID();await ledger(p,'post',allocationPost,postKey);
await p.module('purchasing');await v.module('purchasing');const reverse=await finance(p,'reversal-prepare',{allocation_id:allocation.id,source_revision:sourceRevision,reason:'Synthetic full allocation reversal.'});await finance(v,'reversal-confirm',{proposal_id:reverse.proposal_id,reason:'Independent integral reversal review.'});data=await p.request(focus);check(data.data.obligations[0].balance_minor===6000,'Financial reversal restores60');await p.module('accounting');await v.module('accounting');
data=await p.request('/api/general-ledger/cycle');const reversal=data.data.reversals[0];const rev=await ledger(p,'effect-prepare',effectBody('allocation.reversed',reversal.id,'QA-REVERSAL'));await ledger(v,'validate',{proposal_id:rev.proposal_id,revision:rev.revision,decision:'approved',reason:'Independent reversal accounting review.'});await ledger(p,'post',{proposal_id:rev.proposal_id,revision:rev.revision,reason:'Apply reversal accounting once.'});
const correction=await ledger(p,'correct-prepare',{journal_id:original.journal_id,journal_on:'2026-10-29',period_key:'QA-2026-10',reason:'Technical correction preserves original and debt.'});
html=await v.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('journal.reversal')&&html.text.includes('journal.replacement')&&html.text.includes('Impacto exacto dos dois'),'Both exact corrective impacts presented before decision');
check((await p.request('/api/general-ledger/correct-post',{correction_id:correction.correction_id,revision:correction.revision,reason:'Unreviewed pair must refuse.'})).status===409,'Corrective pair needs independent review');
await ledger(v,'correct-validate',{correction_id:correction.correction_id,revision:correction.revision,decision:'approved',reason:'Independent review of both exact snapshots.'});const correctBody={correction_id:correction.correction_id,revision:correction.revision,reason:'Apply reviewed pair in one transaction.'},correctKey=crypto.randomUUID();const corrected=await ledger(p,'correct-post',correctBody,correctKey);check((await ledger(p,'correct-post',correctBody,correctKey)).replayed,'Corrective same-key replay');check((await ledger(p,'correct-post',correctBody)).result_id===corrected.result_id,'Corrective different-key same effect');
const period=await ledger(p,'period-prepare',{period_key:'QA-2026-10',reason:'Capture reconciled cycle evidence for close.'});check((await v.request('/api/general-ledger/period-validate',{check_id:period.check_id,revision:'0'.repeat(64),decision:'approved',reason:'Obsolete revision must refuse.'})).status===409,'Stale close revision refused');await ledger(v,'period-validate',{check_id:period.check_id,revision:period.revision,decision:'approved',reason:'Independent period evidence review.'});const close={check_id:period.check_id,revision:period.revision,reason:'Close complete synthetic period once.'},closeKey=crypto.randomUUID();const closed=await ledger(p,'period-close',close,closeKey);check((await ledger(p,'period-close',close,closeKey)).replayed,'Close same-key replay');check((await ledger(p,'period-close',close)).closure_id===closed.closure_id,'Close different-key no extra version');
check((await ledger(p,'post',allocationPost,postKey)).replayed,'Old posting receipt recovers after period closed');check((await p.request('/api/general-ledger/prepare',g.body)).status===409,'Closed period refuses new capture');
data=await r.request('/api/general-ledger/cycle');check(data.status===200&&data.data.book.journals.length===5&&data.data.book.lines.length===10&&data.data.correction_results.length===1&&data.data.period_closures.length===1&&data.data.periods.length===2&&data.data.book.totals.debit_minor===22000&&data.data.book.totals.credit_minor===22000,'Final five journals and one period closure reconciled');
html=await r.request('/general-ledger/cycle');check(html.status===200&&html.text.includes('Par aplicado')&&html.text.includes('Fecho #')&&html.text.includes('closed')&&!html.text.includes('data-action='),'Reader sees preserved original, pair, closure and no commands');check(data.headers.get('cache-control').includes('no-store'),'Private responses');
await p.module('purchasing');data=await p.request(focus);check(data.data.obligations[0].principal_minor===6000&&data.data.obligations[0].balance_minor===6000,'Ledger correction and close never mutate financial debt');
fs.writeFileSync(output,JSON.stringify({passed:true,checks,composition:mode,tag,actual_http:true,financial_commands:true,all_ledger_actions:true,principal_minor:6000,partial_balance_minor:4000,final_balance_minor:6000,journals:5,lines:10,debit_minor:22000,credit_minor:22000,correction_pairs:1,period_closures:1,real_csrf:true,no_browser:true,visual_review:false},null,2)+'\n');console.log(JSON.stringify({passed:true,checks,composition:mode,tag}));
}finally{if(server.exitCode===null){server.kill();await new Promise(r=>server.once('exit',r));}fs.closeSync(log);}