Files
Investidor-V2/tests/Feature/InvestmentCorridorContextTest.php

225 lines
17 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityChoices;
use App\Services\InvestmentZones;
use App\Services\InvestorContext;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class InvestmentCorridorContextTest extends TestCase
{
use DatabaseTransactions;
private User $investor;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}