// SPDX-License-Identifier: GPL-3.0-or-later // Copyright 2025 YAMS Contributors // Unit tests for path_tree removePathTreeForDocument functionality #include #include #include #include #include #include #include #include #include using yams::Result; using namespace yams::metadata; namespace { std::filesystem::path tempTestDir(const char* prefix) { const char* t = std::getenv("YAMS_TEST_TMPDIR"); auto base = (t && *t) ? std::filesystem::path(t) : std::filesystem::temp_directory_path(); std::error_code ec; std::filesystem::create_directories(base, ec); auto ts = std::chrono::steady_clock::now().time_since_epoch().count(); return base / (std::string(prefix) + std::to_string(ts)); } struct PathTreeDeleteFixture { PathTreeDeleteFixture() { testDir_ = tempTestDir("yams_path_tree_delete_catch2_test_"); std::filesystem::create_directories(testDir_); dbPath_ = testDir_ / "test.db"; ConnectionPoolConfig poolCfg; poolCfg.minConnections = 1; poolCfg.maxConnections = 2; pool_ = std::make_unique(dbPath_.string(), poolCfg); auto initRes = pool_->initialize(); REQUIRE((initRes.has_value())); repository_ = std::make_shared(*pool_); // Run migrations auto connRes = pool_->acquire(); REQUIRE((connRes.has_value())); auto& db = **connRes.value(); yams::metadata::MigrationManager mgr(db); mgr.registerMigrations(yams::metadata::YamsMetadataMigrations::getAllMigrations()); auto initRes2 = mgr.initialize(); REQUIRE((initRes2.has_value())); auto migrate = mgr.migrate(); REQUIRE((migrate.has_value())); } ~PathTreeDeleteFixture() { repository_.reset(); pool_.reset(); std::error_code ec; std::filesystem::remove_all(testDir_, ec); } DocumentInfo makeDocumentWithPath(const std::string& path, const std::string& hash) { DocumentInfo doc; doc.sha256Hash = hash; doc.filePath = path; doc.mimeType = "text/plain"; doc.fileExtension = ".txt"; doc.fileSize = 100; auto now = std::chrono::time_point_cast(std::chrono::system_clock::now()); doc.indexedTime = now; doc.modifiedTime = now; doc.createdTime = now; return doc; } Result installDocCountAbortTrigger(int64_t nodeId, const std::string& triggerName) { return pool_->withConnection([&](Database& db) -> Result { return db.execute("CREATE TRIGGER " + triggerName + " BEFORE UPDATE OF doc_count ON path_tree_nodes " "WHEN NEW.node_id = " + std::to_string(nodeId) + " AND NEW.doc_count = OLD.doc_count - 1 " "BEGIN " "SELECT RAISE(ABORT, 'injected path tree remove failure'); " "END"); }); } Result countNodeDocumentAssociations(int64_t nodeId, int64_t documentId) { return pool_->withConnection([&](Database& db) -> Result { auto stmtResult = db.prepare("SELECT COUNT(*) FROM path_tree_node_documents " "WHERE node_id = ? AND document_id = ?"); if (!stmtResult) { return stmtResult.error(); } auto stmt = std::move(stmtResult).value(); if (auto bindNode = stmt.bind(1, nodeId); !bindNode) { return bindNode.error(); } if (auto bindDoc = stmt.bind(2, documentId); !bindDoc) { return bindDoc.error(); } auto stepResult = stmt.step(); if (!stepResult) { return stepResult.error(); } if (!stepResult.value()) { return int64_t{0}; } return stmt.getInt64(0); }); } std::filesystem::path testDir_; std::filesystem::path dbPath_; std::unique_ptr pool_; std::shared_ptr repository_; }; } // namespace TEST_CASE("PathTreeDelete: remove document decrements count", "[unit][metadata][path_tree]") { PathTreeDeleteFixture fix; auto docInfo = fix.makeDocumentWithPath( "/src/example.txt", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); auto docInsert = fix.repository_->insertDocument(docInfo); REQUIRE((docInsert.has_value())); auto docId = docInsert.value(); docInfo.id = docId; auto upsert = fix.repository_->upsertPathTreeForDocument(docInfo, docId, true, {}); REQUIRE((upsert.has_value())); auto lookup = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((lookup.has_value())); REQUIRE((lookup.value().has_value())); CHECK((lookup.value()->docCount == 1)); auto remove = fix.repository_->removePathTreeForDocument(docInfo, docId, {}); REQUIRE((remove.has_value())); auto afterRemove = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((afterRemove.has_value())); REQUIRE((afterRemove.value().has_value())); CHECK((afterRemove.value()->docCount == 0)); } TEST_CASE("PathTreeDelete: remove document deletes empty nodes", "[unit][metadata][path_tree]") { PathTreeDeleteFixture fix; auto docInfo = fix.makeDocumentWithPath( "/src/lib/example.txt", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); auto docInsert = fix.repository_->insertDocument(docInfo); REQUIRE((docInsert.has_value())); auto docId = docInsert.value(); docInfo.id = docId; auto upsert = fix.repository_->upsertPathTreeForDocument(docInfo, docId, true, {}); REQUIRE((upsert.has_value())); auto srcLookup = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((srcLookup.has_value())); REQUIRE((srcLookup.value().has_value())); auto libLookup = fix.repository_->findPathTreeNode(srcLookup.value()->id, "lib"); REQUIRE((libLookup.has_value())); REQUIRE((libLookup.value().has_value())); auto remove = fix.repository_->removePathTreeForDocument(docInfo, docId, {}); REQUIRE((remove.has_value())); auto leafLookup = fix.repository_->findPathTreeNodeByFullPath("/src/lib/example.txt"); REQUIRE((leafLookup.has_value())); CHECK_FALSE(leafLookup.value().has_value()); auto libAfter = fix.repository_->findPathTreeNode(srcLookup.value()->id, "lib"); REQUIRE((libAfter.has_value())); CHECK_FALSE(libAfter.value().has_value()); } TEST_CASE("PathTreeDelete: remove document recalculates centroid", "[unit][metadata][path_tree]") { PathTreeDeleteFixture fix; auto doc1 = fix.makeDocumentWithPath( "/src/file1.txt", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"); auto doc1Insert = fix.repository_->insertDocument(doc1); REQUIRE((doc1Insert.has_value())); doc1.id = doc1Insert.value(); auto doc2 = fix.makeDocumentWithPath( "/src/file2.txt", "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"); auto doc2Insert = fix.repository_->insertDocument(doc2); REQUIRE((doc2Insert.has_value())); doc2.id = doc2Insert.value(); std::vector embedding1{1.0F, 2.0F, 3.0F}; std::vector embedding2{5.0F, 6.0F, 7.0F}; auto upsert1 = fix.repository_->upsertPathTreeForDocument( doc1, doc1.id, true, std::span(embedding1.data(), embedding1.size())); REQUIRE((upsert1.has_value())); auto upsert2 = fix.repository_->upsertPathTreeForDocument( doc2, doc2.id, true, std::span(embedding2.data(), embedding2.size())); REQUIRE((upsert2.has_value())); auto srcNode = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((srcNode.has_value())); REQUIRE((srcNode.value().has_value())); CHECK((srcNode.value()->centroidWeight == 2)); REQUIRE((srcNode.value()->centroid.size() == 3)); // Expected centroid: [(1+5)/2, (2+6)/2, (3+7)/2] = [3.0, 4.0, 5.0] CHECK((srcNode.value()->centroid[0] == Catch::Approx(3.0F).epsilon(0.01))); CHECK((srcNode.value()->centroid[1] == Catch::Approx(4.0F).epsilon(0.01))); CHECK((srcNode.value()->centroid[2] == Catch::Approx(5.0F).epsilon(0.01))); auto remove = fix.repository_->removePathTreeForDocument( doc1, doc1.id, std::span(embedding1.data(), embedding1.size())); REQUIRE((remove.has_value())); auto srcAfter = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((srcAfter.has_value())); REQUIRE((srcAfter.value().has_value())); CHECK((srcAfter.value()->centroidWeight == 1)); REQUIRE((srcAfter.value()->centroid.size() == 3)); CHECK((srcAfter.value()->centroid[0] == Catch::Approx(5.0F).epsilon(0.01))); CHECK((srcAfter.value()->centroid[1] == Catch::Approx(6.0F).epsilon(0.01))); CHECK((srcAfter.value()->centroid[2] == Catch::Approx(7.0F).epsilon(0.01))); } TEST_CASE("PathTreeDelete: remove nonexistent document succeeds", "[unit][metadata][path_tree]") { PathTreeDeleteFixture fix; auto docInfo = fix.makeDocumentWithPath( "/nonexistent/file.txt", "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"); auto docInsert = fix.repository_->insertDocument(docInfo); REQUIRE((docInsert.has_value())); docInfo.id = docInsert.value(); auto remove = fix.repository_->removePathTreeForDocument(docInfo, docInfo.id, {}); CHECK((remove.has_value())); } TEST_CASE("PathTreeDelete: remove document keeps shared ancestors", "[unit][metadata][path_tree]") { PathTreeDeleteFixture fix; auto doc1 = fix.makeDocumentWithPath( "/src/dir1/file1.txt", "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); auto doc1Insert = fix.repository_->insertDocument(doc1); REQUIRE((doc1Insert.has_value())); doc1.id = doc1Insert.value(); auto doc2 = fix.makeDocumentWithPath( "/src/dir2/file2.txt", "1111111111111111111111111111111111111111111111111111111111111111"); auto doc2Insert = fix.repository_->insertDocument(doc2); REQUIRE((doc2Insert.has_value())); doc2.id = doc2Insert.value(); auto upsert1 = fix.repository_->upsertPathTreeForDocument(doc1, doc1.id, true, {}); REQUIRE((upsert1.has_value())); auto upsert2 = fix.repository_->upsertPathTreeForDocument(doc2, doc2.id, true, {}); REQUIRE((upsert2.has_value())); auto srcBefore = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((srcBefore.has_value())); REQUIRE((srcBefore.value().has_value())); CHECK((srcBefore.value()->docCount == 2)); auto remove = fix.repository_->removePathTreeForDocument(doc1, doc1.id, {}); REQUIRE((remove.has_value())); auto srcAfter = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src"); REQUIRE((srcAfter.has_value())); REQUIRE((srcAfter.value().has_value())); CHECK((srcAfter.value()->docCount == 1)); auto dir1After = fix.repository_->findPathTreeNodeByFullPath("/src/dir1"); REQUIRE((dir1After.has_value())); CHECK_FALSE(dir1After.value().has_value()); auto dir2After = fix.repository_->findPathTreeNodeByFullPath("/src/dir2"); REQUIRE((dir2After.has_value())); CHECK((dir2After.value().has_value())); } TEST_CASE("PathTreeDelete: remove document rolls back partial changes on failure", "[unit][metadata][path_tree][atomicity]") { PathTreeDeleteFixture fix; auto doc = fix.makeDocumentWithPath( "/atomic/lib/file.txt", "9999999999999999999999999999999999999999999999999999999999999999"); auto docInsert = fix.repository_->insertDocument(doc); REQUIRE((docInsert.has_value())); doc.id = docInsert.value(); auto upsert = fix.repository_->upsertPathTreeForDocument(doc, doc.id, true, {}); REQUIRE((upsert.has_value())); auto leafNode = fix.repository_->findPathTreeNodeByFullPath("/atomic/lib/file.txt"); REQUIRE((leafNode.has_value())); REQUIRE((leafNode.value().has_value())); auto assocBefore = fix.countNodeDocumentAssociations(leafNode.value()->id, doc.id); REQUIRE((assocBefore.has_value())); CHECK((assocBefore.value() == 1)); REQUIRE(fix.installDocCountAbortTrigger(leafNode.value()->id, "abort_path_tree_remove_leaf_decrement") .has_value()); auto remove = fix.repository_->removePathTreeForDocument(doc, doc.id, {}); REQUIRE_FALSE(remove.has_value()); CHECK((remove.error().message.find("constraint failed") != std::string::npos)); auto leafAfter = fix.repository_->findPathTreeNodeByFullPath("/atomic/lib/file.txt"); REQUIRE((leafAfter.has_value())); REQUIRE((leafAfter.value().has_value())); CHECK((leafAfter.value()->docCount == 1)); auto libAfter = fix.repository_->findPathTreeNodeByFullPath("/atomic/lib"); REQUIRE((libAfter.has_value())); REQUIRE((libAfter.value().has_value())); CHECK((libAfter.value()->docCount == 1)); auto assocAfter = fix.countNodeDocumentAssociations(leafNode.value()->id, doc.id); REQUIRE((assocAfter.has_value())); CHECK((assocAfter.value() == 1)); }