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

155 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Services\CatalogueAccounting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\CatalogueAccountingFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class CatalogueAccountingTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase;
private array $f;
private string $token;
private int $service;
private int $draft;
protected function setUp(): void
{
parent::setUp(); $this->f=CatalogueAccountingFixture::create(); $this->login();
$client=$this->a('clients',['name'=>'Cliente sintético'])->json('id');
$this->service=$this->a('services',['name'=>'Serviço pontual sintético'])->json('id');
$this->draft=$this->a('service_drafts',['client_id'=>$client,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'100']]])->json('id');
}
private function login(string $role='manager'): void
{
$this->postJson('/api/login',['email'=>$this->f[$role]->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 a(string $area,array $data) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/a/'.$area,$data)->assertCreated();}
private function command(string $path,array $data=[],?string $key=null) {return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/catalogue-accounting'.$path,$data);}
private function propose(string $kind,array $data,string $from='2026-01-01',array $extra=[]): int
{
return $this->command('/proposals',['kind'=>$kind,'data'=>$data,'valid_from'=>$from,'reason'=>'Fundamento sintético']+$extra)->assertOk()->json('id');
}
private function approve(int $id): void
{
$this->command('/proposals/'.$id.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Validação sintética das condições'])->assertOk();$this->login();
}
private function graph(): array
{
$plan=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);$this->approve($plan);
$account=$this->propose('account',['plan_id'=>$plan,'code'=>'7.2.900','name'=>'Conta empresarial exclusivamente sintética','origin'=>'enterprise','movable'=>true,'evidence'=>'Desdobramento de teste, não conta oficial inventada.']);$this->approve($account);
$category=$this->propose('category',['name'=>'Serviços normais sintéticos','account_id'=>$account,'conditions'=>'Natureza normal validada apenas para o caso sintético.']);$this->approve($category);
$link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$category]);$this->approve($link);
return compact('plan','account','category','link');
}
private function resolve(string $date='2026-10-26') {return $this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve?reference_date='.$date);}
public function test_workflow_separates_preparation_validation_and_immutable_versions(): void
{
$id=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION]);
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Sem direito'])->assertForbidden();
$this->command('/proposals/'.$id.'/submit')->assertOk(); $this->login('reader');
$this->command('/proposals',['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Não permitido'])->assertForbidden();
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Tentativa com edição','data'=>[]])->assertUnprocessable();
$key=(string)Str::uuid(); $decision=['decision'=>'refused','reason'=>'Evidência insuficiente'];
$this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',false);
$this->command('/proposals/'.$id.'/decision',$decision,$key)->assertOk()->assertJsonPath('replayed',true);
$this->command('/proposals/'.$id.'/decision',['decision'=>'approved','reason'=>'Conflito'])->assertStatus(409);
$this->login(); $v2=$this->propose('plan',['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'2026-01-01',['previous_id'=>$id]);
$this->assertDatabaseMissing('ca_decisions',['proposal_id'=>$v2]);
try{DB::transaction(fn()=>DB::table('ca_proposals')->where('id',$id)->update(['reason'=>'Edição indevida']));$this->fail('Mutable proposal');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());}
}
public function test_pending_refused_and_future_exceptions_do_not_replace_valid_category(): void
{
$g=$this->graph(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
$data=['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Excepção de ensaio'];
$draft=$this->propose('service_override',$data); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
$this->command('/proposals/'.$draft.'/submit')->assertOk(); $this->resolve()->assertOk()->assertJsonPath('origin','category_base');
$this->login('reader');$this->command('/proposals/'.$draft.'/decision',['decision'=>'refused','reason'=>'Recusa sintética'])->assertOk();$this->login();
$this->resolve()->assertOk()->assertJsonPath('origin','category_base');
$future=$this->propose('service_override',$data,'2026-11-01',['previous_id'=>$draft]);$this->approve($future);
$this->resolve('2026-10-31')->assertOk()->assertJsonPath('origin','category_base');
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('origin','explicit_service_exception');
}
public function test_explicit_replacement_preserves_dates_history_and_rejects_overlap(): void
{
$g=$this->graph();
$capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>$this->resolve()->json('revision')];
$id=$this->command('/resolutions',$capture)->assertOk()->json('id'); $snapshot=DB::table('ca_resolutions')->where('id',$id)->value('snapshot');
$data=['name'=>'Nova regra sintética','account_id'=>$g['account'],'conditions'=>'Condições revistas'];
$overlap=$this->propose('category',$data,'2026-11-01',['previous_id'=>$g['category']]);
$this->command('/proposals/'.$overlap.'/submit')->assertOk();$this->login('reader');$this->command('/proposals/'.$overlap.'/decision',['decision'=>'approved','reason'=>'Sobreposição indevida'])->assertStatus(409);$this->login();
$revision=$this->propose('category',$data,'2026-11-01',['previous_id'=>$overlap,'replaces_id'=>$g['category']]);$this->approve($revision);
$this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']);
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$revision);
$this->assertSame($snapshot,DB::table('ca_resolutions')->where('id',$id)->value('snapshot'));
try{DB::transaction(fn()=>DB::table('ca_resolutions')->update(['unit_id'=>$this->f['hidden']]));$this->fail('Mass transfer accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_',$e->getMessage());}
}
public function test_category_switch_cannot_bypass_validation_or_catalogue_protection(): void
{
$g=$this->graph(); $other=$this->propose('category',['name'=>'Outra categoria','account_id'=>$g['account'],'conditions'=>'Caso sintético']);$this->approve($other);
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->patchJson('/api/a/services/'.$this->service,['version'=>1,'name'=>'Tentativa','category_id'=>$other])->assertUnprocessable();
$link=$this->propose('service_link',['service_id'=>$this->service,'category_id'=>$other],'2026-11-01',['previous_id'=>$g['link'],'replaces_id'=>$g['link']]);
$this->resolve('2026-12-01')->assertOk()->assertJsonPath('references.category.id',$g['category']);
$this->approve($link); $this->resolve('2026-10-31')->assertOk()->assertJsonPath('references.category.id',$g['category']);$this->resolve('2026-11-01')->assertOk()->assertJsonPath('references.category.id',$other);
}
public function test_cross_company_operations_units_revocation_and_missing_date_are_denied(): void
{
$this->graph();
$this->getJson('/api/catalogue-accounting/services/'.$this->service.'/resolve')->assertOk()->assertJsonPath('state','pending');
$foreign=DB::table('a_services')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'name'=>'Serviço de outra empresa','data'=>'{}','version'=>1,'created_at'=>now(),'updated_at'=>now()]);
$this->getJson('/api/catalogue-accounting/services/'.$foreign.'/resolve?reference_date=2026-10-26')->assertForbidden();
$this->command('/proposals',['kind'=>'service_link','data'=>['service_id'=>$foreign,'category_id'=>1],'valid_from'=>'2026-01-01','reason'=>'Cruzamento'])->assertStatus(403);
$this->login('reader'); $this->getJson('/api/catalogue-accounting')->assertOk();
DB::table('permission_grants')->where('user_id',$this->f['reader']->id)->where('permission','catalogue_accounting.view')->delete(); $this->getJson('/api/catalogue-accounting')->assertForbidden();
$this->login();
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'unit.view',$this->f['hidden']);
$new=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token'); $this->token=$new;
$this->command('/resolutions',['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertForbidden();
$this->getJson('/api/catalogue-accounting/history/service_drafts/'.$this->draft)->assertForbidden();
}
public function test_stale_submission_identity_replay_and_no_fiscal_payloads(): void
{
$data=['kind'=>'plan','data'=>['plan'=>'PGC-NIRF','source_version'=>CatalogueAccounting::SOURCE_VERSION],'valid_from'=>'2026-01-01','reason'=>'Proposta'];$key=(string)Str::uuid();
$id=$this->command('/proposals',$data,$key)->assertOk()->json('id');$this->command('/proposals',$data,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
$this->command('/proposals',array_replace($data,['reason'=>'Diferente']),$key)->assertStatus(409);
$next=$this->propose('plan',$data['data'],'2026-01-01',['previous_id'=>$id]);$this->command('/proposals/'.$id.'/submit')->assertStatus(409);
$this->command('/proposals',['kind'=>'plan','data'=>$data['data']+['vat_rate'=>'16'],'valid_from'=>'2026-01-01','reason'=>'Taxa indevida','previous_id'=>$next])->assertUnprocessable();
$this->assertDatabaseCount('service_calculation_results',0); $this->assertDatabaseCount('b_internal_confirmations',0);
}
public function test_aggregate_missing_reference_and_incompatible_approved_override_are_not_silently_applied(): void
{
$g=$this->graph();
$aggregate=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2','name'=>'Prestação de serviços','origin'=>'reference','movable'=>false,'evidence'=>'Referência agregadora do exemplar, sem conta de movimento.']);$this->approve($aggregate);
$cat=$this->propose('category',['name'=>'Incompleta','account_id'=>$aggregate,'conditions'=>'Exige conta de movimento']);$this->command('/proposals/'.$cat.'/submit')->assertUnprocessable();
$override=$this->propose('service_override',['service_id'=>$this->service,'account_id'=>$g['account'],'conditions'=>'Caso específico']);$this->approve($override);
// An explicitly approved new account version expires the old reference. The override is now incompatible;
// it must report the conflict rather than falling back silently to the category.
$account=$this->propose('account',['plan_id'=>$g['plan'],'code'=>'7.2.900','name'=>'Conta sintética revista','origin'=>'enterprise','movable'=>true,'evidence'=>'Nova versão sintética'],'2026-11-01',['previous_id'=>$g['account'],'replaces_id'=>$g['account']]);$this->approve($account);
$this->resolve('2026-11-01')->assertOk()->assertJsonPath('state','pending')->assertJsonPath('conflict',true);
$this->command('/proposals',['kind'=>'category','data'=>['name'=>'Ausente','account_id'=>999999,'conditions'=>'Sem referência'],'valid_from'=>'2026-01-01','reason'=>'Ensaio'])->assertForbidden();
}
public function test_b1_snapshot_is_untouched_and_unauthorised_unit_cannot_insert_resolution_directly(): void
{
$this->graph(); $billing=$this->a('billing_drafts',['source_id'=>$this->draft])->json('id');
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()])->postJson('/api/b1/billing-drafts/'.$billing.'/confirm-internal',['version'=>1])->assertOk();
$before=DB::table('b_internal_confirmations')->get()->toJson();
$this->command('/resolutions',['area'=>'billing_drafts','record_id'=>$billing,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','resolution_revision'=>str_repeat('0',64)])->assertStatus(409);
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
try{DB::transaction(fn()=>DB::table('ca_resolutions')->insert(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden'],'area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-10-26','snapshot'=>'{}','actor_id'=>$this->f['manager']->id,'created_at'=>now()]));$this->fail('Cross-unit insert accepted');}catch(\Illuminate\Database\QueryException $e){$this->assertStringContainsString('SGF_CA_OPERATION_SCOPE',$e->getMessage());}
}
public function test_capture_refuses_a_different_classification_than_the_one_consulted(): void
{
$g=$this->graph();$old=$this->resolve('2026-11-01')->assertOk()->json('revision');
$id=$this->propose('category',['name'=>'Regra revista','account_id'=>$g['account'],'conditions'=>'Condições revistas'],'2026-11-01',['previous_id'=>$g['category'],'replaces_id'=>$g['category']]);$this->approve($id);
$capture=['area'=>'service_drafts','record_id'=>$this->draft,'record_version'=>1,'service_id'=>$this->service,'reference_date'=>'2026-11-01','resolution_revision'=>$old];
$this->command('/resolutions',$capture)->assertStatus(409);$this->assertDatabaseCount('ca_resolutions',0);
$capture['resolution_revision']=$this->resolve('2026-11-01')->assertOk()->json('revision');$key=(string)Str::uuid();
$this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',false);$this->command('/resolutions',$capture,$key)->assertOk()->assertJsonPath('replayed',true);$this->assertDatabaseCount('ca_resolutions',1);
}
}