Files
compras/tests/Feature/NativePayablePageTest.php
T
2026-11-03 11:51:44 +02:00

83 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Enums\PurchasingRole;
use App\Models\{Invoice, Tenant, User};
use App\Services\{ExternalPaymentWorkflow, SupplierPayables};
use Illuminate\Support\Facades\{Artisan, DB};
use Illuminate\Support\Str;
use Tempo\Finance\Payables\Payables;
use Tests\TestCase;
require_once __DIR__.'/../Support/PaymentFixture.php';
require_once __DIR__.'/../Support/SupplyFixture.php';
final class NativePayablePageTest extends TestCase
{
private array $f;private User $reader;private string $instance;
protected function setUp():void
{
parent::setUp();$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");$this->assertMatchesRegularExpression('/^compras_receipts_test_[a-f0-9]{12}$/D',$i->db);$this->assertSame('stock_receipt_qa_'.substr($i->db,-12),$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster);
if(!DB::getSchemaBuilder()->hasTable('users'))Artisan::call('migrate',['--force'=>true]);
$this->f=\paymentFixture();$this->instance='native-page-'.$this->f['tenant']->id;config(['common-payables.enabled'=>true,'common-payables.history_enabled'=>true,'common-payables.paged_read_enabled'=>true,'common-payables.source_instance'=>$this->instance]);
$this->reader=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'purchasing_role'=>PurchasingRole::Finance]);
foreach([[$this->f['author'],['view','prepare','validate']],[$this->f['validator'],['view','prepare','validate']],[$this->reader,['view']]] as [$u,$actions])foreach($actions as $a)DB::table('pa_access')->insert(['tenant_id'=>$u->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$u->id,'person'=>'native-page-person-'.$u->id,'action'=>$a]);
foreach([['30.00','confirmed'],['10.00','pending'],['5.00','rejected']] as [$amount,$status]){$this->actingAs($this->f['author']);$p=app(ExternalPaymentWorkflow::class)->register($this->f['author'],$this->f['invoice'],[...$this->f['payment_data'],'amount'=>$amount,'external_reference'=>'NATIVE-PAGE-'.$status]);if($status!=='pending'){$this->actingAs($this->f['validator']);app(ExternalPaymentWorkflow::class)->review($this->f['validator'],$p,$status,'Synthetic independent original payment review.');}}
$this->actingAs($this->f['author']);$p=app(SupplierPayables::class)->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic original history proposal.',(string)Str::uuid());$this->actingAs($this->f['validator']);app(SupplierPayables::class)->engine($this->f['validator'])->command('validate_history',$p['proposal_id'],'Synthetic distinct original history review.',(string)Str::uuid());$this->actingAs($this->reader);
}
private function service():SupplierPayables{return app(SupplierPayables::class);}
private function clones(int $count):void
{
$row=DB::table('invoices')->where('id',$this->f['invoice']->id)->first();$line=(array)DB::table('invoice_items')->where('invoice_id',$row->id)->first();unset($line['id']);$audit=(array)DB::table('audit_logs')->where('tenant_id',$row->tenant_id)->where('action','invoice.validated')->where('after->invoice_id',$row->id)->first();unset($audit['id']);
for($n=1;$n<=$count;$n++){$copy=(array)$row;unset($copy['id']);$copy['number']='PAGE-'.str_pad((string)$n,5,'0',STR_PAD_LEFT);$snapshot=json_decode($copy['snapshot'],true);$snapshot['supplier']['name']=$n%2===0?'Page Alpha':'Page Beta';$copy['snapshot']=json_encode($snapshot,JSON_THROW_ON_ERROR);$id=DB::table('invoices')->insertGetId($copy);DB::table('invoice_items')->insert(array_replace($line,['invoice_id'=>$id]));$after=json_decode($audit['after'],true);$after['invoice_id']=$id;DB::table('audit_logs')->insert(array_replace($audit,['after'=>json_encode($after,JSON_THROW_ON_ERROR)]));}
}
private function positions(array $data):string{$rows=$data['obligations'];usort($rows,fn($a,$b)=>$a['id']<=>$b['id']);return Payables::canonical($rows);}
private function duplicateRegistration():void
{
$p=DB::table('external_payments')->where('tenant_id',$this->f['tenant']->id)->where('invoice_id',$this->f['invoice']->id)->where('status','confirmed')->first();$row=(array)DB::table('audit_logs')->where('tenant_id',$p->tenant_id)->where('action','payment.registered')->where('after->payment_id',$p->id)->first();unset($row['id']);DB::table('audit_logs')->insert($row);
}
public function test_native_page_matches_live_documents_and_existing_financial_positions():void
{
$old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$new=$this->service()->listing($this->reader);$this->assertSame(Payables::canonical($old['documents']),Payables::canonical($new['documents']));$this->assertSame($this->positions($old),$this->positions($new));$o=$new['obligations'][0];$this->assertSame(6000,$o['principal_minor']);$this->assertSame(3000,$o['balance_minor']);$this->assertSame(1000,$o['pending_payments_minor']);$this->assertSame(500,$o['rejected_payments_minor']);$this->assertNull($o['due_on']);$this->assertFalse($new['permissions']['prepare']);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',1)->assertJsonPath('obligations.0.balance_minor',3000);
}
public function test_native_invoice_filter_survives_document_and_evidence_pagination():void
{
$this->clones(50);$a=$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',51)->assertJsonCount(25,'documents')->json('documents');$b=$this->getJson('/api/supplier-payables?page=2')->assertOk()->assertJsonCount(25,'documents')->json('documents');$this->assertEmpty(array_intersect(array_column($a,'local_id'),array_column($b,'local_id')));$this->assertGreaterThan($b[0]['local_id'],$a[24]['local_id']);
$id=$this->f['invoice']->id;$this->getJson('/api/supplier-payables?invoice='.$id)->assertOk()->assertJsonPath('documents.0.local_id',$id)->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonPath('listing_query.invoice',$id);$this->getJson('/api/supplier-payables?recognition=recognised')->assertOk()->assertJsonPath('pagination.documents.total',1);$this->getJson('/api/supplier-payables?recognition=unrecognised&per_page=100')->assertOk()->assertJsonPath('pagination.documents.total',50)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?q=Page%20Alpha')->assertOk()->assertJsonPath('pagination.documents.total',25);$this->getJson('/api/supplier-payables?q=%25')->assertOk()->assertJsonPath('pagination.documents.total',0);
$page=$this->get('/obrigacoes-fornecedores?invoice='.$id)->assertOk()->getContent();$this->assertTrue(str_contains($page,'action="/obrigacoes-fornecedores"'),'Native page action retained');$this->assertTrue(str_contains($page,'name="invoice"'),'Invoice filter retained in search form');$this->assertTrue(str_contains($page,'value="'.$id.'"'),'Original invoice selection retained');
}
public function test_native_page_batches_sources_and_payment_audits_without_writer_locks():void
{
$this->clones(50);$queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture)$queries[]=$q->sql;});$this->service()->listing($this->reader);$capture=false;$small=count($queries);$this->assertEmpty(array_filter($queries,fn($q)=>str_contains(strtolower($q),'for update')));$queries=[];$capture=true;$data=$this->service()->listing($this->reader,null,['per_page'=>100]);$capture=false;$this->assertCount(51,$data['documents']);$this->assertSame($small+2,count($queries),'Only two nonempty proposal/event data queries are added when original recognised invoice enters the window');$this->assertCount(2,array_filter($queries,fn($q)=>str_contains($q,'from "audit_logs"')));$this->assertCount(1,array_filter($queries,fn($q)=>str_contains($q,'from "external_payments"')));
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-query.json',json_encode(['documents'=>51,'page_sizes'=>[25,100],'page25_queries'=>$small,'page100_queries'=>count($queries),'extra_queries_reason'=>'Proposal/event data SELECTs skipped by paginator for empty evidence windows.','audit_selects'=>2,'payment_selects'=>1,'no_writer_lock'=>true],JSON_PRETTY_PRINT));
}
public function test_native_page_refuses_tenant_leaks_revoked_rights_and_stale_role():void
{
DB::table('pa_access')->where('user_id',$this->reader->id)->delete();$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('pa_access')->insert(['tenant_id'=>$this->reader->tenant_id,'company_id'=>0,'unit_id'=>0,'user_id'=>$this->reader->id,'person'=>'native-page-person-'.$this->reader->id,'action'=>'view']);DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'requester']);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('users')->where('id',$this->reader->id)->update(['purchasing_role'=>'finance']);
$tenant=Tenant::query()->create(['name'=>'Foreign native page tenant']);$other=User::factory()->create(['tenant_id'=>$tenant->id,'purchasing_role'=>PurchasingRole::Finance]);DB::table('pa_access')->insert(['tenant_id'=>$tenant->id,'company_id'=>0,'unit_id'=>0,'user_id'=>$other->id,'person'=>'foreign-native-page','action'=>'view']);$this->actingAs($other);$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('pagination.documents.total',0)->assertJsonCount(0,'obligations');$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertNotFound();
}
public function test_native_audit_changes_require_new_conference_without_using_read_snapshots():void
{
$this->assertSame(3000,$this->service()->listing($this->reader)['obligations'][0]['balance_minor']);$this->duplicateRegistration();$new=$this->service()->listing($this->reader);$old=$this->service()->engine($this->reader)->listing([$this->f['invoice']->id]);$this->assertSame($this->positions($old),$this->positions($new));$this->assertNull($new['obligations'][0]['balance_minor']);$this->assertTrue($new['obligations'][0]['source_conflict']);$this->actingAs($this->f['author']);$r=$this->service()->engine($this->f['author'])->command('prepare_history',$this->f['invoice']->id,'Synthetic new audit after read.',(string)Str::uuid());$this->assertSame('conflict',$r['state']);$this->assertSame(1,DB::table('pa_obligations')->where('tenant_id',$this->f['tenant']->id)->count());
}
public function test_native_feature_disabled_preserves_legacy_and_invalid_filters_are_refused():void
{
foreach(['page=0','per_page=1000','tenant_id=999','recognition=paid','q%5Ba%5D=x'] as $q)$this->getJson('/api/supplier-payables?'.$q)->assertStatus(422);config(['common-payables.paged_read_enabled'=>false]);$this->getJson('/api/supplier-payables?page=1')->assertStatus(503);$this->getJson('/api/supplier-payables?invoice='.$this->f['invoice']->id)->assertOk()->assertJsonPath('obligations.0.balance_minor',3000)->assertJsonMissingPath('pagination');
}
public function test_native_profiles_expiry_maintenance_and_people_context_do_not_grant_financial_read():void
{
\supplyGrant($this->reader,['finance']);DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>now()->subMinute()]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['expires_at'=>null,'maintenance'=>true]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->reader->id)->update(['maintenance'=>false]);
$tenant=$this->f['tenant']->id;$this->f['tenant']->update(['rules_provisional'=>false,'work_context_policy'=>['revision'=>1,'enabled'=>['procurement.internal','people.internal']]]);config(['work_context.entitlements.'.$tenant=>['procurement.internal','people.internal']]);DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['people.internal'])]);$this->getJson('/api/supplier-payables')->assertForbidden();DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$this->getJson('/api/supplier-payables')->assertOk()->assertJsonPath('obligations.0.balance_minor',3000);
}
public function test_native_read_finishes_before_writer_release_and_sees_committed_audits_only():void
{
$dir=base_path('../private/native-page-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$process=null;DB::beginTransaction();
try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->firstOrFail();$this->duplicateRegistration();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$deadline=microtime(true)+8;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);$this->assertFalse($status['running'],'Read must not wait for writer tenant lock');$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(200,$r['status']);$this->assertSame([3000],$r['balances']);$this->assertSame(1,DB::transactionLevel());file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/native-page-concurrency.json',json_encode(['reader_completed_before_writer_release'=>true,'uncommitted_audit_not_observed'=>true,'balance_minor'=>3000,'reader_time_ms'=>$r['time_ms']],JSON_PRETTY_PRINT));}
finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
}
public function test_native_missing_package_capability_is_503_without_legacy_fallback():void
{
$dir=base_path('../private/native-page-package-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';file_put_contents($input,json_encode(['actor'=>$this->reader->id,'instance'=>$this->instance],JSON_THROW_ON_ERROR));$pipes=[];$environment=getenv();$environment['COMMON_PAYABLES_PATH']='C:/laragon/www/compras/packages/payables';$p=proc_open([PHP_BINARY,base_path('tests/Support/native-payable-page-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path(),$environment);$this->assertIsResource($p);fclose($pipes[0]);$this->assertSame(0,proc_close($p));$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(503,$r['status']);
}
}