payload(); $created = $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => $key, 'X-Simulation-Token' => $token, ])->assertCreated()->assertJsonPath('data.version_no', 1); $id = $created->json('data.id'); $stored = DB::table('simulation_form_drafts')->where('id', $id)->first(); $this->assertNotSame(json_encode($payload['personal_snapshot']), $stored->personal_snapshot); $this->assertNotSame(json_encode($payload['economic_snapshot']), $stored->economic_snapshot); $this->assertSame([], SimulationFormDraft::decryptSnapshot($stored->personal_snapshot)); $this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token]) ->assertOk()->assertJsonPath('data.personal_snapshot', []) ->assertJsonPath('data.economic_snapshot.base', '1200') ->assertJsonPath('data.economic_snapshot.journey_type', 'personal'); $this->getJson('/api/v1/simulation-drafts/'.$id, ['X-Simulation-Token' => Str::random(48)])->assertNotFound(); } public function test_anonymous_autosave_accepts_an_explicitly_empty_personal_snapshot(): void { $token = Str::random(48); $payload = $this->payload(); $payload['personal_snapshot'] = []; $created = $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertCreated()->assertJsonPath('data.version_no', 1); $stored = DB::table('simulation_form_drafts')->where('id', $created->json('data.id'))->first(); $this->assertSame([], SimulationFormDraft::decryptSnapshot($stored->personal_snapshot)); $this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token]) ->assertOk()->assertJsonPath('data.personal_snapshot', []) ->assertJsonPath('data.economic_snapshot.purpose', 'simulate_only'); } public function test_exploratory_small_business_idea_and_market_assumptions_are_saved_in_the_economic_snapshot(): void { $token = Str::random(48); $payload = $this->payload(); $payload['personal_snapshot'] = []; $payload['economic_snapshot'] = [ 'journey_type' => 'small_business', 'purpose' => 'simulate_only', 'project_title' => 'Piloto sintetico sem semeadura', 'maturity_stage' => 'operation', 'selection_labels' => ['maturity_stage' => 'Operação'], 'project_problem' => 'Necessidade declarada para teste.', 'target_customers' => 'Clientes hipoteticos para teste.', 'value_proposition' => 'Proposta de valor declarada para teste.', 'market_assumption' => 'Hipotese de procura, sem fonte oficial.', 'market_source' => 'Declaracao de teste, 2026-10-04.', 'competitor_assumption' => 'Alternativas locais por verificar.', 'sales_channel_assumption' => 'Venda directa em avaliação.', 'marketing_approach' => 'Testar divulgação local.', 'location_rationale' => 'Proximidade de clientes a confirmar.', 'customer_validation_status' => 'partial', 'cost_estimate_status' => 'unknown', 'location_assessment_status' => 'partial', 'funding_status' => 'in_process', 'project_questionnaire_version' => 'investmoz-project-dossier-2026-10-05.1', 'project_risk_responses_snapshot' => json_encode([['risk_id' => 'customers', 'action' => 'Entrevistar potenciais clientes', 'owner' => 'Equipa', 'estimated_cost' => '0', 'due_date' => '2026-11-30']], JSON_THROW_ON_ERROR), 'project_milestones' => 'Planeado: validar procura; prazo: 2026-11-30.', 'project_team_snapshot' => json_encode([['competency' => 'Gestão', 'state' => 'gap', 'action' => 'Formação', 'estimated_cost' => '1200.00', 'due_date' => '2026-11-30']], JSON_THROW_ON_ERROR), 'project_execution_snapshot' => json_encode([['title' => 'Abrir loja', 'due_date' => '2026-12-01', 'status' => 'planned', 'planned_amount' => '5000', 'actual_amount' => '']], JSON_THROW_ON_ERROR), ]; $created = $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertCreated(); $this->getJson('/api/v1/simulation-drafts/current', ['X-Simulation-Token' => $token]) ->assertOk() ->assertJsonPath('data.personal_snapshot', []) ->assertJsonPath('data.economic_snapshot.project_problem', 'Necessidade declarada para teste.') ->assertJsonPath('data.economic_snapshot.market_source', 'Declaracao de teste, 2026-10-04.') ->assertJsonPath('data.economic_snapshot.project_questionnaire_version', 'investmoz-project-dossier-2026-10-05.1') ->assertJsonPath('data.economic_snapshot.customer_validation_status', 'partial') ->assertJsonPath('data.economic_snapshot.project_risk_responses_snapshot', $payload['economic_snapshot']['project_risk_responses_snapshot']) ->assertJsonPath('data.economic_snapshot.selection_labels.maturity_stage', 'Operação') ->assertJsonPath('data.economic_snapshot.project_milestones', 'Planeado: validar procura; prazo: 2026-11-30.') ->assertJsonPath('data.economic_snapshot.project_team_snapshot', $payload['economic_snapshot']['project_team_snapshot']) ->assertJsonPath('data.economic_snapshot.project_execution_snapshot', $payload['economic_snapshot']['project_execution_snapshot']); } public function test_structured_project_plans_reject_negative_amounts(): void { $payload = $this->payload(); $payload['economic_snapshot']['project_execution_snapshot'] = json_encode([ ['title' => 'Marco', 'due_date' => '', 'status' => 'planned', 'planned_amount' => '-1', 'actual_amount' => ''], ], JSON_THROW_ON_ERROR); $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.project_execution_snapshot'); } public function test_project_risk_responses_reject_negative_costs_and_invalid_statuses(): void { $payload = $this->payload(); $payload['economic_snapshot']['project_risk_responses_snapshot'] = json_encode([ ['risk_id' => 'customers', 'action' => 'Validar', 'owner' => '', 'estimated_cost' => '-1', 'due_date' => ''], ], JSON_THROW_ON_ERROR); $payload['economic_snapshot']['funding_status'] = 'guaranteed'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable()->assertJsonValidationErrors([ 'economic_snapshot.project_risk_responses_snapshot', 'economic_snapshot.funding_status', ]); } public function test_creation_is_idempotent_and_rejects_key_reuse_with_changed_data(): void { $token = Str::random(48); $key = (string) Str::uuid(); $headers = ['Idempotency-Key' => $key, 'X-Simulation-Token' => $token]; $payload = $this->payload(); $first = $this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertCreated(); $id = $first->json('data.id'); $this->postJson('/api/v1/simulation-drafts', $payload, $headers) ->assertOk()->assertJsonPath('data.id', $id)->assertJsonPath('data.replayed', true); $payload['economic_snapshot']['base'] = '1300'; $this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertConflict(); } public function test_clients_cannot_forge_server_owned_simulation_sources(): void { $payload = $this->payload(); $payload['economic_snapshot']['simulation_sources'] = [['simulation_id' => (string) Str::uuid()]]; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable(); } public function test_updates_require_a_current_etag_and_delete_is_reversible_until_expiry(): void { $token = Str::random(48); $headers = ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token]; $payload = $this->payload(); $created = $this->postJson('/api/v1/simulation-drafts', $payload, $headers)->assertCreated(); $id = $created->json('data.id'); $url = '/api/v1/simulation-drafts/'.$id; $this->putJson($url, $payload, ['X-Simulation-Token' => $token])->assertStatus(428); $this->putJson($url, $payload, ['X-Simulation-Token' => $token, 'If-Match' => '"sfd-'.$id.'-v99"'])->assertStatus(412); $updated = $this->putJson($url, $payload, ['X-Simulation-Token' => $token, 'If-Match' => $created->headers->get('ETag')]) ->assertOk()->assertJsonPath('data.version_no', 2); $expectedTokenHash = hash_hmac('sha256', $token, (string) config('app.key')); $this->assertSame( $expectedTokenHash, DB::selectOne("select current_setting('app.simulation_token_hash', true) as token_hash")->token_hash, ); $storedBeforeDelete = DB::table('simulation_form_drafts')->where('id', $id)->first(); $this->assertSame($expectedTokenHash, rtrim($storedBeforeDelete->anonymous_token_hash)); $this->assertNull($storedBeforeDelete->deleted_at); $this->deleteJson($url, [], ['X-Simulation-Token' => $token, 'If-Match' => $updated->headers->get('ETag')])->assertNoContent(); $this->assertDatabaseHas('simulation_form_drafts', ['id' => $id]); $this->assertDatabaseMissing('simulation_form_drafts', ['id' => $id, 'deleted_at' => null]); $this->getJson($url, ['X-Simulation-Token' => $token])->assertNotFound(); } public function test_completed_simulation_is_attached_with_explicit_consent_as_an_immutable_versioned_snapshot(): void { Queue::fake(); $token = Str::random(48); $formalPayload = $this->payload(); $formalPayload['economic_snapshot']['purpose'] = 'own_investment'; $draftResponse = $this->postJson('/api/v1/simulation-drafts', $formalPayload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertCreated(); $draftId = $draftResponse->json('data.id'); $simulationResponse = $this->postJson('/api/v1/simulations', [ 'base' => '1200', 'rate' => '8', 'reduction' => '0', ], ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token])->assertAccepted(); $simulationId = $simulationResponse->json('data.id'); app(SimulationExecution::class)->setContext(null, hash_hmac('sha256', $token, (string) config('app.key'))); $result = ['base' => '1200', 'rate_percent' => '8', 'reduction_percent' => '0', 'indicative_total' => '96.00000000', 'legal_interpretation' => false, 'formula_version' => 'portal-tax-arithmetic-v1']; DB::table('simulations')->where('id', $simulationId)->update([ 'status' => 'completed', 'version_no' => 2, 'result_snapshot' => json_encode($result), ]); $canonicalResult = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [json_encode($result, JSON_THROW_ON_ERROR)])->canonical; $reportHash = hash('sha256', $canonicalResult); DB::table('simulation_reports')->insert([ 'id' => (string) Str::uuid(), 'simulation_id' => $simulationId, 'version_no' => 1, 'status' => 'ready', 'report_snapshot' => json_encode($result), 'report_sha256' => $reportHash, 'created_at' => now(), 'updated_at' => now(), ]); $url = '/api/v1/simulation-drafts/'.$draftId.'/conversions'; $headers = ['X-Simulation-Token' => $token, 'If-Match' => $draftResponse->headers->get('ETag')]; $this->postJson($url, ['simulation_id' => $simulationId, 'consent_reuse' => false], $headers)->assertUnprocessable(); $attached = $this->postJson($url, ['simulation_id' => $simulationId, 'consent_reuse' => true], $headers) ->assertOk()->assertJsonPath('replayed', false) ->assertJsonPath('data.version_no', 2) ->assertJsonPath('data.simulation_sources.0.result_snapshot.legal_interpretation', false) ->assertJsonPath('data.simulation_sources.0.report_sha256', $reportHash) ->assertJsonMissingPath('data.economic_snapshot.simulation_sources'); $this->postJson($url, ['simulation_id' => $simulationId, 'consent_reuse' => true], $headers) ->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('data.version_no', 2); $this->assertSame(1, count($attached->json('data.simulation_sources'))); $this->assertSame(2, $attached->json('data.simulation_sources.0.source_version_no')); $this->assertNotEmpty($attached->json('data.simulation_sources.0.consented_at')); $this->assertFalse($attached->json('replayed')); $formalDraft = $this->payload(); $formalDraft['economic_snapshot']['purpose'] = 'own_investment'; $formalDraft['economic_snapshot']['reuse_simulation'] = 'no'; $this->putJson('/api/v1/simulation-drafts/'.$draftId, $formalDraft, [ 'X-Simulation-Token' => $token, 'If-Match' => $attached->headers->get('ETag'), ])->assertOk(); $this->getJson('/api/v1/simulation-drafts/'.$draftId, ['X-Simulation-Token' => $token]) ->assertOk()->assertJsonPath('data.simulation_sources', []); } public function test_simulation_conversion_rejects_a_report_that_does_not_match_the_result_snapshot(): void { Queue::fake(); $token = Str::random(48); $formalPayload = $this->payload(); $formalPayload['economic_snapshot']['purpose'] = 'own_investment'; $draft = $this->postJson('/api/v1/simulation-drafts', $formalPayload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertCreated(); $simulation = $this->postJson('/api/v1/simulations', [ 'base' => '1200', 'rate' => '8', 'reduction' => '0', ], ['Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token])->assertAccepted(); $simulationId = $simulation->json('data.id'); app(SimulationExecution::class)->setContext(null, hash_hmac('sha256', $token, (string) config('app.key'))); $result = ['indicative_total' => '96.00000000', 'legal_interpretation' => false, 'formula_version' => 'portal-tax-arithmetic-v1']; $forgedReport = $result; $forgedReport['indicative_total'] = '96000.00000000'; DB::table('simulations')->where('id', $simulationId)->update([ 'status' => 'completed', 'version_no' => 2, 'result_snapshot' => json_encode($result), ]); $canonicalReport = DB::selectOne('select CAST(? AS jsonb)::text as canonical', [json_encode($forgedReport, JSON_THROW_ON_ERROR)])->canonical; DB::table('simulation_reports')->insert([ 'id' => (string) Str::uuid(), 'simulation_id' => $simulationId, 'version_no' => 1, 'status' => 'ready', 'report_snapshot' => json_encode($forgedReport), 'report_sha256' => hash('sha256', $canonicalReport), 'created_at' => now(), 'updated_at' => now(), ]); $this->postJson('/api/v1/simulation-drafts/'.$draft->json('data.id').'/conversions', [ 'simulation_id' => $simulationId, 'consent_reuse' => true, ], ['X-Simulation-Token' => $token, 'If-Match' => $draft->headers->get('ETag')]) ->assertStatus(409); DB::table('simulation_reports')->insert([ 'id' => (string) Str::uuid(), 'simulation_id' => $simulationId, 'version_no' => 2, 'status' => 'ready', 'report_snapshot' => json_encode($result), 'report_sha256' => str_repeat('0', 64), 'created_at' => now()->addSecond(), 'updated_at' => now()->addSecond(), ]); $this->postJson('/api/v1/simulation-drafts/'.$draft->json('data.id').'/conversions', [ 'simulation_id' => $simulationId, 'consent_reuse' => true, ], ['X-Simulation-Token' => $token, 'If-Match' => $draft->headers->get('ETag')]) ->assertStatus(409); } public function test_simulation_conversion_rejects_an_unowned_execution_token(): void { Queue::fake(); $draftToken = Str::random(48); $draft = $this->postJson('/api/v1/simulation-drafts', $this->payload(), [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $draftToken, ])->assertCreated(); $otherToken = Str::random(48); $simulation = $this->postJson('/api/v1/simulations', ['base' => '1200', 'rate' => '8', 'reduction' => '0'], [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $otherToken, ])->assertAccepted(); $this->postJson('/api/v1/simulation-drafts/'.$draft->json('data.id').'/conversions', [ 'simulation_id' => $simulation->json('data.id'), 'consent_reuse' => true, ], ['X-Simulation-Token' => $draftToken, 'If-Match' => $draft->headers->get('ETag')])->assertNotFound(); } public function test_server_rejects_invalid_rates_and_unlisted_snapshot_fields(): void { $token = Str::random(48); $payload = $this->payload(); $payload['economic_snapshot']['rate'] = '101'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.rate'); $payload = $this->payload(); $payload['economic_snapshot']['unapproved_field'] = 'not accepted'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot'); } public function test_fiscal_mode_rejects_manual_rates_and_incomplete_custom_assumptions(): void { $token = Str::random(48); $payload = $this->payload(); unset($payload['economic_snapshot']['tax_calculation'], $payload['economic_snapshot']['base'], $payload['economic_snapshot']['rate'], $payload['economic_snapshot']['reduction']); $payload['economic_snapshot']['fiscal_scenario_mode'] = 'approved_parameters'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => $token, ])->assertCreated(); $payload['economic_snapshot']['base'] = '1000'; $payload['economic_snapshot']['rate'] = '5'; $payload['economic_snapshot']['reduction'] = '0'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable()->assertJsonValidationErrors(['economic_snapshot.base', 'economic_snapshot.rate', 'economic_snapshot.reduction']); $payload = $this->payload(); unset($payload['economic_snapshot']['tax_calculation']); $payload['economic_snapshot']['fiscal_scenario_mode'] = 'custom_planning'; unset($payload['economic_snapshot']['base'], $payload['economic_snapshot']['rate'], $payload['economic_snapshot']['reduction']); $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertCreated(); $payload = $this->payload(); unset($payload['economic_snapshot']['tax_calculation'], $payload['economic_snapshot']['base'], $payload['economic_snapshot']['reduction']); $payload['economic_snapshot']['fiscal_scenario_mode'] = 'custom_planning'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertUnprocessable()->assertJsonValidationErrors(['economic_snapshot.base', 'economic_snapshot.reduction']); $payload = $this->payload(); unset($payload['economic_snapshot']['tax_calculation']); $payload['economic_snapshot']['fiscal_scenario_mode'] = 'custom_planning'; $payload['economic_snapshot']['base'] = '1000'; $payload['economic_snapshot']['rate'] = '5'; $payload['economic_snapshot']['reduction'] = '0'; $this->postJson('/api/v1/simulation-drafts', $payload, [ 'Idempotency-Key' => (string) Str::uuid(), 'X-Simulation-Token' => Str::random(48), ])->assertCreated(); } /** @return array{personal_snapshot: array, economic_snapshot: array} */ private function payload(): array { return [ 'personal_snapshot' => ['legal_name' => 'Entidade de teste', 'email' => 'teste@example.com'], 'economic_snapshot' => [ 'journey_type' => 'personal', 'detail_level' => 'simple', 'business_plan_snapshot' => '{"version":1}', 'purpose' => 'simulate_only', 'tax_calculation' => 'yes', 'base' => '1200', 'rate' => '8', 'reduction' => '0', ], ]; } }