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(); } }