Files
Investidor-V2/tests/Feature/InvestorIntentContinuityTest.php
T
2026-11-15 18:56:39 +02:00

203 lines
20 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Models\InvestmentZone;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestorContext;
use App\Services\ProactiveSupply;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class InvestorIntentContinuityTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('investor_intent_20261115_test', $db->name);
$this->assertSame(55441, (int) $db->port);
$this->withoutVite();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
public function test_internal_business_observations_remain_closed_to_an_ordinary_investor(): void
{
$this->expectException(HttpException::class);
app(ProactiveSupply::class)->authorize(User::factory()->create());
}
public function test_demography_source_recovers_its_hierarchy_without_replacing_a_saved_location(): void
{
$owner = User::factory()->create();
$parent = null;
$ids = [];
foreach (['country', 'province', 'district'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
$ids[$type] = $id;
$parent = $id;
}
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => 'SYNTHETIC demography source', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$url = '/investimentos/'.$id.'/contexto/editar?'.http_build_query(['source' => 'territory:'.$ids['district']]);
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $ids['district'])->where('territorySelection.0.id', $ids['province'])->where('territorySelection.1.id', $ids['district']));
$version = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json('case.version');
$body = ['version' => $version, 'selection' => ['territory' => 'territory:'.$ids['province']], 'opportunity_choices' => [], 'declarations' => []];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->where('initial.context.selection.territory.id', $ids['province'])->where('territorySelection.0.id', $ids['province'])->has('territorySelection', 1));
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
}
public function test_an_own_idea_preserves_sector_locality_and_budget_interval_without_any_advert_or_corridor(): void
{
$owner = User::factory()->create();
$parent = null;
$path = [];
foreach (['country' => 'QA Mozambique', 'province' => 'Maputo Provincia', 'district' => 'Boane', 'admin_post' => 'Matola Rio', 'locality' => 'Matola Rio-Sede'] as $type => $name) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$name, 'code' => $id, 'location_type' => $type,
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
$path[$type.'_id'] = $id;
$parent = $id;
}
$reviewer = User::factory()->create();
$zone = InvestmentZone::query()->create(['id' => (string) Str::uuid(), 'code' => 'SINT-QA-'.Str::random(8), 'name' => 'SYNTHETIC documented site',
'category' => 'industrial_park', 'physical_category' => 'industrial_park', 'primary_territory_id' => $parent, 'publication_status' => 'published', 'validation_status' => 'validated',
'editor_id' => $owner->id, 'reviewer_id' => $reviewer->id, 'published_at' => now(), 'source_evidence' => [['source_type' => 'promoter', 'url' => 'https://example.invalid/synthetic-qa', 'supports' => 'Synthetic QA only, no operational or legal assertion.']]]);
$zones = $this->actingAs($owner)->getJson('/investimentos/opcoes-corredor?include_corridors=0&territory='.$parent);
$this->assertSame(200, $zones->status(), $zones->getContent());
$zones->assertJsonPath('parks.0.id', $zone->id)->assertJsonCount(1, 'parks');
$this->getJson('/investimentos/opcoes-corredor?include_corridors=0')->assertOk()->assertJsonPath('parks', []);
config(['investor-path.aggregate_supply_qa_enabled' => true]);
$source = hash('sha256', 'Synthetic private aggregate source');
DB::table('proactive_supply_sources')->insert(['sha256' => $source, 'name' => 'PRIVATE-SOURCE-NOT-FOR-OUTPUT.xlsx', 'period' => '2020', 'limitations' => 'Synthetic only', 'receipt' => '{}', 'imported_at' => now()]);
$base = ['source_hash' => $source, 'sheet' => 'PRIVATE-SHEET', 'entity_key' => hash('sha256', 'entity-a'), 'name' => 'PRIVATE-COMPANY-NAME', 'province' => 'Synthetic', 'district' => 'Synthetic', 'cae' => '01110', 'activity' => 'PRIVATE-CONTACT-+258000000000', 'sector' => 'PRIVATE-RAW-SECTOR', 'source_date' => '2020', 'fingerprint' => hash('sha256', 'fingerprint'), 'duplicate_of' => null, 'identity_conflict' => false, 'territory_id' => $path['district_id'], 'territory_resolution' => 'exact_normalized_province_district'];
foreach ([1 => [], 2 => ['duplicate_of' => hash('sha256', 'row-1')], 3 => ['identity_conflict' => true, 'entity_key' => hash('sha256', 'uncertain')], 4 => ['entity_key' => hash('sha256', 'unclassified'), 'cae' => 'PRIVATE-UNCLASSIFIED-TEXT']] as $number => $changes) {
DB::table('proactive_supply_observations')->insert([...$base, 'id' => hash('sha256', 'row-'.$number), 'row_number' => $number, ...$changes]);
}
foreach (['Agronegocio' => 'agro-qa', 'Criar Hotel' => 'hotel-qa'] as $title => $slug) {
$sector = Sector::create(['name' => 'SYNTHETIC '.$slug, 'slug' => $slug, 'is_active' => true]);
$id = $this->actingAs($owner)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$entry = [...array_diff_key($path, ['country_id' => true]), 'sector_id' => (string) $sector->id, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000'];
$this->get('/investimentos/iniciar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryQuery.investment_budget_max', '5000000')->where('entryQuery.locality_id', $parent));
$this->get('/investimentos/'.$id.'/contexto/editar?'.http_build_query($entry))->assertOk()->assertInertia(fn ($page) => $page->where('entryTerritory.id', $parent)->where('entryIdea.sector_id', (string) $sector->id));
$body = ['version' => $view['case']['version'], 'selection' => ['sector' => 'sector:'.$sector->id, 'territory' => 'territory:'.$parent],
'opportunity_choices' => [], 'declarations' => ['project_title' => $title, 'products_services' => 'SYNTHETIC explicit activity '.$slug, 'currency' => 'MZN', 'investment_budget_min' => '1000000', 'investment_budget_max' => '5000000']];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$saved = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.locality_id', $parent)
->assertJsonPath('prefill.simulator.investment_budget_min', '1000000')->assertJsonPath('prefill.simulator.investment_budget_max', '5000000')
->assertJsonPath('prefill.simulator.project_title', $title)->json();
$this->assertSame('SYNTHETIC explicit activity '.$slug, $saved['prefill']['simulator']['products_services']);
$this->assertEmpty($saved['context']['opportunity_choices'] ?? []);
$this->assertEmpty($saved['context']['selection']['opportunity'] ?? null);
$this->assertEmpty($saved['prefill']['simulator']['total_investment'] ?? null);
$command = ['version' => $saved['case']['version'], 'operation_key' => (string) Str::uuid()];
$url = '/investimentos/'.$id.'/contexto/oferta-agregada';
$aggregate = $this->postJson($url, $command)->assertOk()->assertJsonPath('analysis.entities', 2)->assertJsonPath('analysis.repeated', 1)->assertJsonPath('analysis.uncertain', 1)
->assertJsonPath('analysis.broader_context', true)->assertJsonPath('analysis.scope.id', $path['district_id']);
$this->assertStringNotContainsString('PRIVATE-', $aggregate->getContent());
$this->assertStringNotContainsString($source, $aggregate->getContent());
$this->postJson($url, $command)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'supply_aggregates')->assertJsonPath('supply_aggregates.0.entities', 2);
config(['investor-path.aggregate_supply_qa_enabled' => false]);
$this->postJson($url, $command)->assertNotFound();
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonPath('supply_aggregates', [])->assertJsonPath('supply_aggregate_enabled', false);
config(['investor-path.aggregate_supply_qa_enabled' => true]);
$this->actingAs(User::factory()->create())->postJson($url, $command)->assertForbidden();
$this->actingAs($owner);
// UI keeps display labels in selection_labels, outside canonical fields.
$economic = array_filter($saved['prefill']['simulator'], fn ($key) => ! str_ends_with($key, '_label') && $key !== 'email', ARRAY_FILTER_USE_KEY);
$economic['purpose'] = 'own_investment';
// Explicit synthetic scenario; the budget interval remains separate.
$economic['total_investment'] = '2000000';
$economic['secured_amount'] = '1000000';
$economic['annual_revenue'] = '300000';
$economic['planning_loan'] = 'no';
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'other', 'capacity' => '100', 'price' => '250', 'occupancy' => '', 'frequency' => '12', 'source' => 'Synthetic explicit assumptions', 'costs' => []]], JSON_THROW_ON_ERROR);
$economic['financial_tools_snapshot'] = json_encode(['version' => 1, 'data' => ['budgetPeriod' => 'Mensal',
'loan' => ['principal' => '', 'rate' => '', 'months' => '', 'rateType' => 'nominal', 'periodMonths' => '1', 'gracePeriods' => '', 'upfrontFee' => '', 'recurringFee' => ''],
'breakEven' => ['fixed' => '100000', 'price' => '5000', 'variable' => '2000'], 'funding' => ['total' => '2000000', 'secured' => '1000000'],
'incomes' => [['label' => 'Synthetic declared revenue', 'amount' => '300000']], 'expenses' => [['label' => 'Synthetic declared costs', 'amount' => '200000']]]], JSON_THROW_ON_ERROR);
$allowed = explode(',', substr((new StoreSimulationFormDraftRequest)->rules()['economic_snapshot'][1], 6));
$this->assertSame([], array_values(array_diff(array_keys($economic), $allowed)));
$simulation = $this->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$draft = $this->getJson('/api/v1/simulation-drafts/'.$simulation)->assertOk()->assertJsonPath('data.economic_snapshot.investment_budget_min', '1000000')->assertJsonPath('data.economic_snapshot.investment_budget_max', '5000000')->assertJsonPath('data.economic_snapshot.financial_tools_snapshot', $economic['financial_tools_snapshot'])->json('data');
$dossier = $this->getJson('/investimentos/'.$id)->assertOk()->json();
$return = ['version' => $dossier['case']['version'], 'draft_id' => $simulation, 'draft_version' => $draft['version_no'], 'context_revision' => $dossier['investment_context']['revision'], 'context_reviewed' => true];
$result = $this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.financial_snapshot', $economic['financial_tools_snapshot'])->assertJsonPath('result.preparation_snapshot.planning_loan', 'no')->assertJsonPath('result.preparation_snapshot.annual_revenue', '300000')->assertJsonPath('result.preparation_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->json('result.id');
$this->postJson('/investimentos/'.$id.'/resultados', $return)->assertOk()->assertJsonPath('result.id', $result);
$this->getJson('/investimentos/'.$id)->assertOk()->assertJsonCount(1, 'results')->assertJsonPath('results.0.id', $result)->assertJsonPath('results.0.currency', 'MZN');
$this->actingAs(User::factory()->create())->getJson('/api/v1/simulation-drafts/'.$simulation)->assertNotFound();
$this->actingAs($owner);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_budget_max', '5000000');
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$id.'/contexto')->assertForbidden();
}
}
public function test_an_incomplete_currency_can_be_recovered_privately_but_cannot_be_confirmed(): void
{
$this->actingAs(User::factory()->create());
$id = $this->postJson('/investimentos', ['title' => 'Synthetic draft', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'MZ']];
$this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertOk();
$this->assertSame('MZ', app(InvestorContext::class)->draft(auth()->user(), $id)['declarations']['currency']);
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.currency');
$body['declarations'] = ['currency' => 'MZN', 'investment_budget_min' => '5000000', 'investment_budget_max' => '1000000'];
$this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable()->assertJsonValidationErrors('declarations.investment_budget_max');
}
public function test_district_demography_is_never_presented_as_locality_population(): void
{
$owner = User::factory()->create();
$parent = null;
$district = null;
foreach (['country', 'province', 'district', 'admin_post', 'locality'] as $type) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'parent_id' => $parent, 'name' => 'SYNTHETIC '.$type, 'code' => $id, 'location_type' => $type, 'data_classification' => 'public',
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:QA:'.$id]);
if ($type === 'district') {
$district = $id;
}
$parent = $id;
}
$batch = (string) Str::uuid();
DB::table('demographic_imports')->insert(['id' => $batch, 'sha256' => hash('sha256', $batch), 'file_name' => 'SYNTHETIC.xlsx', 'source' => 'Synthetic only', 'publication_version' => 'QA/1', 'status' => 'approved', 'report' => '{}', 'preview_sha256' => str_repeat('0', 64), 'prepared_by' => $owner->id, 'created_at' => now(), 'updated_at' => now()]);
DB::table('demographic_observations')->insert(['import_id' => $batch, 'geo_location_id' => $district, 'year' => 2026, 'indicator' => 'population', 'sex' => 'all', 'residence' => 'all', 'age_group' => 'all', 'unit' => 'pessoas', 'data_kind' => 'projected', 'original_value' => '12345', 'numeric_value' => '12345', 'sheet' => '2026', 'cell' => 'B4', 'validation_status' => 'valid']);
$url = '/api/v1/investment/demography?'.http_build_query(['territories' => [$parent], 'start_year' => 2026, 'investment_context' => 1]);
$this->getJson($url)->assertOk()->assertJsonPath('broader_context', true)->assertJsonPath('requested_territory.id', $parent)
->assertJsonPath('territories.0.id', $district)->assertJsonPath('territories.0.level', 'district')->assertJsonPath('territories.0.series.0.values.0.original_value', '12345');
DB::table('geo_locations')->where('id', $parent)->update(['data_classification' => 'internal']);
$this->getJson($url)->assertOk()->assertJsonPath('requested_territory', null)->assertJsonPath('territories', []);
}
public function test_guided_unknown_answers_and_their_labels_save_and_recover_in_the_existing_simulator_contract(): void
{
$this->actingAs(User::factory()->create());
$economic = ['purpose' => 'simulate_only', 'journey_type' => 'investor'];
$economic['preparation_step'] = 'operating';
$economic['fiscal_tools_selection'] = ['tax:sales', 'iva:acquisitions'];
$economic['initial_budget_snapshot'] = json_encode(['version' => 1, 'rows' => [['label' => 'Synthetic equipment', 'amount' => '1000000']]], JSON_THROW_ON_ERROR);
$economic['planning_assumptions_snapshot'] = json_encode(['version' => 1, 'data' => ['model' => 'hotel', 'capacity' => '10', 'price' => '5000', 'occupancy' => '50', 'frequency' => '300', 'source' => 'Synthetic only', 'costs' => []]], JSON_THROW_ON_ERROR);
foreach (['customer_validation_status', 'location_assessment_status', 'cost_estimate_status', 'receivables_timing_status', 'funding_status'] as $field) {
$economic[$field] = 'unknown';
$economic['selection_labels'][$field] = 'Ainda não sei';
}
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->assertJsonPath('data.economic_snapshot.selection_labels.funding_status', 'Ainda não sei')->assertJsonPath('data.economic_snapshot.preparation_step', 'operating')->assertJsonPath('data.economic_snapshot.planning_assumptions_snapshot', $economic['planning_assumptions_snapshot'])->assertJsonPath('data.economic_snapshot.fiscal_tools_selection', $economic['fiscal_tools_selection'])->assertJsonPath('data.economic_snapshot.initial_budget_snapshot', $economic['initial_budget_snapshot']);
$economic['selection_labels']['unsupported_field'] = 'Unsupported';
$this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $economic], ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable()->assertJsonValidationErrors('economic_snapshot.selection_labels.unsupported_field');
}
}