// Copyright (c) 2025 YAMS Contributors // SPDX-License-Identifier: GPL-3.0-or-later // // Migrated from GTest: search_service_test.cpp // Full SearchService integration tests with DB lifecycle and async coroutines. #include "src/app/services/glob_matcher.h" #include #include #include #ifdef _WIN32 #include #define getpid _getpid #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "common/fixture_manager.h" #include "common/test_data_generator.h" #include "common/test_helpers_catch2.h" using namespace yams; using namespace yams::app::services; using namespace yams::metadata; using namespace yams::api; using Catch::Matchers::ContainsSubstring; namespace { constexpr std::size_t kZeroTotal = 0; // ---------- Mock repositories (same as GTest originals) ---------- class FlakyMetadataRepository : public MetadataRepository { public: explicit FlakyMetadataRepository(ConnectionPool& pool) : MetadataRepository(pool) {} void setGetDocumentByHashFailures(std::size_t count) { getDocumentByHashFailures_ = count; } Result> getDocumentByHash(const std::string& hash) override { if (yams::test::consume_failure(getDocumentByHashFailures_)) { return Error{ErrorCode::NotInitialized, "metadata warming up"}; } return MetadataRepository::getDocumentByHash(hash); } private: std::size_t getDocumentByHashFailures_{0}; }; class TagLookupCountingRepository : public MetadataRepository { public: explicit TagLookupCountingRepository(ConnectionPool& pool) : MetadataRepository(pool) {} Result> getAllMetadata(int64_t documentId) override { ++allMetadataCalls; return MetadataRepository::getAllMetadata(documentId); } Result>> batchGetDocumentTags(std::span documentIds) override { ++batchTagCalls; if (failTagLookup) return Error{ErrorCode::InternalError, "injected tag lookup failure"}; return MetadataRepository::batchGetDocumentTags(documentIds); } std::atomic allMetadataCalls{0}; std::atomic batchTagCalls{0}; bool failTagLookup{false}; }; class SlowSnippetMetadataRepository : public MetadataRepository { public: SlowSnippetMetadataRepository(ConnectionPool& pool, std::chrono::milliseconds delay) : MetadataRepository(pool), delay_(delay) {} Result> getContent(int64_t documentId) override { std::this_thread::sleep_for(delay_); return MetadataRepository::getContent(documentId); } Result> batchGetContent(const std::vector& documentIds) override { std::this_thread::sleep_for(delay_); return MetadataRepository::batchGetContent(documentIds); } Result search(const std::string& query, int limit = 50, int offset = 0, const std::optional>& docIds = std::nullopt) override { auto result = MetadataRepository::search(query, limit, offset, docIds); if (result) { for (auto& entry : result.value().results) { entry.snippet.clear(); } } return result; } private: std::chrono::milliseconds delay_; }; // ---------- Async helper ---------- template yams::Result runAwait(boost::asio::awaitable> aw) { boost::asio::io_context ioc; auto wrapper = [aw = std::move(aw)]() mutable -> boost::asio::awaitable>> { try { auto v = co_await std::move(aw); co_return std::optional>(std::move(v)); } catch (...) { co_return std::optional>{}; } }; auto fut = boost::asio::co_spawn(ioc, wrapper(), boost::asio::use_future); ioc.run(); auto opt = fut.get(); if (opt) return std::move(*opt); return yams::Result(yams::Error{yams::ErrorCode::InternalError, "Awaitable failed"}); } // ---------- Fixture ---------- struct SearchServiceFixture { SearchServiceFixture() { auto pid = std::to_string(::getpid()); auto timestamp = std::to_string(std::chrono::system_clock::now().time_since_epoch().count()); testDir = std::filesystem::temp_directory_path() / ("search_service_catch2_" + pid + "_" + timestamp); std::error_code ec; std::filesystem::create_directories(testDir, ec); REQUIRE_FALSE(ec); fixtureManager = std::make_unique(testDir / "fixtures"); // DB dbPath = std::filesystem::absolute(testDir / "test.db"); database = std::make_unique(); auto openResult = database->open(dbPath.string(), ConnectionMode::Create); REQUIRE(openResult); ConnectionPoolConfig poolConfig; poolConfig.maxConnections = 4; pool = std::make_unique(dbPath.string(), poolConfig); metadataRepo = std::make_shared(*pool); KnowledgeGraphStoreConfig kgConfig; kgConfig.enable_wal = false; auto kgResult = makeSqliteKnowledgeGraphStore(*pool, kgConfig); REQUIRE(kgResult); kgStore = std::shared_ptr(std::move(kgResult).value()); metadataRepo->setKnowledgeGraphStore(kgStore); MigrationManager mm(*database); auto initResult = mm.initialize(); REQUIRE(initResult); mm.registerMigrations(YamsMetadataMigrations::getAllMigrations()); auto migrateResult = mm.migrate(); REQUIRE(migrateResult); // Content store ContentStoreBuilder builder; auto storeResult = builder.withStoragePath(testDir / "storage") .withChunkSize(65536) .withCompression(true) .withDeduplication(true) .build(); REQUIRE(storeResult); auto& uniqueStore = storeResult.value(); contentStore = std::shared_ptr( const_cast&>(uniqueStore).release()); searchEngine = nullptr; appContext.store = contentStore; appContext.metadataRepo = metadataRepo; appContext.searchEngine = searchEngine; appContext.kgStore = kgStore; workerGuard.emplace(workerIo.get_executor()); workerThread = std::thread([this]() { workerIo.run(); }); appContext.workerExecutor = workerIo.get_executor(); searchService = makeSearchService(appContext); // Test data yams::test::TestDataGenerator generator(1337); testDocuments = { {generator.generateMarkdown(2, "Artificial Intelligence Primer"), "ai.txt"}, {generator.generateMarkdown(2, "Python Programming Tutorial"), "python.txt"}, {generator.generateMarkdown(2, "Database Design Handbook"), "database.md"}, {generator.generateMarkdown(2, "Web Development Guide"), "web.md"}, {generator.generateMarkdown(3, "Climate Research Summary"), "climate.txt"}}; auto docService = makeDocumentService(appContext); for (const auto& [content, filename] : testDocuments) { auto fixture = fixtureManager->createTextFixture(filename, content, {"search", "unit", "fixture"}); StoreDocumentRequest storeReq; storeReq.path = fixture.path.string(); auto storeResult2 = docService->store(storeReq); if (storeResult2) { testHashes.push_back(storeResult2.value().hash); indexDocumentContent(storeResult2.value().hash, filename, content); } } } ~SearchServiceFixture() { // Cleanup order matters: services first, then stores, then DB searchService.reset(); searchEngine.reset(); appContext.searchEngine.reset(); workerGuard.reset(); if (workerThread.joinable()) { workerThread.join(); } appContext.workerExecutor = {}; appContext.kgStore.reset(); appContext.store.reset(); appContext.metadataRepo.reset(); kgStore.reset(); contentStore.reset(); metadataRepo.reset(); fixtureManager.reset(); if (database) { database->close(); database.reset(); } if (pool) { pool->shutdown(); pool.reset(); } if (!testDir.empty() && std::filesystem::exists(testDir)) { std::error_code ec; std::filesystem::remove_all(testDir, ec); } } void indexDocumentContent(const std::string& hash, const std::string& title, const std::string& content) { auto docResult = metadataRepo->getDocumentByHash(hash); if (!docResult || !docResult.value().has_value()) return; auto docInfo = docResult.value().value(); auto indexResult = metadataRepo->indexDocumentContent( docInfo.id, title, content, docInfo.mimeType.empty() ? "text/plain" : docInfo.mimeType); if (!indexResult) spdlog::warn("Failed to index document content: {}", indexResult.error().message); } void setMetadataForHash(const std::string& hash, const std::string& key, const std::string& value) { auto docResult = metadataRepo->getDocumentByHash(hash); REQUIRE(docResult); REQUIRE(docResult.value().has_value()); auto result = metadataRepo->setMetadata(docResult.value()->id, key, MetadataValue(value)); REQUIRE(result); } SearchRequest createBasicSearchRequest(const std::string& query) { SearchRequest request; request.query = query; request.limit = 10; request.globalSearch = true; return request; } void seedRelationsForResult(const SearchItem& item) const { REQUIRE(kgStore != nullptr); REQUIRE_FALSE(item.path.empty()); REQUIRE_FALSE(item.hash.empty()); KGNode fileNode; fileNode.nodeKey = "path:file:" + item.path; fileNode.type = std::string("file"); fileNode.label = item.fileName.empty() ? item.path : item.fileName; auto fileNodeId = kgStore->upsertNode(fileNode); REQUIRE(fileNodeId.has_value()); KGNode docNode; docNode.nodeKey = "doc:" + item.hash; docNode.type = std::string("document"); docNode.label = item.fileName.empty() ? item.path : item.fileName; auto docNodeId = kgStore->upsertNode(docNode); REQUIRE(docNodeId.has_value()); KGNode symbolOne; symbolOne.nodeKey = "symbol:test:one:" + item.hash; symbolOne.type = std::string("symbol"); symbolOne.label = std::string("SymbolOne"); auto symbolOneId = kgStore->upsertNode(symbolOne); REQUIRE(symbolOneId.has_value()); KGNode symbolTwo; symbolTwo.nodeKey = "symbol:test:two:" + item.hash; symbolTwo.type = std::string("symbol"); symbolTwo.label = std::string("SymbolTwo"); auto symbolTwoId = kgStore->upsertNode(symbolTwo); REQUIRE(symbolTwoId.has_value()); KGEdge versionEdge; versionEdge.srcNodeId = fileNodeId.value(); versionEdge.dstNodeId = docNodeId.value(); versionEdge.relation = "has-version"; REQUIRE(kgStore->addEdge(versionEdge).has_value()); KGEdge definesOne; definesOne.srcNodeId = docNodeId.value(); definesOne.dstNodeId = symbolOneId.value(); definesOne.relation = "defines"; REQUIRE(kgStore->addEdge(definesOne).has_value()); KGEdge definesTwo; definesTwo.srcNodeId = docNodeId.value(); definesTwo.dstNodeId = symbolTwoId.value(); definesTwo.relation = "defines"; REQUIRE(kgStore->addEdge(definesTwo).has_value()); } std::filesystem::path testDir; std::filesystem::path dbPath; std::unique_ptr database; std::unique_ptr pool; std::shared_ptr metadataRepo; std::shared_ptr kgStore; std::shared_ptr contentStore; std::shared_ptr searchEngine; boost::asio::io_context workerIo; std::optional> workerGuard; std::thread workerThread; AppContext appContext; std::shared_ptr searchService; std::unique_ptr fixtureManager; std::vector> testDocuments; std::vector testHashes; }; } // namespace // ============ Basic Search Tests ============ TEST_CASE("SearchService: basic text search", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("programming"); request.showHash = true; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); const auto& resp = result.value(); CHECK(resp.total >= kZeroTotal); CHECK(resp.executionTimeMs >= 0); REQUIRE(resp.searchStats.contains("metadata_operations")); for (const auto& doc : resp.results) { CHECK_FALSE(doc.hash.empty()); CHECK(doc.score >= 0.0); } } TEST_CASE("SearchService: host cancellation stops the search", "[unit][services][search]") { SearchServiceFixture f; auto cancelled = std::make_shared>(true); auto ctx = f.appContext; ctx.cancellationSignal = cancelled; auto service = makeSearchService(ctx); auto request = f.createBasicSearchRequest("programming"); auto result = runAwait(service->search(request)); REQUIRE_FALSE(result); CHECK((result.error().code == ErrorCode::OperationCancelled)); cancelled->store(false); auto resumed = runAwait(service->search(request)); REQUIRE(resumed); } TEST_CASE("SearchService: a lines: qualifier shapes result snippets", "[unit][services][search][extract-scope]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("programming lines:1-1"); request.showHash = true; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); const auto& resp = result.value(); REQUIRE_FALSE(resp.results.empty()); for (const auto& item : resp.results) { std::string expected; for (const auto& [content, filename] : f.testDocuments) { if (item.path.ends_with(filename)) { expected = content.substr(0, content.find('\n')); } } REQUIRE_FALSE(expected.empty()); CHECK((item.snippet == expected)); } REQUIRE(resp.searchStats.contains("extract_scope")); CHECK_THAT(resp.searchStats.at("extract_scope"), Catch::Matchers::ContainsSubstring("lines:1-1 applied to")); } TEST_CASE("SearchService: relation metadata enrichment", "[unit][services][search][relations]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("artificial"); request.showHash = true; auto baseline = runAwait(f.searchService->search(request)); REQUIRE(baseline); REQUIRE_FALSE(baseline.value().results.empty()); const auto seedItem = baseline.value().results.front(); f.seedRelationsForResult(seedItem); auto enriched = runAwait(f.searchService->search(request)); REQUIRE(enriched); auto it = std::find_if(enriched.value().results.begin(), enriched.value().results.end(), [&](const SearchItem& item) { return item.hash == seedItem.hash; }); REQUIRE(it != enriched.value().results.end()); REQUIRE(it->metadata.contains("relation_count")); // 3 seeded edges + 1 auto-created path hierarchy edge from document storage CHECK(std::stoi(it->metadata.at("relation_count")) >= 3); REQUIRE(it->metadata.contains("relation_types")); CHECK_THAT(it->metadata.at("relation_types"), ContainsSubstring("defines:2")); REQUIRE(it->metadata.contains("relation_summary")); CHECK_THAT(it->metadata.at("relation_summary"), ContainsSubstring("defines(2)")); REQUIRE(it->metadata.contains("primary_relation")); CHECK(it->metadata.at("primary_relation") == "defines"); REQUIRE(enriched.value().searchStats.contains("relation_results_enriched")); CHECK(enriched.value().searchStats.at("relation_results_enriched") != "0"); } TEST_CASE("SearchService: multiple terms ranked by relevance", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("python programming tutorial"); auto result = runAwait(f.searchService->search(request)); REQUIRE(result); if (result.value().results.size() > 1) { auto& docs = result.value().results; for (size_t i = 1; i < docs.size(); ++i) { CHECK(docs[i - 1].score >= docs[i].score); } } } TEST_CASE("SearchService: case-insensitive search", "[unit][services][search]") { SearchServiceFixture f; auto r1 = runAwait(f.searchService->search(f.createBasicSearchRequest("ARTIFICIAL"))); auto r2 = runAwait(f.searchService->search(f.createBasicSearchRequest("artificial"))); auto r3 = runAwait(f.searchService->search(f.createBasicSearchRequest("Artificial"))); REQUIRE(r1); REQUIRE(r2); REQUIRE(r3); CHECK(r1.value().total == r2.value().total); CHECK(r2.value().total == r3.value().total); } // ============ Advanced Search Features ============ TEST_CASE("SearchService: limit", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("test"); request.limit = 3; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().results.size() <= 3); } TEST_CASE("SearchService: offset", "[unit][services][search]") { SearchServiceFixture f; auto r1 = runAwait(f.searchService->search(f.createBasicSearchRequest("test"))); REQUIRE(r1); if (r1.value().total > 1) { auto r2 = runAwait(f.searchService->search(f.createBasicSearchRequest("test"))); REQUIRE(r2); static_cast(r2.value().results.size()); } } TEST_CASE("SearchService: fuzzy search", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("progamming"); // intentional typo request.fuzzy = true; request.similarity = 0.8f; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); } // ============ Search Filters ============ TEST_CASE("SearchService: tag filter", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("programming"); request.tags = {"tutorial", "example"}; request.matchAllTags = false; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); } TEST_CASE("SearchService: tag filtering loads tags once per request, not per document", "[unit][services][search][tags]") { SearchServiceFixture f; REQUIRE(f.testHashes.size() >= 3); f.setMetadataForHash(f.testHashes[0], "tag", "tutorial"); f.setMetadataForHash(f.testHashes[1], "tag:example", "1"); f.setMetadataForHash(f.testHashes[1], "tag:tutorial", "1"); auto counting = std::make_shared(*f.pool); f.metadataRepo = counting; f.appContext.metadataRepo = counting; f.searchService = makeSearchService(f.appContext); SECTION("tag lookup failure is reported rather than an empty successful search") { counting->failTagLookup = true; for (bool byHash : {true, false}) { auto request = f.createBasicSearchRequest(byHash ? "" : "*.txt"); if (byHash) request.hash = f.testHashes[1].substr(0, 12); request.tags = {"tutorial"}; auto result = runAwait(f.searchService->search(request)); CHECK_FALSE(result.has_value()); if (!result) CHECK(result.error().message == "injected tag lookup failure"); } } SECTION("hash prefix search") { auto request = f.createBasicSearchRequest(""); request.hash = f.testHashes[1].substr(0, 12); request.tags = {"tutorial", "example"}; request.matchAllTags = true; auto result = runAwait(f.searchService->search(request)); INFO(std::string(result ? "ok" : result.error().message.c_str())); REQUIRE(result); CHECK(result.value().results.size() == 1); CHECK(counting->batchTagCalls.load() == 1); CHECK(counting->allMetadataCalls.load() == 0); } SECTION("path search") { auto request = f.createBasicSearchRequest("*.txt"); request.tags = {"tutorial"}; request.matchAllTags = false; auto result = runAwait(f.searchService->search(request)); INFO(std::string(result ? "ok" : result.error().message.c_str())); REQUIRE(result); CHECK(result.value().results.size() == 2); CHECK(counting->batchTagCalls.load() == 1); CHECK(counting->allMetadataCalls.load() == 0); } } TEST_CASE("SearchService: file type filter", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("development"); request.fileType = "text"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); for (const auto& doc : result.value().results) { if (!doc.mimeType.empty()) { CHECK((doc.mimeType.find("text/") == 0 || doc.mimeType.find("application/json") == 0 || doc.mimeType.find("application/xml") == 0)); } } } TEST_CASE("SearchService: extension filter", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("tutorial"); request.extension = ".txt"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); for (const auto& doc : result.value().results) { if (!doc.fileName.empty()) { CHECK_THAT(doc.fileName, ContainsSubstring(".txt")); } } } // ============ Search Types ============ TEST_CASE("SearchService: keyword search", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("artificial intelligence"); request.type = "keyword"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); for (const auto& doc : result.value().results) { CHECK(doc.score > 0.0); } } TEST_CASE("SearchService: explicit keyword search does not auto-fallback to fuzzy", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("xyzzyveryunlikelytomatchanything"); request.type = "keyword"; request.fuzzy = false; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total == kZeroTotal); CHECK(result.value().results.empty()); CHECK(result.value().type == "full-text"); } TEST_CASE( "SearchService: literal-looking queries default to keyword retrieval without fuzzy fallback", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("resolveGraphCandidateNodeId"); request.fuzzy = false; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total == kZeroTotal); CHECK(result.value().results.empty()); CHECK(result.value().type == "full-text"); REQUIRE(result.value().searchStats.contains("retrieval_mode")); CHECK(result.value().searchStats.at("retrieval_mode") == "literal"); CHECK(result.value().searchStats.at("effective_type") == "keyword"); REQUIRE(result.value().searchStats.contains("lexical_delta_pending_docs")); CHECK(result.value().searchStats.at("lexical_delta_pending_docs") == "0"); } TEST_CASE("SearchService: explicit keyword key=value query uses metadata filters", "[unit][services][search]") { SearchServiceFixture f; REQUIRE(f.testHashes.size() >= 2); f.setMetadataForHash(f.testHashes[0], "task", "performance"); f.setMetadataForHash(f.testHashes[0], "owner", "opencode"); f.setMetadataForHash(f.testHashes[1], "task", "performance"); f.setMetadataForHash(f.testHashes[1], "owner", "someone-else"); auto request = f.createBasicSearchRequest("task=performance owner=opencode"); request.type = "keyword"; request.fuzzy = false; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().type == "metadata"); CHECK(result.value().total >= 1); REQUIRE_FALSE(result.value().results.empty()); const bool matchedExpectedHash = std::any_of(result.value().results.begin(), result.value().results.end(), [&](const SearchItem& item) { return item.hash == f.testHashes[0]; }); CHECK(matchedExpectedHash); } TEST_CASE("SearchService: semantic search", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("machine learning algorithms"); request.type = "semantic"; auto result = runAwait(f.searchService->search(request)); // Semantic search might not be available if (result) { CHECK(result.value().total >= kZeroTotal); for (const auto& doc : result.value().results) { CHECK(doc.score > 0.0); } } else { CHECK_FALSE(result); } } TEST_CASE("SearchService: hybrid search", "[unit][services][search]") { SearchServiceFixture f; auto engine = search::createSearchEngine(f.metadataRepo, nullptr, nullptr, f.kgStore, {}); REQUIRE(engine); f.searchEngine = std::shared_ptr(std::move(engine)); f.appContext.searchEngine = f.searchEngine; f.searchService = makeSearchService(f.appContext); auto request = f.createBasicSearchRequest("python programming"); request.type = "hybrid"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); CHECK_FALSE(result.value().searchStats.contains("symbol_rank_requested")); for (const auto& doc : result.value().results) { CHECK(doc.score > 0.0); } } // ============ Error Handling ============ TEST_CASE("SearchService: empty query", "[unit][services][search]") { SearchServiceFixture f; auto result = runAwait(f.searchService->search(f.createBasicSearchRequest(""))); REQUIRE_FALSE(result); CHECK(result.error().code == ErrorCode::InvalidArgument); } TEST_CASE("SearchService: invalid limit", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("test"); request.limit = -1; auto result = runAwait(f.searchService->search(request)); REQUIRE_FALSE(result); CHECK(result.error().code == ErrorCode::InvalidArgument); } TEST_CASE("SearchService: invalid search type", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("test"); request.type = "invalid_type"; auto result = runAwait(f.searchService->search(request)); if (!result) { CHECK(result.error().code == ErrorCode::InvalidArgument); } else { CHECK(result.value().total >= kZeroTotal); } } // ============ Performance ============ TEST_CASE("SearchService: search performance", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("programming tutorial example"); auto start = std::chrono::high_resolution_clock::now(); auto result = runAwait(f.searchService->search(request)); auto end = std::chrono::high_resolution_clock::now(); REQUIRE(result); auto duration = std::chrono::duration_cast(end - start); CHECK(duration.count() < 200); } TEST_CASE("SearchService: large result set performance", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("test"); request.limit = 100; auto start = std::chrono::high_resolution_clock::now(); auto result = runAwait(f.searchService->search(request)); auto end = std::chrono::high_resolution_clock::now(); REQUIRE(result); auto duration = std::chrono::duration_cast(end - start); CHECK(duration.count() < 200); } // ============ Special Queries ============ TEST_CASE("SearchService: special characters", "[unit][services][search]") { SearchServiceFixture f; auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("C++ programming"))); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); } TEST_CASE("SearchService: quoted phrase", "[unit][services][search]") { SearchServiceFixture f; auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("\"machine learning\""))); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); } TEST_CASE("SearchService: wildcards", "[unit][services][search]") { SearchServiceFixture f; auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("program*"))); REQUIRE(result); CHECK(result.value().total >= kZeroTotal); } // ============ Result Quality ============ TEST_CASE("SearchService: relevance scoring", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("python programming tutorial"); auto result = runAwait(f.searchService->search(request)); REQUIRE(result); if (result.value().results.size() > 1) { auto& docs = result.value().results; for (size_t i = 1; i < docs.size(); ++i) { CHECK(docs[i - 1].score >= docs[i].score); } if (!docs.empty()) { CHECK(docs[0].score > 0.0); } } } TEST_CASE("SearchService: no results", "[unit][services][search]") { SearchServiceFixture f; auto result = runAwait( f.searchService->search(f.createBasicSearchRequest("xyzzyveryunlikelytomatchanything"))); REQUIRE(result); CHECK(result.value().total == kZeroTotal); CHECK(result.value().results.empty()); } // ============ Integration ============ TEST_CASE("SearchService: context integration", "[unit][services][search]") { SearchServiceFixture f; REQUIRE(f.searchService != nullptr); auto result = runAwait(f.searchService->search(f.createBasicSearchRequest("test"))); REQUIRE(result); } TEST_CASE("SearchService: host execution context supplies steady-state readiness", "[unit][services][search][readiness]") { SearchServiceFixture f; f.appContext.searchExecutionContextProvider = [] { auto context = search::defaultSearchExecutionContext(); context.freshness.lexicalReady = true; context.freshness.vectorReady = true; return context; }; f.searchService = makeSearchService(f.appContext); auto request = f.createBasicSearchRequest("programming"); request.type = "keyword"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); REQUIRE(result.value().searchStats.contains("search_engine_ready")); CHECK(result.value().searchStats.at("search_engine_ready") == "true"); CHECK(result.value().searchStats.at("vector_ready") == "true"); CHECK(result.value().searchStats.at("corpus_warming") == "false"); } // ============ Edge Cases ============ TEST_CASE("SearchService: very long query", "[unit][services][search]") { SearchServiceFixture f; std::string longQuery(1000, 'a'); auto result = runAwait(f.searchService->search(f.createBasicSearchRequest(longQuery))); if (result) { CHECK(result.value().total >= kZeroTotal); } else { CHECK(result.error().code == ErrorCode::InvalidArgument); } } TEST_CASE("SearchService: paths only", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("programming"); request.pathsOnly = true; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); CHECK_FALSE(result.value().paths.empty()); for (const auto& doc : result.value().results) { CHECK_FALSE(doc.hash.empty()); } } TEST_CASE("SearchService: snippet hydration exports bounded stage telemetry", "[unit][services][search][hydration][profiling]") { SearchServiceFixture f; auto slowRepo = std::make_shared(*f.pool, std::chrono::milliseconds{25}); slowRepo->setKnowledgeGraphStore(f.kgStore); f.metadataRepo = slowRepo; f.appContext.metadataRepo = slowRepo; f.searchService = makeSearchService(f.appContext); auto request = f.createBasicSearchRequest("programming"); request.type = "keyword"; request.snippetHydrationTimeoutMs = 1; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); const auto& stats = result.value().searchStats; REQUIRE(stats.contains("timing_snippet_hydration_us")); REQUIRE(stats.contains("timing_snippet_lookup_us")); REQUIRE(stats.contains("timing_snippet_content_fetch_us")); REQUIRE(stats.contains("snippet_hydration_candidates")); REQUIRE(stats.contains("snippet_hydration_missing")); CHECK(std::stoll(stats.at("timing_snippet_hydration_us")) >= std::stoll(stats.at("timing_snippet_content_fetch_us"))); CHECK(stats.at("snippet_timeout_hit") == "true"); } TEST_CASE("SearchService: snippet hydration centers query matches beyond file prefixes", "[unit][services][search][hydration]") { SearchServiceFixture f; const std::string query = "graph affected tests traversal implementation"; const std::string content = std::string(12'000, 'x') + "\n// graph affected tests traversal implementation\n" "int affectedTests() { return traverseGraph(); }\n"; auto fixture = f.fixtureManager->createTextFixture("graph_context_service.cpp", content, {"search"}); auto documentService = makeDocumentService(f.appContext); StoreDocumentRequest storeRequest; storeRequest.path = fixture.path.string(); auto stored = documentService->store(storeRequest); REQUIRE(stored); f.indexDocumentContent(stored.value().hash, "graph_context_service.cpp", content); auto missingSnippetRepo = std::make_shared(*f.pool, std::chrono::milliseconds{0}); missingSnippetRepo->setKnowledgeGraphStore(f.kgStore); f.metadataRepo = missingSnippetRepo; f.appContext.metadataRepo = missingSnippetRepo; f.searchService = makeSearchService(f.appContext); auto request = f.createBasicSearchRequest(query); request.type = "keyword"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); const auto match = std::find_if( result.value().results.begin(), result.value().results.end(), [](const SearchItem& item) { return item.path.find("graph_context_service.cpp") != std::string::npos; }); REQUIRE((match != result.value().results.end())); CHECK((match->snippet.find("affectedTests") != std::string::npos)); CHECK((match->snippet.find(std::string(80, 'x')) == std::string::npos)); CHECK((result.value().searchStats.at("snippet_query_centered_results") == "1")); CHECK((result.value().searchStats.at("snippet_query_fallback_results") == "0")); } TEST_CASE("SearchService: lightIndex retries transient metadata errors", "[unit][services][search]") { SearchServiceFixture f; REQUIRE_FALSE(f.testHashes.empty()); auto flakyRepo = std::make_shared(*f.pool); flakyRepo->setGetDocumentByHashFailures(1); f.metadataRepo = flakyRepo; f.appContext.metadataRepo = flakyRepo; f.searchService = makeSearchService(f.appContext); auto result = f.searchService->lightIndexForHash(f.testHashes.front(), 512 * 1024); REQUIRE(result); } TEST_CASE("SearchService: extension facet telemetry is populated for local keyword searches", "[unit][services][search]") { SearchServiceFixture f; auto request = f.createBasicSearchRequest("Artificial"); request.type = "keyword"; auto result = runAwait(f.searchService->search(request)); REQUIRE(result); const auto& response = result.value(); REQUIRE(response.searchStats.contains("facet_workers_used")); REQUIRE(response.searchStats.contains("facet_input_count")); REQUIRE(response.searchStats.contains("facet_sample_count")); REQUIRE(response.searchStats.contains("facet_approximate")); CHECK(response.searchStats.at("facet_approximate") == "false"); CHECK(response.searchStats.at("budget_short_query") == "true"); } TEST_CASE("GlobMatcher keeps the search service glob semantics", "[unit][services][glob]") { using yams::app::services::GlobMatcher; // '*' stays inside a segment, '**' crosses, '?' is one character, dots are literal. CHECK(GlobMatcher("**/*.md").matches("docs/guide/intro.md")); CHECK_FALSE(GlobMatcher("*.md").matches("docs/intro.md")); CHECK(GlobMatcher("*.md").matches("intro.md")); CHECK(GlobMatcher("src/**/main.cpp").matches("src/a/b/main.cpp")); CHECK_FALSE(GlobMatcher("src/*/main.cpp").matches("src/a/b/main.cpp")); CHECK(GlobMatcher("file?.txt").matches("file1.txt")); CHECK_FALSE(GlobMatcher("a.b").matches("aXb")); // Regex metacharacters in a glob are literal, so brackets never open a class. CHECK(GlobMatcher("**/[x].md").matches("a/[x].md")); CHECK_FALSE(GlobMatcher("[x].md").matches("x.md")); // The compiled form is reused: matching many texts against one pattern stays correct. const GlobMatcher shared("**/*.cpp"); for (int i = 0; i < 100; ++i) { CHECK(shared.matches("src/" + std::to_string(i) + "/file.cpp")); } } // --------------------------------------------------------------------------- // lines:/pages:/section:/selector: qualifiers were parsed from the query and then ignored: // the query header promised "downstream snippet shaping" and nothing applied it. // --------------------------------------------------------------------------- namespace { yams::app::services::SearchItem scopeItem(std::string hash, std::string path, std::string mime) { yams::app::services::SearchItem item; item.hash = std::move(hash); item.path = std::move(path); item.mimeType = std::move(mime); item.snippet = "original snippet"; return item; } std::vector scopeBytes(std::string_view text) { std::vector out(text.size()); std::memcpy(out.data(), text.data(), text.size()); return out; } yams::search::ExtractScope scopeOf(yams::search::ExtractScopeType type, std::string range = {}) { yams::search::ExtractScope scope; scope.type = type; scope.range = std::move(range); return scope; } } // namespace TEST_CASE("Extract scope: lines: shapes each result's snippet to those lines", "[unit][services][search][extract-scope]") { using yams::app::services::applyExtractScope; std::vector results{ scopeItem("text", "notes/a.md", "text/markdown"), scopeItem("pdf", "papers/b.pdf", "application/pdf"), scopeItem("huge", "logs/c.txt", "text/plain"), }; auto read = [](const std::string& hash) -> yams::Result> { if (hash == "text") { return scopeBytes("one\ntwo\nthree\nfour\n"); } if (hash == "pdf") { return scopeBytes("%PDF-1.7 binary"); } return scopeBytes(std::string(64, 'x')); }; const auto outcome = applyExtractScope( results, scopeOf(yams::search::ExtractScopeType::Lines, "2-3"), read, /*maxBytes=*/32); CHECK((results[0].snippet == "two\nthree")); // No line-range handler for PDF, and the third exceeds the read cap: both keep their // snippet and are counted, never silently presented as shaped. CHECK((results[1].snippet == "original snippet")); CHECK((results[2].snippet == "original snippet")); CHECK((outcome.shaped == 1U)); CHECK((outcome.unshaped == 2U)); CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("lines:2-3 applied to 1 of 3")); } TEST_CASE("Extract scope: an invalid line range is reported, not applied", "[unit][services][search][extract-scope]") { std::vector results{ scopeItem("text", "notes/a.md", "text/markdown")}; auto read = [](const std::string&) -> yams::Result> { return scopeBytes("one\ntwo\n"); }; const auto outcome = yams::app::services::applyExtractScope( results, scopeOf(yams::search::ExtractScopeType::Lines, "two-five"), read); CHECK((results[0].snippet == "original snippet")); CHECK((outcome.shaped == 0U)); CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("invalid")); } TEST_CASE("Extract scope: pages, section and selector are reported as not applied", "[unit][services][search][extract-scope]") { std::vector results{ scopeItem("text", "notes/a.md", "text/markdown")}; bool read = false; auto reader = [&](const std::string&) -> yams::Result> { read = true; return scopeBytes("one\n"); }; const auto outcome = yams::app::services::applyExtractScope( results, scopeOf(yams::search::ExtractScopeType::Pages, "1-2"), reader); CHECK_FALSE(read); CHECK((results[0].snippet == "original snippet")); CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("pages:1-2")); CHECK_THAT(outcome.note, Catch::Matchers::ContainsSubstring("not applied")); } TEST_CASE("Extract scope: a query without a scope qualifier changes nothing", "[unit][services][search][extract-scope]") { std::vector results{ scopeItem("text", "notes/a.md", "text/markdown")}; bool read = false; auto reader = [&](const std::string&) -> yams::Result> { read = true; return scopeBytes("one\n"); }; const auto outcome = yams::app::services::applyExtractScope(results, yams::search::ExtractScope{}, reader); CHECK_FALSE(read); CHECK((results[0].snippet == "original snippet")); CHECK(outcome.note.empty()); }