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

406 lines
30 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use Database\Seeders\InvestmentReferenceSeeder;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentZoneTest extends TestCase
{
use RefreshDatabase;
private function manager(): User
{
$user = User::factory()->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])->assertForbidden();
$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();
$publisher = $this->manager();
config(['investment-zones.publishers' => [$publisher->email]]);
$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($publisher)->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_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]);
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'QA-ZONE', 'slug' => 'qa-zone', 'title' => 'Synthetic investment', 'status' => 'published', 'published_at' => now()->subMinute()]);
$zoneCountry = $this->geoNode('country', 'Synthetic Country for zone link', null);
$zoneProvince = $this->geoNode('province', 'Synthetic Province for zone link', $zoneCountry);
$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->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])->assertForbidden();
$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);
}
}