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

368 lines
22 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\SimulationFormDraft;
use App\Services\SimulationExecution;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class SimulationFormDraftTest extends TestCase
{
use RefreshDatabase;
public function test_anonymous_draft_is_encrypted_scoped_and_restorable_without_persisting_personal_data(): void
{
$token = Str::random(48);
$key = (string) Str::uuid();
$payload = $this->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<string,string>, economic_snapshot: array<string,string>} */
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',
],
];
}
}