146 lines
11 KiB
PHP
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;
|