56 lines
8.7 KiB
JavaScript
56 lines
8.7 KiB
JavaScript
import fs from 'node:fs';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {spawn,spawnSync} from 'node:child_process';
|
|
const repo=process.env.SOURCING_REPO,work=process.env.SOURCING_WORK,host=process.env.SOURCING_CLONE,base='http://127.0.0.1:'+process.env.SOURCING_PORT,php='C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe';
|
|
assert(/^sgf_sourcing_test_[a-f0-9]{12}$/.test(process.env.SOURCING_DB));
|
|
const privateData=JSON.parse(fs.readFileSync('C:/laragon/data/sgf-tempo/run/modular-regression-2576427d1939/private/development.json','utf8')),fixture=JSON.parse(fs.readFileSync(work+'/fixture.json','utf8'));
|
|
const clients={},checks=[];
|
|
for(const p of [...privateData.profiles,{role:'decisor',email:fixture.email}]){
|
|
const jar=new Map();let csrf='',token='';
|
|
const api=async(path,method='GET',body,status=200,override={})=>{
|
|
const r=await fetch(base+path,{method,headers:{Accept:'application/json','Content-Type':'application/json',Cookie:[...jar].map(([k,v])=>k+'='+v).join('; '),'X-CSRF-TOKEN':csrf,'X-SGF-Context':token,'X-Purchasing-Context':'procurement.internal',...override},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
|
for(const cookie of r.headers.getSetCookie()){const pair=cookie.split(';')[0],at=pair.indexOf('=');jar.set(pair.slice(0,at),pair.slice(at+1));}const text=await r.text();let d;try{d=JSON.parse(text);}catch{d={message:text.slice(0,120)};}
|
|
assert.equal(r.status,status,`${p.role} ${path}: ${d.message??JSON.stringify(d.errors)}`);if(d.csrf)csrf=d.csrf;if(d.context?.token)token=d.context.token;return d;
|
|
};
|
|
await api('/api/session');await api('/api/login','POST',{email:p.email,password:privateData.password});await api('/api/context','POST',{tenant_id:1,company_id:1,unit_id:1,module_id:'purchasing'});clients[p.role]=api;
|
|
}
|
|
const read=who=>clients[who]('/api/sourcing/1');
|
|
async function command(who,action,data,status=200){const current=await read(who);const payload={operation_key:crypto.randomUUID(),version:current.state.version,...(action==='propose'?{delivery_date:'2027-01-15',delivery_location:'Local de entrega exclusivamente sintético OPER'}:{}),...data};return {result:await clients[who]('/api/sourcing/1/'+action,'POST',payload,status),payload};}
|
|
const prep='preparador',rev='revisor',dec='decisor',consult='consulta';
|
|
for(const id of [1,2,3])await command(prep,'select',{supplier_id:id});
|
|
const quote=(id,price='100.00',days=5)=>({supplier_id:id,reference:'SYNTHETIC-Q-'+id,currency:'MZN',valid_until:'2027-12-31',delivery_days:days,payment_terms:'Pagamento sintético a 30 dias',items:[{line:0,unit_price:price}]});
|
|
for(const [id,amount] of [[1,'100.00'],[2,'110.00'],[3,'120.00']])await command(prep,'quote',{...quote(id,amount),delivery_days:'5',supplier_id:String(id)});
|
|
const qSaved=await read(prep);assert.equal(qSaved.request.id,1);assert.equal(qSaved.request.total_minor,10000);assert(!qSaved.comparison.ready);
|
|
for(const action of ['select','quote','evaluate','propose','confirm'])await clients[consult]('/api/sourcing/1/'+action,'POST',{},403);
|
|
await clients[consult]('/api/sourcing/1','GET',undefined,409,{'X-SGF-Context':'bad'});await clients[consult]('/api/sourcing/1','GET',undefined,403,{'X-Purchasing-Context':'procurement.third_party'});await clients[consult]('/api/sourcing/9999999','GET',undefined,404);
|
|
checks.push('Source request reused; three suppliers and quotes; consultation is read-only; context and direct access protected');
|
|
const evalData=async(id,who)=>{const s=await read(who);return {supplier_id:id,quote_revision:s.state.quotes[id].revision,quality:8,history:8,payment:8,no_conflict:true,reason:'Synthetic independent evaluation for isolated test.'};};
|
|
// Real concurrent database sessions, not just serial requests to PHP's development server.
|
|
const initial=await read(rev),workerInput={actor_id:2,payload:{operation_key:crypto.randomUUID(),version:initial.state.version,...await evalData(1,rev)}};fs.writeFileSync(work+'/worker.json',JSON.stringify(workerInput));
|
|
const worker=(file='worker.json')=>new Promise((resolve,reject)=>{const p=spawn(php,[repo+'/tests/Support/sgf-sourcing-worker.php',work+'/'+file],{cwd:host,env:process.env,windowsHide:true});let out='',err='';p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('exit',code=>{try{if(code!==0)throw Error(err);resolve(JSON.parse(out));}catch(e){reject(e);}});});
|
|
const workers=await Promise.all([worker(),worker()]);assert(workers.every(w=>w.status===200),JSON.stringify(workers));assert.deepEqual(workers[0].result,workers[1].result);checks.push('Concurrent identical commands return one committed version');
|
|
const raceVersion=(await read(rev)).state.version;
|
|
for(const [file,id] of [['race-a.json',2],['race-b.json',3]])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:2,payload:{operation_key:crypto.randomUUID(),version:raceVersion,...await evalData(id,rev)}}));
|
|
const racing=await Promise.all([worker('race-a.json'),worker('race-b.json')]);assert.deepEqual(racing.map(r=>r.status).sort(),[200,409]);checks.push('Concurrent distinct commands use compare-and-swap; stale version refused');
|
|
for(const id of [1,2,3]){if(id!==1)await command(rev,'evaluate',await evalData(id,rev));await command(dec,'evaluate',await evalData(id,dec));}
|
|
let ready=await read(rev);assert(ready.comparison.ready);assert.deepEqual(ready.comparison.top_ids,[1]);
|
|
const proposed=await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Best eligible synthetic quote with exact comparison version.'});
|
|
await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload);checks.push('Proposal retry is idempotent');
|
|
const confirmData=async()=>{const d=await read(dec);return {proposal_index:d.state.proposals.length-1,comparison_hash:d.state.proposals.at(-1).comparison_hash,reason:'Independent confirmation of the preserved comparison.'};};
|
|
// The proposer does not possess confirm; independence is also tested in the shared core.
|
|
await command(rev,'confirm',await confirmData(),403);
|
|
await command(prep,'quote',quote(1,'100.00',6));await command(dec,'confirm',await confirmData(),409);checks.push('Changed quote blocks stale confirmation and invalidates evaluations');
|
|
function control(name){const r=spawnSync(php,[repo+'/tests/Support/sgf-sourcing-controls.php',name],{cwd:host,env:process.env,encoding:'utf8',windowsHide:true});assert.equal(r.status,0,r.stderr);}
|
|
for(const who of [rev,dec])await command(who,'evaluate',await evalData(1,who));
|
|
ready=await read(rev);await command(rev,'propose',{supplier_id:1,comparison_hash:ready.comparison.fingerprint,reason:'Re-proposed using current quote revision and comparison.'});
|
|
control('revoke-evaluate');await command(dec,'confirm',await confirmData(),409);control('restore-evaluate');
|
|
control('revoke-propose');await clients[rev]('/api/sourcing/1/propose','POST',proposed.payload,403);control('restore-propose');
|
|
control('revoke-view');await clients[consult]('/api/sourcing/1','GET',undefined,403);control('restore-view');checks.push('Revocation is effective for existing sessions, replay and qualified evaluations');
|
|
const beforeFail=await read(prep);control('fail-on');await command(prep,'quote',quote(2,'110.00',7),500);control('fail-off');const afterFail=await read(prep);assert.equal(afterFail.state.version,beforeFail.state.version);assert.equal(afterFail.history.length,beforeFail.history.length);checks.push('Injected failure after event rolls back state, event and receipt');
|
|
const confirmVersion=(await read(dec)).state.version,confirmation=await confirmData();
|
|
for(const file of ['confirm-a.json','confirm-b.json'])fs.writeFileSync(work+'/'+file,JSON.stringify({actor_id:fixture.user_id,action:'confirm',payload:{operation_key:crypto.randomUUID(),version:confirmVersion,...confirmation}}));
|
|
const confirmations=await Promise.all([worker('confirm-a.json'),worker('confirm-b.json')]);assert.deepEqual(confirmations.map(r=>r.status).sort(),[200,409]);
|
|
const confirmed=await read(consult);assert(confirmed.state.confirmed);assert.equal(confirmed.state.confirmed.confirmed_by,fixture.user_id);assert.equal(confirmed.state.confirmed.proposal.proposed_by,2);checks.push('Concurrent confirmations produce one immutable independent award');
|
|
await command(prep,'quote',quote(1),409);checks.push('Independent confirmation references exact comparison and final state is immutable');
|
|
const origin=await clients[consult]('/api/internal-requisitions/1');assert.equal(origin.record.version,3);assert.equal(origin.record.total_minor,10000);assert.equal(origin.history.length,3);checks.push('Accepted requisition read path remains intact');
|
|
await clients[consult]('/purchasing/sourcing/1');await clients[consult]('/purchasing/sourcing/screen.mjs');checks.push('Authenticated read-only screen and module script served through protected routes');
|
|
console.log(JSON.stringify({passed:true,checks,request_id:1,confirmed_only_in_isolated_copy:true,account_fixture_only_in_copy:true}));
|