Files
SGF/tests/Feature/ServiceFiscalTest.php
2026-11-02 08:06:45 +02:00

191 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Services\ServiceFiscal;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\DocumentPreviewFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class ServiceFiscalTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase;
private array $f; private string $token; private int $draft; private int $service;
protected function setUp(): void
{
parent::setUp(); $this->f=DocumentPreviewFixture::create();
foreach (['manager'=>['view','propose'],'reader'=>['view','approve']] as $user=>$actions) foreach($actions as $a) IdentityFixture::grant($this->f[$user]->id,$this->f['tenant'],$this->f['company'],'service_fiscal.'.$a);
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_fiscal.calculate',$this->f['unit']);
$this->login();
$client=$this->write('clients',['name'=>'Cliente isolado'])->json('id');
$this->service=$this->write('services',['name'=>'Alojamento sintético mensal'])->json('id');
$source=$this->write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'7850.00']]])->json('id');
$this->draft=$this->write('billing_drafts',['source_id'=>$source])->json('id');
$this->withHeader('X-SGF-Context',$this->token)->patchJson('/api/document-preparation/billing_drafts/'.$this->draft,['version'=>0,'draft_version'=>1,'currency'=>'MZN','price_mode'=>'added','period_start'=>'2026-10-26','units'=>['PERIODO'],'payment_instruction_ids'=>[]])->assertOk();
}
private function login(string $who='manager'): void
{
$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
}
private function write(string $area,array $data)
{
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/a/'.$area,$data)->assertSuccessful();
}
private function profile(array $changes=[],?int $previous=null)
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/proposals',['kind'=>'profile','identifier'=>'ISOLATED-HOSTING','previous_id'=>$previous,'data'=>array_replace(['service_id'=>$this->service,'service_version'=>1,'currency'=>'MZN','price_mode'=>'added','rate_percent'=>'16','valid_from'=>'2026-10-26','treatment'=>'domestic_normal','conditions'=>'Prestação interna sintética; um período; sem outras componentes.','evidence'=>'Fixture sintética; CIVA7/8/15/17 e Lei10/2025; não é validação Tempo.'],$changes)]);
}
private function approve(int $id,int $version=1,string $decision='approved'): void
{
$this->login('reader');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$id/decision",['version'=>$version,'decision'=>$decision,'reason'=>'Validação sintética da versão e âmbito exactos.'])->assertOk();
$this->login();
}
private function temporal(int $profile,array $changes=[],?int $previous=null)
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/proposals',['kind'=>'temporal','identifier'=>'ISOLATED-PERIOD','previous_id'=>$previous,'data'=>array_replace(['profile_proposal_id'=>$profile,'draft_id'=>$this->draft,'draft_version'=>1,'preparation_version'=>1,'reference_date'=>'2026-10-26','reference_basis'=>'Início contratual explícito usado apenas para verificar vigência; não é exigibilidade.','period_start'=>'2026-10-26','next_billing'=>'2026-11-26','event'=>'pending','evidence'=>'Período sintético; exigibilidade ainda pendente.'],$changes)]);
}
private function calculate(int $p,int $t,array $changes=[])
{
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-fiscal/drafts/'.$this->draft.'/calculate',array_replace(['profile_proposal_id'=>$p,'profile_version'=>1,'temporal_proposal_id'=>$t,'temporal_version'=>1,'draft_version'=>1,'preparation_version'=>1],$changes));
}
private function activate(int $p,int $version=1,bool $active=true): void
{
$this->login('reader');
app(\App\Shared\Context\CompanyContext::class)->selectFor($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
app(ServiceFiscal::class)->proofActivation($p,$this->f['reader']->id,$version,$active); $this->login();
}
private function ready(): array
{
$p=$this->profile()->assertOk()->json('id'); $this->approve($p);
$response=$this->temporal($p); $this->assertSame(200,$response->status(),$response->getContent()); $t=$response->json('id'); $this->approve($t);
return [$p,$t];
}
public function test_pending_temporal_result_exact_values_repetition_history_and_revocation(): void
{
[$p,$t]=$this->ready(); $this->calculate($p,$t)->assertStatus(409); $this->activate($p);
$id=$this->calculate($p,$t)->assertOk()->assertJsonPath('replayed',false)->json('id');
$this->calculate($p,$t)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$h=$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk();
foreach(['base_exact'=>'7850','vat_exact'=>'1256','vat_document_proposed'=>'1256.00','discarded_difference'=>'0','total_document_proposed'=>'9106.00','temporal_state'=>'temporal_pending','exigibility_date'=>null,'assessment_period'=>null,'fiscally_validated'=>false] as $key=>$value) $h->assertJsonPath('history.0.snapshot.result.'.$key,$value);
$h->assertJsonPath('history.0.current',true);
$this->getJson('/api/document-preview/draft/'.$this->draft)->assertOk()->assertJsonPath('fiscal_history.0.id',$id);
$this->activate($p,1,false); $this->calculate($p,$t)->assertStatus(409);
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk()->assertJsonPath('history.0.current',false)->assertJsonPath('history.0.snapshot.result.total_document_proposed','9106.00');
}
public function test_separation_of_duties_and_calculator_cannot_edit_temporal_decision(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$t/decision",['version'=>1,'decision'=>'approved','reason'=>'Calculator attempt'])->assertForbidden();
$this->calculate($p,$t,['event_date'=>'2026-10-26'])->assertUnprocessable();
$this->temporal($p,['event'=>'advance_payment','event_date'=>'2026-10-26'],$t)->assertUnprocessable();
$this->login('reader'); $this->profile()->assertForbidden(); $this->calculate($p,$t)->assertForbidden();
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$t/decision",['version'=>1,'decision'=>'refused','reason'=>'Overwrite'])->assertStatus(409);
}
public function test_versions_dates_compatibility_and_unknown_information_fail_closed(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$this->calculate($p,$t,['profile_version'=>2])->assertStatus(409);
$this->temporal($p,['reference_date'=>null],$t)->assertUnprocessable();
$this->temporal($p,['event_date'=>'2026-10-26'],$t)->assertUnprocessable();
$bad=$this->temporal($p,['reference_date'=>'2026-10-25'],$t)->assertOk()->json('id');
$this->login('reader'); $this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$bad/decision",['version'=>2,'decision'=>'approved','reason'=>'Invalid validity'])->assertUnprocessable(); $this->login();
$this->write('services',['name'=>'Other service']);
$this->calculate($p,$t)->assertOk();
$p2=$this->profile(['conditions'=>'Material change'],$p)->assertOk()->json('id');
$this->calculate($p,$t)->assertOk(); // merely proposed version does not replace approved one
$this->approve($p2,2); $this->calculate($p,$t)->assertStatus(409);
}
public function test_temporal_correction_is_versioned_and_determines_month_only_after_approval(): void
{
[$p,$t]=$this->ready(); $this->activate($p); $this->calculate($p,$t)->assertOk();
$t2=$this->temporal($p,['event'=>'invoice_in_time','reference_date'=>'2026-11-26','event_date'=>'2026-11-26','evidence'=>'Synthetic invoice date, deadline and timely issue reviewed; no invoice issued by application.'],$t)->assertOk()->json('id');
$this->calculate($p,$t,['temporal_proposal_id'=>$t2,'temporal_version'=>2])->assertStatus(409);
$this->approve($t2,2); $this->calculate($p,$t)->assertStatus(409);
$this->calculate($p,$t,['temporal_proposal_id'=>$t2,'temporal_version'=>2])->assertOk();
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertOk()->assertJsonPath('history.0.snapshot.result.assessment_period','2026-11')->assertJsonPath('history.1.current',false)->assertJsonPath('history.1.snapshot.result.exigibility_date',null);
}
public function test_mass_changes_cross_scope_revocation_b1_and_activation_are_protected(): void
{
[$p,$t]=$this->ready(); $this->activate($p); $this->calculate($p,$t)->assertOk();
foreach (['sf_proposals','sf_decisions','sf_activation_events'] as $table) {
try { DB::transaction(fn()=>DB::table($table)->delete()); $this->fail('History deleted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
$this->getJson('/api/service-fiscal')->assertForbidden(); $this->login();
DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','service_fiscal.calculate')->delete();
$this->calculate($p,$t)->assertForbidden();
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$this->draft.'/confirm-internal',['version'=>1])->assertOk();
$before=DB::table('b_internal_confirmations')->get()->toJson();
$this->getJson('/api/service-fiscal/drafts/'.$this->draft.'/history')->assertStatus(409);
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
$this->app->instance('env','local');
try { app(ServiceFiscal::class)->proofActivation($p,$this->f['manager']->id,1,true); $this->fail('Real activation allowed'); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
public function test_future_profile_does_not_replace_present_and_reference_cannot_be_invented(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$next=$this->profile(['valid_from'=>'2027-01-01','conditions'=>'Future material version'],$p)->assertOk()->json('id');
$this->approve($next,2); $this->calculate($p,$t)->assertOk();
$this->temporal($p,['event'=>'invoice_in_time','event_date'=>'2026-11-26'],$t)->assertUnprocessable();
$this->calculate($p,$t,['temporal_version'=>2])->assertStatus(409);
$this->login('reader');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$next/decision",['version'=>1,'decision'=>'approved','reason'=>'Wrong exact version'])->assertForbidden();
}
public function test_stale_catalogue_and_draft_and_raw_cross_scope_inserts_are_rejected(): void
{
[$p,$t]=$this->ready(); $this->activate($p);
$row=(array)DB::table('sf_proposals')->where('id',$p)->first(); unset($row['id']);
$row['unit_id']=$this->f['hidden']; $row['identifier']='CROSS-SCOPE';
try { DB::transaction(fn()=>DB::table('sf_proposals')->insert($row)); $this->fail('Cross scope inserted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_FISCAL_SCOPE',$e->getMessage()); }
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/services/'.$this->service,['version'=>1,'name'=>'Changed synthetic catalogue'])->assertOk();
$this->calculate($p,$t)->assertUnprocessable();
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/billing_drafts/'.$this->draft,['version'=>1,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'7850.00']]])->assertOk();
$this->calculate($p,$t)->assertStatus(409);
}
public function test_combined_rights_do_not_allow_self_approval_or_bypass_database_immutability(): void
{
$p=$this->profile()->assertOk()->json('id');
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'service_fiscal.approve');
$this->withHeader('X-SGF-Context',$this->token)->postJson("/api/service-fiscal/proposals/$p/decision",['version'=>1,'decision'=>'approved','reason'=>'Self approval attempt'])->assertForbidden();
foreach(['update','delete'] as $op) {
try { DB::transaction(fn()=> $op==='update' ? DB::table('sf_proposals')->where('id',$p)->update(['unit_id'=>$this->f['hidden']]) : DB::table('sf_proposals')->where('id',$p)->delete()); $this->fail('Immutable proposal changed'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
}
try { DB::transaction(fn()=>DB::table('sf_decisions')->insert(['proposal_id'=>$p,'actor_id'=>$this->f['manager']->id,'decision'=>'approved','reason'=>'Raw self approval','created_at'=>now()])); $this->fail('Raw self approval accepted'); }
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_FISCAL_DECISION',$e->getMessage()); }
}
public function test_local_activation_requires_reviewed_exact_approved_manifest_and_is_idempotent(): void
{
$p=$this->profile()->assertOk()->json('id');
$manifest=['proposal_id'=>$p,'version'=>1,'identifier'=>'ISOLATED-HOSTING','tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'service_id'=>$this->service,'service_version'=>1,'valid_from'=>'2026-10-26','operator_id'=>$this->f['manager']->id];
$activation=app(\App\Modules\Services\LocalFiscalActivation::class);
try { $activation->execute($manifest); $this->fail('Unapproved activation'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(409,$e->getStatusCode()); }
$this->approve($p);
$review=$activation->execute($manifest); $this->assertFalse($review['active']); $this->assertFalse($review['applied']);
foreach([['version'=>2],['service_id'=>$this->service+1],['unit_id'=>$this->f['hidden']],['valid_from'=>'2026-10-27'],['definition_hash'=>str_repeat('0',64)]] as $change) {
try { $activation->execute(array_replace($manifest,['definition_hash'=>$review['definition_hash']],$change),true); $this->fail('Different manifest accepted'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertContains($e->getStatusCode(),[403,409]); }
}
$manifest['definition_hash']=$review['definition_hash'];
$result=$activation->execute($manifest,true); $this->assertTrue($result['active']); $this->assertTrue($result['applied']);
$replay=$activation->execute($manifest,true); $this->assertTrue($replay['replayed']); $this->assertFalse($replay['applied']);
$this->assertSame(1,DB::table('sf_activation_events')->where('proposal_id',$p)->count());
$audit=DB::table('identity_audit')->where('action','service_fiscal.local_activated')->first();
$this->assertSame($p,json_decode($audit->details,true)['record_id']);
$this->app->instance('env','production');
try { $activation->execute($manifest,true); $this->fail('Production allowed'); }
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
}
}