Files

146 lines
11 KiB
PHP

<?php
// Runs inside a real Laravel host, with a coordinator-provisioned disposable database.
$root = $argv[1] ?? '';
$mode = $argv[2] ?? '';
$database = getenv('DB_DATABASE');
$pattern = $mode === 'compras' ? '/^compras_receipts_test_[a-f0-9]{12}$/D' : '/^budget_core_sgf_[a-f0-9]{12}$/D';
if (! in_array($mode,['compras','sgf'],true) || ! preg_match($pattern,$database) || getenv('DB_HOST') !== '127.0.0.1') throw new LogicException('Unsafe host test configuration.');
require $root.'/vendor/autoload.php';
$app = require $root.'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
if (DB::connection()->getDatabaseName() !== $database || DB::selectOne('select current_database() as db')->db !== $database) throw new LogicException('Effective QA database mismatch.');
require __DIR__.'/host-support.php';
$worker = in_array('--worker',$argv,true);
if ($worker) {
$input = json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);
DB::select('select set_config(?, ?, false)',['application_name',$input['label']]);
try {
budgetQaRecord($mode,$input['fixture'],$input['kind'],$input['amount']);
$status = 200;
} catch (\Tempo\Finance\Budget\BudgetFailure|\Illuminate\Validation\ValidationException $e) { $status = 422; }
echo json_encode(['status'=>$status,'pid'=>DB::selectOne('select pg_backend_pid() as pid')->pid],JSON_THROW_ON_ERROR);
exit;
}
if ($mode === 'compras') {
\Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);
} else {
require __DIR__.'/sgf-schema.php';
}
$prefix = $mode==='sgf'?'cb_':'';
$f = budgetQaFixture($mode);
$budget = budgetQaCreate($mode,$f);
$steps = [];
foreach ([['reserve',30000],['award',35000],['commit',35000]] as [$kind,$amount]) {
budgetQaRecord($mode,$f,$kind,$amount);
budgetQaRecord($mode,$f,$kind,$amount);
$steps[$kind] = budgetQaEngine($mode,$f)->balance($budget);
}
// Real invoice foreign key in Compras, source-reference-only consumption in SGF.
if ($mode==='compras') {
budgetQaSelect($mode,$f);
$sourceInvoice = \App\Models\Invoice::query()->create(['tenant_id'=>$f['tenant'],'supplier_id'=>\App\Models\PurchaseOrder::findOrFail($f['invoice'])->supplier_id,'purchase_order_id'=>$f['invoice'],'created_by'=>$f['actor'],'number'=>'TEST-BUDGET-ONLY','issued_on'=>now()->format('Y-m-d'),'subtotal_minor'=>10000,'tax_minor'=>0,'total_minor'=>10000,'snapshot'=>[]]);
$f['invoice'] = $sourceInvoice->id;
}
$engine = budgetQaEngine($mode,$f);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$engine->consume($f['origin'],$f['invoice'],10000,10000);
$steps['consume'] = $engine->balance($budget);
budgetQaAssert($steps['consume']['reserved_minor']===0 && $steps['consume']['committed_minor']===25000 && $steps['consume']['consumed_minor']===10000 && $steps['consume']['available_minor']===65000,'Partial consumption differs.');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'reserve',30001),'changed replay');
budgetQaReject(fn()=>budgetQaRecord($mode,$f,'release'),'release committed source');
budgetQaReject(fn()=>$engine->consume($f['origin'],$f['invoice'],10001,10001),'changed consumption replay');
$rid = $engine->proposeReinforcement($budget,20000,'REF-QA','Synthetic reinforcement');
budgetQaAssert($engine->balance($budget)['available_minor']===65000,'Pending reinforcement changed availability.');
budgetQaReject(fn()=>$engine->reviewReinforcement($rid,'approved','Self approval'),'self approval');
$review = budgetQaEngine($mode,$f,'validator');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$review->reviewReinforcement($rid,'approved','Distinct synthetic validation');
$steps['reinforcement'] = $review->balance($budget);
budgetQaAssert($steps['reinforcement']['available_minor']===85000,'Reinforcement differs.');
budgetQaSelect($mode,$f);
$foreign = budgetQaFixture($mode);
budgetQaCreate($mode,$foreign);
budgetQaSelect($mode,$f);
budgetQaReject(fn()=>budgetQaEngine($mode,$f)->balance($budget+1),'foreign allocation');
if ($mode === 'sgf') {
budgetQaReject(fn()=>app(\App\Modules\Purchasing\BudgetAdapter::class)->record(budgetQaSelect($mode,$f),$foreign['origin'],'reserve',100,100),'foreign mapped source');
budgetQaReject(fn()=>app(\App\Shared\Context\CompanyContext::class)->selectFor($f['actor'],$f['tenant'],$f['company'],$f['unit']-1),'reserved unit');
$stale = budgetQaEngine($mode,$f);
budgetQaSelect($mode,$foreign);
budgetQaReject(fn()=>$stale->balance($budget),'stale context');
}
// Release is allowed once before commitment.
$release = budgetQaFixture($mode); $releaseBudget = budgetQaCreate($mode,$release,10000);
budgetQaRecord($mode,$release,'reserve',4000);
budgetQaRecord($mode,$release,'release'); budgetQaRecord($mode,$release,'release');
budgetQaAssert(budgetQaEngine($mode,$release)->balance($releaseBudget)['available_minor']===10000,'Release differs.');
// The below-tax basis is preserved, without a document recalculation.
$net = budgetQaFixture($mode); $netBudget = budgetQaCreate($mode,$net,10000,false);
budgetQaEngine($mode,$net)->record($net['origin'],$net['centre'],'operating','reserve',11600,10000);
budgetQaAssert(budgetQaEngine($mode,$net)->balance($netBudget)['reserved_minor']===10000,'Net basis differs.');
// Failed audit must roll back the ledger and make retry safe.
$failure = budgetQaFixture($mode); $failureBudget = budgetQaCreate($mode,$failure,10000);
$auditTable = $mode==='compras'?'audit_logs':'cb_audit';
DB::unprepared("CREATE FUNCTION budget_qa_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='budget.reserve' THEN RAISE EXCEPTION 'QA_AUDIT_FAILURE'; END IF; RETURN NEW; END $$; CREATE TRIGGER budget_qa_fail BEFORE INSERT ON {$auditTable} FOR EACH ROW EXECUTE FUNCTION budget_qa_fail()");
try { budgetQaRecord($mode,$failure,'reserve',4000); throw new RuntimeException('Audit fault was not observed.'); }
catch (\Illuminate\Database\QueryException $expected) { budgetQaAssert(str_contains($expected->getMessage(),'QA_AUDIT_FAILURE'),'Wrong injected failure.'); }
DB::unprepared("DROP TRIGGER budget_qa_fail ON {$auditTable}; DROP FUNCTION budget_qa_fail()");
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['available_minor']===10000,'Fault left an orphan reservation.');
budgetQaRecord($mode,$failure,'reserve',4000); budgetQaRecord($mode,$failure,'reserve',4000);
budgetQaAssert(budgetQaEngine($mode,$failure)->balance($failureBudget)['reserved_minor']===4000,'Recovery duplicated effect.');
// Revalidate host permission even when the command is an idempotent replay.
if ($mode==='compras') DB::table('users')->where('id',$f['validator'])->update(['purchasing_role'=>'warehouse']);
else DB::table('permission_grants')->where('user_id',$f['validator'])->where('permission','purchasing_budget.reinforcement.review')->delete();
budgetQaReject(fn()=>budgetQaEngine($mode,$f,'validator')->reviewReinforcement($rid,'approved','Replay after revocation'),'revoked reviewer replay');
$races=[];
$monitorConfig=DB::connection()->getConfig();
config(['database.connections.budget_monitor'=>$monitorConfig]);
$monitor=DB::connection('budget_monitor');
foreach (['over_reserve','same_reserve','same_commit','same_release'] as $scenario) {
$r = budgetQaFixture($mode); $rb = budgetQaCreate($mode,$r,10000); $second=$r;
if ($scenario==='over_reserve') {
if ($mode==='compras') {
budgetQaSelect($mode,$r);
$clone=\App\Models\Requisition::findOrFail($r['origin'])->replicate(); $clone->number='REQ-QA-'.bin2hex(random_bytes(6)); $clone->save(); $second['origin']=$clone->id;
} else $second['origin']=DB::table('cb_origins')->insertGetId($r['scope']+['cost_center_id'=>$r['centre'],'required_kind'=>'operating','reference'=>'REQ-RACE-'.bin2hex(random_bytes(6))]);
}
if (in_array($scenario,['same_commit','same_release'],true)) budgetQaRecord($mode,$r,'reserve',6000);
$kind=match($scenario){'same_commit'=>'commit','same_release'=>'release',default=>'reserve'};
$db=DB::connection(); $db->beginTransaction();
$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$r['tenant']:$r['company'])->lockForUpdate()->firstOrFail();
$processes=[];
try {
foreach ([$r,$second] as $i=>$fixture) {
$p=new Process([PHP_BINARY,__FILE__,$root,$mode,'--worker'],$root);
$p->setInput(json_encode(['fixture'=>$fixture,'label'=>'common_budget_'.$mode.'_'.$i,'kind'=>$kind,'amount'=>6000],JSON_THROW_ON_ERROR));
$p->setTimeout(35); $p->start(); $processes[]=$p;
}
$deadline=microtime(true)+15; $waits=0;
do {
$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in (?,?) and wait_event_type='Lock'",[$database,'common_budget_'.$mode.'_0','common_budget_'.$mode.'_1'])->n;
if ($waits===2) break;
foreach($processes as $p) if(!$p->isRunning()) throw new RuntimeException('Race worker exited before overlap: '.$p->getOutput().$p->getErrorOutput());
usleep(20000);
} while(microtime(true)<$deadline);
budgetQaAssert($waits===2,'Two overlapping PostgreSQL lock waits not observed.');
$db->commit(); $results=[];
foreach($processes as $p){$p->wait();budgetQaAssert($p->isSuccessful(),'Race worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$statuses=array_column($results,'status');sort($statuses);
budgetQaAssert($statuses===($scenario==='over_reserve'?[200,422]:[200,200]),'Race statuses differ.');
budgetQaAssert(count(array_unique(array_column($results,'pid')))===2,'Independent DB workers required.');
$b=budgetQaEngine($mode,$r)->balance($rb);
budgetQaAssert($b['available_minor']===($scenario==='same_release'?10000:4000),'Race duplicated budget effect.');
$races[]=['scenario'=>$scenario,'passed'=>true,'observed_lock_waits'=>$waits,'balance'=>$b];
} finally {
if($db->transactionLevel())$db->rollBack();
foreach($processes as $p)if($p->isRunning())$p->stop();
}
}
$reflection = new ReflectionClass(\Tempo\Finance\Budget\BudgetControl::class);
if($mode==='compras')budgetQaAssert(str_contains(str_replace('\\','/',$reflection->getFileName()),'common-budget-artifact-'),'Standalone did not load the detached package artifact.');
echo json_encode(['host'=>$mode,'passed'=>true,'detached_package_artifact'=>$mode==='compras','core_sha256'=>hash_file('sha256',$reflection->getFileName()),'steps'=>$steps,'races'=>$races,'checks'=>['independent_approval','partial_consumption','same_command_replay','changed_replay_rejected','committed_release_refused','reinforcement_pending_and_approved','tenant_isolation','release_once','net_tax_basis','audit_rollback_and_retry','revoked_reviewer_replay'],'sgf_context_checks'=>$mode==='sgf'?['foreign_source_refused','reserved_unit_refused','stale_context_refused']:[],'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;