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

189 lines
12 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ProactiveSupply;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\Support\ProactiveInvestmentFixture as F;
use Tests\TestCase;
final class ProactiveSupplyReviewTest extends TestCase
{
use DatabaseTransactions;
private User $actor;
private string $source;
private string $territory;
private string $base = '/minhas-oportunidades/oferta-interna/revisao';
protected function setUp(): void
{
parent::setUp();
F::guard();
$this->withoutVite();
config(['portal.profile_access_enabled' => true, 'portal.managers' => [], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = F::operator();
$this->login($this->actor);
$parent = null;
foreach (['country', 'province', 'district'] as $level) {
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'name' => 'SYNTHETIC review '.$level, 'code' => $id, 'location_type' => $level,
'parent_id' => $parent, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
$parent = $id;
}
$this->territory = $parent;
$this->source = hash('sha256', 'SYNTHETIC-SUPPLY-REVIEW-20261102');
DB::table('proactive_supply_sources')->insert(['sha256' => $this->source, 'name' => 'SYNTHETIC review source.xlsx', 'period' => 'Synthetic historical data',
'limitations' => 'Synthetic observations, not actual establishments', 'receipt' => json_encode(['private_contact' => 'SYNTHETIC PRIVATE CONTACT', 'nuit' => 'SYNTHETIC PRIVATE IDENTIFIER']), 'imported_at' => now()]);
$base = ['source_hash' => $this->source, 'sheet' => 'SYNTHETIC QA', 'entity_key' => hash('sha256', 'SYNTHETIC ENTITY'),
'name' => 'SYNTHETIC 100% Algodão', 'province' => 'Zambézia', 'district' => 'Gurué', 'cae' => '01610', 'activity' => 'Cultivo de algodão', 'sector' => 'Synthetic agriculture',
'source_date' => 'Serial Excel original: 45000', 'duplicate_of' => null, 'identity_conflict' => false, 'territory_id' => $this->territory, 'territory_resolution' => 'exact_normalized_province_district'];
foreach ([
2 => [], 3 => ['name' => 'SYNTHETIC 1000', 'activity' => '', 'territory_id' => null, 'territory_resolution' => 'unresolved'],
4 => ['name' => 'Denominação não indicada', 'territory_id' => null, 'territory_resolution' => 'ambiguous'],
5 => ['name' => 'SYNTHETIC other denomination', 'identity_conflict' => true, 'territory_id' => '10000000-0000-4000-8000-000000000001'],
6 => ['duplicate_of' => $this->id(2)], 7 => ['name' => 'SYNTHETIC_UNDER_SCORE', 'territory_id' => null, 'territory_resolution' => 'unresolved'],
] as $number => $changes) {
DB::table('proactive_supply_observations')->insert([...$base, 'id' => $this->id($number), 'row_number' => $number,
'fingerprint' => hash('sha256', 'SYNTHETIC-FINGERPRINT-'.($number === 6 ? 2 : $number)), ...$changes]);
}
}
private function login(User $actor): void
{
$this->actingAs($actor)->withSession(['portal.mfa_actor' => $actor->id, 'portal.mfa_at' => time(), 'portal.access_revision' => (int) $actor->portal_access_revision]);
}
private function id(int $row): string
{
return hash('sha256', $this->source.'|SYNTHETIC QA|'.$row);
}
private function url(array $filters = []): string
{
return $this->base.'?'.http_build_query(['source_hash' => $this->source, ...$filters]);
}
public function test_unmapped_and_ambiguous_rows_are_recoverable_without_mutation_or_a_guessed_territory(): void
{
$before = DB::table('proactive_supply_observations')->where('source_hash', $this->source)->orderBy('id')->get()->toJson();
$response = $this->getJson($this->url())->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertJsonPath('total', 3)->assertJsonPath('coverage.observations', 6)->assertJsonPath('coverage.distinct_observations', 5)
->assertJsonPath('coverage.repeated', 1)->assertJsonPath('coverage.unmapped', 3)->assertJsonPath('coverage.ambiguous', 1)
->assertJsonPath('coverage.territory_unavailable', 1)->assertJsonPath('coverage.identity_conflict', 1)
->assertJsonPath('coverage.unclassified', 1)->assertJsonPath('coverage.unnamed', 1);
foreach ($response->json('rows') as $row) {
$this->assertNull($row['territory_id']);
$this->assertNull($row['canonical_territory']);
}
$this->getJson($this->url(['status' => 'ambiguous']))->assertOk()->assertJsonPath('total', 1)->assertJsonPath('rows.0.id', $this->id(4));
$this->getJson($this->url(['status' => 'all', 'observation_id' => $this->id(4)]))->assertOk()
->assertJsonPath('rows.0.sheet', 'SYNTHETIC QA')->assertJsonPath('rows.0.row_number', 4)->assertJsonPath('rows.0.source_hash', $this->source)
->assertJsonPath('rows.0.source_date', 'Serial Excel original: 45000');
$this->assertSame($before, DB::table('proactive_supply_observations')->where('source_hash', $this->source)->orderBy('id')->get()->toJson());
}
public function test_private_receipts_identity_hashes_contacts_and_private_territories_are_not_projected(): void
{
$response = $this->getJson($this->url(['status' => 'all']))->assertOk()->assertDontSee('SYNTHETIC PRIVATE CONTACT')->assertDontSee('SYNTHETIC PRIVATE IDENTIFIER');
foreach ($response->json('rows') as $row) {
foreach (['entity_key', 'fingerprint', 'receipt', 'contact', 'nuit', 'geom'] as $key) {
$this->assertArrayNotHasKey($key, $row);
}
}
foreach ($response->json('sources') as $source) {
$this->assertArrayNotHasKey('receipt', $source);
}
$this->getJson($this->url(['status' => 'territory_unavailable']))->assertOk()->assertJsonCount(1, 'rows')->assertJsonPath('rows.0.canonical_territory', null);
$this->getJson('/minhas-oportunidades/oferta-interna?territory_id='.$this->territory)->assertOk()->assertJsonMissingPath('rows.0.entity_key');
}
public function test_repeated_sightings_identity_conflicts_and_activity_gaps_are_distinct_and_not_consolidated(): void
{
$this->getJson($this->url(['status' => 'repeated']))->assertOk()->assertJsonPath('total', 1)->assertJsonPath('rows.0.duplicate_of', $this->id(2));
$this->getJson($this->url(['status' => 'identity_conflict']))->assertOk()->assertJsonPath('rows.0.id', $this->id(5));
$this->getJson($this->url(['status' => 'unclassified']))->assertOk()->assertJsonPath('rows.0.id', $this->id(3));
$this->getJson($this->url(['status' => 'unnamed']))->assertOk()->assertJsonPath('rows.0.id', $this->id(4));
$this->assertSame(6, DB::table('proactive_supply_observations')->where('source_hash', $this->source)->count());
}
public function test_text_search_is_accent_insensitive_and_wildcards_are_literal(): void
{
foreach (['algodao', 'ALGODÃO', '100%'] as $q) {
$response = $this->getJson($this->url(['status' => 'all', 'q' => $q]))->assertOk();
$this->assertGreaterThan(0, $response->json('total'));
$this->assertNotContains($this->id(3), array_column($response->json('rows'), 'id'));
}
$this->getJson($this->url(['status' => 'all', 'q' => 'UNDER_']))->assertOk()->assertJsonPath('total', 1)->assertJsonPath('rows.0.id', $this->id(7));
$this->getJson($this->url(['status' => 'all', 'q' => 'NO-SYNTHETIC-MATCH']))->assertOk()->assertJsonPath('total', 0)->assertJsonPath('status', 'Informação insuficiente nesta base e nestes filtros.');
}
public function test_pagination_and_direct_reference_recover_the_same_source_line(): void
{
$base = (array) DB::table('proactive_supply_observations')->where('id', $this->id(2))->first();
for ($row = 100; $row < 126; $row++) {
DB::table('proactive_supply_observations')->insert([...$base, 'id' => $this->id($row), 'row_number' => $row,
'name' => 'SYNTHETIC PAGE '.$row, 'fingerprint' => hash('sha256', 'SYNTHETIC-PAGE-'.$row)]);
}
$this->getJson($this->url(['status' => 'all', 'q' => 'SYNTHETIC PAGE']))->assertOk()->assertJsonPath('total', 26)->assertJsonCount(25, 'rows')->assertJsonPath('rows.0.row_number', 100);
$this->getJson($this->url(['status' => 'all', 'q' => 'SYNTHETIC PAGE', 'page' => 2]))->assertOk()->assertJsonCount(1, 'rows')->assertJsonPath('rows.0.row_number', 125);
$this->getJson($this->url(['status' => 'all', 'observation_id' => $this->id(125), 'page' => 999]))->assertOk()->assertJsonPath('filters.page', 1)->assertJsonPath('rows.0.row_number', 125);
}
public function test_unassigned_users_cannot_enumerate_or_validate_private_inventory_and_invalid_filters_are_explicit(): void
{
$this->getJson($this->url(['status' => 'not-a-state']))->assertUnprocessable();
$this->getJson($this->url(['source_hash' => str_repeat('f', 64)]))->assertUnprocessable()->assertJsonValidationErrors('source_hash');
$this->login(User::factory()->create());
$this->getJson($this->url())->assertForbidden();
$this->getJson($this->base.'?record_id=999999999&source_hash=invalid')->assertForbidden();
$this->getJson('/minhas-oportunidades/oferta-interna?record_id=999999999')->assertForbidden();
}
public function test_private_page_is_wired_and_preserves_applied_filters(): void
{
$this->get($this->url(['status' => 'ambiguous']))->assertOk()->assertHeader('Cache-Control', 'no-store, private')
->assertInertia(fn ($page) => $page->component('management/proactive-supply-review')->where('filters.status', 'ambiguous')->where('rows.0.id', $this->id(4)));
}
public function test_existing_comparison_keeps_server_side_lineage_and_rejects_repetitions(): void
{
$service = app(ProactiveSupply::class);
$evidence = $service->evidence($this->actor, $this->id(2), null);
$this->assertSame('internal', $evidence['access']);
$this->assertStringContainsString($this->source, $evidence['source']);
$this->assertSame('SYNTHETIC QA!linha 2', $evidence['locator']);
$comparison = ['observation_id' => $this->id(2), 'relationship' => 'uncertain', 'rationale' => 'SYNTHETIC comparison', 'limitations' => 'SYNTHETIC unknown current operation'];
$this->assertSame([$comparison], $service->comparisons($this->actor, [$comparison], null));
$this->expectException(ValidationException::class);
$service->comparisons($this->actor, [[...$comparison, 'observation_id' => $this->id(6)]], null);
}
public function test_database_policy_requires_actor_context_for_sources_and_observations(): void
{
$role = 'supply_review_qa_'.bin2hex(random_bytes(6));
DB::statement('CREATE ROLE '.$role.' NOLOGIN');
DB::statement('GRANT USAGE ON SCHEMA public TO '.$role);
DB::statement('GRANT SELECT ON proactive_supply_sources,proactive_supply_observations TO '.$role);
DB::select("select set_config('app.actor_user_id','',true),set_config('app.proactive_supply_read','',true)");
try {
DB::statement('SET LOCAL ROLE '.$role);
$this->assertSame(0, DB::table('proactive_supply_observations')->count());
$this->assertSame(0, DB::table('proactive_supply_sources')->count());
DB::select("select set_config('app.actor_user_id',?,true),set_config('app.proactive_supply_read','internal',true)", [(string) $this->actor->id]);
$this->assertSame(6, DB::table('proactive_supply_observations')->where('source_hash', $this->source)->count());
$this->assertSame(1, DB::table('proactive_supply_sources')->where('sha256', $this->source)->count());
} finally {
DB::statement('RESET ROLE');
}
}
}