Files
SGF/tests/Unit/SgcInvoiceClientTest.php
2026-11-11 09:00:17 +02:00

134 lines
9.0 KiB
PHP

<?php
namespace Tests\Unit;
require_once dirname(__DIR__,2).'/packages/sgc-invoice-client/src/InvoiceClient.php';
use PHPUnit\Framework\TestCase;
use Tempo\SgcInvoiceClient\InvoiceClient;
final class SgcInvoiceClientTest extends TestCase
{
private InvoiceClient $client;
private array $calls=[];
private array $queue=[];
private array $mapping;
private array $draft;
private const KEY='1eeae432-841d-4d8a-aed7-a627fa39fc19';
protected function setUp():void
{
$this->mapping=['id'=>'mapping-qa','version'=>1,'source_scope'=>['tenant_id'=>91],
'target_scope'=>['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],'order_id'=>3,'lines'=>['SOURCE-LINE'=>4],'authority'=>'sgf_only'];
$this->draft=['reference'=>'DRAFT-QA-1','version'=>'source-v1','state'=>'unregistered_draft','document'=>[
'number'=>'QA-CLIENT-1','issued_on'=>'2026-11-10','subtotal'=>'100.00','tax'=>'0.00','total'=>'100.00',
'items'=>[['source_line_ref'=>'SOURCE-LINE','quantity'=>'1.000','unit'=>'UN','unit_price'=>'100.00','tax_rate'=>'0.00']]]];
$this->client=$this->make();
}
private function make(string $context='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'):InvoiceClient
{
return new InvoiceClient('synthetic',str_repeat('t',64),$context,['tenant_id'=>91],['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],
function($method,$url,$headers,$body){$this->calls[]=compact('method','url','headers','body');$next=array_shift($this->queue);if($next instanceof \Throwable)throw $next;if(!is_array($next))throw new \LogicException('No fake response');return $next;});
}
private function intent():array{return $this->client->intent($this->mapping,$this->draft,self::KEY,'Conferência individual exclusivamente sintética.');}
private function prepared():array
{
$this->queue[]=['status'=>200,'json'=>['order'=>['id'=>3],'revision'=>str_repeat('b',64)]];
$this->queue[]=['status'=>200,'json'=>['preview'=>'encrypted-test-cut','impact'=>['operation'=>'Registar pendente','payable_created'=>false]]];
return $this->client->preview($this->intent(),'source-v1',InvoiceClient::mappingHash($this->mapping));
}
private function receipt(int $status=201):array{return ['status'=>$status,'json'=>['invoice_id'=>8,'order_id'=>3,'status'=>'pending','replayed'=>$status===200]];}
public function test_explicit_mapping_builds_intent_without_network_or_token_persistence():void
{
$intent=$this->intent();self::assertCount(0,$this->calls);self::assertSame(4,$intent['data']['items'][0]['purchase_order_item_id']);
self::assertArrayNotHasKey('source_line_ref',$intent['data']['items'][0]);self::assertStringNotContainsString(str_repeat('t',64),json_encode($intent));
self::assertStringNotContainsString(str_repeat('t',64),print_r($this->client,true));
}
public function test_registered_sgc_invoice_cannot_become_an_outgoing_draft():void
{
$this->draft['state']='pending';$this->expectException(\InvalidArgumentException::class);$this->intent();
}
public function test_missing_mapping_does_not_guess_a_line():void
{
$this->mapping['lines']=[];$this->expectException(\InvalidArgumentException::class);$this->intent();
}
public function test_foreign_target_scope_is_refused_before_transport():void
{
$this->mapping['target_scope']['company_id']=999;$this->expectException(\InvalidArgumentException::class);$this->intent();
}
public function test_mapping_version_change_requires_new_conference():void
{
$intent=$this->intent();$this->mapping['version']=2;$this->expectException(\LogicException::class);
$this->client->preview($intent,'source-v1',InvoiceClient::mappingHash($this->mapping));
}
public function test_preview_reads_exact_context_then_previews_without_confirmation():void
{
$p=$this->prepared();self::assertSame('previewed',$p['phase']);self::assertCount(2,$this->calls);
self::assertSame('GET',$this->calls[0]['method']);self::assertSame('http://127.0.0.1:8085/api/v1/integration/sgc/invoices/orders/3',$this->calls[0]['url']);
self::assertSame('preview',$this->calls[1]['body']['phase']);self::assertSame(self::KEY,$this->calls[1]['headers']['X-Idempotency-Key']);
self::assertSame(str_repeat('b',64),$this->calls[1]['body']['revision']);
}
public function test_individual_approval_confirms_once_and_does_not_claim_validation():void
{
$p=$this->prepared();$this->queue[]=$this->receipt();$r=$this->client->confirm($p,$p['approval_hash'],'source-v1',InvoiceClient::mappingHash($this->mapping));
self::assertSame('received_pending',$r['phase']);self::assertSame('pending',$r['receipt']['status']);self::assertCount(3,$this->calls);
self::assertSame(['phase'=>'confirm','order_id'=>3,'preview'=>'encrypted-test-cut'],$this->calls[2]['body']);
}
public function test_unapproved_impact_never_reaches_confirmation_transport():void
{
$p=$this->prepared();try{$this->client->confirm($p,str_repeat('0',64),'source-v1',InvoiceClient::mappingHash($this->mapping));self::fail('Expected refusal');}
catch(\LogicException){self::assertCount(2,$this->calls);}
}
public function test_changed_source_version_blocks_confirmation():void
{
$p=$this->prepared();$this->expectException(\LogicException::class);$this->client->confirm($p,$p['approval_hash'],'source-v2',InvoiceClient::mappingHash($this->mapping));
}
public function test_changed_connection_blocks_stored_envelope():void
{
$intent=$this->intent();$other=$this->make(str_repeat('c',64));$this->expectException(\LogicException::class);
$other->preview($intent,'source-v1',InvoiceClient::mappingHash($this->mapping));
}
public function test_uncertain_confirmation_retains_key_cut_and_content_for_explicit_recovery():void
{
$p=$this->prepared();$this->queue[]=new \RuntimeException('Transport failure containing secret '.str_repeat('t',64));
$unknown=$this->client->confirm($p,$p['approval_hash'],'source-v1',InvoiceClient::mappingHash($this->mapping));
self::assertSame('uncertain',$unknown['phase']);self::assertCount(3,$this->calls);self::assertSame($p['preview'],$unknown['preview']);
self::assertStringNotContainsString(str_repeat('t',64),json_encode($unknown));
$this->queue[]=$this->receipt(200);$r=$this->client->confirm($unknown,$p['approval_hash'],'source-v1',InvoiceClient::mappingHash($this->mapping));
self::assertSame('received_pending',$r['phase']);self::assertTrue($r['receipt']['replayed']);
self::assertSame($this->calls[2],$this->calls[3]);
}
public function test_revocation_conflict_and_server_failure_are_not_success():void
{
foreach([401=>'blocked',403=>'blocked',409=>'review_required',422=>'not_sent',503=>'uncertain',302=>'uncertain']as$status=>$phase){
$p=$this->prepared();$this->queue[]=['status'=>$status,'json'=>null];$r=$this->client->confirm($p,$p['approval_hash'],'source-v1',InvoiceClient::mappingHash($this->mapping));
self::assertSame($phase,$r['phase']);self::assertSame(self::KEY,$r['intent']['operation_key']);
}
}
public function test_unverified_success_does_not_authorise_another_document():void
{
$p=$this->prepared();$bad=$this->receipt();$bad['json']['order_id']=999;$this->queue[]=$bad;
self::assertSame('uncertain',$this->client->confirm($p,$p['approval_hash'],'source-v1',InvoiceClient::mappingHash($this->mapping))['phase']);
}
public function test_preview_can_recover_existing_durable_receipt():void
{
$this->queue[]=['status'=>200,'json'=>['order'=>['id'=>3],'revision'=>str_repeat('b',64)]];$this->queue[]=$this->receipt(200);
self::assertSame('received_pending',$this->client->preview($this->intent(),'source-v1',InvoiceClient::mappingHash($this->mapping))['phase']);
}
public function test_another_host_cannot_receive_credentials():void
{
$this->expectException(\InvalidArgumentException::class);
new InvoiceClient('synthetic',str_repeat('t',64),str_repeat('a',64),['tenant_id'=>91],['tenant_id'=>1,'company_id'=>2,'unit_id'=>2],fn()=>[], 'http://example.com');
}
public function test_disabled_sgc_gateway_refuses_before_database_or_network():void
{
$path=dirname(__DIR__,3).'/compras/app/Services/SgfInvoiceGateway.php';if(!is_file($path))self::markTestSkipped('Adjacent SGC workspace required.');
require_once $path;
$previous=\Illuminate\Container\Container::getInstance();$container=new \Illuminate\Container\Container;
$container->instance('config',new \Illuminate\Config\Repository(['sgf_invoice'=>['enabled'=>false]]));\Illuminate\Container\Container::setInstance($container);
try{(new \App\Services\SgfInvoiceGateway)->clientFor(new \App\Models\User,'missing');self::fail('Expected disabled gateway');}
catch(\LogicException $e){self::assertSame('SGF invoice gateway disabled.',$e->getMessage());}
finally{\Illuminate\Container\Container::setInstance($previous);}
}
}