233 lines
16 KiB
PHP
233 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
|
|
{
|
|
$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');
|
|
$intakeMigration->down();
|
|
$businessMigration->down();
|
|
$originMigration->down();
|
|
$geometryMigration->down();
|
|
$migration->down();
|
|
$migration->up();
|
|
$geometryMigration->up();
|
|
$originMigration->up();
|
|
$businessMigration->up();
|
|
$intakeMigration->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_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);
|
|
}
|
|
}
|