#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace yams; using namespace yams::metadata; namespace { std::filesystem::path make_temp_dir(std::string_view prefix = "yams_version_test_") { namespace fs = std::filesystem; const auto base = fs::temp_directory_path(); std::uniform_int_distribution dist(0, 9999); thread_local std::mt19937_64 rng{std::random_device{}()}; for (int attempt = 0; attempt < 512; ++attempt) { const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); auto candidate = base / (std::string(prefix) + std::to_string(stamp) + "_" + std::to_string(dist(rng))); std::error_code ec; if (fs::create_directories(candidate, ec)) { return candidate; } } return base; } DocumentInfo makeDocInfo(const std::string& filePath, const std::string& hash, const std::string& fileName) { DocumentInfo info; info.filePath = filePath; info.fileName = fileName; info.fileExtension = ".txt"; info.fileSize = 100; info.sha256Hash = hash; info.mimeType = "text/plain"; auto now = std::chrono::floor(std::chrono::system_clock::now()); info.createdTime = now; info.modifiedTime = now; info.indexedTime = now; info.contentExtracted = true; info.extractionStatus = ExtractionStatus::Success; auto derived = computePathDerivedValues(filePath); info.filePath = derived.normalizedPath; info.pathPrefix = derived.pathPrefix; info.reversePath = derived.reversePath; info.pathHash = derived.pathHash; info.parentHash = derived.parentHash; info.pathDepth = derived.pathDepth; return info; } class BatchFailingMetadataRepository : public MetadataRepository { public: explicit BatchFailingMetadataRepository(ConnectionPool& pool) : MetadataRepository(pool) {} Result setMetadataBatch( const std::vector>& entries) override { ++batchAttempts; lastBatchSize = entries.size(); if (failNextBatch) { failNextBatch = false; return Error{ErrorCode::DatabaseError, "injected metadata batch failure"}; } return MetadataRepository::setMetadataBatch(entries); } bool failNextBatch{true}; std::size_t batchAttempts{0}; std::size_t lastBatchSize{0}; }; struct VersioningFixture { VersioningFixture() { testDir = make_temp_dir(); } ~VersioningFixture() { repo_.reset(); pool_.reset(); std::error_code ec; std::filesystem::remove_all(testDir, ec); } std::shared_ptr makeRepo() { auto dbPath = (testDir / "meta.db").string(); pool_ = std::make_shared(dbPath); REQUIRE(pool_->initialize()); repo_ = std::make_shared(*pool_); return repo_; } std::shared_ptr makeBatchFailingRepo() { auto dbPath = (testDir / "meta.db").string(); pool_ = std::make_shared(dbPath); REQUIRE(pool_->initialize()); auto repo = std::make_shared(*pool_); repo_ = repo; return repo; } std::filesystem::path testDir; std::shared_ptr pool_; std::shared_ptr repo_; }; } // namespace TEST_CASE_METHOD(VersioningFixture, "Versioning: first document gets version 1 and is_latest", "[versioning][metadata][catch2]") { #ifdef _WIN32 _putenv_s("YAMS_ENABLE_VERSIONING", "1"); #else setenv("YAMS_ENABLE_VERSIONING", "1", 1); #endif auto repo = makeRepo(); auto docInfo = makeDocInfo("/path/doc.txt", "hash_v1", "doc.txt"); auto ins = repo->insertDocument(docInfo); REQUIRE(ins); auto docId = ins.value(); int64_t version = applyPathSeriesVersioning(*repo, docInfo.filePath, docId, std::nullopt); CHECK((version == 1)); auto verMeta = repo->getMetadata(docId, "version"); REQUIRE(verMeta); REQUIRE(verMeta.value().has_value()); CHECK((verMeta.value()->asInteger() == 1)); auto latestMeta = repo->getMetadata(docId, "is_latest"); REQUIRE(latestMeta); REQUIRE(latestMeta.value().has_value()); CHECK((latestMeta.value()->asBoolean() == true)); auto seriesMeta = repo->getMetadata(docId, "series_key"); REQUIRE(seriesMeta); REQUIRE(seriesMeta.value().has_value()); CHECK((seriesMeta.value()->asString() == docInfo.filePath)); } TEST_CASE_METHOD(VersioningFixture, "Versioning: re-index with different hash creates version edge", "[versioning][metadata][catch2]") { #ifdef _WIN32 _putenv_s("YAMS_ENABLE_VERSIONING", "1"); #else setenv("YAMS_ENABLE_VERSIONING", "1", 1); #endif auto repo = makeRepo(); const std::string path = "/path/evolving.txt"; auto v1 = makeDocInfo(path, "hash_v1", "evolving.txt"); auto ins1 = repo->insertDocument(v1); REQUIRE(ins1); auto id1 = ins1.value(); int64_t ver1 = applyPathSeriesVersioning(*repo, path, id1, std::nullopt); CHECK((ver1 == 1)); auto v2 = makeDocInfo(path, "hash_v2", "evolving.txt"); auto ins2 = repo->insertDocument(v2); REQUIRE(ins2); auto id2 = ins2.value(); REQUIRE((id1 != id2)); auto priorDocResult = repo->findDocumentByExactPath(path); REQUIRE(priorDocResult); auto priorDoc = priorDocResult.value(); REQUIRE(priorDoc.has_value()); int64_t ver2 = applyPathSeriesVersioning(*repo, path, id2, priorDoc); CHECK((ver2 == 2)); // v1 is no longer latest auto latest1 = repo->getMetadata(id1, "is_latest"); REQUIRE(latest1); REQUIRE(latest1.value().has_value()); CHECK((latest1.value()->asBoolean() == false)); // v2 is latest auto latest2 = repo->getMetadata(id2, "is_latest"); REQUIRE(latest2); REQUIRE(latest2.value().has_value()); CHECK((latest2.value()->asBoolean() == true)); auto verMeta2 = repo->getMetadata(id2, "version"); REQUIRE(verMeta2); REQUIRE(verMeta2.value().has_value()); CHECK((verMeta2.value()->asInteger() == 2)); // VersionOf relationship exists auto relResult = repo->getRelationships(id2); REQUIRE(relResult); bool foundVersionOf = false; for (const auto& rel : relResult.value()) { if (rel.relationshipType == RelationshipType::VersionOf && rel.parentId == id1) { foundVersionOf = true; break; } } CHECK(foundVersionOf); } TEST_CASE_METHOD(VersioningFixture, "Versioning: same hash re-index does not create version edge", "[versioning][metadata][catch2]") { #ifdef _WIN32 _putenv_s("YAMS_ENABLE_VERSIONING", "1"); #else setenv("YAMS_ENABLE_VERSIONING", "1", 1); #endif auto repo = makeRepo(); const std::string path = "/path/identical.txt"; auto v1 = makeDocInfo(path, "hash_same", "identical.txt"); auto ins1 = repo->insertDocument(v1); REQUIRE(ins1); auto id1 = ins1.value(); applyPathSeriesVersioning(*repo, path, id1, std::nullopt); // When caller detects same hash, versioning is not called (handled by caller). // This test verifies the guard logic: if we accidentally called it, it still // wouldn't create a second version because the path lookup finds the same doc. auto priorDoc = repo->findDocumentByExactPath(path); REQUIRE(priorDoc); REQUIRE(priorDoc.value().has_value()); CHECK((priorDoc.value()->sha256Hash == "hash_same")); } TEST_CASE_METHOD(VersioningFixture, "Versioning: metadata batch failure falls back to best-effort individual writes", "[versioning][metadata][catch2]") { #ifdef _WIN32 _putenv_s("YAMS_ENABLE_VERSIONING", "1"); #else setenv("YAMS_ENABLE_VERSIONING", "1", 1); #endif auto repo = makeBatchFailingRepo(); const std::string path = "/path/fallback.txt"; auto v1 = makeDocInfo(path, "hash_v1_fallback", "fallback.txt"); auto ins1 = repo->insertDocument(v1); REQUIRE(ins1); const auto id1 = ins1.value(); REQUIRE((applyPathSeriesVersioning(*repo, path, id1, std::nullopt) == 1)); REQUIRE((repo->batchAttempts == 1)); REQUIRE((repo->lastBatchSize == 3)); repo->failNextBatch = true; auto v2 = makeDocInfo(path, "hash_v2_fallback", "fallback.txt"); auto ins2 = repo->insertDocument(v2); REQUIRE(ins2); const auto id2 = ins2.value(); auto priorDoc = repo->findDocumentByExactPath(path); REQUIRE(priorDoc); REQUIRE(priorDoc.value().has_value()); const auto version = applyPathSeriesVersioning(*repo, path, id2, priorDoc.value()); CHECK((version == 2)); CHECK((repo->batchAttempts == 2)); CHECK((repo->lastBatchSize == 4)); auto latest1 = repo->getMetadata(id1, "is_latest"); REQUIRE(latest1); REQUIRE(latest1.value().has_value()); CHECK((latest1.value()->asBoolean() == false)); auto version2 = repo->getMetadata(id2, "version"); REQUIRE(version2); REQUIRE(version2.value().has_value()); CHECK((version2.value()->asInteger() == 2)); auto latest2 = repo->getMetadata(id2, "is_latest"); REQUIRE(latest2); REQUIRE(latest2.value().has_value()); CHECK((latest2.value()->asBoolean() == true)); auto series2 = repo->getMetadata(id2, "series_key"); REQUIRE(series2); REQUIRE(series2.value().has_value()); CHECK((series2.value()->asString() == path)); auto relResult = repo->getRelationships(id2); REQUIRE(relResult); bool foundVersionOf = false; for (const auto& rel : relResult.value()) { if (rel.relationshipType == RelationshipType::VersionOf && rel.parentId == id1) { foundVersionOf = true; break; } } CHECK(foundVersionOf); } TEST_CASE_METHOD(VersioningFixture, "Versioning respects YAMS_ENABLE_VERSIONING=0", "[versioning][metadata][catch2]") { #ifdef _WIN32 _putenv_s("YAMS_ENABLE_VERSIONING", "0"); #else setenv("YAMS_ENABLE_VERSIONING", "0", 1); #endif auto repo = makeRepo(); auto docInfo = makeDocInfo("/path/disabled.txt", "hash_disabled", "disabled.txt"); auto ins = repo->insertDocument(docInfo); REQUIRE(ins); auto docId = ins.value(); int64_t version = applyPathSeriesVersioning(*repo, docInfo.filePath, docId, std::nullopt); CHECK((version == 0)); auto verMeta = repo->getMetadata(docId, "version"); REQUIRE(verMeta); CHECK(!verMeta.value().has_value()); } TEST_CASE_METHOD(VersioningFixture, "Retention selects only superseded versions", "[versioning][metadata][catch2]") { auto repo = makeRepo(); const std::string path = "/path/retained.txt"; std::vector ids; for (int version = 1; version <= 3; ++version) { auto doc = makeDocInfo(path, "retention_hash_" + std::to_string(version), "retained.txt"); auto inserted = repo->insertDocument(doc); REQUIRE(inserted); ids.push_back(inserted.value()); REQUIRE(repo->setMetadata(ids.back(), "series_key", MetadataValue(path))); REQUIRE( repo->setMetadata(ids.back(), "version", MetadataValue(static_cast(version)))); REQUIRE(repo->setMetadata(ids.back(), "is_latest", MetadataValue(version == 3))); } auto candidates = selectVersionRetentionCandidates(*repo, {.keepLatest = 2}); REQUIRE(candidates); REQUIRE(candidates.value().size() == 1); CHECK(candidates.value().front().document.id == ids.front()); CHECK(candidates.value().front().version == 1); } TEST_CASE_METHOD(VersioningFixture, "Retention never selects the marked latest version", "[versioning][metadata][catch2]") { auto repo = makeRepo(); const std::string path = "/path/latest-protected.txt"; for (int version = 1; version <= 2; ++version) { auto doc = makeDocInfo(path, "protected_hash_" + std::to_string(version), "latest-protected.txt"); auto inserted = repo->insertDocument(doc); REQUIRE(inserted); REQUIRE(repo->setMetadata(inserted.value(), "series_key", MetadataValue(path))); REQUIRE(repo->setMetadata(inserted.value(), "version", MetadataValue(static_cast(version)))); // A stale version number must not cause a document explicitly marked // latest to be pruned. REQUIRE(repo->setMetadata(inserted.value(), "is_latest", MetadataValue(version == 1))); } auto candidates = selectVersionRetentionCandidates(*repo, {.keepLatest = 1}); REQUIRE(candidates); REQUIRE(candidates.value().size() == 1); CHECK(candidates.value().front().version == 2); } TEST_CASE_METHOD(VersioningFixture, "Prune candidates include stored native build artifacts", "[prune][metadata][catch2]") { auto repo = makeRepo(); const auto insert = [&](const std::string& path, const std::string& hash) { auto result = repo->insertDocument( makeDocInfo(path, hash, std::filesystem::path(path).filename().string())); REQUIRE(result); }; insert("/workspace/x64/Release/tool.ilk", "build-ilk"); insert("/workspace/build/Debug/tool.dSYM/Contents/Resources/DWARF/tool", "build-dsym"); insert("/workspace/src/tool.cpp", "source-cpp"); integrity::PruneConfig config; config.categories = {"build-artifacts"}; const auto candidates = integrity::RepairManager::queryCandidatesForPrune(*repo, config); REQUIRE(candidates.size() == 2); CHECK(std::ranges::any_of( candidates, [](const auto& candidate) { return candidate.path.ends_with("tool.ilk"); })); CHECK(std::ranges::any_of( candidates, [](const auto& candidate) { return candidate.path.ends_with("/tool"); })); } TEST_CASE_METHOD(VersioningFixture, "Prune candidates match dotted metadata extensions", "[prune][metadata][catch2]") { auto repo = makeRepo(); auto log = makeDocInfo("/workspace/logs/daemon.log", "dotted-log", "daemon.log"); log.fileExtension = ".log"; REQUIRE(repo->insertDocument(log)); integrity::PruneConfig config; config.categories = {"all"}; const auto candidates = integrity::RepairManager::queryCandidatesForPrune(*repo, config); REQUIRE(candidates.size() == 1); CHECK(candidates.front().category == "logs"); }