397 lines
26 KiB
PHP
397 lines
26 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Jobs\RunSimulation;
|
|
use App\Models\User;
|
|
use App\Services\SimulationExecution;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class SimulationExecutionTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Queue::fake();
|
|
}
|
|
|
|
public function test_account_history_contains_only_the_verified_users_personal_simulations(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$other = User::factory()->create(['email_verified_at' => now()]);
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', [
|
|
'base' => '1200', 'rate' => '7.5', 'reduction' => '0',
|
|
], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$this->actingAs($other)->postJson('/api/v1/simulations', [
|
|
'base' => '9900', 'rate' => '9', 'reduction' => '1',
|
|
], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$organizationId = (string) Str::uuid();
|
|
DB::table('organizations')->insert([
|
|
'id' => $organizationId, 'name' => 'History organization', 'organization_type' => 'private',
|
|
'data_classification' => 'internal', 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
DB::table('memberships')->insert([
|
|
'id' => (string) Str::uuid(), 'user_id' => $owner->id, 'organization_id' => $organizationId,
|
|
'role' => 'admin', 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', [
|
|
'base' => '700', 'rate' => '4', 'reduction' => '0', 'organization_id' => $organizationId,
|
|
], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
auth()->logout();
|
|
$this->postJson('/api/v1/simulations', [
|
|
'base' => '500', 'rate' => '10', 'reduction' => '0',
|
|
], ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48)])->assertAccepted();
|
|
|
|
$response = $this->actingAs($owner)->getJson('/api/v1/simulations/history')
|
|
->assertOk()->assertJsonPath('meta.total', 2)->assertJsonPath('data.0.status', 'queued')
|
|
->assertJsonStructure(['data' => [['id', 'scope', 'organization_name', 'status', 'version_no', 'created_at', 'updated_at', 'has_ready_report']],
|
|
'meta' => ['current_page', 'last_page', 'per_page', 'total']]);
|
|
$entries = $response->json('data');
|
|
$this->assertEqualsCanonicalizing(['personal', 'organization'], array_column($entries, 'scope'));
|
|
$this->assertSame('History organization', collect($entries)->firstWhere('scope', 'organization')['organization_name']);
|
|
$entry = $entries[0];
|
|
$this->assertArrayHasKey('id', $entry);
|
|
$this->assertArrayNotHasKey('input_snapshot', $entry);
|
|
$this->assertArrayNotHasKey('result_snapshot', $entry);
|
|
$this->assertArrayNotHasKey('anonymous_token_hash', $entry);
|
|
$this->actingAs($other)->getJson('/api/v1/simulations/history')
|
|
->assertOk()->assertJsonPath('meta.total', 1);
|
|
}
|
|
|
|
public function test_account_history_requires_authentication_and_verified_email(): void
|
|
{
|
|
$this->getJson('/api/v1/simulations/history')->assertUnauthorized();
|
|
$user = User::factory()->create(['email_verified_at' => null]);
|
|
$this->actingAs($user)->getJson('/api/v1/simulations/history')->assertForbidden();
|
|
}
|
|
|
|
public function test_anonymous_execution_keeps_only_token_hash_and_reconciles_immutable_sources(): void
|
|
{
|
|
$token = Str::random(48);
|
|
$key = (string) Str::uuid();
|
|
$response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '50'], [
|
|
'Idempotency-Key' => $key, 'X-Simulation-Token' => $token,
|
|
])->assertAccepted()->assertJsonPath('data.status', 'queued')->assertJsonPath('access_token', $token);
|
|
$queuedEtag = $response->headers->get('ETag');
|
|
$simulationId = $response->json('data.id');
|
|
$this->assertDatabaseHas('simulations', ['id' => $simulationId, 'anonymous_token_hash' => hash_hmac('sha256', $token, (string) config('app.key'))]);
|
|
$this->assertDatabaseMissing('simulations', ['id' => $simulationId, 'anonymous_token_hash' => $token]);
|
|
|
|
app(RunSimulation::class, ['simulationId' => $simulationId, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))])
|
|
->handle(app(SimulationExecution::class));
|
|
|
|
$completedResponse = $this->getJson('/api/v1/simulations/'.$simulationId, ['X-Simulation-Token' => $token])
|
|
->assertOk()->assertJsonPath('data.status', 'completed');
|
|
$this->assertNotSame($queuedEtag, $completedResponse->headers->get('ETag'));
|
|
$simulationView = $completedResponse->json('data');
|
|
$this->assertEqualsWithDelta(50.0, (float) $simulationView['result_snapshot']['indicative_total'], 0.0000001);
|
|
$resultSnapshot = $simulationView['result_snapshot'];
|
|
$scenario = DB::table('simulation_scenarios')->where('simulation_id', $simulationId)->first();
|
|
$this->assertSame('custom_planning', $resultSnapshot['scenario_kind']);
|
|
$this->assertSame('custom_planning', json_decode($scenario->parameters, true, flags: JSON_THROW_ON_ERROR)['scenario_kind']);
|
|
$this->assertSame(1, $resultSnapshot['contract_version']);
|
|
$this->assertSame('calculated', $resultSnapshot['result']['status']);
|
|
$this->assertSame('Resultado aritmético determinístico; não constitui cálculo nem conclusão fiscal.', $resultSnapshot['result']['explanation']);
|
|
$this->assertSame($resultSnapshot['indicative_total'], $resultSnapshot['result']['value']);
|
|
$this->assertIsString($resultSnapshot['result']['value']);
|
|
$this->assertSame([], $resultSnapshot['result']['missing_inputs']);
|
|
$this->assertSame([], $resultSnapshot['result']['source_ids']);
|
|
$this->assertSame((string) $scenario->id, $resultSnapshot['context']['scenario_id']);
|
|
$this->assertSame((int) $scenario->version_no, $resultSnapshot['context']['scenario_version']);
|
|
$this->assertSame(substr((string) $scenario->created_at, 0, 10), $resultSnapshot['context']['reference_date']);
|
|
$this->assertNull($resultSnapshot['context']['currency']);
|
|
$this->assertNull($resultSnapshot['context']['ruleset_version']);
|
|
$this->assertSame('portal-arithmetic-v1+simulation-v1', $resultSnapshot['context']['engine_version']);
|
|
$this->assertSame('portal-arithmetic-v1', $resultSnapshot['formula_version']);
|
|
$this->assertDatabaseHas('simulation_result_sources', ['source_type' => 'user_input']);
|
|
$this->assertSame(1, DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->count());
|
|
$savedReport = DB::table('simulation_reports')->where('simulation_id', $simulationId)->where('status', 'ready')->first();
|
|
$this->assertSame($resultSnapshot, json_decode($savedReport->report_snapshot, true, flags: JSON_THROW_ON_ERROR));
|
|
$reportResponse = $this->getJson('/api/v1/simulations/'.$simulationId.'/reports/'.$savedReport->id, [
|
|
'X-Simulation-Token' => $token,
|
|
])->assertOk()->assertJsonPath('data.id', $savedReport->id)
|
|
->assertJsonPath('data.status', 'ready')
|
|
->assertJsonStructure(['data' => ['id', 'simulation_id', 'version_no', 'status', 'report_snapshot', 'report_sha256']]);
|
|
$this->assertSame($resultSnapshot, $reportResponse->json('data.report_snapshot'));
|
|
$this->assertSame(trim((string) $savedReport->report_sha256), $reportResponse->json('data.report_sha256'));
|
|
$jobsResponse = $this->getJson('/api/v1/simulations/'.$simulationId.'/jobs', ['X-Simulation-Token' => $token])
|
|
->assertOk()->assertJsonPath('data.0.status', 'succeeded')
|
|
->assertJsonStructure(['data' => [['id', 'simulation_id', 'job_type', 'status', 'attempts', 'available_at', 'started_at', 'finished_at', 'error_code']]]);
|
|
$this->assertNotNull($jobsResponse->json('data.0.started_at'));
|
|
$this->assertNotNull($jobsResponse->json('data.0.finished_at'));
|
|
$this->assertNull($jobsResponse->json('data.0.error_code'));
|
|
$this->getJson('/api/v1/simulations/'.$simulationId.'/reports/'.$savedReport->id, [
|
|
'X-Simulation-Token' => Str::random(48),
|
|
])->assertNotFound();
|
|
|
|
$simulation = DB::table('simulations')->where('id', $simulationId)->first();
|
|
$result = DB::table('simulation_results')->where('simulation_id', $simulationId)->first();
|
|
$source = DB::table('simulation_result_sources')->where('result_id', $result->id)->first();
|
|
$resultCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$result->result_snapshot])->canonical;
|
|
$sourceCanonical = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [$source->source_snapshot])->canonical;
|
|
$this->assertSame($result->result_sha256, hash('sha256', $resultCanonical));
|
|
$this->assertSame($source->source_sha256, hash('sha256', $sourceCanonical));
|
|
$this->assertNotNull($simulation->questionnaire_version_id);
|
|
}
|
|
|
|
public function test_simulation_audit_events_are_append_only_for_an_authorized_owner(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$response = $this->actingAs($owner)->postJson('/api/v1/simulations', [
|
|
'base' => '1000', 'rate' => '10', 'reduction' => '0',
|
|
], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$simulationId = $response->json('data.id');
|
|
app(SimulationExecution::class)->setContext($owner->id, null);
|
|
$event = DB::table('simulation_audit_events')->where('simulation_id', $simulationId)->first();
|
|
$this->assertNotNull($event);
|
|
|
|
try {
|
|
DB::transaction(fn () => DB::table('simulation_audit_events')
|
|
->where('id', $event->id)
|
|
->update(['action' => 'audit.tampered']));
|
|
} catch (QueryException) {
|
|
// Superuser-based test connections reach the immutable trigger;
|
|
// restricted roles may be denied earlier by row-level security.
|
|
}
|
|
$this->assertDatabaseMissing('simulation_audit_events', [
|
|
'id' => $event->id,
|
|
'action' => 'audit.tampered',
|
|
]);
|
|
$this->assertSame(1, (int) DB::selectOne(
|
|
'select count(*) as n from pg_trigger where tgname = ? and not tgisinternal',
|
|
['simulation_audit_immutable'],
|
|
)->n);
|
|
}
|
|
|
|
public function test_execution_requires_an_explicit_user_supplied_reduction(): void
|
|
{
|
|
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5'], [
|
|
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48),
|
|
])->assertUnprocessable()->assertJsonValidationErrors('reduction');
|
|
}
|
|
|
|
public function test_idempotency_replays_same_payload_and_rejects_key_reuse_with_different_payload(): void
|
|
{
|
|
$token = Str::random(48);
|
|
$key = (string) Str::uuid();
|
|
$headers = ['Idempotency-Key' => $key, 'X-Simulation-Token' => $token];
|
|
$first = $this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers)->assertAccepted();
|
|
$id = $first->json('data.id');
|
|
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '5', 'reduction' => '0'], $headers)
|
|
->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('replayed', true);
|
|
$this->postJson('/api/v1/simulations', ['base' => '500', 'rate' => '6', 'reduction' => '0'], $headers)->assertConflict();
|
|
$this->assertSame(1, DB::table('simulations')->where('id', $id)->count());
|
|
}
|
|
|
|
public function test_if_match_is_required_and_stale_versions_return_412_then_success_advances_version(): void
|
|
{
|
|
$token = Str::random(48);
|
|
$response = $this->postJson('/api/v1/simulations', ['base' => '1000', 'rate' => '10', 'reduction' => '0'], [
|
|
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
|
])->assertAccepted();
|
|
$id = $response->json('data.id');
|
|
$scenarioId = $response->json('data.scenarios.0.id');
|
|
app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => hash_hmac('sha256', $token, (string) config('app.key'))])
|
|
->handle(app(SimulationExecution::class));
|
|
$response = $this->getJson('/api/v1/simulations/'.$id, ['X-Simulation-Token' => $token])->assertOk();
|
|
$payload = ['label' => 'Cenário revisto', 'base' => '2000', 'rate' => '8', 'reduction' => '25'];
|
|
$url = "/api/v1/simulations/{$id}/scenarios/{$scenarioId}";
|
|
$headers = ['X-Simulation-Token' => $token];
|
|
$this->patchJson($url, $payload, $headers)->assertStatus(428);
|
|
$this->patchJson($url, $payload, $headers + ['If-Match' => '"sim-'.$id.'-v999"'])->assertStatus(412);
|
|
$etag = $response->headers->get('ETag');
|
|
$updated = $this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertAccepted();
|
|
$updated->assertHeader('ETag', '"sim-'.$id.'-v3"')
|
|
->assertJsonPath('data.status', 'queued')
|
|
->assertJsonPath('data.version_no', 3)
|
|
->assertJsonPath('data.scenarios.0.parameters.base', '2000')
|
|
->assertJsonPath('data.scenarios.0.parameters.rate', '8')
|
|
->assertJsonPath('data.scenarios.0.parameters.reduction', '25');
|
|
|
|
$tokenHash = hash_hmac('sha256', $token, (string) config('app.key'));
|
|
app(RunSimulation::class, ['simulationId' => $id, 'actorId' => null, 'tokenHash' => $tokenHash])->handle(app(SimulationExecution::class));
|
|
$this->assertSame(2, DB::table('simulation_results')->where('simulation_id', $id)->count());
|
|
$this->assertSame(2, DB::table('simulation_reports')->where('simulation_id', $id)->count());
|
|
$this->patchJson($url, $payload, $headers + ['If-Match' => $etag])->assertStatus(412);
|
|
}
|
|
|
|
public function test_rls_hides_rows_without_context_and_from_an_unrelated_actor(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$other = User::factory()->create(['email_verified_at' => now()]);
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id');
|
|
$execution = app(SimulationExecution::class);
|
|
|
|
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
|
DB::statement('GRANT simulation_rls_test TO CURRENT_USER');
|
|
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
|
DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test');
|
|
try {
|
|
DB::statement('SET ROLE simulation_rls_test');
|
|
$execution->setContext($other->id, null);
|
|
$this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
|
$execution->setContext($owner->id, null);
|
|
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
|
} finally {
|
|
DB::statement('RESET ROLE');
|
|
DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER');
|
|
DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
|
}
|
|
$this->actingAs($other)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
|
}
|
|
|
|
public function test_terminal_job_failure_is_reconciled_without_exposing_exception_text(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first();
|
|
$job = new RunSimulation($simulation->id, $owner->id, null);
|
|
$job->failed(new \RuntimeException('sensitive internal detail'));
|
|
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']);
|
|
$this->assertDatabaseHas('simulations', ['id' => $simulation->id, 'status' => 'failed', 'error_code' => 'execution_failed']);
|
|
$this->assertDatabaseMissing('simulation_audit_events', ['simulation_id' => $simulation->id, 'action' => 'sensitive internal detail']);
|
|
}
|
|
|
|
public function test_organization_membership_scopes_simulation_reads(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$member = User::factory()->create(['email_verified_at' => now()]);
|
|
$outsider = User::factory()->create(['email_verified_at' => now()]);
|
|
$organizationId = (string) Str::uuid();
|
|
DB::table('organizations')->insert(['id' => $organizationId, 'name' => 'Test organization', 'organization_type' => 'private',
|
|
'data_classification' => 'internal', 'created_at' => now(), 'updated_at' => now()]);
|
|
DB::table('memberships')->insert([
|
|
['id' => (string) Str::uuid(), 'user_id' => $owner->id, 'organization_id' => $organizationId, 'role' => 'admin', 'created_at' => now(), 'updated_at' => now()],
|
|
['id' => (string) Str::uuid(), 'user_id' => $member->id, 'organization_id' => $organizationId, 'role' => 'viewer', 'created_at' => now(), 'updated_at' => now()],
|
|
]);
|
|
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '100', 'rate' => '2', 'reduction' => '0', 'organization_id' => $organizationId],
|
|
['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$id = DB::table('simulations')->where('owner_user_id', $owner->id)->value('id');
|
|
$this->actingAs($member)->getJson('/api/v1/simulations/'.$id)->assertOk();
|
|
$this->actingAs($outsider)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
|
|
|
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
|
DB::statement('GRANT simulation_rls_test TO CURRENT_USER');
|
|
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
|
DB::statement('GRANT SELECT, UPDATE ON public.simulations TO simulation_rls_test');
|
|
DB::statement('GRANT SELECT ON public.memberships TO simulation_rls_test');
|
|
try {
|
|
DB::statement('SET ROLE simulation_rls_test');
|
|
$execution = app(SimulationExecution::class);
|
|
$execution->setContext($member->id, null);
|
|
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
|
$affected = null;
|
|
try {
|
|
$affected = DB::transaction(fn () => DB::affectingStatement(
|
|
'update public.simulations set version_no = version_no + 1 where id = ?',
|
|
[$id],
|
|
));
|
|
} catch (QueryException) {
|
|
// PostgreSQL may reject the UPDATE with WITH CHECK instead of returning zero rows.
|
|
}
|
|
$this->assertTrue($affected === null || $affected === 0);
|
|
} finally {
|
|
DB::statement('RESET ROLE');
|
|
DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER');
|
|
DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
|
}
|
|
DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $organizationId)->delete();
|
|
$this->actingAs($owner)->getJson('/api/v1/simulations/'.$id)->assertNotFound();
|
|
}
|
|
|
|
public function test_postgres_rls_uses_anonymous_token_hmac_not_plain_token(): void
|
|
{
|
|
$token = Str::random(48);
|
|
$response = $this->postJson('/api/v1/simulations', ['base' => '40', 'rate' => '10', 'reduction' => '0'], [
|
|
'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token,
|
|
])->assertAccepted();
|
|
$id = $response->json('data.id');
|
|
$tokenHash = hash_hmac('sha256', $token, (string) config('app.key'));
|
|
|
|
DB::unprepared('DO $$ BEGIN CREATE ROLE simulation_rls_test NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$');
|
|
DB::statement('GRANT simulation_rls_test TO CURRENT_USER');
|
|
DB::statement('GRANT USAGE ON SCHEMA public TO simulation_rls_test');
|
|
DB::statement('GRANT SELECT ON public.simulations, public.memberships TO simulation_rls_test');
|
|
try {
|
|
DB::statement('SET ROLE simulation_rls_test');
|
|
$execution = app(SimulationExecution::class);
|
|
$execution->setContext(null, hash_hmac('sha256', 'wrong-token', (string) config('app.key')));
|
|
$this->assertSame(0, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
|
$execution->setContext(null, $tokenHash);
|
|
$this->assertSame(1, (int) DB::scalar('select count(*) from public.simulations where id = ?', [$id]));
|
|
} finally {
|
|
DB::statement('RESET ROLE');
|
|
DB::statement('REVOKE simulation_rls_test FROM CURRENT_USER');
|
|
DB::statement('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('REVOKE USAGE ON SCHEMA public FROM simulation_rls_test');
|
|
DB::statement('DROP ROLE IF EXISTS simulation_rls_test');
|
|
}
|
|
}
|
|
|
|
public function test_retry_budget_is_bounded_and_failed_attempt_is_reconciled(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => now()]);
|
|
$this->actingAs($owner)->postJson('/api/v1/simulations', ['base' => '10', 'rate' => '1', 'reduction' => '0'], ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted();
|
|
$simulation = DB::table('simulations')->where('owner_user_id', $owner->id)->first();
|
|
$execution = app(SimulationExecution::class);
|
|
DB::transaction(function () use ($execution, $owner, $simulation): void {
|
|
$execution->setContext($owner->id, null);
|
|
DB::table('simulation_scenarios')->where('simulation_id', $simulation->id)->delete();
|
|
});
|
|
|
|
$job = new RunSimulation($simulation->id, $owner->id, null);
|
|
try {
|
|
$job->handle($execution);
|
|
$this->fail('A simulation without a scenario must fail and remain retryable.');
|
|
} catch (\RuntimeException $exception) {
|
|
$this->assertSame('Simulation has no scenario.', $exception->getMessage());
|
|
}
|
|
$this->assertSame(3, $job->tries);
|
|
$this->assertSame([10, 60], $job->backoff());
|
|
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'running', 'attempts' => 1]);
|
|
$job->failed(new \RuntimeException('do not persist this detail'));
|
|
$this->assertDatabaseHas('simulation_jobs', ['simulation_id' => $simulation->id, 'status' => 'failed', 'attempts' => 3]);
|
|
}
|
|
|
|
public function test_core_rollback_refuses_published_path_definition_and_preserves_rls(): void
|
|
{
|
|
$migration = require database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php');
|
|
// The full installation is no longer a base-only empty schema: it owns a second immutable definition.
|
|
$before = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
|
|
try {
|
|
$migration->down();
|
|
$this->fail('A published path definition must not be silently erased by fiscal core rollback.');
|
|
} catch (\RuntimeException $exception) {
|
|
$this->assertSame('Cannot rollback simulation execution: questionnaire_versions contains data.', $exception->getMessage());
|
|
}
|
|
$after = DB::select('SELECT relname,relrowsecurity,relforcerowsecurity FROM pg_class WHERE relnamespace = to_regnamespace(?) AND relname IN (?,?) ORDER BY relname', ['public', 'simulations', 'questionnaire_versions']);
|
|
$this->assertEquals($before, $after);
|
|
$this->assertTrue(Schema::hasTable('simulation_results'));
|
|
$this->assertDatabaseHas('questionnaire_versions', ['id' => '61000100-0000-4000-8000-000000000001', 'status' => 'published']);
|
|
$this->assertDatabaseHas('questionnaire_versions', ['code' => 'investor-path-19', 'status' => 'published']);
|
|
}
|
|
|
|
public function test_questionnaire_endpoint_exposes_only_published_version_with_etag(): void
|
|
{
|
|
$this->getJson('/api/v1/questionnaires/user-entered-arithmetic')->assertOk()
|
|
->assertJsonStructure(['data' => ['id', 'code', 'version_no', 'locale', 'schema_snapshot', 'status', 'valid_from', 'valid_to', 'created_at', 'updated_at']])
|
|
->assertHeader('ETag', '"questionnaire-61000100-0000-4000-8000-000000000001-v1"')
|
|
->assertJsonPath('data.locale', 'pt-MZ')->assertJsonPath('data.schema_snapshot.rate_origin', 'user_input');
|
|
$this->getJson('/api/v1/questionnaires/unknown')->assertNotFound();
|
|
}
|
|
}
|