withoutVite(); $t = DB::selectOne('select current_database() as name,inet_server_port() as port'); $this->assertSame('simulator_fiscal_delta_20261021_test', $t->name); $this->assertSame(55441, (int) $t->port); config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); $this->investor = User::factory()->create(); $this->actingAs($this->investor); } private function record(string $corridor): Opportunity { $entry = collect(app(CorridorAgribusinessOpportunities::class)->entries())->firstWhere('corridor', config('investment_corridors.'.$corridor.'.name')); return Opportunity::where('management_creation_key', $entry['key'])->firstOrFail(); } public function test_land_status_uses_the_real_decision_preserves_inactive_values_and_allows_incomplete_drafts(): void { $id = $this->postJson('/investimentos', ['title' => 'QA situação do terreno', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $setNeed = function (string $need) use ($id): array { $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'); $this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $version, 'land_needed' => $need, 'external_funding' => 'unknown', 'viability' => 'unknown', 'reason' => 'Cenário QA '.$need])->assertOk(); return $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json(); }; $view = $setNeed('yes'); $body = ['context' => $id, 'personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'context_applicability_snapshot' => $view['prefill']['simulator']['context_applicability_snapshot'], 'land_status' => 'duat', 'selection_labels' => ['land_status' => 'DUAT']]]; $created = $this->postJson('/api/v1/simulation-drafts', $body, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated(); $draft = $created->json('data.id'); $etag = $created->headers->get('ETag'); foreach (['no', 'unknown'] as $need) { $view = $setNeed($need); $this->assertSame($need, json_decode($view['prefill']['simulator']['context_applicability_snapshot'], true)['land_preparation']['need']); $update = $this->putJson('/api/v1/simulation-drafts/'.$draft, ['personal_snapshot' => [], 'economic_snapshot' => ['purpose' => 'simulate_only', 'context_applicability_snapshot' => $body['economic_snapshot']['context_applicability_snapshot']]], ['If-Match' => $etag])->assertOk(); $recovered = $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk(); // Omitting context cannot bypass the real dossier decision. Hidden values are retained. $this->assertSame('duat', $recovered->json('data.economic_snapshot.land_status')); $this->assertSame('DUAT', $recovered->json('data.economic_snapshot.selection_labels.land_status')); $this->assertSame($need, json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'), true)['land_preparation']['need']); $etag = $update->headers->get('ETag'); } $setNeed('yes'); $this->flushSession(); $this->actingAs($this->investor); $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status', 'duat'); $updated = $this->putJson('/api/v1/simulation-drafts/'.$draft, [...$body, 'economic_snapshot' => ['purpose' => 'simulate_only', 'land_status' => 'leased']], ['If-Match' => $etag])->assertOk(); $recovered = $this->getJson('/api/v1/simulation-drafts/'.$draft.'?context='.$id)->assertOk()->assertJsonPath('data.economic_snapshot.land_status', 'leased'); $this->assertSame('yes', json_decode($recovered->json('data.economic_snapshot.context_applicability_snapshot'), true)['land_preparation']['need']); $this->putJson('/api/v1/simulation-drafts/'.$draft, [...$body, 'economic_snapshot' => ['purpose' => 'simulate_only', 'land_status' => 'invalid']], ['If-Match' => $updated->headers->get('ETag')])->assertUnprocessable(); $empty = app(InvestorContext::class)->prefill(['fields' => ['site_tenure' => 'lease'], 'revision' => 0, 'provenance' => [], 'land_preparation' => ['need' => 'yes', 'reason' => 'QA']]); $this->assertSame('leased', $empty['simulator']['land_status']); } public function test_simulator_reuses_names_for_the_exact_chosen_territory_and_sector_without_creating_a_location(): void { $id = $this->postJson('/investimentos', ['title' => 'QA continuidade simulador', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $province = collect(app(InvestmentCorridorContext::class)->provinces('beira'))->firstWhere('source_code', '07')->id; $district = app(InvestmentGeography::class)->publicReferences()->where('parent_id', $province)->where('location_type', 'district')->firstOrFail(); $record = $this->record('beira'); $plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'), 'selection' => [], 'opportunity_choices' => [['source' => 'opportunity:'.$record->id, 'corridor_key' => 'beira', 'territory' => 'territory:'.$district->id]]])->assertOk()->json(); $sim = app(InvestorContext::class)->prefill($plan)['simulator']; $this->assertSame('investor', $sim['journey_type']); $this->assertSame('simulate_only', $sim['purpose']); $this->assertSame($province, $sim['province_id']); $this->assertSame('Sofala', $sim['province_id_label']); $this->assertSame($district->id, $sim['district_id']); $this->assertSame($district->name, $sim['district_id_label']); $this->assertSame((string) $record->sector_id, $sim['sector_id']); $this->assertSame(Sector::findOrFail($record->sector_id)->name, $sim['sector_id_label']); $this->assertArrayNotHasKey('locality_id_label', $sim); $this->assertArrayNotHasKey('admin_post_id_label', $sim); } public function test_nonapplicable_land_hides_the_action_and_reopening_recovers_saved_evidence(): void { $id = $this->postJson('/investimentos', ['title' => 'QA adaptação sem perda', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $view = $this->getJson('/investimentos/'.$id)->assertOk()->json(); $this->assertNotNull($view['tools']['land']); $this->assertSame('undetermined', collect($view['steps'])->firstWhere('code', 'S10')['applicability']); $this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Informação territorial já guardada'])->assertOk(); $view = $this->getJson('/investimentos/'.$id)->json(); $facts = ['land_needed' => 'no', 'external_funding' => 'no', 'viability' => 'unknown', 'reason' => 'Investimento sem necessidade de terra nem financiamento externo']; $this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts])->assertOk(); $view = $this->getJson('/investimentos/'.$id)->assertJsonPath('tools.land', null)->json(); $step = collect($view['steps'])->firstWhere('code', 'S10'); $this->assertFalse($step['preparation_allowed']); $this->assertSame('preparing', $step['saved_state']); $this->assertSame('Informação territorial já guardada', $step['detail']['reason']); $this->postJson('/investimentos/'.$id.'/etapas/S10', ['version' => $view['case']['version'], 'state' => 'preparing', 'action' => 'prepare', 'reason' => 'Não deve executar etapa sem aplicação'])->assertUnprocessable(); $this->postJson('/investimentos/'.$id.'/aplicabilidade', ['version' => $view['case']['version'], ...$facts, 'land_needed' => 'yes'])->assertOk(); $view = $this->getJson('/investimentos/'.$id)->assertOk()->json(); $this->assertNotNull($view['tools']['land']); $this->assertSame('preparing', collect($view['steps'])->firstWhere('code', 'S10')['state']); $this->assertCount(19, $view['steps']); } public function test_tempo_configuration_uses_existing_ids_and_maputo_includes_both_units(): void { foreach (config('investment_corridors') as $key => $entry) { $provinces = app(InvestmentCorridorContext::class)->provinces($key); $this->assertEqualsCanonicalizing($entry['provinces'], array_column($provinces, 'source_code')); $response = $this->getJson('/api/v1/investment/territory-options?type=province&corridor='.$key)->assertOk(); $this->assertEqualsCanonicalizing(array_column($provinces, 'id'), array_column($response->json('data'), 'id')); } $this->assertEqualsCanonicalizing(['Maputo Província', 'Maputo Cidade'], array_column(app(InvestmentCorridorContext::class)->provinces('maputo'), 'name')); foreach (['nacala', 'limpopo'] as $key) { $choice = app(InvestmentOpportunityChoices::class)->resolve([['source' => 'opportunity:'.$this->record($key)->id]])[0]; $this->assertSame(app(InvestmentCorridorContext::class)->provinces($key)[0]->id, $choice['territory']['id']); } } public function test_server_refuses_incompatible_location_in_draft_and_confirmation_preview(): void { $id = $this->postJson('/investimentos', ['title' => 'QA corridor guard', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'); $gaza = app(InvestmentCorridorContext::class)->provinces('limpopo')[0]->id; $body = ['version' => $version, 'selection' => [], 'opportunity_choices' => [['source' => 'opportunity:'.$this->record('beira')->id, 'corridor_key' => 'beira', 'territory' => 'territory:'.$gaza]]]; $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable(); $this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertUnprocessable(); $this->getJson('/api/v1/investment/territory-options?type=district&corridor=beira&parent_id='.$gaza)->assertUnprocessable(); $this->getJson('/investimentos/'.$id.'/contexto')->assertJsonPath('context.revision', 0); } public function test_encrypted_draft_survives_refresh_and_reauthentication_without_committing_context(): void { $id = $this->postJson('/investimentos', ['title' => 'QA recovery', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $before = $this->getJson('/investimentos/'.$id.'/contexto')->json(); $record = $this->record('beira'); $province = app(InvestmentCorridorContext::class)->provinces('beira')[0]->id; $body = ['version' => $before['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'MZN'], 'opportunity_choices' => [['source' => 'opportunity:'.$record->id, 'corridor_key' => 'beira', 'territory' => 'territory:'.$province]]]; $this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 10])->assertOk(); $this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 9])->assertConflict(); $encrypted = DB::table('desk_cases')->where('id', $id)->value('payload'); $this->assertStringNotContainsString($province, $encrypted); $this->flushSession(); $this->actingAs($this->investor); $this->get('/investimentos/'.$id.'/contexto/editar')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page ->where('draft.declarations.currency', 'MZN')->where('requestedChoices.0.territory.id', $province)->where('requestedChoices.0.corridor_key', 'beira')); $after = $this->getJson('/investimentos/'.$id.'/contexto')->json(); $this->assertSame($before['context'], $after['context']); $this->assertSame($before['case']['version'], $after['case']['version']); $empty = [...$body, 'opportunity_choices' => [], 'entry_sources' => ['opportunity:'.$record->id], 'draft_sequence' => 11]; $this->postJson('/investimentos/'.$id.'/contexto/rascunho', $empty)->assertOk(); $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$record->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->has('requestedChoices', 0)); $new = $this->record('nacala'); $this->get('/investimentos/'.$id.'/contexto/editar?source=opportunity:'.$new->id)->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('requestedChoices.0.opportunity.key', 'opportunity:'.$new->id)); $this->actingAs(User::factory()->create()); $this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 12])->assertForbidden(); } public function test_zones_need_compatible_documented_territory_and_catalogue_presence_alone_is_insufficient(): void { $service = app(InvestmentCorridorContext::class); $sofala = collect($service->provinces('beira'))->firstWhere('source_code', '07')->id; $gaza = $service->provinces('limpopo')[0]->id; $base = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->firstOrFail(); $clones = []; foreach ([$sofala, $gaza, null] as $territory) { $zone = $base->replicate(); $zone->code = 'QA-'.Str::uuid(); $zone->name = 'Synthetic corridor zone'; $zone->primary_territory_id = $territory; $zone->save(); $clones[] = $zone; } $available = array_column($service->parks('beira', $sofala), 'id'); $this->assertContains($clones[0]->id, $available); $this->assertNotContains($clones[1]->id, $available); $this->assertNotContains($clones[2]->id, $available); $id = $this->postJson('/investimentos', ['title' => 'QA zone guard', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id'); $version = $this->getJson('/investimentos/'.$id.'/contexto')->json('case.version'); $body = ['version' => $version, 'selection' => ['park' => 'park:'.$clones[1]->id], 'opportunity_choices' => [['source' => 'opportunity:'.$this->record('beira')->id, 'corridor_key' => 'beira', 'territory' => 'territory:'.$sofala]]]; $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertUnprocessable(); $this->postJson('/investimentos/'.$id.'/contexto/rascunho', [...$body, 'draft_sequence' => 1])->assertUnprocessable(); $this->getJson('/investimentos/opcoes-corredor?corridor=beira&territory='.$sofala)->assertOk()->assertJsonFragment(['id' => $clones[0]->id]); } public function test_corridor_change_keeps_compatible_paths_and_removes_only_incompatible_geography(): void { $service = app(InvestmentCorridorContext::class); $source = 'opportunity:'.$this->record('beira')->id; $sofala = collect($service->provinces('beira'))->firstWhere('source_code', '07')->id; $district = app(InvestmentGeography::class)->publicReferences()->where('parent_id', $sofala)->where('location_type', 'district')->firstOrFail(); $resolver = app(InvestmentOpportunityChoices::class); $old = $resolver->resolve([['source' => $source, 'corridor_key' => 'beira', 'territory' => 'territory:'.$district->id]]); $same = $resolver->resolve([['source' => $source, 'corridor_key' => 'beira', 'territory' => 'territory:'.$district->id]], $old); $this->assertSame($old, $same); $changed = $resolver->resolve([['source' => $source, 'corridor_key' => 'limpopo']], $old); $this->assertSame($service->provinces('limpopo')[0]->id, $changed[0]['territory']['id']); $this->assertCount(1, $changed[0]['territory_path']); $this->assertNull($this->record('beira')->primary_territory_id); $this->getJson('/api/v1/investment/territory-options?type=locality')->assertOk()->assertJsonPath('data', []); config(['investment_corridors.nacala.provinces' => ['03', '99']]); $this->getJson('/api/v1/investment/territory-options?type=province&corridor=nacala')->assertStatus(503); } }