Files
Investidor-V2/tests/Feature/SimulationFiscalQaPreviewTest.php
T

162 lines
9.4 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\RunSimulation;
use App\Models\InvestmentLegalSource;
use App\Models\Simulation;
use App\Models\SimulationTaxRuleVersion;
use App\Models\User;
use App\Services\SimulationExecution;
use App\Services\SimulationTaxParameterResolver;
use App\Services\SimulationTaxRuleGovernance;
use Brick\Math\BigRational;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class SimulationFiscalQaPreviewTest extends TestCase
{
use DatabaseTransactions;
private User $editor;
private InvestmentLegalSource $source;
private string $ruleId;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
$this->editor = User::factory()->withTwoFactor()->create();
config(['portal.managers' => [$this->editor->email], 'simulation-tax-governance.editors' => [$this->editor->email],
'simulation-tax-governance.publishers' => [$this->editor->email]]);
$this->source = InvestmentLegalSource::create([
'title' => 'QA fixture: Lei22/2022 republicada; sem activação',
'url' => 'https://www.inm.gov.mz/', 'diploma' => 'Lei 22/2022', 'article' => 'Artigo 17(1)',
'competent_entity' => 'Assembleia da República', 'valid_from' => '2023-01-01', 'valid_until' => null,
'validation_status' => 'validated', 'owner_id' => $this->editor->id, 'version_no' => 1,
'validator_id' => User::factory()->create()->id, 'validated_at' => now(),
]);
$this->artisan('fiscal:qa-register-iva', ['actor' => $this->editor->id, 'source' => $this->source->id])->assertSuccessful();
$this->ruleId = SimulationTaxRuleVersion::where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->value('id');
}
private function preview(array $overrides = [], ?string $key = null)
{
return $this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules/'.$this->ruleId.'/qa-preview', [
'base' => '0.03', 'currency' => 'MZN', 'reference_date' => '2026-10-21', 'operation' => 'internal_general_net', ...$overrides,
], ['Idempotency-Key' => $key ?? (string) Str::uuid()]);
}
private function finish(string $id): array
{
(new RunSimulation($id, $this->editor->id, null))->handle(app(SimulationExecution::class));
return Simulation::findOrFail($id)->result_snapshot;
}
public function test_draft_registration_reuses_version_and_never_exposes_a_published_parameter(): void
{
$this->artisan('fiscal:qa-register-iva', ['actor' => $this->editor->id, 'source' => $this->source->id])->assertSuccessful();
$this->assertSame(1, SimulationTaxRuleVersion::where('rule_code', 'IVA-GERAL-BASE-LIQUIDA')->count());
$this->assertDatabaseHas('simulation_tax_rule_versions', ['id' => $this->ruleId, 'status' => 'draft']);
$resolved = app(SimulationTaxParameterResolver::class)->resolvePublishedForPeriod('IVA', '2026-10-21', '2026-10-21',
['operation' => 'internal_general_net', 'currency' => 'MZN']);
$this->assertSame([], $resolved);
$this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules/'.$this->ruleId.'/publish', ['version_no' => 1, 'evidence' => 'QA attempt must not publish an incomplete fiscal rule.'])->assertUnprocessable();
}
public function test_fractional_result_is_exact_and_snapshot_survives_rule_edits_and_new_session(): void
{
$key = (string) Str::uuid();
$id = $this->preview([], $key)->assertAccepted()->json('data.id');
$this->preview([], $key)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.id', $id);
$this->preview(['base' => '1'], $key)->assertConflict();
$snapshot = $this->finish($id);
$this->assertSame('0.0048', $snapshot['result']['value']);
$this->assertSame('calculated', $snapshot['result']['status']);
$this->assertSame('0.0048', $snapshot['presentation']['value']);
$this->assertFalse($snapshot['presentation']['rounding_applied']);
$this->assertEquals(0, BigRational::of($snapshot['intermediate']['result'])->compareTo('0.0048'));
$this->assertSame('draft', $snapshot['fiscal_provenance']['rule']['status']);
$this->assertSame('user_declared', $snapshot['fiscal_provenance']['scope_status']);
$this->assertSame([], $snapshot['result']['missing_inputs']);
$rule = SimulationTaxRuleVersion::findOrFail($this->ruleId);
$params = $rule->parameters;
$params['IVA_GENERAL_RATE']['value'] = '17';
app(SimulationTaxRuleGovernance::class)->update($this->editor, $this->ruleId, 1, [
'rule_code' => $rule->rule_code, 'rule_version' => 1, 'component' => 'IVA', 'scope_key' => $rule->scope_key,
'valid_from' => '2023-01-01', 'valid_until' => null, 'legal_source_id' => $this->source->id,
'source_sha256' => $rule->source_sha256, 'rule_definition' => $rule->rule_definition, 'parameters' => $params,
'rounding_policy' => $rule->rounding_policy,
]);
auth()->logout();
$this->actingAs(User::findOrFail($this->editor->id))->getJson('/api/v1/simulations/'.$id)->assertOk()
->assertJsonPath('data.result_snapshot.fiscal_provenance.rule.parameters.IVA_GENERAL_RATE.value', '16');
$reportId = DB::table('simulation_reports')->where('simulation_id', $id)->value('id');
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$reportId)->assertOk()->assertJsonPath('data.report_snapshot', $snapshot);
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1);
$this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'tax_rule_draft', 'source_id' => $this->ruleId]);
$resultId = DB::table('simulation_results')->where('simulation_id', $id)->value('id');
$declared = json_decode(DB::table('simulation_result_sources')->where('result_id', $resultId)->where('source_type', 'user_input')->value('source_snapshot'), true);
$this->assertSame('0.03', $declared['base']);
$this->assertArrayNotHasKey('fiscal_preview', $declared);
$other = User::factory()->create(['email_verified_at' => now()]);
$this->actingAs($other)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$reportId)->assertNotFound();
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 0);
}
public function test_coverage_and_source_version_mismatch_stay_indeterminate_without_an_intermediate(): void
{
$id = $this->preview(['reference_date' => '2025-12-31'])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertContains('demonstrated_coverage', $snapshot['result']['missing_inputs']);
$this->assertNull($snapshot['intermediate']);
$this->source->update(['version_no' => 2]);
$id = $this->preview()->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertContains('eligible_parameter', $snapshot['result']['missing_inputs']);
$this->assertNull($snapshot['intermediate']);
}
public function test_zero_is_an_explicit_input_missing_currency_is_rejected_and_non_qa_preview_is_unavailable(): void
{
$id = $this->preview(['base' => '0'])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id);
$this->assertEquals(0, BigRational::of($snapshot['intermediate']['result'])->compareTo(0));
$this->assertSame('0', $snapshot['result']['value']);
$largeId = $this->preview(['base' => '9007199254740993.0003'])->assertAccepted()->json('data.id');
$this->assertSame('1441151880758558.880048', $this->finish($largeId)['result']['value']);
$this->preview(['currency' => null])->assertUnprocessable();
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->preview()->assertNotFound();
}
public function test_this_consumer_refuses_a_round_node_instead_of_silently_applying_it(): void
{
$rule = SimulationTaxRuleVersion::findOrFail($this->ruleId);
$definition = $rule->rule_definition;
$definition['nodes']['rounded'] = ['op' => 'round', 'args' => ['intermediate'], 'scale' => 2, 'mode' => 'HALF_UP'];
$definition['result'] = 'rounded';
// Deliberately altered QA draft; no rule is published by this test.
$rule->update(['rule_definition' => $definition]);
$this->preview()->assertUnprocessable()->assertJsonPath('message', 'Este percurso preserva o valor exacto e não aplica arredondamento automático.');
}
public function test_qa_review_page_uses_existing_administration_and_environment_guards(): void
{
$path = '/management/investment/tax-rules/'.$this->ruleId.'/qa-review';
$this->get($path)->assertRedirect('/login');
$this->actingAs($this->editor)->get($path)->assertOk()->assertInertia(fn ($page) => $page
->component('management/fiscal-qa-review')->where('rule.id', $this->ruleId)->where('rule.code', 'IVA-GERAL-BASE-LIQUIDA'));
$this->actingAs(User::factory()->withTwoFactor()->create())->get($path)->assertForbidden();
config(['database.connections.pgsql.url' => 'not-an-isolated-qa-target']);
$this->actingAs($this->editor)->get($path)->assertNotFound();
}
}