Files
SGF/scripts/settlement-read-cases.php
2026-11-04 09:37:20 +02:00

75 lines
7.1 KiB
PHP

<?php
if(is_file($s['root'].'/settlement-read-cases.json'))throw new LogicException('Completed read-isolation cases preserved');
$checks=0;$cases=[];$capture=false;$queries=[];$state=[];
$assert=function(bool $value,string $message)use(&$checks):void{$checks++;if(!$value)throw new RuntimeException($message);};
$case=function(string $name,callable $test)use(&$cases,&$checks,$enter):void{
$n=$checks;try{$enter('read');$test();$cases[]=['name'=>$name,'assertions'=>$checks-$n,'passed'=>true];}
finally{while(DB::transactionLevel()>0)DB::rollBack();$enter('read');}echo 'Passed settlement read case: '.$name."\n";
};
DB::listen(function($event)use(&$capture,&$queries,&$state){
if(!$capture)return;$queries[]=$event->sql;
if(!$state&&str_contains($event->sql,'"ps_payments"')){$pdo=$event->connection->getPdo();$state=['isolation'=>$pdo->query('SHOW transaction_isolation')->fetchColumn(),'read_only'=>$pdo->query('SHOW transaction_read_only')->fetchColumn()];}
});
$makeBook=fn()=>$service->engine($enter('read'))->settlements();
$case('standalone-reader-and-old-constructor-contract',function()use($makeBook,$g,$scope,$u,$assert,&$capture,&$queries,&$state):void{
$queries=[];$state=[];$capture=true;$book=$makeBook();$data=$book->listingPage(['payment'=>$g['payment_id']]);$capture=false;
$assert($data['payments'][0]['unallocated_minor']===41,'Full-history position preserved');
$assert($state===['isolation'=>'repeatable read','read_only'=>'on'],'Own reader transaction is coherent and read only');
$assert(!array_filter($queries,fn($sql)=>str_contains(strtolower($sql),'for update')),'Read callback never takes writer locks');
$assert(DB::transactionLevel()===0&&DB::selectOne('SHOW default_transaction_read_only')->default_transaction_read_only==='off','Read mode does not stick to later transactions');
$writer=(new ReflectionProperty($book,'authorize'))->getValue($book);
$legacy=new Tempo\Finance\Payables\SupplierSettlements(DB::connection(),$u->id,'sgf-history-person-'.$u->id,$scope,$writer,fn()=>throw new LogicException('No source callback required by a read'));
$queries=[];$state=[];$capture=true;$old=$legacy->listingPage(['payment'=>$g['payment_id']]);$capture=false;
$assert(json_encode($old['payments'],JSON_THROW_ON_ERROR)===json_encode($data['payments'],JSON_THROW_ON_ERROR),'Old constructor retains identical position');
$assert(count(array_filter($queries,fn($sql)=>str_contains(strtolower($sql),'for update')))===2&&$state['read_only']==='off','Without new callback existing writer authorization is retained');
$reads=0;$writes=0;
$separate=new Tempo\Finance\Payables\SupplierSettlements(DB::connection(),$u->id,'test-only',$scope,
function()use(&$writes){$writes++;abort(403);},fn()=>throw new LogicException('No obligation requested'),function()use(&$reads){$reads++;});
$separate->listingPage(['payment'=>$g['payment_id']]);$refused=false;
try{$separate->command('allocation-decide',['proposal_id'=>$g['pending_proposal_id'],'decision'=>'confirmed','reason'=>'Read callback must not authorize a command.'],(string)Illuminate\Support\Str::uuid());}
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$refused=$e->getStatusCode()===403;}
$assert($reads===1&&$writes===1&&$refused,'Read authorization cannot replace command authority');
});
$case('rights-identity-and-authenticated-user-revalidated',function()use($makeBook,$g,$scope,$f,$enter,$assert):void{
foreach(['right','person','actor'] as $change){
DB::beginTransaction();try{
$book=$makeBook();
if($change==='right')DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['read'],'action'=>'payments_view'])->delete();
if($change==='person')DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['read'],'action'=>'view'])->update(['person'=>'QA_CHANGED_AFTER_ENGINE_CREATION']);
if($change==='actor')$enter('validate');
$status=null;try{$book->listingPage(['payment'=>$g['payment_id']]);}catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$status=$e->getStatusCode();}
$assert($status===($change==='person'?409:403),'Fresh authority refused change '.$change);
}finally{DB::rollBack();$enter('read');}
}
});
$case('existing-page-and-financial-proofs-remain-equivalent',function()use($makeBook,$s,$g,$assert):void{
foreach([25,100] as $size){
$before=json_decode(file_get_contents($s['root'].'/settlement-bench-page'.$size.'.json'),true,128,JSON_THROW_ON_ERROR);
$now=$makeBook()->listingPage(['payment_per_page'=>$size]);
foreach(['payments','proposals','allocations','events'] as $key){
$rows=json_decode(json_encode($now[$key],JSON_THROW_ON_ERROR),true,128,JSON_THROW_ON_ERROR);
foreach($rows as &$row)unset($row['local_source_id']);unset($row);usort($rows,fn($a,$b)=>$b['id']<=>$a['id']);
$assert(hash('sha256',Tempo\Finance\Payables\Payables::canonical($rows))===$before['window_fingerprints'][$key][$size],'Earlier facts equal after read authorization change: '.$key.'/'.$size);
}
}
$before=json_decode(file_get_contents($s['root'].'/settlement-bench-focus.json'),true,128,JSON_THROW_ON_ERROR);
$payments=$makeBook()->listingPage(['payment'=>$g['payment_id']])['payments'];
$assert(hash('sha256',Tempo\Finance\Payables\Payables::canonical(json_decode(json_encode($payments,JSON_THROW_ON_ERROR),true,128,JSON_THROW_ON_ERROR)))===$before['original_payment_fingerprint'],'Full original position identical without repeating old benchmark');
});
$case('read-only-database-guard-and-recovery',function()use($makeBook,$g,$scope,$u,$assert):void{
$armed=true;DB::listen(function($event)use(&$armed,$g,$scope,$u){
if($armed&&str_contains($event->sql,'"ps_payments"')){
$armed=false;
$event->connection->table('ps_events')->insert($scope+['payment_id'=>$g['payment_id'],'actor_id'=>$u->id,'person'=>'QA','kind'=>'qa.write_must_fail','payload'=>'{}','created_at'=>now()]);
}
});
$blocked=false;try{$makeBook()->listingPage(['payment'=>$g['payment_id']]);}catch(Illuminate\Database\QueryException $e){$blocked=($e->errorInfo[0]??null)==='25006';}
$assert($blocked&&DB::transactionLevel()===0,'Database refuses accidental write and reader rolls back');
$assert(!DB::table('ps_events')->where('kind','qa.write_must_fail')->exists(),'No accidental event recorded');
$assert($makeBook()->listingPage(['payment'=>$g['payment_id']])['payments'][0]['unallocated_minor']===41,'Next read recovers with no sticky aborted transaction');
});
$baseline=json_decode(file_get_contents($s['root'].'/settlement-volume.json'),true,64,JSON_THROW_ON_ERROR);
$assert($fingerprint()===$baseline['baseline_fingerprint'],'New read tests changed financial rows');
file_put_contents($s['root'].'/settlement-read-cases.json',json_encode(['passed'=>true,'cases'=>$cases,'assertions'=>$checks,'financial_fingerprint_equal'=>true,'previous_benchmarks_reused'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode(['cases'=>count($cases),'assertions'=>$checks,'passed'=>true])."\n";