Files
SGF/scripts/payable-history-sgf-cases.php
2026-11-02 20:29:34 +02:00

72 lines
14 KiB
PHP

<?php
// Included only after the strict owned-database guard in payable-history-sgf-proof.php.
use App\Modules\Purchasing\{SupplierPayables,SupplierHistoryReceipts};
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tempo\Finance\Payables\{Payables,HistoricalPayments};
$fixture=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$fixture['scope'];$local=$fixture['source_id'];$service=app(SupplierPayables::class);$checks=0;$cases=[];
if(DB::table('pa_obligations')->exists())throw new LogicException('Use a new owned QA fixture for regression; retained recognised evidence must not be reset.');
config(['supplier_payable_history.synthetic_enabled'=>true]);
$assert=function(bool $condition,string $message)use(&$checks){$checks++;if(!$condition)throw new RuntimeException($message);};
$acting=function(string $name)use($fixture,$scope){$u=App\Models\User::findOrFail($fixture['actors'][$name]);auth()->setUser($u);app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);return $u;};
$command=function(string $name,string $action,int $id,?string $key=null)use($acting,$service){return $service->engine($acting($name))->command($action,$id,'Synthetic exact history conference.', $key??(string)Str::uuid());};
$refused=function(callable $call,int|string $expected)use($assert){try{$call();throw new RuntimeException('Expected refusal was not raised');}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$assert($e->getStatusCode()===$expected,'Unexpected HTTP refusal');}catch(\DomainException $e){$assert($expected==='domain','Unexpected domain refusal');}};
$case=function(string $name,callable $call)use(&$cases,&$checks){$before=$checks;DB::beginTransaction();try{$call();$cases[]=['name'=>$name,'assertions'=>$checks-$before,'passed'=>true];}finally{while(DB::transactionLevel()>0)DB::rollBack();config(['supplier_payable_history.synthetic_enabled'=>true]);}echo 'Passed: '.$name."\n";};
$receipt=function(array $change=[],?string $hash=null)use($scope,$local){$rows=DB::table('pa_source_histories')->where($scope)->where('local_source_id',$local);$version=(int)(clone $rows)->max('version')+1;$payload=json_decode((clone $rows)->where('version',1)->value('payload'),true,64,JSON_THROW_ON_ERROR);$payload=array_replace($payload,['receipt_version'=>$version],$change);DB::table('pa_source_histories')->insert($scope+['local_source_id'=>$local,'version'=>$version,'payload'=>Payables::canonical($payload),'receipt_hash'=>$hash??hash('sha256',Payables::canonical($payload)),'created_at'=>now()]);};
$case('default-disabled-and-old-path-preserved',function()use($assert,$service,$acting,$command,$refused,$local){
config(['supplier_payable_history.synthetic_enabled'=>false]);$assert(!$service->listing($acting('read'))['history_import_available'],'History must remain disabled');$refused(fn()=>$command('prepare','prepare_history',$local),'domain');$r=$command('prepare','prepare',$local);$assert($r['state']==='pending_source','Old path must retain payment-history refusal');$assert(DB::table('pa_obligations')->count()===0,'No debt on disabled path');
});
$case('principal-confirmed-only-and-idempotency',function()use($assert,$service,$acting,$command,$refused,$local){
$p=$command('prepare','prepare_history',$local);$assert($p['state']==='prepared','History preparation');$assert(DB::table('pa_obligations')->count()===0,'Preparation must not recognise');
$refused(fn()=>$command('prepare','validate_history',$p['proposal_id']),403);
$u=$acting('prepare');$scope=app(CompanyContext::class)->identifiers()+['unit_id'=>app(CompanyContext::class)->unitId()];DB::table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'sgf-history-person-'.$u->id,'action'=>'validate']);$refused(fn()=>$command('prepare','validate_history',$p['proposal_id']),'domain');
$key=(string)Str::uuid();$r=$command('validate','validate_history',$p['proposal_id'],$key);$assert($r['state']==='recognised','History recognition');$assert($command('validate','validate_history',$p['proposal_id'],$key)['replayed'],'Replay same result');$command('validate','validate_history',$p['proposal_id']);
$d=$service->listing($acting('read'));$o=$d['obligations'][0];foreach(['principal_minor'=>6000,'balance_minor'=>3000,'confirmed_payments_minor'=>3000,'pending_payments_minor'=>1000,'rejected_payments_minor'=>500] as $k=>$value)$assert($o[$k]===$value,'Incorrect '.$k);
$assert($o['due_on']===null&&$o['days_overdue']===null&&$o['due_status']==='unknown','No invented due date');$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_history_imports')->count()===1&&DB::table('pa_imported_payments')->count()===3,'One obligation/import, three original payments');$assert(!DB::getSchemaBuilder()->hasTable('pa_payment_decisions'),'No imported payment decisions installed');
$html=Tempo\Finance\Payables\Workspace::render($d,['host'=>'SGF QA','actor'=>'Synthetic','organisation'=>'Synthetic','api'=>'/api/supplier-payables','csrf'=>'synthetic','context'=>'synthetic']);$assert(str_contains($html,'Saldo por liquidar: 30.00 MZN'),'History visible in existing UI');$assert(!str_contains(DB::table('pa_history_imports')->value('snapshot'),'TEST-PAY-ACCOUNT'),'Private bank data excluded');
});
$case('receipt-integrity-and-identity-refusals',function()use($assert,$receipt,$command,$refused,$local){
foreach([[[] ,str_repeat('0',64)],[['source_key'=>str_repeat('0',64)],null],[['source_revision'=>str_repeat('0',64)],null],[['version'=>'unsupported'],null],[['history_hash'=>str_repeat('0',64)],null]] as [$change,$hash]){$receipt($change,$hash);$refused(fn()=>$command('prepare','prepare_history',$local),'domain');}
$assert(DB::table('pa_obligations')->count()===0,'Invalid receipts must not recognise');
});
$case('complete-compatible-payment-facts-required',function()use($receipt,$command,$refused,$local,$assert){
$row=json_decode(DB::table('pa_source_histories')->orderByDesc('version')->value('payload'),true,64,JSON_THROW_ON_ERROR);$original=$row['payments'];
foreach(['missing','duplicate','company','currency','audit','self-review'] as $variant){$rows=$original;if($variant==='missing')array_pop($rows);if($variant==='duplicate')$rows[1]=$rows[0];if($variant==='company')$rows[0]['company']+=100;if($variant==='currency')$rows[0]['currency']='USD';if($variant==='audit')$rows[0]['registration_audit_id']=null;if($variant==='self-review')$rows[0]['reviewed_by']=$rows[0]['created_by'];$receipt(['payments'=>$rows]);$refused(fn()=>$command('prepare','prepare_history',$local),'domain');}
$assert(DB::table('pa_imported_payments')->count()===0,'No partial history imports');
});
$case('changed-history-requires-new-conference',function()use($receipt,$command,$assert,$local){
$p=$command('prepare','prepare_history',$local);$source=DB::table('pa_imported_sources')->where('id',$local)->first();$doc=json_decode($source->payload,true,64,JSON_THROW_ON_ERROR);$row=DB::table('pa_source_histories')->orderByDesc('version')->first();$payload=json_decode($row->payload,true,64,JSON_THROW_ON_ERROR);$payments=$payload['payments'];
foreach($payments as &$payment)if($payment['status']==='pending'){$payment['status']='rejected';$payment['reviewed_by']=999;$payment['reviewed_at']='2026-11-02 12:00:00';$payment['decision_audit_id']=999;}unset($payment);
$capture=HistoricalPayments::capture($doc,$payments);$receipt(['payments'=>$payments,'history_hash'=>$capture['history_hash']]);$r=$command('validate','validate_history',$p['proposal_id']);$assert($r['state']==='conflict','Changed history cannot confirm old proposal');$assert(DB::table('pa_obligations')->count()===0,'No debt from stale conference');$assert(DB::table('pa_proposals')->count()===1,'Old proposal preserved');
});
$case('permissions-person-and-context-revalidated',function()use($command,$acting,$refused,$scope,$fixture,$service,$local,$assert){
$p=$command('prepare','prepare_history',$local);DB::table('pa_access')->where($scope)->where(['user_id'=>$fixture['actors']['validate'],'action'=>'validate'])->delete();$refused(fn()=>$command('validate','validate_history',$p['proposal_id']),403);
$u=$acting('read');DB::table('permission_grants')->where(['user_id'=>$u->id,'permission'=>'supplier_payables.view'])->delete();$refused(fn()=>$service->document($u,$local),403);
$u=$acting('prepare');$refused(fn()=>app(CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id']+100,$scope['unit_id']),403);
$acting('prepare');$engine=$service->engine($u);auth()->setUser(App\Models\User::findOrFail($fixture['actors']['validate']));$refused(fn()=>$engine->command('prepare_history',$local,'Synthetic stale actor.',(string)Str::uuid()),403);$assert(DB::table('pa_obligations')->count()===0,'No unauthorised debt');
});
$case('audit-and-import-failure-atomic-recovery',function()use($command,$assert,$local){
$p=$command('prepare','prepare_history',$local);$fault=true;DB::listen(function($q)use(&$fault){if($fault&&str_contains($q->sql,'insert into "pa_imported_payments"')){$fault=false;throw new RuntimeException('Synthetic import failure');}});
try{$command('validate','validate_history',$p['proposal_id']);throw new RuntimeException('Fault was not raised');}catch(RuntimeException $e){$assert($e->getMessage()==='Synthetic import failure','Expected injected failure');}
$assert(DB::table('pa_obligations')->count()===0&&DB::table('pa_history_imports')->count()===0&&DB::table('pa_imported_payments')->count()===0,'Import rollback must be complete');$command('validate','validate_history',$p['proposal_id']);$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_imported_payments')->count()===3,'Explicit retry creates one complete import');
});
$case('receipt-append-only-and-existing-controller',function()use($assert,$command,$acting,$service,$local){
try{DB::transaction(fn()=>DB::table('pa_source_histories')->update(['version'=>99]));throw new RuntimeException('Update should fail');}catch(Illuminate\Database\QueryException $e){$assert($e->errorInfo[0]==='P0001','Immutable receipt');}
$u=$acting('prepare');$request=Illuminate\Http\Request::create('/api/supplier-payables/prepare_history','POST',['id'=>$local,'reason'=>'Synthetic controller history preparation.']);$request->headers->set('Idempotency-Key',(string)Str::uuid());$request->setUserResolver(fn()=>$u);$response=app(App\Modules\Purchasing\SupplierPayablesController::class)->command($request,'prepare_history',$service);$assert($response->getStatusCode()===200&&$response->getData(true)['state']==='prepared','Existing controller delegates historical command');
$match=app('router')->getRoutes()->match(Illuminate\Http\Request::create('/api/supplier-payables/validate_history','POST'));$assert(str_contains($match->getActionName(),'SupplierPayablesController@command'),'History route uses existing controller');
});
$case('invalid-receipt-keeps-history-readable-and-balance-indeterminate',function()use($assert,$command,$receipt,$service,$acting,$local){
$p=$command('prepare','prepare_history',$local);$command('validate','validate_history',$p['proposal_id']);$receipt([],str_repeat('0',64));$d=$service->listing($acting('read'));$o=$d['obligations'][0];$assert($o['balance_minor']===null&&$o['source_conflict']===true,'Divergent receipt is indeterminate, not zero');$assert($o['principal_minor']===6000&&count($d['documents'])===1&&$d['events']->count()>0,'Original and evidence remain readable');
});
$case('concurrent-confirmations-wait-and-replay-one-import',function()use($assert,$command,$local,$s,$file){
$p=$command('prepare','prepare_history',$local);$key=(string)Str::uuid();$command('validate','validate_history',$p['proposal_id'],$key);$input=$s['root'].'/worker.json';$application='history_'.$s['tag'];file_put_contents($input,json_encode(['proposal'=>$p['proposal_id'],'key'=>$key,'application'=>$application],JSON_THROW_ON_ERROR));$process=null;
try{$pipes=[];$process=proc_open([PHP_BINARY,__DIR__.'/payable-history-sgf-proof.php',$file,'worker',$input],[0=>['pipe','r'],1=>['file',$s['root'].'/worker-stdout.txt','w'],2=>['file',$s['root'].'/worker-stderr.txt','w']],$pipes,dirname(__DIR__));$assert(is_resource($process),'Worker launched');fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&&microtime(true)<$deadline);$assert($wait!==null,'Concurrent company blocker observed');$assert(in_array($parent,json_decode($wait->blockers,true),true),'First transaction blocks second');DB::commit();
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$assert(!$status['running'],'Worker completed after release');$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$assert($exit===0,'Worker exited normally');$result=json_decode(file_get_contents($input.'.result'),true,64,JSON_THROW_ON_ERROR);$assert($result['status']===200&&$result['result']['replayed']===true,'Concurrent replay original result');$assert(DB::table('pa_obligations')->count()===1&&DB::table('pa_history_imports')->count()===1&&DB::table('pa_imported_payments')->count()===3,'One obligation, one import, three payments');
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}}
});
$report=['kind'=>'sgf_historical_payable_adapter_synthetic','cases'=>$cases,'tests'=>count($cases),'assertions'=>$checks,'principal_minor'=>6000,'confirmed_minor'=>3000,'pending_minor'=>1000,'rejected_minor'=>500,'balance_minor'=>3000,'shared_engine_unchanged'=>true,'native_source_preparation_only'=>true,'actual_http_kernel_tested'=>false,'browser_interactions_tested'=>false,'visual_review'=>false,'deployment_capacity_validated'=>false];
file_put_contents($s['root'].'/regression.json',json_encode($report,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['tests'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";