/** * @file retrieval_and_ingestion_catch2_test.cpp * @brief Catch2 integration tests for RetrievalService and DocumentIngestionService * * Tests the daemon-based services layer for document ingestion and retrieval, * using DaemonHarness for proper lifecycle management. */ #include #include #include #include #include #include #include #include #include #include #include #include #include "../../common/test_helpers_catch2.h" #include "../daemon/test_daemon_harness.h" #include "common/capability.h" #include "common/fixture_manager.h" #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; using namespace std::chrono_literals; namespace { constexpr int kRequestTimeoutMs = 5000; constexpr int kBodyTimeoutMs = 15000; using yams::test::ScopedEnvVar; struct BarrierStats { std::uint64_t lastPostIngestQueued{0}; std::uint64_t lastPostIngestInflight{0}; std::uint64_t lastPostIngestDrained{0}; std::uint64_t lastIndexVisible{0}; std::uint32_t statusPolls{0}; std::uint32_t statusErrors{0}; std::uint32_t snapshotPolls{0}; std::size_t lastSnapshotCount{0}; std::size_t lastDocsByLabelCount{0}; }; // Helper: wait until post-ingest queue drains (queued==0 && inflight==0) or timeout. // Returns true when condition is observed; false on timeout. bool waitForPostIngestQuiescent(const fs::path& socket, const fs::path& dataDir, std::chrono::milliseconds timeout, std::chrono::milliseconds poll = 50ms, BarrierStats* stats = nullptr) { yams::daemon::ClientConfig cfg; cfg.socketPath = socket; cfg.dataDir = dataDir; cfg.requestTimeout = 1500ms; auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { auto client = std::make_shared(cfg); std::promise> prom; auto fut = prom.get_future(); boost::asio::co_spawn( yams::daemon::GlobalIOContext::global_executor(), [client, p = std::move(prom)]() mutable -> boost::asio::awaitable { auto r = co_await client->status(); p.set_value(std::move(r)); co_return; }, boost::asio::detached); if (stats) { stats->statusPolls++; } if (fut.wait_for(1500ms) == std::future_status::ready) { auto res = fut.get(); if (res) { const auto& s = res.value(); auto getU64 = [&](const char* key) -> std::uint64_t { auto it = s.requestCounts.find(key); return (it == s.requestCounts.end()) ? 0ull : static_cast(it->second); }; const auto queued = getU64("post_ingest_queued"); const auto inflight = getU64("post_ingest_inflight"); const auto drained = getU64("post_ingest_drained"); const auto indexVisible = getU64("index_visible"); if (stats) { stats->lastPostIngestQueued = queued; stats->lastPostIngestInflight = inflight; stats->lastPostIngestDrained = drained; stats->lastIndexVisible = indexVisible; } if (queued == 0 && inflight == 0) return true; } else if (stats) { stats->statusErrors++; } } else if (stats) { stats->statusErrors++; } std::this_thread::sleep_for(poll); } return false; } template bool waitForSnapshotVisible(MetadataRepoT* repo, const std::string& snapshotId, const std::string& snapshotLabel, std::size_t minDocsByLabel, std::chrono::milliseconds timeout, std::chrono::milliseconds poll = 25ms, BarrierStats* stats = nullptr) { if (!repo || snapshotId.empty()) { return false; } const auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { if (stats) { stats->snapshotPolls++; } auto snaps = repo->listTreeSnapshots(256); if (snaps) { auto records = snaps.value(); std::sort(records.begin(), records.end(), [](const auto& lhs, const auto& rhs) { if (lhs.createdTime != rhs.createdTime) return lhs.createdTime > rhs.createdTime; return lhs.snapshotId > rhs.snapshotId; }); if (stats) { stats->lastSnapshotCount = records.size(); } auto it = std::find_if(records.begin(), records.end(), [&](const auto& rec) { return rec.snapshotId == snapshotId; }); if (it != records.end()) { bool labelOk = true; if (!snapshotLabel.empty()) { auto labelIt = it->metadata.find("snapshot_label"); labelOk = (labelIt != it->metadata.end() && labelIt->second == snapshotLabel); } std::size_t docsByLabelCount = 0; if (minDocsByLabel > 0 && !snapshotLabel.empty()) { auto docsByLabel = repo->findDocumentsBySnapshotLabel(snapshotLabel); if (docsByLabel) { docsByLabelCount = docsByLabel.value().size(); } if (stats) { stats->lastDocsByLabelCount = docsByLabelCount; } } if (labelOk && docsByLabelCount >= minDocsByLabel) { return true; } } } std::this_thread::sleep_for(poll); } return false; } } // namespace // Windows daemon shutdown hangs during WorkCoordinator thread join. // The tests themselves work - IPC communication succeeds - but cleanup blocks indefinitely. // Windows daemon IPC does not yet support this path. // TODO: Remove this skip once Windows daemon shutdown is fixed. #ifdef _WIN32 #define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() \ SKIP("Windows daemon shutdown hangs - see windows-daemon-ipc-plan.md") #else #define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() ((void)0) #endif /** * @brief Test fixture using DaemonHarness for proper lifecycle management */ class ServicesRetrievalIngestionFixture { public: ServicesRetrievalIngestionFixture() { auto unique = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); testRoot_ = fs::temp_directory_path() / ("yams_services_it_" + unique); fs::create_directories(testRoot_); fixtures_ = std::make_unique(testRoot_ / "fixtures"); // Ensure test daemon never kills any existing user/system daemon killOthersEnv_.emplace("YAMS_DAEMON_KILL_OTHERS", "0"); harness_ = std::make_unique(); } ~ServicesRetrievalIngestionFixture() { bool keepArtifacts = std::getenv("YAMS_KEEP_IT_DIR") != nullptr; if (harness_) { harness_->stop(); harness_.reset(); } fixtures_.reset(); if (keepArtifacts) { std::cout << "Keeping integration test artifacts under " << testRoot_.string() << " (YAMS_KEEP_IT_DIR set)." << std::endl; } else { std::error_code ec; fs::remove_all(testRoot_, ec); } } bool startDaemon(std::chrono::seconds timeout = 30s) { if (!harness_->start(timeout)) return false; socketPath_ = harness_->socketPath(); storageDir_ = harness_->dataDir(); return true; } yams::daemon::YamsDaemon* daemon() const { return harness_ ? harness_->daemon() : nullptr; } fs::path testRoot_; fs::path socketPath_; fs::path storageDir_; std::optional killOthersEnv_; std::unique_ptr harness_; std::unique_ptr fixtures_; }; TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "AddViaDaemonAndListGetGrep", "[integration][services][retrieval][ingestion]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; // 1) Create a small file to add auto doc = fixtures_->createTextFixture("hello/hello.txt", "hello yams services", {"services", "smoke"}); fs::path filePath = doc.path; // 2) Add via DocumentIngestionService (daemon-first) DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.path = filePath.string(); opts.recursive = false; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; opts.explicitDataDir = storageDir_; auto addRes = ing.addViaDaemon(opts); REQUIRE(addRes); REQUIRE_FALSE(addRes.value().hash.empty()); BarrierStats barrierStats; const bool quiescent = waitForPostIngestQuiescent(socketPath_, storageDir_, 5000ms, 50ms, &barrierStats); CAPTURE(barrierStats.statusPolls, barrierStats.statusErrors, barrierStats.lastPostIngestQueued, barrierStats.lastPostIngestInflight, barrierStats.lastPostIngestDrained, barrierStats.lastIndexVisible); REQUIRE(quiescent); // 3) List via RetrievalService RetrievalService rsvc; RetrievalOptions ropts; ropts.socketPath = socketPath_; ropts.explicitDataDir = storageDir_; ropts.headerTimeoutMs = kRequestTimeoutMs; ropts.bodyTimeoutMs = kBodyTimeoutMs; ropts.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::ListOptions lreq; lreq.limit = 25; bool found = false; for (int attempt = 0; attempt < 40 && !found; ++attempt) { auto lres = rsvc.list(lreq, ropts); REQUIRE(lres); for (const auto& e : lres.value().items) { if (e.name == "hello.txt") { found = true; break; } } if (!found) std::this_thread::sleep_for(50ms); } if (!found) { SKIP("List output did not surface hello.txt; eventual consistency issue."); } // 4) Get by hash yams::app::services::GetOptions greq; greq.hash = addRes.value().hash; greq.metadataOnly = false; auto gres = rsvc.get(greq, ropts); REQUIRE(gres); CHECK((gres.value().name == "hello.txt")); // 5) Grep for pattern yams::app::services::GrepOptions gpreq; gpreq.pattern = "hello"; gpreq.pathsOnly = true; bool grepReady = false; for (int attempt = 0; attempt < 40 && !grepReady; ++attempt) { auto gpres = rsvc.grep(gpreq, ropts); REQUIRE(gpres); grepReady = !gpres.value().matches.empty(); if (!grepReady) std::this_thread::sleep_for(50ms); } if (!grepReady) { SKIP("Grep matches not available; skipping on this runner."); } // 6) Name-smart get auto gname = rsvc.getByNameSmart("hello.txt", false, true, false, std::string{}, ropts, {}); REQUIRE(gname); CHECK((gname.value().name == "hello.txt")); // 7) Chunked buffer get yams::app::services::GetInitOptions ginit; ginit.hash = addRes.value().hash; ropts.requestTimeoutMs = 30000; auto chunked = rsvc.getChunkedBuffer(ginit, 8, ropts); REQUIRE(chunked); CHECK((chunked.value().content.size() <= 8u)); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "AddBatchPreservesOrderAndMixedResults", "[integration][services][retrieval][ingestion][batch]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; auto docA = fixtures_->createTextFixture("batch/a.txt", "alpha", {"batch"}); auto docB = fixtures_->createTextFixture("batch/b.txt", "bravo", {"batch"}); DocumentIngestionService ing; std::vector batch; yams::app::services::AddOptions a; a.socketPath = socketPath_; a.explicitDataDir = storageDir_; a.path = docA.path.string(); a.noEmbeddings = true; a.timeoutMs = kRequestTimeoutMs; batch.push_back(a); yams::app::services::AddOptions bad = a; bad.content = "invalid inline content"; batch.push_back(bad); yams::app::services::AddOptions b = a; b.path = docB.path.string(); batch.push_back(b); auto batchRes = boost::asio::co_spawn(yams::daemon::GlobalIOContext::global_executor(), ing.addBatchAsync(batch, 2), boost::asio::use_future) .get(); REQUIRE((batchRes.results.size() == batch.size())); CHECK((batchRes.succeeded == 2)); CHECK((batchRes.failed == 1)); REQUIRE(batchRes.results[0]); REQUIRE_FALSE(batchRes.results[1]); REQUIRE(batchRes.results[2]); BarrierStats barrierStats; const bool quiescent = waitForPostIngestQuiescent(socketPath_, storageDir_, 5000ms, 50ms, &barrierStats); CAPTURE(barrierStats.statusPolls, barrierStats.statusErrors, barrierStats.lastPostIngestQueued, barrierStats.lastPostIngestInflight, barrierStats.lastPostIngestDrained, barrierStats.lastIndexVisible); REQUIRE(quiescent); RetrievalService rsvc; RetrievalOptions ropts; ropts.socketPath = socketPath_; ropts.explicitDataDir = storageDir_; ropts.headerTimeoutMs = kRequestTimeoutMs; ropts.bodyTimeoutMs = kBodyTimeoutMs; ropts.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::GetOptions getA; getA.hash = batchRes.results[0].value().hash; getA.metadataOnly = true; yams::Result getARes; for (int attempt = 0; attempt < 40; ++attempt) { getARes = rsvc.get(getA, ropts); if (getARes) { break; } std::this_thread::sleep_for(50ms); } REQUIRE(getARes); CHECK((getARes.value().name == "a.txt")); yams::app::services::GetOptions getB; getB.hash = batchRes.results[2].value().hash; getB.metadataOnly = true; yams::Result getBRes; for (int attempt = 0; attempt < 40; ++attempt) { getBRes = rsvc.get(getB, ropts); if (getBRes) { break; } std::this_thread::sleep_for(50ms); } REQUIRE(getBRes); CHECK((getBRes.value().name == "b.txt")); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "SearchDoesNotPersistRetrievalServedFeedbackEvent", "[integration][services][retrieval][feedback]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); REQUIRE((ctx.metadataRepo != nullptr)); using yams::app::services::DocumentIngestionService; auto doc = fixtures_->createTextFixture("feedback/search_trace.txt", "trace feedback body", {"feedback", "trace"}); DocumentIngestionService ing; yams::app::services::AddOptions addOpts; addOpts.socketPath = socketPath_; addOpts.explicitDataDir = storageDir_; addOpts.path = doc.path.string(); addOpts.recursive = false; addOpts.noEmbeddings = true; addOpts.timeoutMs = kRequestTimeoutMs; auto addRes = ing.addViaDaemon(addOpts); REQUIRE(addRes); BarrierStats barrierStats; const bool quiescent = waitForPostIngestQuiescent(socketPath_, storageDir_, 5000ms, 50ms, &barrierStats); CAPTURE(barrierStats.statusPolls, barrierStats.statusErrors, barrierStats.lastPostIngestQueued, barrierStats.lastPostIngestInflight, barrierStats.lastPostIngestDrained, barrierStats.lastIndexVisible); REQUIRE(quiescent); yams::daemon::ClientConfig cfg; cfg.socketPath = socketPath_; cfg.dataDir = storageDir_; cfg.requestTimeout = 5000ms; auto client = std::make_shared(cfg); auto beforeEvents = ctx.metadataRepo->getRecentFeedbackEvents(50); REQUIRE(beforeEvents); const auto beforeCount = std::count_if( beforeEvents.value().begin(), beforeEvents.value().end(), [](const auto& event) { return event.eventType == "retrieval_served" && event.source == "daemon"; }); yams::daemon::SearchRequest req; req.query = "trace feedback body"; req.limit = 10; req.searchType = "keyword"; std::promise> prom; auto fut = prom.get_future(); boost::asio::co_spawn( yams::daemon::GlobalIOContext::global_executor(), [client, req, p = std::move(prom)]() mutable -> boost::asio::awaitable { auto r = co_await client->call(req); p.set_value(std::move(r)); co_return; }, boost::asio::detached); REQUIRE((fut.wait_for(5s) == std::future_status::ready)); auto searchRes = fut.get(); REQUIRE(searchRes); auto afterEvents = ctx.metadataRepo->getRecentFeedbackEvents(50); REQUIRE(afterEvents); const auto afterCount = std::count_if( afterEvents.value().begin(), afterEvents.value().end(), [](const auto& event) { return event.eventType == "retrieval_served" && event.source == "daemon"; }); CHECK((afterCount == beforeCount)); CHECK((searchRes.value().searchStats["phase_dispatch_feedback_us"] == "0")); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "StatsZeroThenGrowthAndUnreachableHints", "[integration][services][retrieval]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::makeStatsService; using yams::app::services::StatsRequest; auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); auto statsSvc = makeStatsService(ctx); // Capture baseline stats StatsRequest rq{}; auto s0 = statsSvc->getStats(rq); REQUIRE(s0); auto baseObjects = s0.value().totalObjects; auto baseBytes = s0.value().totalBytes; // Ingest one small doc auto doc = fixtures_->createTextFixture("stats/one.txt", "alpha beta", {"stats", "it"}); DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = doc.path.string(); opts.recursive = false; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; auto addRes = ing.addViaDaemon(opts); REQUIRE(addRes); waitForPostIngestQuiescent(socketPath_, storageDir_, 5000ms); auto s2 = statsSvc->getStats(StatsRequest{}); REQUIRE(s2); CHECK((s2.value().totalObjects >= baseObjects)); CHECK((s2.value().totalBytes >= baseBytes)); // Unreachable daemon should fail cleanly { DocumentIngestionService ing2; yams::app::services::AddOptions bad; bad.socketPath = fs::path("/tmp") / ("yams-does-not-exist-" + std::to_string(::getpid()) + ".sock"); bad.explicitDataDir = storageDir_; bad.path = doc.path.string(); bad.recursive = false; bad.noEmbeddings = true; bad.timeoutMs = kRequestTimeoutMs; auto r = ing2.addViaDaemon(bad); CHECK_FALSE(r); } } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "AddDirectoryWithPatternsAndTags", "[integration][services][directory][patterns]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; // Create fixtures auto keepCpp = fixtures_->createTextFixture("ingest/dirA/dirB/keep.cpp", "int main() { return 0; }\n", {"code", "cpp"}); auto keepMd = fixtures_->createTextFixture("ingest/dirA/dirB/keep.md", "# Title\nhello pattern tags\n", {"docs", "md"}); std::vector skipData = {std::byte{0x00}, std::byte{0x01}, std::byte{0x02}}; auto skipBin = fixtures_->createBinaryFixture("ingest/dirA/dirB/skip.bin", skipData, {"bin"}); (void)keepCpp; (void)keepMd; (void)skipBin; DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.path = (fixtures_->root() / "ingest").string(); opts.recursive = true; opts.includePatterns = {"*.cpp", "*.md"}; opts.excludePatterns = {"*.bin"}; opts.tags = {"code", "working", "kg"}; opts.metadata = {{"pbi", "002"}, {"system", "yams"}}; opts.collection = "workspace"; opts.noEmbeddings = true; opts.explicitDataDir = storageDir_; opts.timeoutMs = kRequestTimeoutMs; auto addRes = ing.addViaDaemon(opts); REQUIRE(addRes); const auto& firstResp = addRes.value(); // Directory ingestion is async: immediate response has documentsAdded=0 // Actual indexing happens asynchronously; we verify via list operations after quiescence CHECK((firstResp.documentsUpdated == 0)); waitForPostIngestQuiescent(socketPath_, storageDir_, 7000ms); // Verify via list RetrievalService rsvc; RetrievalOptions ropts; ropts.socketPath = socketPath_; yams::app::services::ListOptions lreq; lreq.limit = 10; lreq.namePattern = (fixtures_->root() / "ingest" / "**").string(); lreq.showTags = true; lreq.showMetadata = true; bool haveCpp = false, haveMd = false, skipPresent = false; yams::daemon::ListEntry keepCppEntry, keepMdEntry; for (int attempt = 0; attempt < 40; ++attempt) { auto lres = rsvc.list(lreq, ropts); REQUIRE(lres); const auto& items = lres.value().items; for (const auto& e : items) { if (e.name == "keep.cpp") { haveCpp = true; keepCppEntry = e; } if (e.name == "keep.md") { haveMd = true; keepMdEntry = e; } if (e.name == "skip.bin") skipPresent = true; } if (haveCpp && haveMd && !skipPresent) break; std::this_thread::sleep_for(50ms); } REQUIRE(haveCpp); REQUIRE(haveMd); CHECK_FALSE(skipPresent); // Check tags/metadata were persisted CHECK(std::find(keepCppEntry.tags.begin(), keepCppEntry.tags.end(), "code") != keepCppEntry.tags.end()); auto cppMeta = keepCppEntry.metadata.find("pbi"); REQUIRE((cppMeta != keepCppEntry.metadata.end())); CHECK((cppMeta->second == "002")); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "LightIndexInvalidHashTolerantError", "[integration][services][search][error]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); auto searchSvc = yams::app::services::makeSearchService(ctx); std::string bogus = "notahashvalue"; auto r = searchSvc->lightIndexForHash(bogus); REQUIRE_FALSE(r); std::string msg = r.error().message; if (msg.find("Worker executor not available") != std::string::npos) { SKIP("SearchService worker executor unavailable on this runner."); } bool ok = (msg.find("not found") != std::string::npos) || (msg.find("Invalid") != std::string::npos) || (msg.find("invalid") != std::string::npos); CHECK(ok); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "IndexingAddDirectoryVerifyAndDefer", "[integration][services][indexing]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::AddDirectoryRequest; using yams::app::services::makeIndexingService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; auto dir = fixtures_->root() / "idx"; fs::create_directories(dir); std::ofstream(dir / "a.md") << "alpha"; std::ofstream(dir / "b.bin") << std::string(4, '\0'); auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); auto idxSvc = makeIndexingService(ctx); if (!yams::test::capability::indexing_available(ctx)) { SKIP("IndexingService capability not available on this runner."); } AddDirectoryRequest req; req.directoryPath = dir.string(); req.includePatterns = {"*.md", "*.bin"}; req.excludePatterns = {"*.bin"}; req.tags = {"idx", "phase2"}; req.metadata = {{"pbi", "028-45"}}; req.recursive = true; req.verify = true; auto ar = idxSvc->addDirectory(req); if (!ar) { SKIP("IndexingService.addDirectory unavailable: " + ar.error().message); } const auto& resp = ar.value(); if (resp.filesProcessed == 0) { SKIP("IndexingService processed=0 (capability gap)"); } CHECK((resp.filesIndexed + resp.filesFailed + resp.filesSkipped >= 1)); if (resp.filesIndexed == 0 && resp.filesFailed > 0) { SKIP("IndexingService reports failure for all files."); } } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "RetrievalGetToFileWritesContent", "[integration][services][retrieval]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; auto doc = fixtures_->createTextFixture("rtv/one.txt", "retrieval content", {"retrieval"}); DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = doc.path.string(); opts.recursive = false; opts.noEmbeddings = true; auto addRes = ing.addViaDaemon(opts); REQUIRE(addRes); RetrievalService rsvc; RetrievalOptions ro; ro.socketPath = socketPath_; ro.explicitDataDir = storageDir_; yams::app::services::GetInitOptions gi; gi.hash = addRes.value().hash; auto outPath = testRoot_ / "out_one.txt"; if (auto chunked = rsvc.getChunkedBuffer(gi, 8, ro); chunked) { CHECK((chunked.value().content.size() > 0u)); return; } auto wr = rsvc.getToFile(gi, outPath, ro); if (!wr) { SKIP("RetrievalService getToFile/chunked unavailable: " + wr.error().message); } std::error_code ec; if (!fs::exists(outPath, ec)) { SKIP("Output path not created."); } auto sz = fs::file_size(outPath, ec); if (ec) { SKIP("Cannot stat output path: " + ec.message()); } CHECK((sz > 0u)); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "BinaryChunkedGetBytesRoundTrip", "[integration][services][retrieval]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); fs::create_directories(fixtures_->root() / "bin"); auto p = fixtures_->root() / "bin" / "b.dat"; std::string bytes; bytes.push_back('\0'); bytes.push_back((char)0xFF); bytes.push_back((char)0x10); bytes.push_back('A'); bytes.push_back('B'); bytes.push_back('C'); bytes.push_back('\n'); bytes.push_back((char)0x7F); { std::ofstream out(p, std::ios::binary); out.write(bytes.data(), static_cast(bytes.size())); } yams::app::services::DocumentIngestionService ing; yams::app::services::AddOptions a; a.socketPath = socketPath_; a.explicitDataDir = storageDir_; a.path = p.string(); a.recursive = false; a.noEmbeddings = true; a.timeoutMs = kRequestTimeoutMs; auto addRes = ing.addViaDaemon(a); REQUIRE(addRes); yams::app::services::RetrievalService rsvc; yams::app::services::RetrievalOptions ropts; ropts.socketPath = socketPath_; ropts.explicitDataDir = storageDir_; ropts.headerTimeoutMs = kRequestTimeoutMs; ropts.bodyTimeoutMs = kBodyTimeoutMs; ropts.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::GetInitOptions gi{}; gi.name = p.filename().string(); gi.maxBytes = 0; gi.chunkSize = 4; auto cr = rsvc.getChunkedBuffer(gi, 16, ropts); if (!cr) { SKIP("Chunked retrieval unavailable: " + cr.error().message); } auto out = cr.value().content; if (out.empty()) { SKIP("Chunked retrieval returned empty content."); } REQUIRE((bytes.size() >= out.size())); CHECK((out == bytes.substr(0, out.size()))); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "RetrievalListRespectsLimitAndPattern", "[integration][services][retrieval]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; fs::create_directories(fixtures_->root() / "list" / "d"); fixtures_->createTextFixture("list/d/a.md", "alpha", {"list", "md"}); fixtures_->createTextFixture("list/d/b.md", "bravo", {"list", "md"}); fixtures_->createTextFixture("list/d/c.txt", "charlie", {"list", "txt"}); DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = (fixtures_->root() / "list").string(); opts.recursive = true; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; auto addRes = ing.addViaDaemon(opts); REQUIRE(addRes); RetrievalService rsvc; RetrievalOptions ro; ro.socketPath = socketPath_; ro.explicitDataDir = storageDir_; ro.headerTimeoutMs = kRequestTimeoutMs; ro.bodyTimeoutMs = kBodyTimeoutMs; ro.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::ListOptions lreq; lreq.limit = 1; lreq.namePattern = (fixtures_->root() / "list" / "**" / "*.md").string(); auto lres = rsvc.list(lreq, ro); REQUIRE(lres); const auto& items = lres.value().items; REQUIRE((items.size() <= 1)); for (const auto& e : items) { CHECK((e.name.rfind(".md") != std::string::npos)); } } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "RetrievalListEchoesTotalCountAndStructure", "[integration][services][retrieval][list][structure]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; using yams::app::services::RetrievalOptions; using yams::app::services::RetrievalService; fs::create_directories(fixtures_->root() / "list2"); fixtures_->createTextFixture("list2/one.txt", "one", {"l2"}); fixtures_->createTextFixture("list2/two.txt", "two", {"l2"}); fixtures_->createTextFixture("list2/three.md", "three", {"l2"}); DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = (fixtures_->root() / "list2").string(); opts.recursive = true; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; REQUIRE(ing.addViaDaemon(opts)); RetrievalService rsvc; RetrievalOptions ro; ro.socketPath = socketPath_; ro.explicitDataDir = storageDir_; ro.headerTimeoutMs = kRequestTimeoutMs; ro.bodyTimeoutMs = kBodyTimeoutMs; ro.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::ListOptions lq; lq.limit = 2; lq.showTags = true; lq.showMetadata = true; auto lr = rsvc.list(lq, ro); REQUIRE(lr); const auto& resp = lr.value(); CHECK((resp.items.size() <= static_cast(lq.limit))); CHECK((resp.totalCount >= static_cast(resp.items.size()))); for (const auto& e : resp.items) { CHECK_FALSE(e.name.empty()); CHECK_FALSE(e.path.empty()); } } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "AppDocumentList_HotOnlyStillHydratesMetadataWhenRequested", "[integration][services][document][list][hot_only]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); ScopedEnvVar listMode("YAMS_LIST_MODE", "hot_only"); REQUIRE(startDaemon()); using yams::app::services::ListDocumentsRequest; using yams::app::services::makeDocumentService; fs::create_directories(fixtures_->root() / "hot_list"); fixtures_->createTextFixture("hot_list/one.txt", "one", {"it-list-hot"}); yams::app::services::DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = (fixtures_->root() / "hot_list").string(); opts.recursive = true; // Apply a tag via ingestion so we can assert metadata hydration under hot_only list mode. opts.tags = {"it-list-hot"}; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; REQUIRE(ing.addViaDaemon(opts)); BarrierStats barrierStats; const bool quiescent = waitForPostIngestQuiescent(socketPath_, storageDir_, 8000ms, 50ms, &barrierStats); CAPTURE(quiescent, barrierStats.statusPolls, barrierStats.statusErrors, barrierStats.lastPostIngestQueued, barrierStats.lastPostIngestInflight, barrierStats.lastPostIngestDrained, barrierStats.lastIndexVisible); auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); auto docSvc = makeDocumentService(ctx); ListDocumentsRequest rq; rq.limit = 10; rq.pattern = (fixtures_->root() / "hot_list" / "**").string(); rq.showSnippets = false; rq.showTags = true; rq.showMetadata = true; // In hot_only mode the list path may depend on post-ingest indexing visibility. // Retry briefly to reduce flakiness. decltype(docSvc->list(rq)) r1; std::optional> resp; bool gotDocs = false; for (int i = 0; i < 40; ++i) { r1 = docSvc->list(rq); REQUIRE(r1); if (!r1.value().documents.empty()) { resp = std::cref(r1.value()); gotDocs = true; break; } std::this_thread::sleep_for(50ms); } REQUIRE(gotDocs); bool foundTagMetadata = false; for (const auto& e : resp->get().documents) { if (e.metadata.contains("tag:it-list-hot")) { foundTagMetadata = true; break; } } CHECK(foundTagMetadata); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "AppDocumentListEchoDetailsAndBurst", "[integration][services][document][list][burst]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::ListDocumentsRequest; using yams::app::services::makeDocumentService; fs::create_directories(fixtures_->root() / "app_list"); fixtures_->createTextFixture("app_list/a.txt", "alpha", {"app-list"}); fixtures_->createTextFixture("app_list/b.md", "bravo", {"app-list"}); yams::app::services::DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = (fixtures_->root() / "app_list").string(); opts.recursive = true; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; REQUIRE(ing.addViaDaemon(opts)); auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); auto docSvc = makeDocumentService(ctx); ListDocumentsRequest rq; rq.limit = 2; rq.pattern = (fixtures_->root() / "app_list" / "**").string(); rq.showSnippets = false; rq.showMetadata = false; rq.showTags = true; auto r1 = docSvc->list(rq); REQUIRE(r1); const auto& resp = r1.value(); REQUIRE((resp.documents.size() <= 2)); CHECK((resp.totalFound >= resp.documents.size())); // Burst: call list repeatedly for (int i = 0; i < 25; ++i) { auto ri = docSvc->list(rq); REQUIRE(ri); CHECK((ri.value().documents.size() <= 2)); } } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "SnapshotContract_NonDirectoryAddsPersistTreeSnapshot", "[integration][services][snapshot][add]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); REQUIRE((ctx.metadataRepo != nullptr)); const std::string snapshotId = "it-snap-nondir-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); const std::string snapshotLabel = "it-snapshot-nondir"; auto doc1 = fixtures_->createTextFixture("snapshot/non_dir/one.txt", "snapshot one", {"snap"}); auto doc2 = fixtures_->createTextFixture("snapshot/non_dir/two.txt", "snapshot two", {"snap"}); DocumentIngestionService ing; yams::app::services::AddOptions addFileOne; addFileOne.socketPath = socketPath_; addFileOne.explicitDataDir = storageDir_; addFileOne.path = doc1.path.string(); addFileOne.recursive = false; addFileOne.noEmbeddings = true; addFileOne.timeoutMs = kRequestTimeoutMs; addFileOne.snapshotId = snapshotId; addFileOne.snapshotLabel = snapshotLabel; REQUIRE(ing.addViaDaemon(addFileOne)); yams::app::services::AddOptions addFileTwo = addFileOne; addFileTwo.path = doc2.path.string(); REQUIRE(ing.addViaDaemon(addFileTwo)); yams::app::services::AddOptions addStdinLike; addStdinLike.socketPath = socketPath_; addStdinLike.explicitDataDir = storageDir_; addStdinLike.content = "snapshot stdin-like content"; addStdinLike.name = "stdin_like.txt"; addStdinLike.recursive = false; addStdinLike.noEmbeddings = true; addStdinLike.timeoutMs = kRequestTimeoutMs; addStdinLike.snapshotId = snapshotId; addStdinLike.snapshotLabel = snapshotLabel; REQUIRE(ing.addViaDaemon(addStdinLike)); BarrierStats barrierStats; waitForPostIngestQuiescent(socketPath_, storageDir_, 8000ms, 50ms, &barrierStats); const bool snapshotVisible = waitForSnapshotVisible( ctx.metadataRepo.get(), snapshotId, snapshotLabel, 3, 8000ms, 25ms, &barrierStats); CAPTURE(snapshotVisible, barrierStats.snapshotPolls, barrierStats.lastSnapshotCount, barrierStats.lastDocsByLabelCount); REQUIRE(snapshotVisible); auto snaps = ctx.metadataRepo->listTreeSnapshots(200); REQUIRE(snaps); auto it = std::find_if(snaps.value().begin(), snaps.value().end(), [&](const auto& rec) { return rec.snapshotId == snapshotId; }); REQUIRE((it != snaps.value().end())); CHECK((it->metadata.count("snapshot_label") > 0)); CHECK((it->metadata.at("snapshot_label") == snapshotLabel)); auto docsByLabel = ctx.metadataRepo->findDocumentsBySnapshotLabel(snapshotLabel); REQUIRE(docsByLabel); CHECK((docsByLabel.value().size() >= 3)); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "SnapshotContract_DirectoryAddPreservesProvidedSnapshotFields", "[integration][services][snapshot][add][directory]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); using yams::app::services::DocumentIngestionService; auto* sm = daemon()->getServiceManager(); REQUIRE((sm != nullptr)); auto ctx = sm->getAppContext(); REQUIRE((ctx.metadataRepo != nullptr)); const std::string snapshotId = "it-snap-dir-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); const std::string snapshotLabel = "it-snapshot-directory"; fs::create_directories(fixtures_->root() / "snapshot" / "dir_add"); fixtures_->createTextFixture("snapshot/dir_add/a.txt", "a", {"snapdir"}); fixtures_->createTextFixture("snapshot/dir_add/b.txt", "b", {"snapdir"}); DocumentIngestionService ing; yams::app::services::AddOptions dirOpts; dirOpts.socketPath = socketPath_; dirOpts.explicitDataDir = storageDir_; dirOpts.path = (fixtures_->root() / "snapshot" / "dir_add").string(); dirOpts.recursive = true; dirOpts.noEmbeddings = true; dirOpts.timeoutMs = kRequestTimeoutMs; dirOpts.snapshotId = snapshotId; dirOpts.snapshotLabel = snapshotLabel; dirOpts.waitForProcessing = true; dirOpts.waitTimeoutSeconds = 10; REQUIRE(ing.addViaDaemon(dirOpts)); BarrierStats barrierStats; const bool quiescent = waitForPostIngestQuiescent(socketPath_, storageDir_, 5000ms, 50ms, &barrierStats); CAPTURE(barrierStats.statusPolls, barrierStats.statusErrors, barrierStats.lastPostIngestQueued, barrierStats.lastPostIngestInflight, barrierStats.lastPostIngestDrained, barrierStats.lastIndexVisible); REQUIRE(quiescent); const bool snapshotVisible = waitForSnapshotVisible( ctx.metadataRepo.get(), snapshotId, snapshotLabel, 0, 5000ms, 25ms, &barrierStats); CAPTURE(barrierStats.snapshotPolls, barrierStats.lastSnapshotCount, barrierStats.lastDocsByLabelCount); REQUIRE(snapshotVisible); auto snaps = ctx.metadataRepo->listTreeSnapshots(200); REQUIRE(snaps); auto it = std::find_if(snaps.value().begin(), snaps.value().end(), [&](const auto& rec) { return rec.snapshotId == snapshotId; }); REQUIRE((it != snaps.value().end())); CHECK((it->metadata.count("snapshot_label") > 0)); CHECK((it->metadata.at("snapshot_label") == snapshotLabel)); } TEST_CASE_METHOD(ServicesRetrievalIngestionFixture, "StressTail", "[integration][services][stress]") { SKIP_ON_WINDOWS_DAEMON_SHUTDOWN(); REQUIRE(startDaemon()); auto p = fixtures_->createTextFixture("stress/tail.txt", "hello", {"stress"}); yams::app::services::DocumentIngestionService ing; yams::app::services::AddOptions opts; opts.socketPath = socketPath_; opts.explicitDataDir = storageDir_; opts.path = p.path.string(); opts.recursive = false; opts.noEmbeddings = true; opts.timeoutMs = kRequestTimeoutMs; REQUIRE(ing.addViaDaemon(opts)); yams::app::services::RetrievalService rsvc; yams::app::services::RetrievalOptions ro; ro.socketPath = socketPath_; ro.explicitDataDir = storageDir_; ro.headerTimeoutMs = kRequestTimeoutMs; ro.bodyTimeoutMs = kBodyTimeoutMs; ro.requestTimeoutMs = kRequestTimeoutMs; yams::app::services::ListOptions lq; lq.limit = 5; lq.namePattern = (fixtures_->root() / "stress" / "**").string(); int stress_iters = 100; if (const char* s = std::getenv("YAMS_STRESS_ITERS")) { int v = std::atoi(s); if (v > 0 && v < 100000) stress_iters = v; } int ok = 0; for (int i = 0; i < stress_iters; ++i) { auto lr = rsvc.list(lq, ro); if (lr) ++ok; std::this_thread::sleep_for(5ms); } CHECK((ok > 0)); }