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

242 lines
14 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\SimulationTaxRuleGovernance;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Published fixtures exist only in an isolated QA transaction, never in homologation. */
class SimulationPublishedFiscalTest extends TestCase
{
use DatabaseTransactions;
private User $consumer;
private User $publisher;
private SimulationTaxRuleVersion $rule;
private InvestmentLegalSource $source;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
$this->consumer = User::factory()->create(['email_verified_at' => now()]);
$actors = [];
foreach (['editors', 'reviewers', 'approvers', 'publishers'] as $role) {
$actors[$role] = User::factory()->withTwoFactor()->create();
config(['simulation-tax-governance.'.$role => [$actors[$role]->email]]);
}
$this->publisher = $actors['publishers'];
$this->source = InvestmentLegalSource::create([
'title' => 'Synthetic QA fixture, not a homologation publication', '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' => $actors['editors']->id, 'version_no' => 1, 'validator_id' => $actors['reviewers']->id, 'validated_at' => now(),
]);
$data = json_decode(file_get_contents(database_path('qa/iva-general-net-v1.json')), true, flags: JSON_THROW_ON_ERROR);
$data['legal_source_id'] = $this->source->id;
$gov = app(SimulationTaxRuleGovernance::class);
$this->rule = $gov->create($actors['editors'], $data);
foreach (['submit' => 'editors', 'review' => 'reviewers', 'approve' => 'approvers', 'publish' => 'publishers'] as $action => $role) {
$this->rule = $gov->transition($actors[$role], $this->rule->id, $this->rule->version_no, $action,
'Synthetic isolated QA fixture for consumer integration only.');
}
config(['simulation-fiscal-consumer.enabled' => true, 'simulation-fiscal-consumer.rule_id' => $this->rule->id,
'simulation-fiscal-consumer.rule_version' => 1]);
}
private function input(array $overrides = []): array
{
return ['scenario_kind' => 'fiscal_rule_published', 'base' => '1000.03', 'currency' => 'MZN',
'reference_date' => '2026-10-21', 'operation' => 'internal_general_net', 'scope_declared' => true, ...$overrides];
}
private function create(array $overrides = [], ?string $key = null)
{
return $this->actingAs($this->consumer)->postJson('/api/v1/simulations', $this->input($overrides),
['Idempotency-Key' => $key ?? (string) Str::uuid()]);
}
private function finish(string $id): Simulation
{
(new RunSimulation($id, $this->consumer->id, null))->handle(app(SimulationExecution::class));
return Simulation::with('reports')->findOrFail($id);
}
public function test_iva_query_diagnostics_explain_the_reported_contract_gap_without_calculating_or_changing_answers(): void
{
$count = Simulation::count();
$input = ['component' => 'IVA', 'period_start' => '2026-01-01', 'period_end' => '2026-12-31',
'context' => ['fiscal_operation' => 'domestic', 'currency' => 'USD']];
$this->actingAs($this->consumer)->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('assumptions.currency', 'USD')
->assertJsonPath('parameters.0.status', 'indeterminate_context')
->assertJsonPath('diagnostics.0.label', 'IVA — taxa geral')
->assertJsonPath('diagnostics.0.rule_version', 1)
->assertJsonPath('diagnostics.0.issues.1.category', 'system_configuration')
->assertJsonPath('diagnostics.0.issues.1.received', 'domestic')
->assertJsonPath('diagnostics.0.issues.0.category', 'out_of_scope')
->assertJsonPath('diagnostics.0.issues.0.received', 'USD')
->assertJsonPath('diagnostics.0.issues.0.expected', 'MZN');
unset($input['context']['currency']);
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('diagnostics.0.issues.0.category', 'missing_field');
$input['context']['currency'] = 'MZN';
$input['period_start'] = '2022-01-01';
$input['period_end'] = '2022-12-31';
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('parameters', [])
->assertJsonPath('diagnostics.0.issues.0.category', 'period_unavailable');
self::assertSame($count, Simulation::count());
}
public function test_normal_simulator_admits_published_rule_and_reuses_history_and_reports(): void
{
$key = (string) Str::uuid();
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', true);
$id = $this->create([], $key)->assertAccepted()->json('data.id');
$this->create([], $key)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.id', $id);
$this->create(['base' => '1001'], $key)->assertConflict();
$simulation = $this->finish($id);
$snapshot = $simulation->result_snapshot;
$this->assertSame('fiscal_rule_published', $snapshot['scenario_kind']);
$this->assertSame('calculated', $snapshot['result']['status']);
$this->assertSame($this->rule->id, $snapshot['fiscal_provenance']['rule']['id']);
$this->assertSame('published', $snapshot['fiscal_provenance']['rule']['status']);
$this->assertArrayNotHasKey('review_evidence', $snapshot['fiscal_provenance']['rule']);
$this->assertArrayNotHasKey('author_id', $snapshot['fiscal_provenance']['rule']);
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1);
$this->getJson('/api/v1/simulations/'.$id)->assertOk()->assertJsonPath('data.result_snapshot', $snapshot);
$report = $simulation->reports->first();
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$report->id)->assertOk()->assertJsonPath('data.report_snapshot', $snapshot);
$this->assertSame($report->report_sha256, DB::table('simulation_results')->where('simulation_id', $id)->value('result_sha256'));
$this->assertSame(1, Simulation::where('owner_user_id', $this->consumer->id)->count());
$this->assertDatabaseHas('simulation_result_sources', ['source_id' => $this->rule->id, 'source_type' => 'tax_rule_published']);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]))->getJson('/api/v1/simulations/'.$id)->assertNotFound();
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$report->id)->assertNotFound();
}
public function test_missing_scope_or_changed_source_is_indeterminate_not_zero(): void
{
$id = $this->create(['scope_declared' => false])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id)->result_snapshot;
$this->assertSame('indeterminate', $snapshot['result']['status']);
$this->assertNull($snapshot['result']['value']);
$this->assertContains('operation_scope', $snapshot['result']['missing_inputs']);
$this->assertSame('undetermined', $snapshot['fiscal_provenance']['scope_status']);
$this->source->update(['version_no' => 2]);
$id = $this->create()->assertAccepted()->json('data.id');
$snapshot = $this->finish($id)->result_snapshot;
$this->assertSame('indeterminate', $snapshot['result']['status']);
$this->assertNull($snapshot['result']['value']);
$this->assertContains('eligible_parameter', $snapshot['result']['missing_inputs']);
}
public function test_suspension_refuses_new_admissions_but_frozen_queue_and_replays_survive(): void
{
$key = (string) Str::uuid();
$id = $this->create([], $key)->assertAccepted()->json('data.id');
$this->assertDatabaseHas('simulations', ['id' => $id, 'status' => 'queued']);
app(SimulationTaxRuleGovernance::class)->transition($this->publisher, $this->rule->id, $this->rule->version_no,
'suspend', 'QA: suspend after admission and before worker execution.');
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false);
$this->create()->assertStatus(503);
$this->create([], $key)->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('replayed', true);
config(['simulation-fiscal-consumer.enabled' => false]);
$this->create()->assertStatus(503);
$simulation = $this->finish($id);
$this->assertSame('completed', $simulation->status);
$this->assertSame('published', $simulation->result_snapshot['fiscal_provenance']['rule']['status']);
$this->assertSame($this->rule->id, $simulation->result_snapshot['fiscal_provenance']['rule']['id']);
$this->assertSame('suspended', $this->rule->fresh()->status);
$this->create([], $key)->assertOk()->assertJsonPath('data.status', 'completed');
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$simulation->reports->first()->id)->assertOk()
->assertJsonPath('data.report_snapshot', $simulation->result_snapshot);
$this->getJson('/api/v1/simulations/history')->assertOk()->assertJsonPath('meta.total', 1);
}
public function test_server_selection_cannot_be_overridden_and_coverage_is_bounded(): void
{
$this->create(['rate' => '0'])->assertUnprocessable();
$this->create(['rule_id' => (string) Str::uuid()])->assertUnprocessable();
$this->create(['reference_date' => '2027-01-01'])->assertUnprocessable();
$this->create(['currency' => 'USD'])->assertUnprocessable();
config(['simulation-fiscal-consumer.rule_version' => 2]);
$this->create()->assertStatus(503);
config(['simulation-fiscal-consumer.rule_id' => 'invalid-configuration']);
$this->getJson('/api/v1/fiscal-consumer')->assertOk()->assertJsonPath('data.enabled', false);
$this->assertSame(0, Simulation::where('owner_user_id', $this->consumer->id)->count());
}
public function test_anonymous_cookie_token_ownership_is_reused(): void
{
$key = (string) Str::uuid();
$response = $this->postJson('/api/v1/simulations', $this->input(), ['Idempotency-Key' => $key]);
$id = $response->assertAccepted()->json('data.id');
$token = $response->json('access_token');
$this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => $token])->assertOk();
$this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => str_repeat('x', 40)])->assertNotFound();
$this->getJson('/api/v1/simulations/history')->assertUnauthorized();
}
public function test_disabled_consumer_does_not_disable_custom_planning(): void
{
config(['simulation-fiscal-consumer.enabled' => false]);
$this->create()->assertStatus(503);
$this->actingAs($this->consumer)->postJson('/api/v1/simulations', ['base' => '100', 'rate' => '5', 'reduction' => '0'],
['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->assertJsonPath('data.input_snapshot.scenario_kind', 'custom_planning');
}
public function test_public_consumer_works_under_existing_rls_without_governance_context(): void
{
// Role and grants roll back with the isolated test transaction. No application permission is added.
DB::statement('CREATE ROLE qa_fiscal_consumer_rls_20261021 NOLOGIN NOSUPERUSER NOBYPASSRLS');
DB::statement('GRANT USAGE ON SCHEMA public TO qa_fiscal_consumer_rls_20261021');
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO qa_fiscal_consumer_rls_20261021');
DB::statement('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO qa_fiscal_consumer_rls_20261021');
DB::statement("SELECT set_config('app.simulation_tax_governance', 'off', true)");
DB::statement('SET LOCAL ROLE qa_fiscal_consumer_rls_20261021');
try {
$this->assertSame(1, SimulationTaxRuleVersion::whereKey($this->rule->id)->count());
$id = $this->create()->assertAccepted()->json('data.id');
$this->assertNotEmpty($id);
$this->assertSame(0, DB::table('simulation_tax_rule_events')->where('rule_version_id', $this->rule->id)->count());
// An UPDATE scope is not granted by the public SELECT policy.
$this->assertSame(0, SimulationTaxRuleVersion::whereKey($this->rule->id)->update(['status' => 'suspended']));
} finally {
DB::statement('RESET ROLE');
}
}
public function test_admission_holds_shared_locks_until_transaction_end(): void
{
$this->create()->assertAccepted();
config(['database.connections.fiscal_lock_probe' => config('database.connections.pgsql')]);
$probe = DB::connection('fiscal_lock_probe');
$probe->beginTransaction();
try {
// A concurrent suspension/source transition cannot cross an admitted transaction.
$rule = $probe->selectOne('select pg_try_advisory_xact_lock(261021, hashtext(?)) as acquired', [$this->rule->id]);
$source = $probe->selectOne('select pg_try_advisory_xact_lock(261022, hashtext(?)) as acquired', [$this->source->id]);
$this->assertFalse($rule->acquired);
$this->assertFalse($source->acquired);
} finally {
$probe->rollBack();
DB::purge('fiscal_lock_probe');
}
}
}