276 lines
16 KiB
PHP
276 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Models\User;
|
|
use App\Services\PublicSearchMetrics;
|
|
use App\Support\IsolatedQaDatabase;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use PDO;
|
|
use Tests\TestCase;
|
|
|
|
final class PublicSearchMetricsTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $manager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->withoutVite();
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-02 12:00:00.123456', 'UTC'));
|
|
Http::preventStrayRequests();
|
|
$this->manager = User::factory()->create();
|
|
config(['portal.managers' => [$this->manager->email], 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false, 'portal.notifications_enabled' => false, 'search-metrics.enabled' => true, 'search-metrics.capture_lock_timeout_ms' => 1500, 'search-metrics.capture_statement_timeout_ms' => 3000]);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->travelBack();
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function opportunity(string $description = 'Cultivo de algodão'): Opportunity
|
|
{
|
|
$sector = Sector::create(['name' => 'SYNTHETIC sector', 'slug' => 'synthetic-'.Str::uuid(), 'is_active' => true, 'published_at' => now()]);
|
|
|
|
return Opportunity::create(['sector_id' => $sector->id, 'reference' => 'SYNTHETIC-'.Str::uuid(), 'slug' => 'synthetic-'.Str::uuid(), 'title' => 'SYNTHETIC opportunity', 'summary' => $description, 'status' => 'published', 'published_at' => now()->subDay()]);
|
|
}
|
|
|
|
private function report(array $filters = []): array
|
|
{
|
|
return $this->actingAs($this->manager)->getJson('/management/product-usage?'.http_build_query(['from' => '2026-11-02', 'to' => '2026-11-02', ...$filters]))->assertOk()->assertHeader('Cache-Control', 'no-store, private')->json('report');
|
|
}
|
|
|
|
private function legacyFixture(): void
|
|
{
|
|
Storage::fake('local');
|
|
config(['portal.legacy_content_qa_enabled' => true]);
|
|
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => 'https://investemoz.tempo.co.mz/agronegocio', 'title' => 'SYNTHETIC article', 'content_html' => '<p>SYNTHETIC content for isolated QA only</p>', 'author' => 'SYNTHETIC author', 'legacy_id' => 171985, 'legacy_category' => 'SYNTHETIC QA', 'published_at' => '2026-11-02T09:00:00+00:00', 'modified_at' => '2026-11-02T10:00:00+00:00'], JSON_THROW_ON_ERROR);
|
|
$id = (string) Str::uuid();
|
|
Storage::disk('local')->put('legacy/'.$id, $bytes);
|
|
DB::table('documents')->insert(['id' => $id, 'source_system' => 'synthetic-test', 'external_id' => 'legacy-tempo-post-171985-20260420', 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'legacy/'.$id, 'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean', 'size_bytes' => strlen($bytes), 'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
|
|
DB::table('portal_resources')->insert(['document_id' => $id, 'title' => 'SYNTHETIC article', 'category' => 'education', 'topic' => 'agronegocio', 'published' => true, 'rights_status' => 'authorized', 'source_url' => 'https://investemoz.tempo.co.mz/agronegocio', 'source_reference' => 'SYNTHETIC isolated QA fixture; not official.', 'metadata_version' => 1]);
|
|
}
|
|
|
|
public function test_primary_search_totals_are_recorded_without_terms_identifiers_or_changes_to_opportunities(): void
|
|
{
|
|
$crop = $this->opportunity();
|
|
$before = DB::table('opportunities')->orderBy('id')->get()->toJson();
|
|
$this->getJson('/api/v1/investment/search?q=plantio+de+algodao')->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $crop->id);
|
|
$this->getJson('/api/v1/opportunities/search?q=PRIVATE-SYNTHETIC-EMAIL%40example.invalid&total=999')->assertOk()->assertJsonPath('total', 0);
|
|
$this->get('/oportunidades?q=plantio+de+algodao')->assertOk();
|
|
$api = DB::table('product_search_counts')->where('channel', 'opportunities_api')->first();
|
|
$this->assertSame(2, $api->responses);
|
|
$this->assertSame(1, $api->zero_responses);
|
|
$this->assertSame(1, DB::table('product_search_counts')->where('channel', 'opportunities_page')->value('responses'));
|
|
$this->assertSame(1, DB::table('product_search_definitions')->count());
|
|
$this->assertStringNotContainsString('PRIVATE-SYNTHETIC', DB::table('product_search_counts')->get()->toJson().DB::table('product_search_definitions')->get()->toJson());
|
|
$this->assertEqualsCanonicalizing(['day', 'channel', 'definition_version', 'responses', 'zero_responses', 'first_recorded_at', 'last_recorded_at'], Schema::getColumnListing('product_search_counts'));
|
|
$this->assertSame($before, DB::table('opportunities')->orderBy('id')->get()->toJson());
|
|
$this->assertDatabaseCount('interest_requests', 0);
|
|
}
|
|
|
|
public function test_each_instrumented_public_catalogue_records_its_own_actual_primary_total(): void
|
|
{
|
|
$this->legacyFixture();
|
|
foreach (['/oportunidades', '/api/v1/investment/search', '/educacao', '/negocios', '/documentos', '/parcerias', '/carteira', '/zonas', '/explorar/agronegocio'] as $url) {
|
|
$this->assertSame(200, $this->get($url.'?q=SYNTHETIC')->status(), 'Expected available synthetic public catalogue: '.$url);
|
|
}
|
|
$this->assertDatabaseCount('product_search_counts', 9);
|
|
$this->assertEqualsCanonicalizing(array_keys(config('search-metrics.channels')), DB::table('product_search_counts')->pluck('channel')->all());
|
|
foreach (['documents', 'sector_content'] as $channel) {
|
|
$this->assertSame(0, DB::table('product_search_counts')->where('channel', $channel)->value('zero_responses'));
|
|
}
|
|
$this->assertSame(9, (int) DB::table('product_search_counts')->sum('responses'));
|
|
$this->assertSame(7, (int) DB::table('product_search_counts')->sum('zero_responses'));
|
|
}
|
|
|
|
public function test_browsing_pagination_prefetch_privacy_signals_auxiliary_routes_and_failed_responses_are_excluded(): void
|
|
{
|
|
$this->get('/oportunidades')->assertOk();
|
|
$this->get('/oportunidades?sort=title&view=list')->assertOk();
|
|
$this->get('/oportunidades?q=SYNTHETIC&page=2')->assertOk();
|
|
foreach (['suggest', 'references', 'map', 'export'] as $action) {
|
|
$this->get('/api/v1/investment/'.$action.'?q=SYNTHETIC')->assertOk();
|
|
}
|
|
$this->call('HEAD', '/oportunidades?q=SYNTHETIC')->assertOk();
|
|
foreach ([['Purpose' => 'prefetch'], ['Sec-Purpose' => 'prefetch;prerender'], ['X-Inertia-Prefetch' => 'true'], ['DNT' => '1'], ['Sec-GPC' => '1']] as $headers) {
|
|
$this->withHeaders(['Purpose' => '', 'Sec-Purpose' => '', 'X-Inertia-Prefetch' => '', 'DNT' => '0', 'Sec-GPC' => '0', ...$headers])->get('/oportunidades?q=SYNTHETIC')->assertOk();
|
|
}
|
|
$this->withHeaders(['Purpose' => '', 'Sec-Purpose' => '', 'X-Inertia-Prefetch' => '', 'DNT' => '0', 'Sec-GPC' => '0'])->getJson('/api/v1/investment/search?'.http_build_query(['q' => str_repeat('x', 121)]))->assertUnprocessable();
|
|
$this->assertDatabaseCount('product_search_counts', 0);
|
|
$this->assertDatabaseCount('product_search_definitions', 0);
|
|
}
|
|
|
|
public function test_related_suggestions_do_not_turn_an_empty_primary_search_into_a_positive_result(): void
|
|
{
|
|
$this->opportunity('Descaroçamento e transformação de algodão');
|
|
$this->get('/oportunidades?q=plantio+de+algodao')->assertOk()->assertInertia(fn ($p) => $p->where('results.total', 0)->has('relatedSearch.items', 1));
|
|
$this->assertSame(1, DB::table('product_search_counts')->value('zero_responses'));
|
|
$this->assertSame(1, DB::table('product_search_counts')->value('responses'));
|
|
}
|
|
|
|
public function test_reports_distinguish_no_measurement_zero_results_and_unrelated_service_filters(): void
|
|
{
|
|
$r = $this->report();
|
|
$this->assertNull($r['search']['responses']);
|
|
$this->assertNull($r['search']['zero_rate']);
|
|
$this->opportunity();
|
|
$this->getJson('/api/v1/investment/search?q=algodao')->assertOk();
|
|
$r = $this->report();
|
|
$this->assertSame(1, $r['search']['responses']);
|
|
$this->assertSame(0, $r['search']['zero_responses']);
|
|
$this->assertEquals(0, $r['search']['zero_rate']);
|
|
$this->assertFalse($r['search']['definition_conflict_now']);
|
|
$this->assertSame('daily_aggregate_at_report_snapshot', $r['search']['temporal_basis']);
|
|
$this->assertSame('partial', collect($r['metrics'])->firstWhere('id', 'recorded_zero_search_rate')['state']);
|
|
$this->assertNull(collect($r['search']['channels'])->firstWhere('id', 'documents')['responses']);
|
|
DB::table('desk_service_versions')->insert(['id' => (string) Str::uuid(), 'code' => 'synthetic-service', 'version' => 1, 'definition' => '{"title":"SYNTHETIC service"}', 'source' => '{}', 'author_id' => $this->manager->id]);
|
|
$filtered = $this->report(['service_code' => 'synthetic-service']);
|
|
$this->assertFalse($filtered['search']['applicable']);
|
|
$this->assertNull($filtered['search']['responses']);
|
|
$this->getJson('/management/product-usage?search_definition=invented')->assertUnprocessable();
|
|
}
|
|
|
|
public function test_missing_collection_schema_preserves_search_and_never_claims_zero_measurement(): void
|
|
{
|
|
Schema::rename('product_search_counts', 'product_search_counts_qa_unavailable');
|
|
try {
|
|
$this->get('/oportunidades?q=SYNTHETIC')->assertOk();
|
|
$r = $this->report();
|
|
$this->assertFalse($r['search']['installed']);
|
|
$this->assertNull($r['search']['responses']);
|
|
$this->assertSame(0, DB::table('product_search_counts_qa_unavailable')->count());
|
|
} finally {
|
|
Schema::rename('product_search_counts_qa_unavailable', 'product_search_counts');
|
|
}
|
|
}
|
|
|
|
public function test_collection_and_diagnostic_failures_cannot_poison_a_search_or_its_outer_transaction(): void
|
|
{
|
|
$logged = [];
|
|
Log::shouldReceive('warning')->twice()->andReturnUsing(function ($message, $context) use (&$logged) {
|
|
$logged[] = [$message, $context];
|
|
if (count($logged) === 2) {
|
|
throw new \RuntimeException('SYNTHETIC unavailable log');
|
|
}
|
|
});
|
|
DB::statement('ALTER TABLE product_search_counts ADD CONSTRAINT synthetic_capture_failure CHECK(responses<0) NOT VALID');
|
|
try {
|
|
for ($n = 0; $n < 2; $n++) {
|
|
$this->getJson('/api/v1/investment/search?q=PRIVATE-SYNTHETIC-TERM')->assertOk()->assertJsonPath('total', 0);
|
|
}
|
|
$this->assertSame(0, DB::table('opportunities')->count());
|
|
$this->assertDatabaseCount('product_search_counts', 0);
|
|
$this->assertDatabaseCount('product_search_definitions', 0);
|
|
$this->assertCount(2, $logged);
|
|
$this->assertSame(['channel', 'definition_version', 'exception_class'], array_keys($logged[0][1]));
|
|
$this->assertStringNotContainsString('PRIVATE-SYNTHETIC', json_encode($logged));
|
|
} finally {
|
|
DB::statement('ALTER TABLE product_search_counts DROP CONSTRAINT synthetic_capture_failure');
|
|
}
|
|
}
|
|
|
|
public function test_editions_are_not_summed_and_registered_definitions_remain_immutable_and_readable(): void
|
|
{
|
|
$collector = app(PublicSearchMetrics::class);
|
|
$v1 = config('search-metrics.version');
|
|
$oldLabel = config('search-metrics.channels.opportunities_api');
|
|
$collector->record('opportunities_api', false);
|
|
config(['search-metrics.version' => 'SYNTHETIC-v2', 'search-metrics.channels.opportunities_api' => 'SYNTHETIC second edition']);
|
|
$collector->record('opportunities_api', true);
|
|
$current = $this->report();
|
|
$this->assertSame(1, $current['search']['responses']);
|
|
$this->assertEquals(100, $current['search']['zero_rate']);
|
|
$old = $this->report(['search_definition' => $v1]);
|
|
$this->assertSame(1, $old['search']['responses']);
|
|
$this->assertEquals(0, $old['search']['zero_rate']);
|
|
$this->assertSame($oldLabel, collect($old['search']['channels'])->firstWhere('id', 'opportunities_api')['label']);
|
|
Log::spy();
|
|
config(['search-metrics.channels.opportunities_api' => 'SYNTHETIC conflicting unversioned change']);
|
|
$collector->record('opportunities_api', false);
|
|
$this->assertSame(1, $this->report()['search']['responses']);
|
|
$this->assertTrue($this->report()['search']['definition_conflict_now']);
|
|
Log::shouldHaveReceived('warning')->once();
|
|
try {
|
|
DB::transaction(fn () => DB::table('product_search_definitions')->where('version', $v1)->update(['sha256' => str_repeat('0', 64)]));
|
|
$this->fail('Definitions cannot be rewritten.');
|
|
} catch (QueryException $error) {
|
|
$this->assertSame('23514', $error->getCode());
|
|
}
|
|
}
|
|
|
|
public function test_daily_boundaries_and_disabled_collection_preserve_the_existing_history(): void
|
|
{
|
|
$collector = app(PublicSearchMetrics::class);
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-01 23:59:59.999999', 'UTC'));
|
|
$collector->record('opportunities_api', false);
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-02 00:00:00.000001', 'UTC'));
|
|
$collector->record('opportunities_api', true);
|
|
$this->travelTo(CarbonImmutable::parse('2026-11-03 00:00:00.000001', 'UTC'));
|
|
$collector->record('opportunities_api', false);
|
|
config(['search-metrics.enabled' => false]);
|
|
$this->get('/oportunidades?q=SYNTHETIC')->assertOk();
|
|
$r = $this->report();
|
|
$this->assertFalse($r['search']['enabled_now']);
|
|
$this->assertSame(1, $r['search']['responses']);
|
|
$this->assertSame(1, $r['search']['zero_responses']);
|
|
$this->assertDatabaseCount('product_search_counts', 3);
|
|
}
|
|
|
|
public function test_collector_respects_and_restores_the_callers_stricter_query_budgets(): void
|
|
{
|
|
config(['search-metrics.capture_lock_timeout_ms' => 1000, 'search-metrics.capture_statement_timeout_ms' => 2000]);
|
|
DB::statement("SET LOCAL lock_timeout='500ms'");
|
|
DB::statement("SET LOCAL statement_timeout='1000ms'");
|
|
$during = [];
|
|
DB::listen(function ($event) use (&$during) {
|
|
if (str_contains($event->sql, 'INSERT INTO product_search_counts')) {
|
|
$pdo = DB::connection()->getPdo();
|
|
$during[] = [$pdo->query('SHOW lock_timeout')->fetchColumn(), $pdo->query('SHOW statement_timeout')->fetchColumn()];
|
|
}
|
|
});
|
|
app(PublicSearchMetrics::class)->record('opportunities_api', false);
|
|
$this->assertSame([['500ms', '1s']], $during);
|
|
$this->assertSame('500ms', DB::selectOne('SHOW lock_timeout')->lock_timeout);
|
|
$this->assertSame('1s', DB::selectOne('SHOW statement_timeout')->statement_timeout);
|
|
$this->assertDatabaseCount('product_search_counts', 1);
|
|
}
|
|
|
|
public function test_a_real_competing_database_lock_does_not_hold_the_collector_indefinitely(): void
|
|
{
|
|
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
|
$c = config('database.connections.pgsql');
|
|
$other = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$c['database'], $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$other->beginTransaction();
|
|
$other->exec("SET LOCAL lock_timeout='1000ms'");
|
|
$other->exec('LOCK TABLE product_search_counts IN ACCESS EXCLUSIVE MODE');
|
|
config(['search-metrics.capture_lock_timeout_ms' => 50, 'search-metrics.capture_statement_timeout_ms' => 500]);
|
|
Log::spy();
|
|
$before = microtime(true);
|
|
try {
|
|
app(PublicSearchMetrics::class)->record('opportunities_api', true);
|
|
} finally {
|
|
$other->rollBack();
|
|
}
|
|
$this->assertLessThan(5, microtime(true) - $before);
|
|
$this->assertDatabaseCount('product_search_counts', 0);
|
|
$this->assertDatabaseCount('product_search_definitions', 0);
|
|
Log::shouldHaveReceived('warning')->once();
|
|
app(PublicSearchMetrics::class)->record('opportunities_api', true);
|
|
$this->assertSame(1, DB::table('product_search_counts')->value('responses'));
|
|
}
|
|
}
|