create(); $user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save(); config(['portal.managers' => [...config('portal.managers', []), $user->email]]); foreach (['legal_reviewers', 'gis_reviewers', 'territory_reviewers', 'publishers'] as $role) { config(['investment-zones.'.$role => [...config('investment-zones.'.$role, []), $user->email]]); } return $user; } public function test_private_drafts_require_mfa_and_preserve_encrypted_immutable_revisions(): void { $user = User::factory()->create(); config(['portal.managers' => [$user->email]]); $url = '/api/v2/investment/admin/zones'; $payload = ['code' => 'SYNTHETIC-ZEE', 'name' => 'Synthetic private zone', 'category' => 'zee']; $this->actingAs($user)->postJson($url, $payload)->assertForbidden(); $this->actingAs($this->manager()); $id = $this->postJson($url, $payload)->assertCreated()->json('id'); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 0); $this->postJson($url.'/'.$id, [...$payload, '_method' => 'PUT', 'version_no' => 1, 'name' => 'Changed synthetic zone'])->assertOk()->assertJsonPath('version_no', 2); $this->putJson($url.'/'.$id, [...$payload, 'version_no' => 1])->assertConflict(); $this->postJson($url.'/'.$id.'/publish', ['version_no' => 2])->assertUnprocessable(); $this->actingAs($this->manager())->postJson($url.'/'.$id.'/publish', ['version_no' => 2])->assertUnprocessable(); $this->assertDatabaseCount('investment_zone_revisions', 2); $snapshot = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', 1)->value('snapshot'); $this->assertStringNotContainsString('Synthetic private zone', $snapshot); $this->assertStringContainsString('Synthetic private zone', Crypt::decryptString($snapshot)); DB::statement('SAVEPOINT immutable_check'); try { DB::table('investment_zone_revisions')->where('zone_id', $id)->delete(); $this->fail('Revisions must reject deletion.'); } catch (QueryException $e) { DB::statement('ROLLBACK TO SAVEPOINT immutable_check'); $this->assertStringContainsString('Zone revisions are immutable', $e->getMessage()); } $this->actingAs(User::factory()->create())->getJson($url.'/'.$id.'/revisions')->assertForbidden(); } public function test_rollback_refuses_populated_foundation_and_empty_rollback_is_reversible(): void { $skillsMigrations = glob(database_path('migrations/2026_09_30_*_skills_education_*.php')); foreach (array_reverse($skillsMigrations) as $file) { (require $file)->down(); } $migration = require database_path('migrations/2026_09_28_100000_investment_zone_foundation.php'); $originMigration = require database_path('migrations/2026_09_28_120000_investment_submission_zones.php'); $geometryMigration = require database_path('migrations/2026_09_28_110000_zone_geometry_review.php'); $businessMigration = require database_path('migrations/2026_09_28_130000_existing_business_foundation.php'); $intakeMigration = require database_path('migrations/2026_09_28_140000_existing_business_intake.php'); $marketplaceMigration = require database_path('migrations/2026_09_30_120000_extend_existing_business_marketplace.php'); $taxGovernanceMigration = require database_path('migrations/2026_10_03_170000_create_simulation_tax_rule_governance.php'); $draftContinuityMigration = require database_path('migrations/2026_10_04_120000_add_simulation_draft_continuity.php'); $zoneExtensionMigration = require database_path('migrations/2026_10_04_130000_extend_investment_zone_records.php'); $zoneAttachmentsMigration = require database_path('migrations/2026_10_04_140000_create_investment_zone_attachments.php'); $zonePublicationMigration = require database_path('migrations/2026_10_04_150000_relax_zone_operator_publication_requirement.php'); $zoneDevelopmentMigration = require database_path('migrations/2026_10_04_160000_extend_zone_development_and_area_scopes.php'); $primaryTerritoryMigration = require database_path('migrations/2026_09_28_160000_investment_primary_territory.php'); $zoneDevelopmentMigration->down(); $zonePublicationMigration->down(); $zoneAttachmentsMigration->down(); $zoneExtensionMigration->down(); $draftContinuityMigration->down(); $taxGovernanceMigration->down(); $marketplaceMigration->down(); $intakeMigration->down(); $businessMigration->down(); $originMigration->down(); $geometryMigration->down(); $primaryTerritoryMigration->down(); $migration->down(); $migration->up(); $taxGovernanceMigration->up(); $geometryMigration->up(); $originMigration->up(); $businessMigration->up(); $intakeMigration->up(); $marketplaceMigration->up(); $primaryTerritoryMigration->up(); $zoneExtensionMigration->up(); $zoneAttachmentsMigration->up(); $zonePublicationMigration->up(); $zoneDevelopmentMigration->up(); $draftContinuityMigration->up(); foreach ($skillsMigrations as $file) { (require $file)->up(); } $this->assertTrue(Schema::hasTable('investment_zone_links')); $this->actingAs($this->manager())->postJson('/api/v2/investment/admin/zones', ['code' => 'QA', 'name' => 'Synthetic', 'category' => 'pending'])->assertCreated(); try { $migration->down(); $this->fail('Populated rollback must refuse.'); } catch (\RuntimeException $e) { $this->assertStringContainsString('Zone data exists', $e->getMessage()); } $this->assertDatabaseCount('investment_zones', 1); } public function test_documented_general_industrial_park_can_publish_without_special_regime_or_operator_and_filters_match_csv(): void { $editor = $this->manager(); config(['investment-zones.publishers' => []]); $country = $this->geoNode('country', 'Synthetic country for park', null); $province = $this->geoNode('province', 'Synthetic province for park', $country); $evidence = [['title' => 'Synthetic municipal project page', 'url' => 'https://example.test/munhava', 'source_type' => 'institutional', 'supports' => 'Existence and project status only; no availability claim.', 'source_date' => null]]; $id = $this->actingAs($editor)->postJson('/api/v2/investment/admin/zones', [ 'code' => 'QA-GENERAL-PARK', 'name' => 'Synthetic planned industrial park', 'category' => 'industrial_park', 'physical_category' => 'industrial_logistics_park', 'legal_regime' => 'pending', 'development_status' => 'planned', 'availability_status' => 'not_informed', 'description' => 'A planned informational park record.', 'province_id' => $province, 'source_evidence' => $evidence, 'area_components' => [['type' => 'pilot', 'value' => 50, 'unit' => 'ha', 'status' => 'reported', 'source_reference' => 'Synthetic source reports pilot area; not available land.', 'source_date' => null]], ])->assertCreated()->json('id'); $this->actingAs($editor)->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zones?development_status=planned')->assertOk()->assertJsonPath('records.total', 1) ->assertJsonPath('records.data.0.id', $id)->assertJsonPath('records.data.0.availability_status', 'not_informed'); $csv = $this->get('/api/v2/investment/zones/export.csv?development_status=planned')->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); $this->assertStringStartsWith("\xEF\xBB\xBF", $csv->streamedContent()); $this->assertStringContainsString('Regime;Desenvolvimento;Disponibilidade', $csv->streamedContent()); $this->assertStringContainsString('Synthetic planned industrial park', $csv->streamedContent()); $this->assertStringContainsString('https://example.test/munhava', $csv->streamedContent()); $this->getJson('/api/v2/investment/zones?availability_status=confirmed')->assertJsonPath('records.total', 0); $this->assertDatabaseHas('investment_zones', ['id' => $id, 'operator_id' => null, 'legal_source_id' => null, 'publication_status' => 'published']); } public function test_promoter_sourced_industrial_park_can_be_published_as_informational_only(): void { $manager = $this->manager(); config(['investment-zones.publishers' => []]); $country = $this->geoNode('country', 'Synthetic country for promoter park', null); $province = $this->geoNode('province', 'Synthetic province for promoter park', $country); $id = $this->actingAs($manager)->postJson('/api/v2/investment/admin/zones', [ 'code' => 'QA-PROMOTER-PARK', 'name' => 'Synthetic promoter-sourced park', 'category' => 'industrial_park', 'physical_category' => 'industrial_park', 'legal_regime' => 'pending', 'development_status' => 'in_development', 'availability_status' => 'not_informed', 'description' => 'Informational project record; legal status, operations, incentives and available land are not confirmed.', 'province_id' => $province, 'source_evidence' => [['title' => 'Promoter project page', 'url' => 'https://example.test/promoter-park', 'source_type' => 'promoter', 'supports' => 'The promoter identifies the project and province; no legal designation or land availability claim.', 'source_date' => null]], ])->assertCreated()->json('id'); $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zones?category=industrial_park')->assertOk() ->assertJsonPath('records.total', 1)->assertJsonPath('records.data.0.id', $id) ->assertJsonPath('records.data.0.legal_regime', 'pending') ->assertJsonPath('records.data.0.availability_status', 'not_informed') ->assertJsonPath('records.data.0.source_evidence.0.source_type', 'promoter'); $this->assertDatabaseHas('investment_zones', ['id' => $id, 'publication_status' => 'published', 'legal_regime' => 'pending', 'operator_id' => null, 'legal_source_id' => null]); } public function test_officially_sourced_zee_can_be_published_informationally_by_the_authenticated_author(): void { $manager = $this->manager(); config(['investment-zones.publishers' => []]); $country = $this->geoNode('country', 'Synthetic country for ZEE', null); $province = $this->geoNode('province', 'Synthetic province for ZEE', $country); $id = $this->actingAs($manager)->postJson('/api/v2/investment/admin/zones', [ 'code' => 'QA-INFO-ZEE', 'name' => 'Synthetic reported ZEE', 'category' => 'zee', 'physical_category' => 'economic_zone', 'legal_regime' => 'zee', 'description' => 'Informational record; legal status and incentives remain unconfirmed.', 'province_id' => $province, 'source_evidence' => [['title' => 'Institutional source', 'url' => 'https://example.test/zee', 'source_type' => 'institutional', 'supports' => 'Reports the zone identity and province; does not confirm current legal status or incentives.', 'source_date' => null]], ])->assertCreated()->json('id'); $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zones?category=zee')->assertOk()->assertJsonPath('records.total', 1) ->assertJsonPath('records.data.0.id', $id); $this->get('/oportunidades?province_id='.$province)->assertOk() ->assertInertia(fn ($page) => $page->has('references.zones', 1)->where('references.zones.0.id', $id)); $this->assertDatabaseHas('investment_zones', ['id' => $id, 'editor_id' => $manager->id, 'reviewer_id' => $manager->id, 'publication_status' => 'published', 'operator_id' => null, 'legal_source_id' => null]); } public function test_zone_record_separates_physical_type_and_legal_regime_and_searches_multiple_reviewed_territories_once(): void { $editor = $this->manager(); $legalReviewer = $this->manager(); $territoryReviewer = $this->manager(); $publisher = $this->manager(); config(['investment-zones.legal_reviewers' => [$legalReviewer->email], 'investment-zones.territory_reviewers' => [$territoryReviewer->email], 'investment-zones.publishers' => [$publisher->email]]); $country = $this->geoNode('country', 'Synthetic Mozambique', null); $province = $this->geoNode('province', 'Synthetic Zambézia', $country); $districtA = $this->geoNode('district', 'Synthetic Mocuba', $province); $districtB = $this->geoNode('district', 'Synthetic Lugela', $province); $source = $this->actingAs($editor)->postJson('/api/v2/investment/admin/legal-sources', [ 'title' => 'Synthetic zone creation act', 'url' => 'https://example.test/zone-act', 'diploma' => 'Synthetic act', 'article' => 'Article 1', 'competent_entity' => 'Synthetic authority', 'valid_from' => today()->toDateString(), ])->assertCreated()->json('id'); $this->actingAs($legalReviewer)->postJson('/api/v2/investment/admin/legal-sources/'.$source.'/validate', ['version_no' => 1])->assertOk(); $zoneId = $this->actingAs($editor)->postJson('/api/v2/investment/admin/zones', [ 'code' => 'SYNTHETIC-MULTI-ZEE', 'category' => 'zee', 'physical_category' => 'economic_zone', 'legal_regime' => 'zee', 'name' => 'Synthetic Mocuba scope', 'name_en' => 'Synthetic Mocuba scope', 'description' => 'Synthetic test record.', 'description_en' => 'Synthetic test record.', 'legal_source_id' => $source, 'province_id' => $province, 'activities' => [['name' => 'Agriculture', 'status' => 'potential', 'source_reference' => 'Synthetic test source']], 'locations' => [ ['geo_location_id' => $districtA, 'scope_type' => 'legal_scope', 'source_reference' => 'Synthetic legal territory source', 'legal_source_id' => $source], ['geo_location_id' => $districtB, 'scope_type' => 'legal_scope', 'source_reference' => 'Synthetic legal territory source', 'legal_source_id' => $source], ], ])->assertCreated()->json('id'); $links = DB::table('investment_zone_locations')->where('zone_id', $zoneId)->orderBy('geo_location_id')->get(['id', 'version_no']); $this->assertCount(2, $links); foreach ($links as $link) { $this->actingAs($territoryReviewer)->postJson('/api/v2/investment/admin/zone-locations/'.$link->id.'/validate', ['version_no' => $link->version_no])->assertOk(); } $this->actingAs($publisher)->postJson('/api/v2/investment/admin/zones/'.$zoneId.'/publish', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zones?province_id='.$province)->assertOk()->assertJsonPath('records.total', 1)->assertJsonPath('records.data.0.id', $zoneId); $this->getJson('/api/v2/investment/zones?province_id='.$province.'&district_id='.$districtA)->assertOk()->assertJsonPath('records.total', 1); $this->getJson('/api/v2/investment/zones?province_id='.$province.'&district_id='.$districtB)->assertOk()->assertJsonPath('records.total', 1); $this->getJson('/api/v2/investment/zones?province_id='.$province.'&activity=Agriculture')->assertOk()->assertJsonPath('records.total', 1); $this->assertDatabaseHas('investment_zones', ['id' => $zoneId, 'operator_id' => null, 'publication_status' => 'published']); $this->assertDatabaseHas('investment_zones', ['id' => $zoneId, 'physical_category' => 'economic_zone', 'legal_regime' => 'zee']); $this->assertDatabaseCount('investment_zone_locations', 2); } public function test_initial_zone_import_is_idempotent_and_keeps_records_private_until_review(): void { $actor = $this->manager(); $country = $this->geoNode('country', 'Synthetic Mozambique import', null); $maputo = $this->geoNode('province', 'Synthetic Maputo', $country); $nampula = $this->geoNode('province', 'Synthetic Nampula', $country); $sofala = $this->geoNode('province', 'Synthetic Sofala', $country); $zambezia = $this->geoNode('province', 'Synthetic Zambézia import', $country); $districts = [ 'BOANE' => $this->geoNode('district', 'BOANE', $maputo), 'MOSSURIL' => $this->geoNode('district', 'MOSSURIL', $nampula), 'CIDADE DA BEIRA' => $this->geoNode('district', 'CIDADE DA BEIRA', $sofala), 'MOCUBA' => $this->geoNode('district', 'MOCUBA', $zambezia), 'LUGELA' => $this->geoNode('district', 'LUGELA', $zambezia), 'NACALA-PORTO' => $this->geoNode('district', 'NACALA-PORTO', $nampula), 'NACALA-VELHA' => $this->geoNode('district', 'NACALA-VELHA', $nampula), ]; $this->geoNode('admin_post', 'MATOLA RIO', $districts['BOANE']); $this->geoNode('admin_post', 'MATIBANE', $districts['MOSSURIL']); $this->geoNode('admin_post', 'MUNHAMADE', $districts['LUGELA']); $this->geoNode('admin_post', 'CIDADE DE MOCUBA', $districts['MOCUBA']); $this->artisan('investment-zones:import-initial', ['--apply' => true, '--actor' => $actor->id])->assertSuccessful(); $this->assertDatabaseCount('investment_zones', 7); $this->assertDatabaseCount('investment_zone_locations', 9); $this->assertDatabaseCount('investment_legal_sources', 6); $this->assertSame(7, DB::table('investment_zones')->where('publication_status', 'draft')->where('validation_status', 'pending')->count()); $this->assertDatabaseCount('investment_zones', 7); $this->artisan('investment-zones:import-initial', ['--apply' => true, '--actor' => $actor->id])->assertSuccessful(); $this->assertDatabaseCount('investment_zones', 7); $this->assertDatabaseCount('investment_zone_locations', 9); } public function test_industrial_candidate_import_is_exact_idempotent_keeps_ambiguity_and_revubue_single(): void { $actor = $this->manager(); $country = $this->geoNode('country', 'Synthetic country industrial candidates', null); $nampulaProvince = $this->geoNode('province', 'NAMPULA', $country); $cabo = $this->geoNode('province', 'Synthetic Cabo Delgado', $country); $tete = $this->geoNode('province', 'Synthetic Tete', $country); $sofala = $this->geoNode('province', 'Synthetic Sofala industrial', $country); $this->geoNode('district', 'LARDE', $nampulaProvince); $this->geoNode('district', 'NAMPULA', $nampulaProvince); foreach (['MONTEPUEZ', 'BALAMA', 'ANCUABE', 'PALMA'] as $name) { $this->geoNode('district', $name, $cabo); } $chiuta = $this->geoNode('district', 'CHIUTA', $tete); $moatize = $this->geoNode('district', 'MOATIZE', $tete); $this->geoNode('district', 'CIDADE DA BEIRA', $sofala); $this->artisan('investment-zones:import-industrial-candidates')->assertSuccessful(); $this->assertDatabaseCount('investment_zones', 0); $this->artisan('investment-zones:import-industrial-candidates', ['--apply' => true, '--actor' => $actor->id])->assertSuccessful(); $this->assertDatabaseCount('investment_zones', 9); $this->assertDatabaseCount('investment_zone_locations', 10); $this->assertDatabaseCount('investment_legal_sources', 0); $revubue = DB::table('investment_zones')->where('code', 'MOZ-REVUBUE-ZFI')->first(); $this->assertSame('draft', $revubue->publication_status); $this->assertSame('planned', $revubue->development_status); $this->assertSame(2, DB::table('investment_zone_locations')->where('zone_id', $revubue->id)->whereIn('geo_location_id', [$chiuta, $moatize])->count()); $this->assertSame('reported', DB::table('investment_zones')->where('code', 'MOZ-TOPUITO-ZFI')->value('area_components->0->status')); $this->artisan('investment-zones:import-industrial-candidates', ['--apply' => true, '--actor' => $actor->id])->assertSuccessful(); $this->assertDatabaseCount('investment_zones', 9); $this->assertDatabaseCount('investment_zone_locations', 10); $this->assertDatabaseCount('investment_zone_revisions', 9); } private function geoNode(string $type, string $name, ?string $parent): string { $id = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $id, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'version_no' => 1, 'data_classification' => 'public', 'name' => $name, 'location_type' => $type, 'parent_id' => $parent, 'code' => $id, 'source_code' => null, 'created_at' => now(), 'updated_at' => now()]); return $id; } public function test_validated_relations_filter_the_same_opportunity_and_revoked_sources_disappear(): void { $editor = $this->manager(); $reviewer = $this->manager(); $this->actingAs($editor); $source = $this->postJson('/api/v2/investment/admin/legal-sources', [ 'title' => 'Synthetic legal evidence', 'url' => 'https://example.test/legal', 'diploma' => 'TEST ONLY', 'article' => 'Synthetic article', 'competent_entity' => 'Synthetic authority', 'valid_from' => today()->subDay()->toDateString(), ])->assertCreated()->json('id'); $this->postJson('/api/v2/investment/admin/legal-sources/'.$source.'/validate', ['version_no' => 1])->assertForbidden(); $this->actingAs($reviewer)->postJson('/api/v2/investment/admin/legal-sources/'.$source.'/validate', ['version_no' => 1])->assertOk(); $organization = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $organization, 'name' => 'Synthetic operator', 'organization_type' => 'private']); $sector = Sector::create(['slug' => 'qa-zone-sector', 'name' => 'Synthetic sector', 'is_active' => true]); $zoneCountry = $this->geoNode('country', 'Synthetic Country for zone link', null); $zoneProvince = $this->geoNode('province', 'Synthetic Province for zone link', $zoneCountry); $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-ZONE', 'slug' => 'qa-zone', 'title' => 'Synthetic investment', 'status' => 'published', 'published_at' => now()->subMinute(), 'province' => 'Synthetic Province for zone link', 'geography_scope' => 'specific', 'primary_territory_id' => $zoneProvince]); $zoneIds = []; foreach (['zee', 'zfi'] as $category) { $this->actingAs($editor); $zoneId = $this->postJson('/api/v2/investment/admin/zones', ['code' => 'QA-'.$category, 'name' => 'Synthetic '.$category, 'category' => $category, 'physical_category' => $category === 'zfi' ? 'industrial_free_zone' : 'economic_zone', 'legal_regime' => $category, 'province_id' => $zoneProvince, 'name_en' => 'Synthetic zone', 'description' => 'Synthetic description', 'description_en' => 'Synthetic description', 'operator_id' => $organization, 'legal_source_id' => $source])->assertCreated()->json('id'); $linkPayload = ['opportunity_id' => $opportunity->id, 'association_type' => 'potential', 'role' => 'potential', 'source_reference' => 'Synthetic relationship evidence', 'justification' => 'Synthetic potential investment', 'valid_from' => today()->toDateString()]; $link = $this->postJson('/api/v2/investment/admin/zones/'.$zoneId.'/links', $linkPayload)->assertCreated()->json('id'); $this->postJson('/api/v2/investment/admin/zones/'.$zoneId.'/links', $linkPayload)->assertConflict(); $this->postJson('/api/v2/investment/admin/zone-links/'.$link.'/validate', ['version_no' => 1])->assertForbidden(); $this->actingAs($reviewer)->postJson('/api/v2/investment/admin/zone-links/'.$link.'/validate', ['version_no' => 1])->assertOk(); $this->postJson('/api/v2/investment/admin/zones/'.$zoneId.'/publish', ['version_no' => 1])->assertOk(); $zoneIds[$category.'_id'] = $zoneId; $this->actingAs($editor); $geometry = $this->postJson('/api/v2/investment/admin/zones/'.$zoneId.'/geometries', [ 'geometry' => ['type' => 'Point', 'coordinates' => [32.5, -25.9]], 'purpose' => 'site', 'source_reference' => 'Synthetic coordinate evidence', 'validating_entity' => 'Synthetic reviewer', 'observed_at' => today()->toDateString(), ])->assertCreated()->json('id'); $this->actingAs($reviewer)->postJson('/api/v2/investment/admin/zone-geometries/'.$geometry.'/validate', ['version_no' => 1])->assertOk(); } $this->assertDatabaseCount('investment_items', 1); $this->assertDatabaseCount('investment_item_opportunities', 1); $query = http_build_query($zoneIds); $this->getJson('/api/v1/investment/search?'.$query)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $opportunity->id); $this->getJson('/api/v1/investment/search?dimension=zee&view=combined')->assertOk()->assertJsonPath('total', 1); $this->getJson('/api/v1/investment/search?dimension=zfi&view=list')->assertOk()->assertJsonPath('total', 1); $this->getJson('/api/v1/investment/search?dimension=zee&q=not-present')->assertJsonPath('total', 0); $this->getJson('/api/v1/investment/search?dimension=invalid')->assertUnprocessable(); $this->get('/oportunidades?province_id='.$zoneProvince.'&zee_id='.$zoneIds['zee_id'])->assertOk() ->assertInertia(fn ($page) => $page->component('investment/catalogue')->has('references.zones', 2) ->has('results.data', 1)->where('results.data.0.id', $opportunity->id)); $this->getJson('/api/v2/investment/zones/'.$zoneIds['zee_id'])->assertOk() ->assertJsonPath('opportunities.total', 1)->assertJsonPath('opportunities.data.0.id', $opportunity->id); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 2)->assertJsonMissingPath('records.data.0.editor_id'); $this->getJson('/api/v2/investment/zone-map')->assertJsonCount(2, 'features'); $this->seed(InvestmentReferenceSeeder::class); $investor = User::factory()->create(); $this->actingAs($investor); $origin = ['zone_id' => $zoneIds['zee_id'], 'intent' => 'interest', 'opportunity_id' => $opportunity->id]; $submission = $this->postJson('/api/v1/investment/submissions', $origin)->assertCreated()->json('id'); $this->postJson('/api/v1/investment/submissions', $origin)->assertCreated()->assertJsonPath('id', $submission); $this->assertDatabaseCount('investment_submissions', 1); $this->getJson('/api/v1/investment/submissions/'.$submission)->assertJsonPath('zone_origin.zone_id', $zoneIds['zee_id']); $submit = ['version' => 1, 'payload' => ['full_name' => 'Synthetic investor', 'email' => 'investor@example.test', 'country' => 'Test', 'profile_id' => DB::table('investment_reference_options')->where('group', 'investor_profile')->where('code', 'individual')->value('id'), 'sector' => 'Synthetic sector', 'objective' => 'Synthetic investment request with zone context.', 'participation_id' => DB::table('investment_reference_options')->where('group', 'participation')->where('code', 'invest')->value('id'), 'horizon' => 'Synthetic horizon', 'contact_consent' => true]]; $reference = $this->postJson('/api/v1/investment/submissions/'.$submission.'/submit', $submit)->assertOk()->json('reference'); $this->postJson('/api/v1/investment/submissions/'.$submission.'/submit', $submit)->assertOk()->assertJsonPath('reference', $reference); $this->assertDatabaseCount('interest_requests', 1); $metadata = json_decode(DB::table('interest_requests')->value('metadata'), true); $this->assertSame($zoneIds['zee_id'], $metadata['zone_origin']['zone_id']); $this->assertSame('interest', $metadata['zone_origin']['intent']); $this->actingAs($reviewer); $this->postJson('/api/v2/investment/admin/legal-sources/'.$source.'/revoke', ['version_no' => 2])->assertOk(); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 0); $this->getJson('/api/v2/investment/zone-map')->assertJsonCount(0, 'features'); $this->getJson('/api/v1/investment/search?'.$query)->assertJsonPath('total', 0); $this->actingAs($investor)->postJson('/api/v1/investment/submissions', $origin)->assertNotFound(); $this->assertDatabaseCount('investment_zones', 2); } public function test_gis_rejects_invalid_shapes_and_requires_independent_review_without_granting_zone_status(): void { $editor = $this->manager(); $reviewer = $this->manager(); $this->actingAs($editor); $id = $this->postJson('/api/v2/investment/admin/zones', ['code' => 'GIS-QA', 'name' => 'Synthetic GIS zone', 'category' => 'pending'])->assertCreated()->json('id'); $url = '/api/v2/investment/admin/zones/'.$id.'/geometries'; $payload = ['geometry' => ['type' => 'Point', 'coordinates' => [32.5, -25.9]], 'purpose' => 'site', 'source_reference' => 'Synthetic coordinates, not real data', 'validating_entity' => 'Synthetic GIS reviewer', 'observed_at' => today()->toDateString()]; $this->postJson($url, [...$payload, 'geometry' => ['type' => 'Point', 'coordinates' => [190, 0]]])->assertUnprocessable(); $this->postJson($url, [...$payload, 'geometry' => ['type' => 'Polygon', 'coordinates' => [[[0, 0], [1, 1], [1, 0], [0, 1], [0, 0]]]]])->assertUnprocessable(); $link = $this->postJson($url, $payload)->assertCreated()->json('id'); $this->postJson('/api/v2/investment/admin/zone-geometries/'.$link.'/validate', ['version_no' => 1])->assertForbidden(); $this->actingAs($reviewer)->postJson('/api/v2/investment/admin/zone-geometries/'.$link.'/validate', ['version_no' => 1])->assertOk(); $this->getJson('/api/v2/investment/zone-map')->assertJsonCount(0, 'features'); $this->assertDatabaseHas('investment_zones', ['id' => $id, 'category' => 'pending', 'publication_status' => 'draft']); $this->getJson('/api/v2/investment/zone-map?bbox[]=180&bbox[]=0&bbox[]=-180&bbox[]=10')->assertUnprocessable(); } public function test_anchor_can_link_multiple_zones_without_duplicates_or_self_approval(): void { $editor = $this->manager(); $reviewer = $this->manager(); $project = Project::factory()->create(); foreach (['zee', 'zfi', 'industrial_park'] as $category) { $this->actingAs($editor); $id = $this->postJson('/api/v2/investment/admin/zones', ['code' => 'ANCHOR-'.$category, 'name' => 'Synthetic '.$category, 'category' => $category])->assertCreated()->json('id'); $payload = ['project_id' => $project->id, 'association_type' => 'potential', 'role' => 'beneficiary', 'source_reference' => 'Synthetic documentary relation', 'justification' => 'Synthetic anchor relation', 'valid_from' => today()->toDateString()]; $url = '/api/v2/investment/admin/zones/'.$id.'/relations'; $relation = $this->postJson($url, $payload)->assertCreated()->json('id'); $this->postJson($url, $payload)->assertConflict(); $this->postJson('/api/v2/investment/admin/zone-relations/'.$relation.'/validate', ['version_no' => 1])->assertForbidden(); $this->actingAs($reviewer)->postJson('/api/v2/investment/admin/zone-relations/'.$relation.'/validate', ['version_no' => 1])->assertOk(); } $this->assertDatabaseCount('projects', 1); $this->assertDatabaseCount('investment_zone_relations', 3); $this->assertDatabaseCount('investment_items', 0); config(['investment-zones.publishers' => [], 'investment-zones.legal_reviewers' => [], 'investment-zones.gis_reviewers' => []]); $this->postJson('/api/v2/investment/admin/zones/'.$id.'/publish', ['version_no' => 1])->assertUnprocessable(); $this->assertDatabaseHas('investment_zones', ['id' => $id, 'publication_status' => 'draft']); $this->getJson('/api/v2/investment/zones')->assertJsonPath('records.total', 0); } public function test_identity_reconciliation_is_dry_by_default_idempotent_and_preserves_legacy_data(): void { Storage::fake('local'); $sector = Sector::create(['slug' => 'identity-qa', 'name' => 'Synthetic', 'is_active' => true]); $opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'IDENTITY-QA', 'slug' => 'identity-qa', 'title' => 'Synthetic identity', 'status' => 'draft']); $before = $opportunity->fresh()->getAttributes(); $this->artisan('investment:reconcile-identities')->assertSuccessful(); $this->assertDatabaseCount('investment_items', 0); $this->artisan('investment:reconcile-identities', ['--apply' => true])->assertSuccessful(); $id = DB::table('investment_item_opportunities')->where('opportunity_id', $opportunity->id)->value('item_id'); $this->artisan('investment:reconcile-identities', ['--apply' => true])->assertSuccessful(); $this->assertDatabaseCount('investment_items', 1); $this->assertSame($id, DB::table('investment_item_opportunities')->value('item_id')); $this->assertSame($before, $opportunity->fresh()->getAttributes()); $this->assertDatabaseCount('investment_item_projects', 0); } }