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', '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 { $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'); $intakeMigration->down(); $businessMigration->down(); $originMigration->down(); $geometryMigration->down(); $migration->down(); $migration->up(); $geometryMigration->up(); $originMigration->up(); $businessMigration->up(); $intakeMigration->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_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()]); $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, '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); } }