Files
Investidor-V2/tests/Feature/SimulationExecutionTest.php
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

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