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']='';$id=$this->service->create($this->actor,$data); $this->get('/facturas/envio-sgf/'.$id)->assertOk()->assertDontSee('',false)->assertSee('<SCRIPT>',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']); } }