Files
Investidor-V2/tests/Feature/SimulationTaxRuleGovernanceTest.php
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

217 lines
11 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InvestmentLegalSource;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SimulationTaxRuleGovernanceTest extends TestCase
{
use RefreshDatabase;
private User $editor;
private User $reviewer;
private User $approver;
private User $publisher;
private InvestmentLegalSource $source;
protected function setUp(): void
{
parent::setUp();
$this->editor = User::factory()->withTwoFactor()->create();
$this->reviewer = User::factory()->withTwoFactor()->create();
$this->approver = User::factory()->withTwoFactor()->create();
$this->publisher = User::factory()->withTwoFactor()->create();
config([
'portal.managers' => [$this->editor->email, $this->reviewer->email, $this->approver->email, $this->publisher->email],
'simulation-tax-governance.editors' => [$this->editor->email],
'simulation-tax-governance.reviewers' => [$this->editor->email, $this->reviewer->email],
'simulation-tax-governance.approvers' => [$this->approver->email],
'simulation-tax-governance.publishers' => [$this->publisher->email],
]);
$this->source = InvestmentLegalSource::query()->create([
'title' => 'Synthetic source for governance test',
'url' => 'https://law.example.invalid/test',
'diploma' => 'Synthetic test instrument',
'article' => 'Article 1 (test only)',
'competent_entity' => 'Synthetic test reviewer',
'valid_from' => '2020-01-01',
'valid_until' => null,
'validation_status' => 'validated',
'owner_id' => $this->editor->id,
'validator_id' => $this->reviewer->id,
'validated_at' => now(),
'version_no' => 2,
]);
}
public function test_rule_requires_independent_review_approval_publication_and_records_hash_chained_versions(): void
{
$this->actingAs($this->editor);
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())
->assertCreated()->assertJsonPath('status', 'draft')->assertJsonPath('version_no', 1);
$id = $created->json('id');
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))
->assertOk()->assertJsonPath('status', 'submitted')->assertJsonPath('version_no', 2);
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
->assertForbidden();
$this->actingAs($this->reviewer)
->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))
->assertOk()->assertJsonPath('status', 'reviewed')->assertJsonPath('reviewer_id', $this->reviewer->id);
$this->actingAs($this->approver)
->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))
->assertOk()->assertJsonPath('status', 'approved')->assertJsonPath('approver_id', $this->approver->id);
$this->actingAs($this->publisher)
->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
->assertOk()->assertJsonPath('status', 'published')->assertJsonPath('publisher_id', $this->publisher->id);
$events = $this->visibleEvents($id);
$this->assertSame(['created', 'submit', 'review', 'approve', 'publish'], $events->pluck('action')->all());
foreach ($events as $event) {
$canonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$event->snapshot])->canonical;
$this->assertSame(hash('sha256', $canonical), $event->sha256);
}
$this->assertDatabaseHas('simulation_tax_rule_versions', [
'id' => $id, 'status' => 'published', 'rule_version' => 1, 'source_version_no' => 2,
]);
DB::select("select set_config('app.actor_user_id', '', true), set_config('app.simulation_tax_governance', '', true)");
$affected = null;
try {
$affected = DB::transaction(fn () => DB::table('simulation_tax_rule_events')
->where('rule_version_id', $id)->update(['action' => 'tampered']), 1);
} catch (QueryException) {
// The immutable trigger rejects this on superuser-based test connections.
}
$this->assertTrue($affected === null || $affected === 0, 'RLS or the immutable trigger must reject direct event mutation.');
$this->assertSame($events->pluck('action')->all(), $this->visibleEvents($id)->pluck('action')->all());
$publishedStatus = DB::transaction(function () use ($id): ?string {
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $this->publisher->id]);
return DB::table('simulation_tax_rule_versions')->where('id', $id)->value('status');
});
$this->assertSame('published', $publishedStatus);
$this->actingAs($this->publisher)->getJson('/api/v2/investment/admin/tax-rules?rule_code=SYNTHETIC.TEST.RULE')
->assertOk()->assertJsonPath('total', 1);
}
public function test_source_revocation_stops_publication_and_conflicting_effective_rules_are_rejected(): void
{
$first = $this->publishRule($this->rulePayload());
$this->assertSame('published', $first['status']);
$conflict = $this->rulePayload();
$conflict['rule_version'] = 2;
$this->actingAs($this->editor);
$draft = $this->postJson('/api/v2/investment/admin/tax-rules', $conflict)->assertCreated();
$id = $draft->json('id');
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertOk();
$this->actingAs($this->reviewer)->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))->assertOk();
$this->actingAs($this->approver)->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))->assertOk();
$this->actingAs($this->publisher)->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
->assertConflict();
$this->source->update(['validation_status' => 'revoked', 'version_no' => 3]);
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
->assertUnprocessable();
$ruleId = $first['id'];
$this->postJson("/api/v2/investment/admin/tax-rules/{$ruleId}/suspend", $this->transitionPayload(5))
->assertOk()->assertJsonPath('status', 'suspended');
}
public function test_draft_edit_uses_optimistic_locking_and_other_roles_cannot_create_rules(): void
{
$this->actingAs($this->reviewer)->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertForbidden();
$this->actingAs($this->editor);
$created = $this->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertCreated();
$id = $created->json('id');
$payload = $this->rulePayload();
$payload['rule_definition'] = ['kind' => 'unexecuted_declarative_example'];
$this->putJson("/api/v2/investment/admin/tax-rules/{$id}", ['version_no' => 99, ...$payload])->assertConflict();
$this->putJson("/api/v2/investment/admin/tax-rules/{$id}", ['version_no' => 1, ...$payload])
->assertOk()->assertJsonPath('version_no', 2);
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertConflict();
}
public function test_rollback_refuses_to_drop_rule_versions_or_their_audit_history(): void
{
$this->actingAs($this->editor)->postJson('/api/v2/investment/admin/tax-rules', $this->rulePayload())->assertCreated();
$migration = require database_path('migrations/2026_10_03_170000_create_simulation_tax_rule_governance.php');
try {
$migration->down();
$this->fail('Rollback must preserve recorded rule governance.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('history exists', $exception->getMessage());
}
$this->assertTrue(Schema::hasTable('simulation_tax_rule_versions'));
$this->assertTrue(Schema::hasTable('simulation_tax_rule_events'));
}
/** @return array<string,mixed> */
private function publishRule(array $payload): array
{
$this->actingAs($this->editor);
$draft = $this->postJson('/api/v2/investment/admin/tax-rules', $payload)->assertCreated();
$id = $draft->json('id');
$this->postJson("/api/v2/investment/admin/tax-rules/{$id}/submit", $this->transitionPayload(1))->assertOk();
$this->actingAs($this->reviewer)->postJson("/api/v2/investment/admin/tax-rules/{$id}/review", $this->transitionPayload(2))->assertOk();
$this->actingAs($this->approver)->postJson("/api/v2/investment/admin/tax-rules/{$id}/approve", $this->transitionPayload(3))->assertOk();
return $this->actingAs($this->publisher)->postJson("/api/v2/investment/admin/tax-rules/{$id}/publish", $this->transitionPayload(4))
->assertOk()->json();
}
/** @return array<string,mixed> */
private function rulePayload(): array
{
return [
'rule_code' => 'SYNTHETIC.TEST.RULE',
'rule_version' => 1,
'component' => 'IRPC',
'scope_key' => 'MZ:test:synthetic',
'valid_from' => '2026-01-01',
'valid_until' => null,
'legal_source_id' => $this->source->id,
'source_sha256' => str_repeat('a', 64),
'rule_definition' => ['kind' => 'unexecuted_synthetic_fixture'],
'parameters' => ['fixture' => 'not-a-tax-rate'],
'rounding_policy' => ['fixture' => 'not-approved'],
];
}
/** @return array{version_no:int,evidence:string} */
private function transitionPayload(int $version): array
{
return ['version_no' => $version, 'evidence' => 'Synthetic governance evidence for automated test only.'];
}
/** @return Collection<int,object> */
private function visibleEvents(string $ruleId): Collection
{
return DB::transaction(function () use ($ruleId): Collection {
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_tax_governance', 'on', true)", [(string) $this->publisher->id]);
return DB::table('simulation_tax_rule_events')->where('rule_version_id', $ruleId)->orderBy('version_no')->get();
});
}
}