Files
compras/tests/Integration/SgfInvoiceDispatchesTest.php
nala 976271f948
tests / ci (push) Canceled after 0s
commit melhorias1
2026-11-10 12:58:19 +02:00

219 lines
19 KiB
PHP

<?php
namespace Tests\Integration;
use App\Models\User;
use App\Services\{SgfInvoiceDispatches,InvoiceWorkflow};
use Illuminate\Support\Facades\{DB,Schema,Http};
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Client\Request as Outbound;
/** Only this schema and in-memory SQLite. Never RefreshDatabase or a pilot connection. */
final class SgfInvoiceDispatchesTest extends \Tests\TestCase
{
private User $actor;
private SgfInvoiceDispatches $service;
private array $remote=[];
private bool $lose=false;
private string $privateRoot;
public function createApplication()
{
$repo=dirname(__DIR__,2);$this->privateRoot='C:/laragon/data/sgf-tempo/run/sgc-dispatch-tests-'.bin2hex(random_bytes(6));
foreach(['logs','framework/views','framework/sessions','framework/cache/data']as$d)mkdir($this->privateRoot.'/storage/'.$d,0700,true);
foreach(['DB_CONNECTION'=>'sqlite','DB_DATABASE'=>':memory:','DB_URL'=>'','APP_ENV'=>'testing',
'APP_CONFIG_CACHE'=>$this->privateRoot.'/config.php','APP_SERVICES_CACHE'=>$this->privateRoot.'/services.php','APP_PACKAGES_CACHE'=>$this->privateRoot.'/packages.php',
'APP_ROUTES_CACHE'=>$this->privateRoot.'/routes.php','SESSION_DRIVER'=>'array','CACHE_STORE'=>'array']as$k=>$v){putenv($k.'='.$v);$_ENV[$k]=$v;}
$app=require $repo.'/bootstrap/app.php';$app->addAbsoluteCachePathPrefix('C:');$app->useStoragePath($this->privateRoot.'/storage');
$this->traitsUsedByTest=class_uses_recursive(static::class);$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();return $app;
}
protected function setUp():void
{
parent::setUp();
self::assertSame('sqlite',DB::getDriverName());self::assertSame(':memory:',DB::connection()->getDatabaseName());
config(['app.key'=>'base64:'.base64_encode(str_repeat('x',32)),'sgf_invoice.workspace_enabled'=>true,'sgf_invoice.enabled'=>true]);
Schema::create('tenants',fn(Blueprint $t)=>[$t->id(),$t->string('name'),$t->json('work_context_policy')->nullable()]);
Schema::create('users',function(Blueprint $t){$t->id();$t->unsignedBigInteger('tenant_id');$t->string('purchasing_role');$t->string('name');$t->string('email');$t->string('password');$t->timestamp('email_verified_at')->nullable();$t->rememberToken();$t->timestamps();});
Schema::create('suppliers',fn(Blueprint $t)=>[$t->id(),$t->unsignedBigInteger('tenant_id'),$t->string('name')]);
Schema::create('invoices',fn(Blueprint $t)=>[$t->id(),$t->unsignedBigInteger('tenant_id'),$t->unsignedBigInteger('supplier_id'),$t->string('number')]);
DB::table('tenants')->insert([['id'=>1,'name'=>'QA source'],['id'=>2,'name'=>'Foreign QA']]);
foreach([1=>[1,'finance'],2=>[2,'finance'],3=>[1,'warehouse'],4=>[1,'finance']]as$id=>[$tenant,$role])DB::table('users')->insert(['id'=>$id,'tenant_id'=>$tenant,'purchasing_role'=>$role,'name'=>'QA '.$id,'email'=>'qa'.$id.'@example.test','password'=>'unused','email_verified_at'=>now(),'created_at'=>now(),'updated_at'=>now()]);
DB::table('suppliers')->insert([['id'=>1,'tenant_id'=>1,'name'=>'QA Supplier'],['id'=>2,'tenant_id'=>2,'name'=>'Foreign']]);
$migration=require dirname(__DIR__,2).'/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php';$migration->up();
$this->actor=User::findOrFail(1);$this->actingAs($this->actor);$this->service=app(SgfInvoiceDispatches::class);
$package='C:/laragon/www/sgf-tempo/packages/sgc-invoice-client/src/InvoiceClient.php';
config(['sgf_invoice.client_package'=>$package,'sgf_invoice.client_package_sha256'=>hash_file('sha256',$package),
'sgf_invoice.connections'=>['qa'=>['enabled'=>true,'source_scope'=>['tenant_id'=>1],'target_scope'=>['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],
'client_id'=>'qa','token'=>str_repeat('q',64),'context_version'=>str_repeat('a',64),'origin'=>'http://127.0.0.1:8085','mappings'=>['map'=>[
'approved'=>true,'label'=>'Escolha QA','source_supplier_id'=>1,'mapping'=>['id'=>'qa-map','version'=>1,'source_scope'=>['tenant_id'=>1],
'target_scope'=>['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],'authority'=>'sgf_only','order_id'=>7,'lines'=>['QA-LINE'=>8]]]]]]]);
Http::preventStrayRequests();Http::fake(function(Outbound $r){
if($r->method()==='GET')return Http::response(['order'=>['id'=>7],'revision'=>str_repeat('b',64)]);
$key=$r->header('X-Idempotency-Key')[0];
if($r['phase']==='preview')return Http::response(['preview'=>'QA-encrypted-'.$key,'impact'=>['operation'=>'Registar pendente','budget_consumption_minor'=>0,'payable_created'=>false]]);
$replay=isset($this->remote[$key]);$this->remote[$key]??=40+count($this->remote);
if($this->lose){$this->lose=false;throw new \Illuminate\Http\Client\ConnectionException('Synthetic lost response');}
return Http::response(['invoice_id'=>$this->remote[$key],'order_id'=>7,'status'=>'pending','replayed'=>$replay],$replay?200:201);
});
}
private function data(string $ref='QA-SOURCE-1'):array
{
return ['reference'=>$ref,'connection_id'=>'qa','mapping_id'=>'map','reason'=>'Conferência documental exclusivamente sintética.','document'=>[
'number'=>$ref,'issued_on'=>'2026-11-10','subtotal'=>'100.00','tax'=>'0.00','total'=>'100.00','items'=>[
['source_line_ref'=>'QA-LINE','quantity'=>'1.000','unit'=>'UN','unit_price'=>'100.00','tax_rate'=>'0.00']]]];
}
private function prepared():object
{
$id=$this->service->create($this->actor,$this->data());return $this->service->dispatch($this->actor,$id,1,'preview','Conferir impacto documental actual.',null);
}
private function approval(object $row):string{return $this->service->presentation($this->actor,$row)['approval_hash'];}
public function test_full_durable_cycle_and_audited_decision():void
{
$row=$this->prepared();self::assertSame('previewed',$row->phase);self::assertSame(3,$row->version);
$approved=$this->approval($row);self::assertIsString($approved);self::assertStringNotContainsString('QA-encrypted',$row->state_ciphertext);
$done=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Aceitar individualmente este impacto.',$approved);
self::assertSame('received_pending',$done->phase);self::assertNotNull($done->receipt);self::assertCount(1,$this->remote);
self::assertSame(0,DB::table('invoices')->count());
$events=DB::table('sgf_invoice_dispatch_events')->orderBy('id')->get();self::assertCount(5,$events);
self::assertSame('decision.confirmed',$events[3]->action);self::assertSame(1,$events[3]->actor_id);self::assertNotNull($events[3]->before_state);
self::assertSame($approved,json_decode($events[3]->after_state,true)['approval_hash']);
}
public function test_lost_response_reuses_same_key_cut_and_durable_intent():void
{
$row=$this->prepared();$this->lose=true;$unknown=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Aceitar e conservar decisão sintética.',$this->approval($row));
self::assertSame('uncertain',$unknown->phase);$key=$unknown->operation_key;
$recovered=$this->service->dispatch($this->actor,$row->id,$unknown->version,'recover','Recuperar a mesma intenção sem duplicação.',null);
self::assertSame('received_pending',$recovered->phase);self::assertSame($key,$recovered->operation_key);self::assertCount(1,$this->remote);
self::assertTrue(json_decode($recovered->receipt,true)['replayed']);
}
public function test_failure_after_remote_commit_keeps_durable_sending_for_recovery():void
{
$row=$this->prepared();DB::unprepared("CREATE TRIGGER fail_observation BEFORE INSERT ON sgf_invoice_dispatch_events WHEN NEW.action='transport.observed' BEGIN SELECT RAISE(ABORT,'Synthetic persistence failure'); END");
try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Confirmar decisão antes do transporte.',$this->approval($row));self::fail('Expected storage failure');}catch(\Illuminate\Database\QueryException){}
$pending=$this->service->row($this->actor,$row->id);self::assertSame('sending',$pending->phase);self::assertTrue((bool)$pending->ever_sending);self::assertCount(1,$this->remote);
DB::unprepared('DROP TRIGGER fail_observation');$this->travel(121)->seconds();
$done=$this->service->dispatch($this->actor,$row->id,$pending->version,'recover','Recuperar após libertação do lease.',null);
self::assertSame('received_pending',$done->phase);self::assertCount(1,$this->remote);
}
public function test_revision_preserves_before_after_and_invalidates_preview():void
{
$row=$this->prepared();$data=$this->data();$data['document']['subtotal']='120.00';$data['document']['total']='120.00';$data['document']['items'][0]['unit_price']='120.00';
$new=$this->service->revise($this->actor,$row->id,$row->version,$data);self::assertSame('draft',$new->phase);self::assertNull($new->operation_key);self::assertNull($new->state_ciphertext);
self::assertSame(2,$new->source_version);$e=DB::table('sgf_invoice_dispatch_events')->orderByDesc('id')->first();
self::assertSame('100.00',json_decode($e->before_state,true)['document']['total']);self::assertSame('120.00',json_decode($e->after_state,true)['document']['total']);
}
public function test_no_edit_after_any_confirmation_started():void
{
$row=$this->prepared();$this->lose=true;$row=$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Confirmar individualmente o documento.',$this->approval($row));
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->revise($this->actor,$row->id,$row->version,$this->data());
}
public function test_local_registration_is_blocked_even_when_feature_is_disabled():void
{
$this->service->create($this->actor,$this->data());config(['sgf_invoice.workspace_enabled'=>false]);
$this->expectException(\Illuminate\Validation\ValidationException::class);SgfInvoiceDispatches::assertLocalRegistration(1,1,'QA-SOURCE-1');
}
public function test_existing_local_invoice_cannot_be_sent():void
{
DB::table('invoices')->insert(['tenant_id'=>1,'supplier_id'=>1,'number'=>'QA-SOURCE-1']);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->create($this->actor,$this->data());
}
public function test_duplicate_source_cannot_get_second_intent():void
{
$this->service->create($this->actor,$this->data());$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);$this->service->create($this->actor,$this->data());
}
public function test_stale_version_and_wrong_approval_do_not_send():void
{
$row=$this->prepared();foreach([1=>str_repeat('0',64),$row->version=>str_repeat('0',64)]as$version=>$approval){
try{$this->service->dispatch($this->actor,$row->id,$version,'confirm','Fundamento de confirmação explícita.',$approval);self::fail('Expected409');}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}}
self::assertCount(0,$this->remote);
}
public function test_operator_context_and_mapping_changes_require_new_conference():void
{
$row=$this->prepared();$approval=$this->approval($row);$this->actingAs(User::find(4));
try{$this->service->dispatch(User::find(4),$row->id,$row->version,'confirm','Outro operador deve conferir novamente.',$approval);self::fail('Expected409');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}
$this->actingAs($this->actor);config(['sgf_invoice.connections.qa.mappings.map.mapping.version'=>2]);
try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Mapeamento mudou; não deve enviar.',$approval);self::fail('Expected409');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}
self::assertCount(0,$this->remote);
}
public function test_http_screen_csrf_and_audited_history_are_private():void
{
$r=$this->post('/facturas/envio-sgf',$this->data());$r->assertRedirect('/facturas/envio-sgf/1');
$this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('QA-SOURCE-1')->assertSee('Histórico auditado')->assertHeader('Cache-Control','no-store, private');
$this->get('/facturas/envio-sgf')->assertOk()->assertSee('Escolher explicitamente')->assertDontSee('Guardar novo rascunho');
$this->get('/facturas/envio-sgf?connection=qa&mapping=map')->assertOk()->assertSee('Guardar novo rascunho');
$this->app->instance('env','local');try{$this->post('/facturas/envio-sgf',$this->data('CSRF-FAIL'))->assertStatus(419);}finally{$this->app->instance('env','testing');}
self::assertSame(1,DB::table('sgf_invoice_dispatches')->count());
}
public function test_foreign_tenant_and_warehouse_actor_cannot_read_or_write():void
{
$id=$this->service->create($this->actor,$this->data());$this->actingAs(User::find(2));$this->get('/facturas/envio-sgf/'.$id)->assertNotFound();
$this->post('/facturas/envio-sgf',$this->data('FOREIGN'))->assertForbidden();
$this->actingAs(User::find(3));$this->get('/facturas/envio-sgf')->assertForbidden();
self::assertSame(1,DB::table('sgf_invoice_dispatches')->count());
}
public function test_pagination_history_and_output_escaping():void
{
for($i=0;$i<27;$i++)$this->service->create($this->actor,$this->data('QA-LIST-'.$i));
$this->get('/facturas/envio-sgf?page=2')->assertOk()->assertSee('QA-LIST-0')->assertDontSee('QA-LIST-26');
$row=$this->service->row($this->actor,1);for($i=0;$i<27;$i++)$row=$this->service->revise($this->actor,1,$row->version,$this->data('QA-LIST-0'));
$this->get('/facturas/envio-sgf/1?events_page=2')->assertOk()->assertSee('draft.created');
$data=$this->data('XSS');$data['document']['number']='<script>alert(1)</script>';$id=$this->service->create($this->actor,$data);
$this->get('/facturas/envio-sgf/'.$id)->assertOk()->assertDontSee('<script>alert(1)</script>',false)->assertSee('&lt;SCRIPT&gt;',false);
}
public function test_events_and_drafts_cannot_be_deleted_or_rewritten():void
{
$id=$this->service->create($this->actor,$this->data());
foreach([fn()=>DB::table('sgf_invoice_dispatch_events')->update(['action'=>'tampered']),fn()=>DB::table('sgf_invoice_dispatch_events')->delete(),fn()=>DB::table('sgf_invoice_dispatches')->delete()]as$mutate){
try{$mutate();self::fail('Expected immutable audit');}catch(\Illuminate\Database\QueryException){self::assertSame(1,DB::table('sgf_invoice_dispatch_events')->count());}}
}
public function test_disabled_transport_keeps_draft_and_disabled_workspace_refuses():void
{
$id=$this->service->create($this->actor,$this->data());config(['sgf_invoice.enabled'=>false]);
$this->post('/facturas/envio-sgf/'.$id.'/operar',['version'=>1,'action'=>'preview','reason'=>'Não activar o transporte nesta entrega.'])->assertStatus(423);
self::assertNull($this->service->row($this->actor,$id)->operation_key);Http::assertNothingSent();
config(['sgf_invoice.workspace_enabled'=>false]);$this->get('/facturas/envio-sgf')->assertNotFound();
}
public function test_changed_remote_context_refuses_decision_before_recording_it():void
{
$row=$this->prepared();$hash=$this->approval($row);$events=DB::table('sgf_invoice_dispatch_events')->count();
config(['sgf_invoice.connections.qa.context_version'=>str_repeat('c',64)]);
try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Contexto remoto mudou; não confirmar.',$hash);self::fail('Expected409');}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}
self::assertSame($events,DB::table('sgf_invoice_dispatch_events')->count());self::assertCount(0,$this->remote);
$new=$this->service->dispatch($this->actor,$row->id,$row->version,'preview','Conferir novamente o contexto actual.',null);
self::assertSame('previewed',$new->phase);self::assertSame($row->operation_key,$new->operation_key);self::assertNotSame($hash,$this->approval($new));
}
public function test_revoked_operator_cannot_confirm_and_live_lease_prevents_duplicate_send():void
{
$row=$this->prepared();$hash=$this->approval($row);
DB::table('users')->where('id',1)->update(['purchasing_role'=>'warehouse']);
try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Não enviar com autoridade revogada.',$hash);self::fail('Expected403');}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(403,$e->getStatusCode());}
DB::table('users')->where('id',1)->update(['purchasing_role'=>'finance']);
DB::table('sgf_invoice_dispatches')->where('id',$row->id)->update(['lease_until'=>now('UTC')->addSeconds(120)]);
try{$this->service->dispatch($this->actor,$row->id,$row->version,'confirm','Uma operação em curso não duplica.',$hash);self::fail('Expected409');}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){self::assertSame(409,$e->getStatusCode());}
self::assertCount(0,$this->remote);
}
public function test_actual_screen_forms_complete_registration_and_keep_exact_sgf_handoff():void
{
$this->post('/facturas/envio-sgf',$this->data())->assertRedirect('/facturas/envio-sgf/1');
$this->post('/facturas/envio-sgf/1/operar',['version'=>1,'action'=>'preview','reason'=>'Conferência individual pela interface.'])->assertRedirect('/facturas/envio-sgf/1');
$html=$this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('Impacto devolvido pelo SGF')->assertSee('Confirmar individualmente')->assertDontSee('QA-encrypted',false)->getContent();
preg_match('/name="approval_hash" value="([a-f0-9]{64})"/',$html,$match);self::assertNotEmpty($match);
file_put_contents($this->privateRoot.'/screen-preview.html',$html);
$this->post('/facturas/envio-sgf/1/operar',['version'=>3,'action'=>'confirm','reason'=>'Confirmar apenas este impacto mostrado.','approval_hash'=>$match[1]])->assertRedirect('/facturas/envio-sgf/1');
$done=$this->get('/facturas/envio-sgf/1')->assertOk()->assertSee('Recibo conservado')->assertDontSee('Confirmar individualmente')->assertSee('http://127.0.0.1:8085/compras?',false)->getContent();
file_put_contents($this->privateRoot.'/screen-receipt.html',$done);self::assertCount(1,$this->remote);
}
public function test_missing_schema_is_refused_without_activating_or_migrating():void
{
$migration=require dirname(__DIR__,2).'/database/migrations/2026_11_10_000001_create_sgf_invoice_dispatches.php';$migration->down();
$this->get('/facturas/envio-sgf')->assertStatus(503);Http::assertNothingSent();self::assertFalse(Schema::hasTable('sgf_invoice_dispatches'));
}
public function test_audit_freezes_preview_actor_and_context_fingerprint():void
{
$row=$this->prepared();$event=DB::table('sgf_invoice_dispatch_events')->orderByDesc('id')->first();$state=json_decode($event->after_state,true);
self::assertSame(1,$state['preview_actor_id']);self::assertSame($row->source_context_hash,$state['source_context_hash']);
}
}