243 lines
17 KiB
PHP
243 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\BusinessPointPublication;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Tests\Support\AuthenticatesQaActors;
|
|
use Tests\TestCase;
|
|
|
|
final class BusinessPointPublicationTest extends TestCase
|
|
{
|
|
use AuthenticatesQaActors;
|
|
use RefreshDatabase;
|
|
|
|
private User $owner;
|
|
|
|
private string $province;
|
|
|
|
private string $district;
|
|
|
|
private int $sector;
|
|
|
|
private string $base = '/api/v1/existing-business/listings';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->withoutVite();
|
|
config(['existing-business.enabled' => true, 'portal.profile_access_enabled' => false, 'portal.profile_administration_enabled' => false]);
|
|
$this->owner = User::factory()->create();
|
|
$country = $this->territory('country');
|
|
$this->province = $this->territory('province', $country);
|
|
$this->district = $this->territory('district', $this->province);
|
|
$this->sector = DB::table('sectors')->insertGetId(['name' => 'Synthetic point sector', 'slug' => 'synthetic-point-sector', 'is_active' => true]);
|
|
}
|
|
|
|
private function territory(string $kind, ?string $parent = null): string
|
|
{
|
|
$id = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $id, 'name' => 'Synthetic '.$kind, 'code' => $id, 'location_type' => $kind,
|
|
'parent_id' => $parent, 'version_no' => 1, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
|
|
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
|
|
|
|
return $id;
|
|
}
|
|
|
|
private function draft(array $payload = []): array
|
|
{
|
|
return ['modality' => 'participation_sale', 'classification' => 'restricted', 'reason' => 'Synthetic point preparation',
|
|
'public_title' => 'Synthetic public point listing', 'sector_id' => $this->sector,
|
|
'payload' => [...[
|
|
'title' => 'Synthetic private point listing', 'description' => 'Synthetic description for a staged independent publication.',
|
|
'source_reference' => 'SYNTHETIC source already registered', 'source_date' => '2026-10-02',
|
|
'geography_scope' => 'specific', 'province_id' => $this->province, 'district_id' => $this->district,
|
|
'location_unknown' => 'no', 'disclosure_precision' => 'exact', 'location_point_enabled' => 'yes',
|
|
'location_latitude' => '-26.0001', 'location_longitude' => '32.0001',
|
|
'location_point_source_mode' => 'listing', 'location_point_share_source' => 'yes',
|
|
], ...$payload]];
|
|
}
|
|
|
|
private function create(array $payload = []): array
|
|
{
|
|
return $this->actingAs($this->owner)->postJson($this->base, $this->draft($payload))->assertCreated()->json();
|
|
}
|
|
|
|
private function designated(string $role): 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],
|
|
'existing-business.'.$role => [...config('existing-business.'.$role, []), $user->email]]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function approve(string $id, int $version): User
|
|
{
|
|
$locationsBefore = DB::table('existing_business_locations')->orderBy('id')->get()->toJson();
|
|
$roles = ['submitted' => null, 'triage' => 'triagers', 'in_review' => 'reviewers', 'approval_l1' => 'reviewers', 'approval_l2' => 'approvers_l1', 'approved' => 'approvers_l2'];
|
|
$users = [];
|
|
foreach ($roles as $action => $role) {
|
|
if ($role === null) {
|
|
$this->actingAs($this->owner);
|
|
} else {
|
|
$this->actingAsWithMfa($users[$role] ??= $this->designated($role));
|
|
}
|
|
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => $version++, 'action' => $action,
|
|
'note' => 'Synthetic normal workflow decision', 'review_consent' => $action === 'submitted'])->assertOk();
|
|
$this->assertSame($locationsBefore, DB::table('existing_business_locations')->orderBy('id')->get()->toJson());
|
|
}
|
|
|
|
return $this->designated('publishers');
|
|
}
|
|
|
|
public function test_point_stays_private_until_normal_independent_publication_then_feeds_radius_and_source_dates(): void
|
|
{
|
|
$record = $this->create();
|
|
$id = $record['id'];
|
|
$this->assertDatabaseCount('existing_business_locations', 0);
|
|
$raw = DB::table('existing_businesses')->where('id', $id)->value('payload');
|
|
$this->assertStringNotContainsString('-26.0001', $raw);
|
|
$this->getJson($this->base.'/'.$id)->assertOk()->assertJsonPath('record.payload.location_point_source_mode', 'listing')
|
|
->assertHeader('Cache-Control', 'no-store, private');
|
|
$this->actingAs(User::factory()->create())->getJson($this->base.'/'.$id)->assertForbidden();
|
|
$this->getJson('/api/v1/business-opportunities')->assertOk()->assertJsonCount(0, 'data');
|
|
$publisher = $this->approve($id, 1);
|
|
$this->actingAs($this->owner)->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic owner cannot self publish'])->assertForbidden();
|
|
$this->actingAsWithMfa($publisher)->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 6, 'action' => 'published', 'note' => 'Synthetic stale publication'])->assertConflict();
|
|
$this->assertDatabaseCount('existing_business_locations', 0);
|
|
$saved = $this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic authorized publication'])->assertOk()->assertJsonPath('version_no', 8);
|
|
$point = DB::table('existing_business_locations')->where('existing_business_id', $id)->first();
|
|
$this->assertTrue($point->is_primary);
|
|
$this->assertTrue($point->is_public);
|
|
$this->assertSame(8, $point->source_payload_version);
|
|
$this->assertSame($this->district, $point->district_id);
|
|
$response = $this->getJson('/api/v1/business-opportunities?'.http_build_query(['near_business' => $id, 'radius_km' => 10]))->assertOk()
|
|
->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $id)
|
|
->assertJsonPath('data.0.location_source_reference', 'SYNTHETIC source already registered')
|
|
->assertJsonPath('data.0.location_source_date', '2026-10-02')->assertJsonPath('geographicCoverage.origin_source_date', '2026-10-02')
|
|
->assertJsonMissingPath('data.0.location_latitude')->assertJsonMissingPath('data.0.source_payload_version');
|
|
$this->assertStringNotContainsString('-26.0001', $response->getContent());
|
|
$this->get('/negocios/'.$saved->json('public_slug'))->assertOk()->assertInertia(fn ($page) => $page->where('similarRadiusAvailable', true));
|
|
$snapshot = json_decode(Crypt::decryptString(DB::table('existing_business_revisions')->where('business_id', $id)->where('action', 'published')->value('snapshot')), true);
|
|
$this->assertSame('Synthetic district', $snapshot['context']['point_publication']['used']['reference_snapshot'][1]['name']);
|
|
$this->assertSame('SYNTHETIC source already registered', $snapshot['context']['point_publication']['used']['source_reference']);
|
|
$this->assertCount(1, $snapshot['locations']);
|
|
$before = DB::table('existing_business_revisions')->where('business_id', $id)->pluck('sha256')->all();
|
|
DB::table('geo_locations')->where('id', $this->district)->update(['name' => 'Updated synthetic district', 'version_no' => 2]);
|
|
$this->assertSame($before, DB::table('existing_business_revisions')->where('business_id', $id)->pluck('sha256')->all());
|
|
$this->putJson($this->base.'/'.$id, [...$this->draft(), 'version_no' => 8])->assertForbidden();
|
|
}
|
|
|
|
public function test_point_chooses_one_declared_path_and_does_not_assume_the_first_of_multiple_locations(): void
|
|
{
|
|
$other = $this->territory('district', $this->province);
|
|
$paths = [['province_id' => $this->province, 'district_id' => $this->district], ['province_id' => $this->province, 'district_id' => $other]];
|
|
$record = $this->create(['geography_scope' => 'multiple', 'locations_json' => json_encode($paths)]);
|
|
$url = $this->base.'/'.$record['id'];
|
|
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'review_consent' => true, 'note' => 'Synthetic point needs a selected site'])
|
|
->assertUnprocessable()->assertJsonValidationErrors('location_point_territory');
|
|
$key = app(BusinessPointPublication::class)->pathKey($paths[1]);
|
|
$point = app(BusinessPointPublication::class)->candidate([...$this->draft()['payload'], 'geography_scope' => 'multiple', 'locations_json' => json_encode($paths), 'location_point_territory' => $key]);
|
|
$this->assertSame($other, $point['path']['district_id']);
|
|
$this->assertSame('-26.0001', $record['payload']['location_latitude']);
|
|
$this->expectException(ValidationException::class);
|
|
app(BusinessPointPublication::class)->candidate([...$this->draft()['payload'], 'geography_scope' => 'multiple', 'locations_json' => json_encode([$paths[0]]), 'location_point_territory' => $key]);
|
|
}
|
|
|
|
public function test_inapplicable_point_fields_remain_private_and_do_not_block_or_activate_a_point(): void
|
|
{
|
|
foreach ([['geography_scope' => 'national'], ['geography_scope' => 'digital'], ['disclosure_precision' => 'administrative'], ['location_unknown' => 'yes'], ['location_point_enabled' => 'no']] as $changes) {
|
|
$payload = [...$this->draft()['payload'], ...$changes, 'location_latitude' => 'unfinished', 'location_point_source_mode' => 'custom', 'location_point_source_date' => 'unfinished'];
|
|
$record = $this->actingAs($this->owner)->postJson($this->base, $this->draft($payload))->assertCreated()->json();
|
|
$this->assertSame('unfinished', $record['payload']['location_latitude']);
|
|
$this->assertNull(app(BusinessPointPublication::class)->candidate($payload));
|
|
}
|
|
$this->assertDatabaseCount('existing_business_locations', 0);
|
|
}
|
|
|
|
public function test_custom_source_unknown_date_and_reserved_reference_are_preserved_without_false_public_citation(): void
|
|
{
|
|
$record = $this->create(['location_point_source_mode' => 'custom', 'location_point_source_reference' => 'SYNTHETIC PRIVATE point source', 'location_point_source_date' => '', 'location_point_share_source' => 'no']);
|
|
$publisher = $this->approve($record['id'], 1);
|
|
$this->actingAsWithMfa($publisher)->postJson($this->base.'/'.$record['id'].'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic authorized source-aware publication'])->assertOk();
|
|
$this->getJson('/api/v1/business-opportunities')->assertOk()->assertJsonPath('data.0.location_source_reference', null)->assertJsonPath('data.0.location_source_date', null)
|
|
->assertDontSee('SYNTHETIC PRIVATE point source');
|
|
$snapshot = Crypt::decryptString(DB::table('existing_business_revisions')->where('business_id', $record['id'])->where('action', 'published')->value('snapshot'));
|
|
$this->assertStringContainsString('SYNTHETIC PRIVATE point source', $snapshot);
|
|
$this->assertStringNotContainsString('SYNTHETIC PRIVATE point source', DB::table('existing_business_revisions')->where('business_id', $record['id'])->where('action', 'published')->value('snapshot'));
|
|
}
|
|
|
|
public function test_invalid_coordinates_and_retired_territory_do_not_publish_or_change_the_workflow(): void
|
|
{
|
|
foreach ([['location_latitude' => '91'], ['location_longitude' => '-181'], ['location_latitude' => 'NaN'], ['location_latitude' => 'Infinity']] as $change) {
|
|
$this->actingAs($this->owner)->postJson($this->base, $this->draft($change))->assertUnprocessable();
|
|
}
|
|
$record = $this->create(['location_longitude' => '']);
|
|
$this->postJson($this->base.'/'.$record['id'].'/transitions', ['version_no' => 1, 'action' => 'submitted', 'review_consent' => true, 'note' => 'Synthetic incomplete point submission'])->assertUnprocessable();
|
|
$this->assertDatabaseCount('business_workflow_transitions', 0);
|
|
$record = $this->create();
|
|
$publisher = $this->approve($record['id'], 1);
|
|
DB::table('geo_locations')->where('id', $this->district)->update(['data_classification' => 'restricted']);
|
|
$this->actingAsWithMfa($publisher)->postJson($this->base.'/'.$record['id'].'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic retired point cannot publish'])->assertUnprocessable();
|
|
$this->assertDatabaseHas('existing_businesses', ['id' => $record['id'], 'status' => 'approved', 'version_no' => 7]);
|
|
$this->assertDatabaseCount('existing_business_locations', 0);
|
|
}
|
|
|
|
public function test_migration_preserves_legacy_rows_and_public_queries_work_without_optional_source_columns(): void
|
|
{
|
|
$migration = require database_path('migrations/2026_11_03_000000_existing_business_location_sources.php');
|
|
$migration->down();
|
|
$this->assertFalse(Schema::hasColumn('existing_business_locations', 'public_source_reference'));
|
|
$this->getJson('/api/v1/business-opportunities')->assertOk();
|
|
$migration->up();
|
|
$this->create();
|
|
DB::table('existing_business_locations')->insert(['id' => (string) Str::uuid(), 'existing_business_id' => DB::table('existing_businesses')->value('id'),
|
|
'province_id' => $this->province, 'precision_level' => 'province', 'is_primary' => true, 'is_public' => false, 'source_system' => 'synthetic-private-source']);
|
|
$this->expectException(\RuntimeException::class);
|
|
$migration->down();
|
|
}
|
|
|
|
public function test_explicit_point_replacement_preserves_primary_identity_secondary_rows_and_prior_geometry_in_history(): void
|
|
{
|
|
$record = $this->create();
|
|
$primary = (string) Str::uuid();
|
|
$secondary = (string) Str::uuid();
|
|
foreach ([$primary, $secondary] as $location) {
|
|
DB::table('existing_business_locations')->insert(['id' => $location, 'existing_business_id' => $record['id'],
|
|
'province_id' => $this->province, 'district_id' => $this->district, 'precision_level' => 'exact', 'is_primary' => $location === $primary,
|
|
'is_public' => false, 'geom' => DB::raw("ST_GeomFromText('POINT(33 -27)',4326)"), 'source_system' => 'SYNTHETIC previous source']);
|
|
}
|
|
$secondaryBefore = DB::table('existing_business_locations')->where('id', $secondary)->first();
|
|
$this->getJson($this->base.'/'.$record['id'])->assertOk()->assertJsonPath('registeredLocation.longitude', 33);
|
|
$publisher = $this->approve($record['id'], 1);
|
|
$this->actingAsWithMfa($publisher)->postJson($this->base.'/'.$record['id'].'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic explicit reviewed replacement'])->assertOk();
|
|
$this->assertDatabaseCount('existing_business_locations', 2);
|
|
$this->assertDatabaseHas('existing_business_locations', ['id' => $primary, 'is_primary' => true, 'is_public' => true, 'source_system' => 'portal-business-form']);
|
|
$this->assertEquals($secondaryBefore, DB::table('existing_business_locations')->where('id', $secondary)->first());
|
|
$snapshot = json_decode(Crypt::decryptString(DB::table('existing_business_revisions')->where('business_id', $record['id'])->where('action', 'published')->value('snapshot')), true);
|
|
$this->assertSame('SRID=4326;POINT(33 -27)', $snapshot['context']['point_publication']['previous']['geometry_snapshot']);
|
|
$this->assertSame('SYNTHETIC previous source', $snapshot['context']['point_publication']['previous']['source_system']);
|
|
}
|
|
|
|
public function test_no_point_opt_in_preserves_existing_location_and_source_through_publication(): void
|
|
{
|
|
$record = $this->create(['location_point_enabled' => 'no']);
|
|
DB::table('existing_business_locations')->insert(['id' => (string) Str::uuid(), 'existing_business_id' => $record['id'],
|
|
'province_id' => $this->province, 'precision_level' => 'province', 'is_primary' => true, 'is_public' => true,
|
|
'source_system' => 'SYNTHETIC preserved source', 'public_source_reference' => 'Synthetic previous public citation']);
|
|
$before = DB::table('existing_business_locations')->first();
|
|
$publisher = $this->approve($record['id'], 1);
|
|
$this->actingAsWithMfa($publisher)->postJson($this->base.'/'.$record['id'].'/transitions', ['version_no' => 7, 'action' => 'published', 'note' => 'Synthetic preserve source publication'])->assertOk();
|
|
$this->assertEquals($before, DB::table('existing_business_locations')->first());
|
|
$this->getJson('/api/v1/business-opportunities')->assertOk()->assertJsonPath('data.0.location_source_reference', 'Synthetic previous public citation')->assertJsonPath('data.0.distance_km', null);
|
|
}
|
|
}
|