Files
compras/tests/Feature/CommonReceiptStockTest.php
2026-11-02 20:27:11 +02:00

72 lines
7.8 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\{GoodsReceipt,GoodsReceiptItem,StockMovement};
use App\Services\{ConflictGuard,ReceiptStockMovement,ReceiptWorkflow};
use Illuminate\Support\Facades\{Artisan,DB};
use Tests\TestCase;
require_once __DIR__.'/../Support/ReceiptFixture.php';
final class CommonReceiptStockTest extends TestCase
{
public function test_native_receipt_shared_writer_and_single_book_evidence(): void
{
$i=DB::selectOne("SELECT current_database() AS name,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->name);$tag=substr($i->name,-12);
$this->assertSame('stock_receipt_qa_'.$tag,$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->assertSame(0,DB::table('users')->count(),'Use the fresh QA database, never a retained fixture');
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);
$f=\receiptFixture('10.000');$this->actingAs($f['receiver']);$url='/ordens/'.$f['order']->id.'/recepcoes';
$body=\receiptPayload($f,'6.000');$body['items'][0]['accepted_quantity']='4.000';$body['items'][0]['quality_status']='reservations';$body['items'][0]['notes']='Synthetic acceptance: four accepted, two damaged.';
$receipt=$this->postJson($url,$body)->assertCreated()->json('id');
$this->postJson($url,$body)->assertOk()->assertJsonPath('id',$receipt)->assertJsonPath('created',false);
$line=GoodsReceiptItem::query()->where('goods_receipt_id',$receipt)->firstOrFail();$movement=StockMovement::query()->where('goods_receipt_item_id',$line->id)->firstOrFail();
$this->assertSame(4000,(int)$movement->quantity_milli);$this->assertSame(2000,(int)$line->rejected_milli);
$this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/D',$movement->snapshot['inventory_source_hash']);
$coreReplay=DB::transaction(function () use ($f,$line,$movement){
app(ConflictGuard::class)->lockCompany($f['receiver']);$w=app(ReceiptStockMovement::class)->for($f['receiver']);
return $w->write($line->id,$movement->snapshot['inventory_source_hash']);
});$this->assertTrue($coreReplay['replayed']);$this->assertSame($movement->id,$coreReplay['movement_id']);
try{DB::transaction(function()use($f,$line){app(ConflictGuard::class)->lockCompany($f['receiver']);app(ReceiptStockMovement::class)->for($f['receiver'])->write($line->id,str_repeat('0',64));});$this->fail('Old origin hash must be refused');}
catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());}
$bad=$body;$bad['items'][0]['accepted_quantity']='3.000';$this->postJson($url,$bad)->assertUnprocessable();
$rejected=\receiptPayload($f,'2.000');$rejected['warehouse_id']=null;$rejected['items'][0]['stock_item_id']=null;$rejected['items'][0]['accepted_quantity']='0';$rejected['items'][0]['quality_status']='nonconforming';$rejected['items'][0]['notes']='Synthetic rejection.';
$this->postJson($url,$rejected)->assertCreated()->assertJsonPath('status','rejected');
$this->assertSame(1,StockMovement::query()->count());
$this->actingAs($f['issuer'])->postJson($url,\receiptPayload($f,'1.000'))->assertForbidden();$this->actingAs($f['receiver']);
// Fault after the shared SQL insert must undo receipt, movement and native audit.
$failure=\receiptFixture();$this->actingAs($failure['receiver']);$payload=\receiptPayload($failure,'1.000');$fault=true;
$before=array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']);
DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*stock_movements/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic failure after inventory insert');}});
try{app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);$this->fail('Injected failure missing');}
catch(\RuntimeException $e){$this->assertSame('Synthetic failure after inventory insert',$e->getMessage());}
$this->assertSame($before,array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']));
$recovered=app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);
$this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$recovered->items->pluck('id'))->count());
// Two real PHP processes contend on the company lock, then recover the same receipt.
$concurrent=\receiptFixture();$this->actingAs($concurrent['receiver']);$payload=\receiptPayload($concurrent,'1.000');$process=null;
DB::beginTransaction();
try{
$winner=app(ReceiptWorkflow::class)->confirm($concurrent['receiver'],$concurrent['order'],$payload);
$input=$root.'/worker.json';$application='stock_receipt_worker_'.$tag;
file_put_contents($input,json_encode(['actor'=>$concurrent['receiver']->id,'order'=>$concurrent['order']->id,'payload'=>$payload,'application'=>$application],JSON_THROW_ON_ERROR));
$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/common-receipt-worker.php'),$input],[0=>['pipe','r'],1=>['file',$root.'/worker.stdout','w'],2=>['file',$root.'/worker.stderr','w']],$pipes,base_path());
$this->assertIsResource($process);fclose($pipes[0]);$deadline=microtime(true)+10;$wait=null;$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT wait_event,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);
$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit();
$deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline);
$this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit);
$second=json_decode(file_get_contents($input.'.result'),true,512,JSON_THROW_ON_ERROR);$this->assertSame(200,$second['status']);$this->assertSame($winner->id,$second['receipt_id']);
$this->assertSame(1,GoodsReceipt::query()->where('purchase_order_id',$concurrent['order']->id)->count());
$this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$winner->items->pluck('id'))->count());
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
file_put_contents($root.'/receipt-results.json',json_encode(['identity'=>$i,'physical_accepted_milli'=>4000,'rejected_milli'=>2000,'movement_count_main'=>1,
'source_version_stale_refused'=>true,'core_replay_same_movement'=>true,'fault_atomic_and_recovered'=>true,'concurrency'=>['actual_lock_wait'=>true,'wait_event'=>$wait->wait_event,'same_receipt'=>true,'one_movement'=>true],
'link'=>['tenant'=>(int)$f['tenant']->id,'warehouse'=>(int)$f['warehouse']->id,'item'=>(int)$f['stock']->id,'movement'=>(int)$movement->id,'reference'=>$movement->snapshot['reference']],
'writer_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-write/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
}