Files
Investidor-V2/tests/Feature/InvestmentZoneTest.php
T

226 lines
16 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', '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);
}
}