yams/tests/unit/metadata/topology_sync_adapter_catch2_test.cpp
2026-08-19 01:54:59 -06:00

268 lines
11 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2026 YAMS Contributors
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <memory>
#include <string>
#include <yams/memory_sync/memory_sync_service.h>
#include <yams/metadata/knowledge_graph_store.h>
#include <yams/metadata/topology_sync_adapter.h>
#include <yams/storage/storage_backend.h>
using namespace yams::metadata;
using namespace yams::memory_sync;
namespace {
std::vector<std::byte> bytes(std::string_view text) {
std::vector<std::byte> result(text.size());
std::memcpy(result.data(), text.data(), text.size());
return result;
}
std::filesystem::path tempDbPath(const char* prefix) {
auto path =
std::filesystem::temp_directory_path() /
(std::string(prefix) +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".db");
std::error_code ec;
std::filesystem::remove(path, ec);
return path;
}
struct KGFixture {
explicit KGFixture(const char* prefix) : dbPath(tempDbPath(prefix)) {
auto result = makeSqliteKnowledgeGraphStore(dbPath.string());
REQUIRE(result.has_value());
store = std::move(result.value());
}
~KGFixture() {
store.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
std::filesystem::remove(dbPath.string() + "-wal", ec);
std::filesystem::remove(dbPath.string() + "-shm", ec);
}
std::filesystem::path dbPath;
std::unique_ptr<KnowledgeGraphStore> store;
};
std::unique_ptr<yams::storage::FilesystemBackend> makeBackend(const std::filesystem::path& path) {
yams::storage::BackendConfig config;
config.type = "filesystem";
config.localPath = path;
auto backend = std::make_unique<yams::storage::FilesystemBackend>();
REQUIRE(backend->initialize(config).has_value());
return backend;
}
struct TempDirGuard {
TempDirGuard() {
path = std::filesystem::temp_directory_path() /
("yams-topology-sync-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
std::filesystem::create_directories(path);
}
~TempDirGuard() {
std::error_code ec;
std::filesystem::remove_all(path, ec);
}
std::filesystem::path path;
};
} // namespace
TEST_CASE("topology sync adapter rejects payload identity mismatches before mutation",
"[metadata][topology][memory-sync][identity]") {
TempDirGuard temp;
KGFixture graph("topology_sync_mismatch_");
MemorySyncService writer{makeBackend(temp.path / "sync"), MemorySyncConfig{"A", 50}};
MemorySyncService reader{makeBackend(temp.path / "sync"), MemorySyncConfig{"B", 50}};
TopologySyncAdapter adapter{*graph.store, reader};
SECTION("node value") {
TopologyNodeRecord record;
record.nodeKey = "node:B";
REQUIRE(writer.publish("topology-node/node%3AA", bytes(nlohmann::json(record).dump()))
.has_value());
const auto applied = adapter.apply();
REQUIRE_FALSE(applied.has_value());
CHECK(applied.error().code == yams::ErrorCode::InvalidData);
const auto unexpected = graph.store->getNodeByKey(record.nodeKey);
REQUIRE(unexpected.has_value());
CHECK_FALSE(unexpected.value().has_value());
}
SECTION("node tombstone") {
const KGNode retained{.nodeKey = "node:B", .label = "B", .type = "test"};
REQUIRE(graph.store->upsertNode(retained).has_value());
REQUIRE(writer.erase("topology-node/node%3AA", retained.nodeKey).has_value());
const auto applied = adapter.apply();
REQUIRE_FALSE(applied.has_value());
CHECK(applied.error().code == yams::ErrorCode::InvalidData);
REQUIRE(graph.store->getNodeByKey(retained.nodeKey).value().has_value());
}
SECTION("edge value") {
TopologyEdgeRecord record;
record.sourceNodeKey = "node:B";
record.relation = "CALLS";
record.targetNodeKey = "node:C";
REQUIRE(writer
.publish("topology-edge/node%3AA/CALLS/node%3AC",
bytes(nlohmann::json(record).dump()))
.has_value());
const auto applied = adapter.apply();
REQUIRE_FALSE(applied.has_value());
CHECK(applied.error().code == yams::ErrorCode::InvalidData);
}
SECTION("edge tombstone") {
TopologyEdgeRecord record;
record.sourceNodeKey = "node:B";
record.relation = "CALLS";
record.targetNodeKey = "node:C";
REQUIRE(writer.erase("topology-edge/node%3AA/CALLS/node%3AC", nlohmann::json(record).dump())
.has_value());
const auto applied = adapter.apply();
REQUIRE_FALSE(applied.has_value());
CHECK(applied.error().code == yams::ErrorCode::InvalidData);
}
}
TEST_CASE("topology sync adapter makes CALLS and DEFINED_IN edges traversable on B",
"[metadata][topology][memory-sync]") {
TempDirGuard temp;
KGFixture graphA("topology_sync_a_");
KGFixture graphB("topology_sync_b_");
MemorySyncService syncA{makeBackend(temp.path / "sync"), MemorySyncConfig{"A", 50}};
MemorySyncService syncB{makeBackend(temp.path / "sync"), MemorySyncConfig{"B", 50}};
TopologySyncAdapter adapterA{*graphA.store, syncA};
TopologySyncAdapter adapterB{*graphB.store, syncB};
const KGNode caller{.nodeKey = "function:caller@src/main.cpp",
.label = "caller",
.type = "function",
.properties = R"({"start_line":10})"};
const KGNode callee{
.nodeKey = "function:callee@src/lib.cpp", .label = "callee", .type = "function"};
const KGNode file{.nodeKey = "file:src/main.cpp", .label = "src/main.cpp", .type = "file"};
const auto callerId = graphA.store->upsertNode(caller);
const auto calleeId = graphA.store->upsertNode(callee);
const auto fileId = graphA.store->upsertNode(file);
REQUIRE(callerId.has_value());
REQUIRE(calleeId.has_value());
REQUIRE(fileId.has_value());
const KGEdge calls{.srcNodeId = callerId.value(),
.dstNodeId = calleeId.value(),
.relation = "CALLS",
.weight = 0.9F,
.properties = R"({"source_file":"src/main.cpp"})"};
const KGEdge definedIn{.srcNodeId = callerId.value(),
.dstNodeId = fileId.value(),
.relation = "DEFINED_IN",
.weight = 1.0F};
REQUIRE(graphA.store->addEdgesUnique({calls, definedIn}).has_value());
REQUIRE(adapterA.publishNode(caller).has_value());
REQUIRE(adapterA.publishNode(callee).has_value());
REQUIRE(adapterA.publishNode(file).has_value());
REQUIRE(adapterA.publishEdge(caller.nodeKey, calls, callee.nodeKey).has_value());
REQUIRE(adapterA.publishEdge(caller.nodeKey, definedIn, file.nodeKey).has_value());
auto applied = adapterB.apply();
REQUIRE(applied.has_value());
CHECK(applied.value().nodesApplied == 3);
CHECK(applied.value().edgesApplied == 2);
const auto callerB = graphB.store->getNodeByKey(caller.nodeKey);
const auto calleeB = graphB.store->getNodeByKey(callee.nodeKey);
const auto fileB = graphB.store->getNodeByKey(file.nodeKey);
REQUIRE(callerB.has_value());
REQUIRE(callerB.value().has_value());
REQUIRE(calleeB.has_value());
REQUIRE(calleeB.value().has_value());
REQUIRE(fileB.has_value());
REQUIRE(fileB.value().has_value());
CHECK(callerB.value()->properties == caller.properties);
// A selected node winner clears optional properties rather than retaining stale local state.
auto clearedCaller = caller;
clearedCaller.properties.reset();
REQUIRE(adapterA.publishNode(clearedCaller).has_value());
const auto nodeReplaced = adapterB.apply();
REQUIRE(nodeReplaced.has_value());
CHECK(nodeReplaced.value().nodesApplied == 1);
const auto callerWithoutProperties = graphB.store->getNodeByKey(caller.nodeKey);
REQUIRE(callerWithoutProperties.has_value());
REQUIRE(callerWithoutProperties.value().has_value());
CHECK_FALSE(callerWithoutProperties.value()->properties.has_value());
const auto nodeUnchanged = adapterB.apply();
REQUIRE(nodeUnchanged.has_value());
CHECK(nodeUnchanged.value().nodesApplied == 0);
const auto callsB = graphB.store->getEdgesFrom(callerB.value()->id, "CALLS");
REQUIRE(callsB.has_value());
REQUIRE(callsB.value().size() == 1);
CHECK(callsB.value().front().dstNodeId == calleeB.value()->id);
CHECK(callsB.value().front().properties == calls.properties);
const auto definedInB = graphB.store->getEdgesFrom(callerB.value()->id, "DEFINED_IN");
REQUIRE(definedInB.has_value());
REQUIRE(definedInB.value().size() == 1);
CHECK(definedInB.value().front().dstNodeId == fileB.value()->id);
// A later winner replaces the exact edge, including lower weights and removed properties.
auto loweredCalls = calls;
loweredCalls.weight = 0.2F;
loweredCalls.properties.reset();
REQUIRE(adapterA.publishEdge(caller.nodeKey, loweredCalls, callee.nodeKey).has_value());
const auto replaced = adapterB.apply();
REQUIRE(replaced.has_value());
CHECK(replaced.value().edgesApplied == 1);
const auto replacedCalls = graphB.store->getEdgesFrom(callerB.value()->id, "CALLS");
REQUIRE(replacedCalls.has_value());
REQUIRE(replacedCalls.value().size() == 1);
CHECK(replacedCalls.value().front().weight == 0.2F);
CHECK_FALSE(replacedCalls.value().front().properties.has_value());
const auto unchanged = adapterB.apply();
REQUIRE(unchanged.has_value());
CHECK(unchanged.value().edgesApplied == 0);
const auto repeatedCalls = graphB.store->getEdgesFrom(callerB.value()->id, "CALLS");
REQUIRE(repeatedCalls.has_value());
CHECK(repeatedCalls.value().size() == 1);
REQUIRE(adapterA.publishDeleteEdge(caller.nodeKey, "CALLS", callee.nodeKey).has_value());
REQUIRE(adapterA.publishDeleteNode(callee.nodeKey).has_value());
const auto deleted = adapterB.apply();
REQUIRE(deleted.has_value());
CHECK(deleted.value().edgesDeleted == 1);
CHECK(deleted.value().nodesDeleted == 1);
const auto deletedNode = graphB.store->getNodeByKey(callee.nodeKey);
REQUIRE(deletedNode.has_value());
CHECK_FALSE(deletedNode.value().has_value());
const auto callsAfterDelete = graphB.store->getEdgesFrom(callerB.value()->id, "CALLS");
REQUIRE(callsAfterDelete.has_value());
CHECK(callsAfterDelete.value().empty());
}
TEST_CASE("topology sync adapter rejects incomplete stable identities",
"[metadata][topology][memory-sync]") {
TempDirGuard temp;
KGFixture graph("topology_sync_identity_");
MemorySyncService sync{makeBackend(temp.path / "sync"), MemorySyncConfig{"A", 50}};
TopologySyncAdapter adapter{*graph.store, sync};
CHECK_FALSE(adapter.publishNode(KGNode{}).has_value());
const KGEdge edge{.relation = "CALLS"};
CHECK_FALSE(adapter.publishEdge("", edge, "target").has_value());
}