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' => '

SYNTHETIC content for isolated QA only

', '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')); } }