Preserva classificacao contabilistica aceite e bases locais
Regista aceitacao funcional do primeiro incremento PGC-NIRF e preserva as dependencias documentais e tecnicas existentes. A validacao do calculo fiscal continua separada e pendente. Sem credenciais, bases operacionais ou publicacao.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
<?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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\DocumentPreparation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreparationFixture;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DocumentPreparationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $fixture;
|
||||
private string $token;
|
||||
private int $client;
|
||||
private int $service;
|
||||
private int $source;
|
||||
private int $billing;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->fixture = DocumentPreparationFixture::create(); $this->login();
|
||||
$this->client = $this->writeA('clients', ['name' => 'Cliente documental sintético'])->assertCreated()->json('id');
|
||||
$this->service = $this->writeA('services', ['name' => 'Serviço documental sintético'])->assertCreated()->json('id');
|
||||
$this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id');
|
||||
$this->billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
}
|
||||
private function login(string $kind = 'manager'): void
|
||||
{
|
||||
$this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk();
|
||||
$this->select($this->fixture['unit']);
|
||||
}
|
||||
private function select(?int $unit): void
|
||||
{
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $unit])->assertOk()->json('context.token');
|
||||
}
|
||||
private function writeA(string $area, array $data, ?int $id = null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]);
|
||||
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
|
||||
}
|
||||
private function save(string $kind, int $id, array $data)
|
||||
{
|
||||
return $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", $data);
|
||||
}
|
||||
private function profile(): array { return ['version' => 0, 'nuit' => 'NUIT-SINTETICO', 'address' => 'Morada de teste', 'locality' => 'Local sintético', 'country' => 'País de teste']; }
|
||||
private function preparation(int $draftVersion = 1): array
|
||||
{
|
||||
return ['version' => 0, 'draft_version' => $draftVersion, 'currency' => null, 'service_date' => '2026-11-01', 'period_start' => '2026-11-01', 'period_end' => '2026-11-30', 'payment_terms' => 'Prazo a conferir', 'proposed_due_date' => '2026-11-15', 'price_mode' => 'undefined', 'units' => ['hora'], 'payment_instruction_ids' => []];
|
||||
}
|
||||
private function payment(): array { return ['version' => 0, 'label' => 'Instruções sintéticas', 'bank' => 'Banco fictício', 'holder' => 'Titular sintético', 'account_number' => 'SYNTHETIC-'.Str::uuid(), 'currency' => null, 'instructions' => 'Apenas simulação, não executar pagamentos.']; }
|
||||
private function confirm(): array
|
||||
{
|
||||
return $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk()->json();
|
||||
}
|
||||
private function confirmation(): array { return $this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->json('confirmation'); }
|
||||
|
||||
public function test_client_starts_with_name_and_existing_names_are_reused_without_duplicate_fields(): void
|
||||
{
|
||||
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
|
||||
$record->assertJsonPath('version', 0)->assertJsonPath('name', 'Cliente documental sintético');
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->writeA('clients', ['name' => 'Nome revisto', 'version' => 1], $this->client)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/client/$this->client")->assertOk();
|
||||
$record->assertJsonPath('name', 'Nome revisto')->assertJsonPath('data.nuit', 'NUIT-SINTETICO');
|
||||
$this->assertArrayNotHasKey('name', $record->json('data'));
|
||||
$this->assertSame(1, DB::table('a_clients')->count());
|
||||
}
|
||||
public function test_issuer_and_payment_instructions_are_company_scoped_and_audited_without_values(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
|
||||
$this->getJson("/api/document-preparation/payment/$id")->assertOk()->assertJsonPath('data.account_number', $payment['account_number']);
|
||||
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
$this->assertSame(2, DB::table('doc_history')->count());
|
||||
$this->select(null); $this->getJson('/api/document-preparation/payment')->assertOk()->assertJsonCount(1, 'records');
|
||||
}
|
||||
public function test_preparation_selects_instructions_without_receipt_and_preserves_exact_prices(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $data['price_mode'] = 'included';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('version', 1);
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$record->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.price_mode', 'included');
|
||||
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('receipts'));
|
||||
$this->assertFalse(\Illuminate\Support\Facades\Schema::hasTable('journal_entries'));
|
||||
}
|
||||
public function test_preparation_reuses_source_dates_units_and_instructions_for_new_billing(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id];
|
||||
$this->save('service_drafts', $this->source, $data)->assertOk();
|
||||
$billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$billing")->assertOk();
|
||||
$record->assertJsonPath('data.units.0', 'hora')->assertJsonPath('data.service_date', '2026-11-01')->assertJsonPath('data.payment_instruction_ids.0', $id)->assertJsonPath('data.source_preparation.version', 1);
|
||||
}
|
||||
public function test_completeness_distinguishes_missing_filled_and_pending_without_false_validation(): void
|
||||
{
|
||||
$before = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
|
||||
$this->assertSame('missing', collect($before['review'])->firstWhere('field', 'nuit')['state']);
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$after = $this->getJson("/api/document-preparation/client/$this->client")->assertOk()->json();
|
||||
$this->assertSame('filled_not_validated', collect($after['review'])->firstWhere('field', 'nuit')['state']);
|
||||
$this->assertSame('validation_pending', collect($after['review'])->firstWhere('field', 'legal')['state']);
|
||||
$data = $this->preparation(); $data['payment_terms'] = 'Pagamento em 30 dias'; $data['proposed_due_date'] = '2026-11-02';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$record->assertJsonPath('data.proposed_due_date', '2026-11-02');
|
||||
$this->assertSame('validation_pending', collect($record->json('review'))->firstWhere('field', 'terms_due')['state']);
|
||||
}
|
||||
public function test_reader_can_consult_but_not_change_any_new_area(): void
|
||||
{
|
||||
$payment = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
$this->login('reader');
|
||||
foreach (['issuer' => $this->fixture['company'], 'client' => $this->client, 'payment' => $payment, 'service_drafts' => $this->source, 'billing_drafts' => $this->billing] as $kind => $id) {
|
||||
$this->getJson("/api/document-preparation/$kind/$id")->assertOk();
|
||||
$this->save($kind, $id, ['version' => 0])->assertForbidden();
|
||||
}
|
||||
}
|
||||
public function test_no_implicit_new_rights_and_revocation_affects_next_request(): void
|
||||
{
|
||||
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson('/api/document-preparation/payment')->assertForbidden();
|
||||
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
|
||||
$this->getJson('/api/a/clients')->assertOk();
|
||||
}
|
||||
public function test_cross_company_same_tenant_and_cross_unit_are_refused(): void
|
||||
{
|
||||
$id = $this->save('payment', 0, $this->payment())->assertOk()->json('id');
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], 'company.view');
|
||||
foreach (['issuer_documents.view','issuer_documents.edit'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['sibling'], $permission); }
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk()->json('context.token');
|
||||
$this->getJson("/api/document-preparation/payment/$id")->assertForbidden();
|
||||
$this->getJson('/api/document-preparation/issuer/'.$this->fixture['company'])->assertForbidden();
|
||||
$this->save('payment', $id, $this->payment())->assertForbidden();
|
||||
$this->select($this->fixture['unit']);
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
|
||||
foreach (['draft_documents.view','draft_documents.edit','billing_drafts.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
|
||||
$this->select($this->fixture['hidden']); $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertForbidden();
|
||||
}
|
||||
public function test_foreign_payment_selection_and_protected_fields_are_refused(): void
|
||||
{
|
||||
$foreign = DB::table('doc_records')->insertGetId(['tenant_id' => $this->fixture['otherTenant'], 'company_id' => $this->fixture['foreign'], 'unit_id' => null, 'kind' => 'payment', 'record_id' => 0, 'version' => 1, 'data' => json_encode(['label' => 'Outra empresa']), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$foreign];
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertForbidden();
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile() + ['company_id' => $this->fixture['sibling']])->assertUnprocessable();
|
||||
$this->save('payment', 0, $this->payment() + ['password' => 'not-collected'])->assertUnprocessable();
|
||||
}
|
||||
public function test_old_profile_draft_version_and_context_are_refused(): void
|
||||
{
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertStatus(409);
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation(2))->assertStatus(409);
|
||||
$old = $this->token; $this->select(null);
|
||||
$this->withHeader('X-SGF-Context', $old)->patchJson('/api/document-preparation/issuer/'.$this->fixture['company'], $this->profile())->assertStatus(409);
|
||||
}
|
||||
public function test_currency_is_reused_and_synchronised_without_recalculating_tax(): void
|
||||
{
|
||||
$data = $this->preparation(); $data['currency'] = 'TST';
|
||||
$this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('version', 2)->assertJsonPath('data.currency', 'TST')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.tax_status', 'pending');
|
||||
$this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk()->assertJsonPath('data.draft_version', 2);
|
||||
}
|
||||
public function test_existing_v1_confirmation_is_unchanged_after_profile_and_payment_updates(): void
|
||||
{
|
||||
$this->confirm(); $before = $this->confirmation();
|
||||
$this->assertArrayNotHasKey('schema_version', $before['snapshot']);
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$this->save('payment', 0, $this->payment())->assertOk();
|
||||
$this->assertSame($before, $this->confirmation());
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation())->assertStatus(409);
|
||||
}
|
||||
public function test_future_confirmation_v2_preserves_selected_instruction_version_and_blocks_unauthorized_read(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->save('client', $this->client, $this->profile())->assertOk();
|
||||
$payment = $this->payment(); $id = $this->save('payment', 0, $payment)->assertOk()->json('id');
|
||||
$data = $this->preparation(); $data['payment_instruction_ids'] = [$id]; $this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->confirm(); $before = $this->confirmation();
|
||||
$this->assertSame(2, $before['snapshot']['schema_version']);
|
||||
$this->assertDatabaseHas('a_events', ['id' => $before['event']['id'], 'schema_version' => 2]);
|
||||
$this->assertSame($payment['account_number'], $before['snapshot']['document_preparation']['payment_instructions'][0]['data']['account_number']);
|
||||
$payment['version'] = 1; $payment['instructions'] = 'Texto sintético revisto'; $this->save('payment', $id, $payment)->assertOk();
|
||||
$this->assertSame($before, $this->confirmation());
|
||||
$this->assertStringNotContainsString($payment['account_number'], DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertForbidden();
|
||||
}
|
||||
public function test_invalid_dates_mode_and_precision_are_rejected(): void
|
||||
{
|
||||
$data = $this->preparation(); $data['period_end'] = '2026-10-31'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
|
||||
$data = $this->preparation(); $data['price_mode'] = 'tax-zero'; $this->save('billing_drafts', $this->billing, $data)->assertUnprocessable();
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0.123456789']]], $this->billing)->assertUnprocessable();
|
||||
}
|
||||
public function test_sql_mass_scope_transfer_is_blocked(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_DOC_IMMUTABLE_SCOPE');
|
||||
DB::table('doc_records')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
|
||||
}
|
||||
public function test_history_is_append_only(): void
|
||||
{
|
||||
$this->save('issuer', $this->fixture['company'], $this->profile())->assertOk();
|
||||
$this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
|
||||
DB::table('doc_history')->update(['snapshot' => '{}']);
|
||||
}
|
||||
public function test_failure_rolls_back_and_does_not_expose_instruction_values(): void
|
||||
{
|
||||
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_doc_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_DOC_FAILURE'; END; \$\$; CREATE TRIGGER synthetic_doc_fail BEFORE INSERT ON doc_history FOR EACH ROW EXECUTE FUNCTION sgf_doc_test_fail();");
|
||||
$payment = $this->payment(); $response = $this->save('payment', 0, $payment)->assertStatus(500);
|
||||
$this->assertStringNotContainsString($payment['account_number'], $response->getContent());
|
||||
$this->assertSame(0, DB::table('doc_records')->count());
|
||||
$this->assertSame(0, DB::table('doc_history')->count());
|
||||
$log = storage_path('logs/laravel.log');
|
||||
$this->assertFalse(is_file($log) && str_contains(file_get_contents($log), $payment['account_number']), 'Instruction values must not appear in technical logs.');
|
||||
}
|
||||
public function test_preparation_becomes_stale_after_a_draft_edit_and_is_not_silently_reinterpreted(): void
|
||||
{
|
||||
$this->save('billing_drafts', $this->billing, $this->preparation())->assertOk();
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'description' => 'Descrição revista'], $this->billing)->assertOk();
|
||||
$record = $this->getJson("/api/document-preparation/billing_drafts/$this->billing")->assertOk();
|
||||
$this->assertSame('stale', collect($record->json('review'))->firstWhere('field', 'draft_version')['state']);
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 2])->assertStatus(409);
|
||||
$data = $this->preparation(2); $data['version'] = 1; $this->save('billing_drafts', $this->billing, $data)->assertOk();
|
||||
$this->getJson("/api/a/billing_drafts/$this->billing")->assertOk()->assertJsonPath('base_amount', '0.0000000125');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\DocumentPreview;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreviewFixture;
|
||||
use Tests\Support\IdentityFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class DocumentPreviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $fixture;
|
||||
private string $token;
|
||||
private int $client;
|
||||
private int $service;
|
||||
private int $source;
|
||||
private int $billing;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->fixture = DocumentPreviewFixture::create(); $this->login();
|
||||
$this->client = $this->writeA('clients', ['name' => 'Cliente da pré-visualização'])->assertCreated()->json('id');
|
||||
$this->service = $this->writeA('services', ['name' => 'Serviço da pré-visualização'])->assertCreated()->json('id');
|
||||
$this->source = $this->writeA('service_drafts', ['client_id' => $this->client, 'lines' => [['service_id' => $this->service, 'quantity' => '1.25', 'unit_price' => '0.00000001']]])->assertCreated()->json('id');
|
||||
$this->billing = $this->writeA('billing_drafts', ['source_id' => $this->source])->assertCreated()->json('id');
|
||||
}
|
||||
private function login(string $kind = 'manager'): void
|
||||
{
|
||||
$this->postJson('/api/login', ['email' => $this->fixture[$kind]->email, 'password' => $this->fixture['password']])->assertOk();
|
||||
$this->token = $this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['unit']])->assertOk()->json('context.token');
|
||||
}
|
||||
private function writeA(string $area, array $data, ?int $id = null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()]);
|
||||
return $id ? $this->patchJson("/api/a/$area/$id", $data) : $this->postJson("/api/a/$area", $data);
|
||||
}
|
||||
private function preview() { return $this->getJson("/api/document-preview/draft/$this->billing"); }
|
||||
private function model(array $changes = [], int $version = 0) { return $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preview/model', ['version' => $version] + array_replace(DocumentPreview::DEFAULT_MODEL, $changes)); }
|
||||
private function confirm(): void { $this->withHeaders(['X-SGF-Context' => $this->token, 'Idempotency-Key' => (string) Str::uuid()])->postJson("/api/b1/billing-drafts/$this->billing/confirm-internal", ['version' => 1])->assertOk(); }
|
||||
private function saveProfile(string $kind, int $id, string $value, int $version = 0): void { $this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/$kind/$id", ['version' => $version, 'nuit' => $value, 'address' => 'Morada sintética', 'locality' => 'Localidade', 'country' => 'País de teste'])->assertOk(); }
|
||||
|
||||
public function test_preview_reuses_data_exact_arithmetic_and_unknowns_without_writing_business_records(): void
|
||||
{
|
||||
$tables = ['a_clients','a_services','a_service_drafts','a_billing_drafts','a_history','a_events','a_pending','a_outbox','a_commands','b_internal_confirmations','doc_records','doc_history','document_preview_models','identity_audit'];
|
||||
$before = []; foreach ($tables as $table) { $before[$table] = DB::table($table)->count(); }
|
||||
$response = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('data.lines.0.service_name', 'Serviço da pré-visualização')->assertJsonPath('client.name', 'Cliente da pré-visualização')->assertJsonPath('base_amount', '0.0000000125')->assertJsonPath('data.currency', null)->assertJsonPath('model_version', 0);
|
||||
$this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||||
$this->assertArrayHasKey('tax_rule_pending', $response->json('pending'));
|
||||
foreach (['fiscal_number','issued_at','tax_amount','final_total'] as $field) { $this->assertArrayNotHasKey($field, $response->json()); }
|
||||
foreach ($before as $table => $count) { $this->assertSame($count, DB::table($table)->count()); }
|
||||
}
|
||||
public function test_missing_values_remain_unknown_while_explicit_zero_is_preserved(): void
|
||||
{
|
||||
$this->writeA('billing_drafts', ['version' => 1, 'lines' => [['service_id' => $this->service, 'quantity' => null, 'unit_price' => null]]], $this->billing)->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('base_amount', null)->assertJsonPath('data.lines.0.unit_price', null);
|
||||
$this->writeA('billing_drafts', ['version' => 2, 'lines' => [['service_id' => $this->service, 'quantity' => '1', 'unit_price' => '0']]], $this->billing)->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('base_amount', '0')->assertJsonPath('data.lines.0.unit_price', '0');
|
||||
}
|
||||
public function test_configuration_is_explicit_reader_is_denied_and_old_version_conflicts(): void
|
||||
{
|
||||
$this->model(['contact_text' => 'Contactos sintéticos'])->assertOk()->assertJsonPath('version', 1);
|
||||
$this->model()->assertStatus(409);
|
||||
$this->login('reader'); $this->getJson('/api/document-preview/model')->assertOk(); $this->model(version: 1)->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', DocumentPreview::CONFIGURE)->delete();
|
||||
$this->model(version: 1)->assertForbidden();
|
||||
}
|
||||
public function test_configuration_cannot_hide_notice_or_pending_and_rejects_external_resources(): void
|
||||
{
|
||||
$this->model(['show_notice' => false])->assertUnprocessable();
|
||||
$this->model(['pending' => []])->assertUnprocessable();
|
||||
$this->model(['logo_url' => 'https://example.invalid/logo.png'])->assertUnprocessable();
|
||||
$this->model(['show_contacts' => false, 'show_logo' => false, 'show_payment_instructions' => false])->assertOk();
|
||||
$result = $this->preview()->assertOk()->assertJsonPath('notice', DocumentPreview::NOTICE)->assertJsonPath('contacts', null);
|
||||
foreach (['tax_rule_pending','instructions_missing','contacts_missing','logo_missing'] as $field) { $this->assertArrayHasKey($field, $result->json('pending')); }
|
||||
}
|
||||
public function test_revision_detects_model_data_and_instruction_changes_without_replacing_prior_response(): void
|
||||
{
|
||||
$before = $this->preview()->assertOk()->json();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->assertExactJson(['revision' => $before['revision']]);
|
||||
$this->model(['observations' => 'Observação sintética nova'])->assertOk();
|
||||
$after = $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision');
|
||||
$this->assertNotSame($before['revision'], $after); $this->assertSame('', $before['observations']);
|
||||
$this->preview()->assertOk()->assertJsonPath('observations', 'Observação sintética nova');
|
||||
$this->saveProfile('client', $this->client, 'NUIT-SINTETICO');
|
||||
$this->assertNotSame($after, $this->getJson("/api/document-preview/draft/$this->billing/revision")->assertOk()->json('revision'));
|
||||
}
|
||||
public function test_selected_instructions_and_price_mode_are_reused_without_fiscal_computation(): void
|
||||
{
|
||||
$marker = 'SYNTHETIC-'.Str::uuid();
|
||||
$payment = $this->withHeader('X-SGF-Context', $this->token)->patchJson('/api/document-preparation/payment/0', ['version' => 0, 'label' => 'Instruções sintéticas', 'account_number' => $marker])->assertOk()->json('id');
|
||||
$data = ['version' => 0, 'draft_version' => 1, 'currency' => null, 'price_mode' => 'included', 'units' => ['hora'], 'payment_instruction_ids' => [$payment]];
|
||||
$this->withHeader('X-SGF-Context', $this->token)->patchJson("/api/document-preparation/billing_drafts/$this->billing", $data)->assertOk();
|
||||
$before = $this->preview()->assertOk()->assertJsonPath('prepared.price_mode', 'included')->assertJsonPath('base_amount', '0.0000000125');
|
||||
$this->assertTrue($before->json('payments.0.data.account_number') === $marker);
|
||||
$this->model(['show_payment_instructions' => false])->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonCount(0, 'payments')->assertJsonPath('base_amount', '0.0000000125');
|
||||
$this->assertStringNotContainsString($marker, DB::table('identity_audit')->pluck('details')->implode(''));
|
||||
}
|
||||
public function test_history_v1_uses_only_snapshot_and_never_current_profiles_or_model(): void
|
||||
{
|
||||
$this->confirm(); $before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'NOVO-EMITENTE');
|
||||
$this->saveProfile('client', $this->client, 'NOVO-CLIENTE');
|
||||
$this->writeA('clients', ['version' => 1, 'name' => 'Nome actual diferente'], $this->client)->assertOk();
|
||||
$this->model(['contact_text' => 'NÃO HISTÓRICO', 'observations' => 'NÃO CAPTURADO'])->assertOk();
|
||||
$after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after);
|
||||
$this->assertNull($after['issuer']['name']); $this->assertSame([], $after['issuer']['data']);
|
||||
$this->assertSame('Cliente da pré-visualização', $after['client']['name']); $this->assertNull($after['contacts']);
|
||||
$this->preview()->assertStatus(409);
|
||||
}
|
||||
public function test_history_v2_keeps_captured_profile_and_payment_versions(): void
|
||||
{
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-HISTÓRICO');
|
||||
$this->saveProfile('client', $this->client, 'CLIENTE-HISTÓRICO');
|
||||
$this->confirm();
|
||||
$before = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('snapshot_schema', 2)->assertJsonPath('issuer.data.nuit', 'EMITENTE-HISTÓRICO')->json();
|
||||
$this->saveProfile('issuer', $this->fixture['company'], 'EMITENTE-ACTUAL', 1);
|
||||
$after = $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->json();
|
||||
unset($before['consulted_at'], $after['consulted_at']); $this->assertSame($before, $after);
|
||||
$this->assertNull($after['issuer']['name']);
|
||||
}
|
||||
public function test_cross_unit_company_and_revoked_access_are_denied_on_next_consultation(): void
|
||||
{
|
||||
$this->preview()->assertOk();
|
||||
IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], 'unit.view', $this->fixture['hidden']);
|
||||
foreach (['billing_drafts.view','draft_documents.view'] as $permission) { IdentityFixture::grant($this->fixture['manager']->id, $this->fixture['tenant'], $this->fixture['company'], $permission, $this->fixture['hidden']); }
|
||||
$this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['company'], 'unit_id' => $this->fixture['hidden']])->assertOk();
|
||||
$this->preview()->assertForbidden();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden();
|
||||
$this->login();
|
||||
$this->postJson('/api/context', ['tenant_id' => $this->fixture['tenant'], 'company_id' => $this->fixture['sibling'], 'unit_id' => null])->assertOk();
|
||||
$this->preview()->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id', $this->fixture['manager']->id)->where('permission', 'issuer_documents.view')->delete();
|
||||
$this->getJson("/api/document-preview/draft/$this->billing/revision")->assertForbidden();
|
||||
}
|
||||
public function test_logo_is_local_png_only_and_no_history_reconstructs_it(): void
|
||||
{
|
||||
Storage::fake('local'); $path = 'document-logos/company-'.$this->fixture['company'].'.png';
|
||||
$this->model(['show_logo' => true])->assertOk();
|
||||
$this->preview()->assertOk()->assertJsonPath('logo', null);
|
||||
Storage::disk('local')->put($path, '<svg onload="alert(1)"></svg>'); $this->preview()->assertOk()->assertJsonPath('logo', null);
|
||||
Storage::disk('local')->put($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jV0kAAAAASUVORK5CYII='));
|
||||
$logo = $this->preview()->assertOk()->json('logo'); $this->assertStringStartsWith('data:image/png;base64,', $logo);
|
||||
$this->confirm(); $this->getJson("/api/document-preview/history/$this->billing")->assertOk()->assertJsonPath('logo', null);
|
||||
}
|
||||
public function test_model_scope_cannot_be_transferred_in_bulk(): void
|
||||
{
|
||||
$this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_PREVIEW_IMMUTABLE_SCOPE_OR_VERSION');
|
||||
DB::table('document_preview_models')->update(['company_id' => $this->fixture['sibling'], 'version' => 2]);
|
||||
}
|
||||
public function test_model_history_is_immutable(): void
|
||||
{
|
||||
$this->model()->assertOk(); $this->expectException(\Illuminate\Database\QueryException::class); $this->expectExceptionMessage('SGF_APPEND_ONLY_AUDIT');
|
||||
DB::table('document_preview_model_history')->delete();
|
||||
}
|
||||
public function test_configuration_failure_is_atomic_and_does_not_log_content(): void
|
||||
{
|
||||
DB::unprepared("CREATE OR REPLACE FUNCTION sgf_preview_test_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'SYNTHETIC_PREVIEW_FAILURE'; END; \$\$; CREATE TRIGGER preview_synthetic_fail BEFORE INSERT ON document_preview_model_history FOR EACH ROW EXECUTE FUNCTION sgf_preview_test_fail();");
|
||||
$this->model(['observations' => 'Texto sintético de falha'])->assertStatus(500);
|
||||
$this->assertSame(0, DB::table('document_preview_models')->count()); $this->assertSame(0, DB::table('document_preview_model_history')->count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Modules\Services\ServiceCalculation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Support\DocumentPreviewFixture;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ServiceCalculationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
private array $f;
|
||||
private int $draft;
|
||||
private int $profile;
|
||||
private string $token;
|
||||
private int $service;
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp(); $this->f = DocumentPreviewFixture::create(); $this->login();
|
||||
$client = $this->write('clients',['name'=>'Cliente sintético'])->json('id');
|
||||
$this->service = $this->write('services',['name'=>'Serviço sintético'])->json('id');
|
||||
$source = $this->write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'1000.03']]])->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','units'=>['UN'],'payment_instruction_ids'=>[]])->assertOk();
|
||||
$this->profile = $this->newProfile(1);
|
||||
}
|
||||
private function login(string $kind='manager'): void
|
||||
{
|
||||
$this->postJson('/api/login',['email'=>$this->f[$kind]->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,?int $id=null)
|
||||
{
|
||||
$this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>(string)Str::uuid()]);
|
||||
return ($id ? $this->patchJson("/api/a/$area/$id",$data) : $this->postJson("/api/a/$area",$data))->assertSuccessful();
|
||||
}
|
||||
private function newProfile(int $version): int
|
||||
{
|
||||
return DB::table('service_calculation_profiles')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'identifier'=>'SYNTHETIC-MZN-16','version'=>$version,'definition'=>json_encode(['kind'=>'synthetic','currency'=>'MZN','rate_percent'=>'16','policy'=>ServiceCalculation::POLICY,'sources'=>ServiceCalculation::SOURCES]),'created_at'=>now()]);
|
||||
}
|
||||
private function calculate(array $changes=[])
|
||||
{
|
||||
return $this->withHeader('X-SGF-Context',$this->token)->postJson('/api/service-calculations/'.$this->draft,array_replace(['draft_version'=>1,'preparation_version'=>1,'profile_id'=>$this->profile,'profile_version'=>1],$changes));
|
||||
}
|
||||
private function preview() { return $this->getJson('/api/document-preview/draft/'.$this->draft)->assertOk(); }
|
||||
public function test_exact_values_truncation_boundaries_and_missing_rules(): void
|
||||
{
|
||||
$profile=['kind'=>'synthetic','currency'=>'MZN','rate_percent'=>'16','policy'=>ServiceCalculation::POLICY];
|
||||
foreach ([['100','16','16.00','0','116.00'],['1000.03','160.0048','160.00','0.0048','1160.03'],['0.03','0.0048','0.00','0.0048','0.03'],['0.06249999','0.0099999984','0.00','0.0099999984',null],['0.0625','0.01','0.01','0',null],['0.06250001','0.0100000016','0.01','0.0000000016',null],['0','0','0.00','0','0.00']] as [$base,$exact,$doc,$difference,$total]) {
|
||||
$result=ServiceCalculation::evaluate(['currency'=>'MZN','lines'=>[['quantity'=>'1','unit_price'=>$base]]],['price_mode'=>'added'],$profile);
|
||||
foreach (['vat_exact'=>$exact,'vat_document_proposed'=>$doc,'discarded_difference'=>$difference,'total_document_proposed'=>$total] as $field=>$expected) { $this->assertSame($expected,$result[$field],$base.' '.$field); }
|
||||
$this->assertFalse($result['fiscally_validated']);
|
||||
}
|
||||
$data=['currency'=>'MZN','lines'=>[['quantity'=>'1','unit_price'=>'0.03']]];
|
||||
foreach ([null,array_diff_key($profile,['rate_percent'=>true]),array_replace($profile,['currency'=>'USD']),array_replace($profile,['kind'=>'real']),array_replace($profile,['policy'=>'old'])] as $bad) { $this->assertSame('pending',ServiceCalculation::evaluate($data,['price_mode'=>'added'],$bad)['state']); }
|
||||
foreach ([[],['price_mode'=>'included']] as $prepared) { $this->assertSame('pending',ServiceCalculation::evaluate($data,$prepared,$profile)['state']); }
|
||||
$data['currency']=null; $result=ServiceCalculation::evaluate($data,['price_mode'=>'added'],$profile); $this->assertSame('pending',$result['state']); $this->assertArrayNotHasKey('vat_exact',$result);
|
||||
}
|
||||
public function test_persistence_repetition_versions_history_and_profile_changes(): void
|
||||
{
|
||||
$id=$this->calculate()->assertOk()->assertJsonPath('replayed',false)->json('id');
|
||||
$this->calculate()->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
||||
$this->assertDatabaseCount('service_calculation_results',1);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.current',true)->assertJsonPath('calculation.history.0.snapshot.result.vat_exact','160.0048');
|
||||
$this->calculate(['draft_version'=>2])->assertStatus(409);
|
||||
$this->newProfile(2); $this->calculate()->assertStatus(409);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.current',false);
|
||||
$this->write('billing_drafts',['version'=>1,'lines'=>[['service_id'=>$this->service,'quantity'=>'1','unit_price'=>'0.03']]],$this->draft);
|
||||
$this->preview()->assertJsonPath('calculation.history.0.snapshot.result.vat_exact','160.0048');
|
||||
}
|
||||
public function test_reader_revocation_cross_scope_and_normal_environment_cannot_calculate(): void
|
||||
{
|
||||
$this->calculate()->assertOk(); $this->login('reader'); $this->preview()->assertJsonPath('calculation.can_calculate',false); $this->calculate()->assertForbidden();
|
||||
$this->login();
|
||||
$foreign=DB::table('service_calculation_profiles')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden'],'identifier'=>'FOREIGN','version'=>1,'definition'=>'{}','created_at'=>now()]);
|
||||
$this->calculate(['profile_id'=>$foreign])->assertForbidden();
|
||||
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();
|
||||
$this->getJson('/api/document-preview/draft/'.$this->draft)->assertForbidden();
|
||||
$this->login(); DB::table('permission_grants')->where('user_id',$this->f['manager']->id)->where('permission','billing_drafts.edit')->delete(); $this->calculate()->assertForbidden();
|
||||
$this->app->instance('env','local');
|
||||
try { app(ServiceCalculation::class)->calculate($this->draft,$this->f['manager']->id,[]); $this->fail('Normal environment accepted a synthetic calculation'); }
|
||||
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
|
||||
app(\App\Shared\Context\CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);
|
||||
$read=app(ServiceCalculation::class)->read($this->draft,$this->f['manager']->id);
|
||||
$this->assertSame('normal',$read['environment']); $this->assertSame([],$read['history']);
|
||||
}
|
||||
public function test_database_refuses_mass_transfer_and_b1_remains_unchanged(): void
|
||||
{
|
||||
$this->calculate()->assertOk();
|
||||
try { DB::transaction(fn()=>DB::table('service_calculation_results')->update(['unit_id'=>$this->f['hidden']])); $this->fail('Mass transfer accepted'); }
|
||||
catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('SGF_',$e->getMessage()); }
|
||||
$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->calculate()->assertStatus(409);
|
||||
$this->getJson('/api/document-preview/history/'.$this->draft)->assertOk()->assertJsonMissingPath('calculation');
|
||||
$this->assertSame($before,DB::table('b_internal_confirmations')->get()->toJson());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace Tests\Support;
|
||||
final class CatalogueAccountingFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$f=DocumentPreviewFixture::create();
|
||||
foreach(['manager','reader'] as $role) {
|
||||
IdentityFixture::grant($f[$role]->id,$f['tenant'],$f['company'],'catalogue_accounting.view');
|
||||
IdentityFixture::grant($f[$role]->id,$f['tenant'],$f['company'],$role==='manager'?'catalogue_accounting.propose':'catalogue_accounting.approve');
|
||||
}
|
||||
return $f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class DocumentPreparationFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$fixture = InternalConfirmationFixture::create();
|
||||
foreach (['issuer_documents','client_documents','draft_documents'] as $area) {
|
||||
$unit = $area === 'draft_documents' ? $fixture['unit'] : null;
|
||||
foreach (['view','edit'] as $action) { IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], "$area.$action", $unit); }
|
||||
IdentityFixture::grant($fixture['reader']->id, $fixture['tenant'], $fixture['company'], "$area.view", $unit);
|
||||
}
|
||||
return $fixture;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class DocumentPreviewFixture
|
||||
{
|
||||
public static function create(): array
|
||||
{
|
||||
$fixture = DocumentPreparationFixture::create();
|
||||
IdentityFixture::grant($fixture['manager']->id, $fixture['tenant'], $fixture['company'], \App\Modules\Services\DocumentPreview::CONFIGURE);
|
||||
return $fixture;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user