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

158 lines
10 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Sector;
use App\Models\User;
use App\Services\ExistingBusinessClassification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class BusinessProximitySearchTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['existing-business.enabled' => true]);
}
private function business(?string $geometry = 'POINT(32 -26)', array $location = [], array $attributes = []): string
{
$id = (string) Str::uuid();
DB::table('existing_businesses')->insert([...[
'id' => $id, 'code' => 'QA-'.Str::uuid(), 'owner_user_id' => User::factory()->create()->id,
'modality' => 'company_sale', 'status' => 'published', 'classification' => 'confidential',
'version_no' => 1, 'payload' => Crypt::encryptString('{"title":"PRIVATE SYNTHETIC"}'),
'public_title' => 'Synthetic public '.$id, 'public_slug' => 'qa-'.$id,
'published_at' => now()->subDay(), 'created_at' => now(), 'updated_at' => now(),
], ...$attributes]);
if ($geometry !== null || $location !== []) {
DB::table('existing_business_locations')->insert([...[
'id' => (string) Str::uuid(), 'existing_business_id' => $id,
'precision_level' => 'exact', 'is_primary' => true, 'is_public' => true,
'geom' => $geometry === null ? null : DB::raw("ST_GeomFromText('".$geometry."',4326)"),
'created_at' => '2026-10-01 10:00:00+00', 'updated_at' => '2026-10-02 10:00:00+00',
], ...$location]);
}
return $id;
}
private function url(string $origin, array $filters = []): string
{
return '/api/v1/business-opportunities?'.http_build_query(['near_business' => $origin, 'radius_km' => 20, ...$filters]);
}
public function test_radius_orders_by_distance_excludes_unusable_and_private_points_and_retains_context(): void
{
$origin = $this->business();
$near = $this->business('POINT(32.01 -26)');
$far = $this->business('POINT(34 -26)');
$this->business('POINT(32.001 -26)', ['is_public' => false]);
$this->business('POINT(32.001 -26)', ['is_primary' => false]);
$this->business('POLYGON((32 -26,32.01 -26,32.01 -26.01,32 -26))');
$this->business('POINT(200 -26)');
$this->business(null);
$this->business('POINT(32.001 -26)', [], ['status' => 'draft']);
$this->business('POINT(32.001 -26)', [], ['published_at' => now()->addDay()]);
$this->business('POINT(32.001 -26)', [], ['expires_at' => now()->subDay()]);
$deleted = $this->business();
DB::table('existing_businesses')->where('id', $deleted)->update(['deleted_at' => now()]);
$response = $this->getJson($this->url($origin))->assertOk()->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $origin)->assertJsonPath('data.0.distance_km', 0)
->assertJsonPath('data.1.id', $near)->assertJsonPath('geographicCoverage.with_public_point', 3)
->assertJsonPath('geographicCoverage.without_public_point', 5)
->assertJsonPath('geographicCoverage.origin_id', $origin)
->assertJsonPath('data.1.location_recorded_at', '2026-10-02T10:00:00+00:00');
$this->assertGreaterThan(0, $response->json('data.1.distance_km'));
$this->assertLessThan(2, $response->json('data.1.distance_km'));
foreach (['geom', 'point', 'ewkt', 'owner_user_id', 'payload'] as $field) {
$response->assertJsonMissingPath('data.0.'.$field)->assertJsonMissingPath('geographicCoverage.'.$field);
}
$this->assertStringNotContainsString('PRIVATE SYNTHETIC', $response->getContent());
$first = $this->getJson($this->url($origin, ['per_page' => 1]))->assertOk();
$next = $first->json('links.next');
$this->assertStringContainsString('near_business='.$origin, $next);
$this->getJson($next)->assertOk()->assertJsonPath('data.0.id', $near);
$this->getJson($this->url($origin, ['radius_km' => 300]))->assertOk()->assertJsonCount(3, 'data')->assertJsonPath('data.2.id', $far);
$this->get('/negocios?'.http_build_query(['near_business' => $origin, 'radius_km' => 20]))
->assertInertia(fn (Assert $page) => $page->where('geographicCoverage.origin_id', $origin)->has('results.data', 2));
$query = '?'.http_build_query(['near_business' => $origin, 'radius_km' => 20]);
$this->get('/negocios/qa-'.$near.$query)->assertInertia(fn (Assert $page) => $page
->where('similarRadiusAvailable', true)->where('returnUrl', '/negocios'.$query)
->where('interestUrl', '/negocios/qa-'.$near.'/manifestar-interesse'.$query));
}
public function test_radius_combines_sector_modality_and_disclosed_classification_without_widening(): void
{
$sector = Sector::create(['name' => 'Synthetic QA', 'slug' => 'synthetic-proximity', 'is_active' => true, 'published_at' => now()]);
$origin = $this->business();
$match = $this->business('POINT(32.01 -26)', [], ['sector_id' => $sector->id]);
$this->business('POINT(32.01 -26)', [], ['sector_id' => $sector->id]);
$other = $this->business('POINT(32.01 -26)', [], ['sector_id' => $sector->id, 'modality' => 'real_estate']);
$wrongSector = $this->business('POINT(32.01 -26)');
foreach ([$match, $other, $wrongSector] as $id) {
$row = ['related_id' => (string) $sector->id, 'classification' => ['target_type' => 'sector', 'purpose' => 'coverage',
'disclosure' => 'public', 'requested_state' => 'preliminary', 'external_status' => 'not_declared', 'source_reference' => 'Synthetic QA source']];
$state = ['schema_version' => ExistingBusinessClassification::VERSION, 'relations' => [$row], 'authors' => [], 'decisions' => [], 'publication' => [$row]];
DB::table('existing_business_revisions')->insert(['id' => (string) Str::uuid(), 'business_id' => $id, 'version_no' => 1,
'action' => 'classification_disclosed', 'snapshot' => Crypt::encryptString(json_encode(['context' => ['multidimensional' => $state]], JSON_THROW_ON_ERROR)),
'sha256' => hash('sha256', $id), 'actor_id' => DB::table('existing_businesses')->where('id', $id)->value('owner_user_id'),
'reason' => 'Synthetic disclosed classification', 'created_at' => now()]);
}
$this->getJson($this->url($origin, ['sector' => $sector->id, 'modality' => 'company_sale', 'association_type' => 'sector', 'association_target' => (string) $sector->id]))
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $match)
->assertJsonPath('geographicCoverage.with_public_point', 1)->assertJsonPath('geographicCoverage.without_public_point', 0);
}
public function test_invalid_or_unpublished_reference_never_falls_back_to_an_area_or_centroid(): void
{
$private = $this->business('POINT(32 -26)', ['is_public' => false]);
$polygon = $this->business('POLYGON((32 -26,32.01 -26,32.01 -26.01,32 -26))');
$draft = $this->business('POINT(32 -26)', [], ['status' => 'draft']);
foreach ([$private, $polygon, $draft, (string) Str::uuid()] as $origin) {
$this->getJson($this->url($origin))->assertUnprocessable()->assertJsonValidationErrors('near_business');
}
$origin = $this->business();
foreach ([0, -1, 2001, 'bad'] as $radius) {
$this->getJson($this->url($origin, ['radius_km' => $radius]))->assertUnprocessable()->assertJsonValidationErrors('radius_km');
}
$this->getJson('/api/v1/business-opportunities?near_business='.$origin)->assertUnprocessable()->assertJsonValidationErrors('radius_km');
$this->getJson('/api/v1/business-opportunities?radius_km=20')->assertUnprocessable()->assertJsonValidationErrors('near_business');
}
public function test_coarse_precision_does_not_disclose_a_finer_territory_through_filters_or_radius(): void
{
$ids = ['country' => (string) Str::uuid(), 'province' => (string) Str::uuid(), 'district' => (string) Str::uuid()];
foreach ($ids as $kind => $id) {
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Synthetic '.$kind, 'location_type' => $kind,
'parent_id' => $kind === 'country' ? null : $ids[$kind === 'province' ? 'country' : 'province'],
'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
}
$coarse = $this->business('POINT(32 -26)', ['precision_level' => 'province', 'province_id' => $ids['province'], 'district_id' => $ids['district']]);
$fine = $this->business('POINT(32 -26)', ['precision_level' => 'district', 'province_id' => $ids['province'], 'district_id' => $ids['district']]);
$this->getJson('/api/v1/business-opportunities?province_id='.$ids['province'].'&district_id='.$ids['district'])
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $fine);
$this->getJson('/api/v1/business-opportunities/qa-'.$coarse)->assertOk()->assertJsonPath('data.district', null);
foreach ([$coarse, $fine] as $origin) {
$this->getJson($this->url($origin))->assertUnprocessable();
}
$exact = $this->business('POINT(32 -26)', ['province_id' => $ids['province'], 'district_id' => $ids['district']]);
$origin = $this->business();
$this->getJson($this->url($origin, ['province_id' => $ids['province'], 'district_id' => $ids['district']]))
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $exact)
->assertJsonPath('geographicCoverage.with_public_point', 1)->assertJsonPath('geographicCoverage.without_public_point', 1);
DB::table('geo_locations')->where('id', $ids['district'])->update(['data_classification' => 'restricted']);
$this->getJson($this->url($exact))->assertUnprocessable();
$this->getJson('/api/v1/business-opportunities/qa-'.$coarse)->assertOk()->assertJsonPath('data.province', 'Synthetic province');
}
}