using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.CBDDC.Core; using ZB.MOM.WW.CBDDC.Core.Network; using ZB.MOM.WW.CBDDC.Core.Storage; using ZB.MOM.WW.CBDDC.Core.Sync; using ZB.MOM.WW.CBDDC.Persistence; using ZB.MOM.WW.CBDDC.Persistence.Surreal; namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests; public class SurrealOplogStoreContractTests { [Fact] public async Task OplogStore_AppendQueryMergeDrop_AndLastHash_Works() { await using var harness = new SurrealTestHarness(); var store = harness.CreateOplogStore(); var entry1 = CreateOplogEntry("Users", "u1", "node-a", 100, 0, ""); var entry2 = CreateOplogEntry("Users", "u2", "node-a", 110, 0, entry1.Hash); var entry3 = CreateOplogEntry("Users", "u3", "node-a", 120, 1, entry2.Hash); var otherNode = CreateOplogEntry("Users", "u4", "node-b", 115, 0, ""); await store.AppendOplogEntryAsync(entry1); await store.AppendOplogEntryAsync(entry2); await store.AppendOplogEntryAsync(entry3); await store.AppendOplogEntryAsync(otherNode); var chainRange = (await store.GetChainRangeAsync(entry1.Hash, entry3.Hash)).ToList(); chainRange.Select(x => x.Hash).ToList().ShouldBe(new[] { entry2.Hash, entry3.Hash }); var after = (await store.GetOplogAfterAsync(new HlcTimestamp(100, 0, "node-a"))).ToList(); after.Select(x => x.Hash).ToList().ShouldBe(new[] { entry2.Hash, otherNode.Hash, entry3.Hash }); var mergedEntry = CreateOplogEntry("Users", "u5", "node-a", 130, 0, entry3.Hash); await store.MergeAsync(new[] { entry2, mergedEntry }); var exported = (await store.ExportAsync()).ToList(); exported.Count.ShouldBe(5); exported.Count(x => x.Hash == entry2.Hash).ShouldBe(1); var cachedLastNodeAHash = await store.GetLastEntryHashAsync("node-a"); cachedLastNodeAHash.ShouldBe(entry3.Hash); var rehydratedStore = harness.CreateOplogStore(); var persistedLastNodeAHash = await rehydratedStore.GetLastEntryHashAsync("node-a"); persistedLastNodeAHash.ShouldBe(mergedEntry.Hash); await store.DropAsync(); (await store.ExportAsync()).ShouldBeEmpty(); } private static OplogEntry CreateOplogEntry( string collection, string key, string nodeId, long wall, int logic, string previousHash) { return new OplogEntry( collection, key, OperationType.Put, JsonSerializer.SerializeToElement(new { key }), new HlcTimestamp(wall, logic, nodeId), previousHash); } } public class SurrealDocumentMetadataStoreContractTests { [Fact] public async Task DocumentMetadataStore_UpsertMarkDeletedGetAfterAndMergeNewer_Works() { await using var harness = new SurrealTestHarness(); var store = harness.CreateDocumentMetadataStore(); await store.UpsertMetadataAsync(new DocumentMetadata("Users", "doc-1", new HlcTimestamp(100, 0, "node-a"))); await store.UpsertMetadataAsync(new DocumentMetadata("Users", "doc-2", new HlcTimestamp(105, 0, "node-a"))); await store.MarkDeletedAsync("Users", "doc-1", new HlcTimestamp(110, 1, "node-a")); var doc1 = await store.GetMetadataAsync("Users", "doc-1"); doc1.ShouldNotBeNull(); doc1.IsDeleted.ShouldBeTrue(); doc1.UpdatedAt.ShouldBe(new HlcTimestamp(110, 1, "node-a")); var after = (await store.GetMetadataAfterAsync(new HlcTimestamp(100, 0, "node-a"), new[] { "Users" })).ToList(); after.Select(x => x.Key).ToList().ShouldBe(new[] { "doc-2", "doc-1" }); await store.MergeAsync(new[] { new DocumentMetadata("Users", "doc-1", new HlcTimestamp(109, 0, "node-a"), true), new DocumentMetadata("Users", "doc-1", new HlcTimestamp(120, 0, "node-a"), false), new DocumentMetadata("Users", "doc-3", new HlcTimestamp(130, 0, "node-b"), false) }); var mergedDoc1 = await store.GetMetadataAsync("Users", "doc-1"); mergedDoc1.ShouldNotBeNull(); mergedDoc1.UpdatedAt.ShouldBe(new HlcTimestamp(120, 0, "node-a")); mergedDoc1.IsDeleted.ShouldBeFalse(); var exported = (await store.ExportAsync()).ToList(); exported.Count.ShouldBe(3); } } public class SurrealPeerConfigurationStoreContractTests { [Fact] public async Task PeerConfigurationStore_SaveGetRemoveAndMerge_Works() { await using var harness = new SurrealTestHarness(); var store = harness.CreatePeerConfigurationStore(); await store.SaveRemotePeerAsync(CreatePeer("peer-1", "10.0.0.1:5000", true)); var peer1 = await store.GetRemotePeerAsync("peer-1", CancellationToken.None); peer1.ShouldNotBeNull(); peer1.Address.ShouldBe("10.0.0.1:5000"); await store.SaveRemotePeerAsync(CreatePeer("peer-1", "10.0.0.1:6000", false)); await store.MergeAsync(new[] { CreatePeer("peer-1", "10.0.0.1:7000", true), CreatePeer("peer-2", "10.0.0.2:5000", true) }); var afterMergePeer1 = await store.GetRemotePeerAsync("peer-1", CancellationToken.None); var afterMergePeer2 = await store.GetRemotePeerAsync("peer-2", CancellationToken.None); afterMergePeer1.ShouldNotBeNull(); afterMergePeer1.Address.ShouldBe("10.0.0.1:6000"); afterMergePeer1.IsEnabled.ShouldBeFalse(); afterMergePeer2.ShouldNotBeNull(); afterMergePeer2.Address.ShouldBe("10.0.0.2:5000"); await store.RemoveRemotePeerAsync("peer-1"); var removedPeer = await store.GetRemotePeerAsync("peer-1", CancellationToken.None); removedPeer.ShouldBeNull(); var peers = (await store.GetRemotePeersAsync()).ToList(); peers.Count.ShouldBe(1); peers[0].NodeId.ShouldBe("peer-2"); } private static RemotePeerConfiguration CreatePeer(string nodeId, string address, bool enabled) { return new RemotePeerConfiguration { NodeId = nodeId, Address = address, Type = PeerType.StaticRemote, IsEnabled = enabled, InterestingCollections = new List { "Users" } }; } } public class SurrealPeerOplogConfirmationStoreContractTests { [Fact] public async Task PeerOplogConfirmationStore_EnsureUpdateAndDeactivate_Works() { await using var harness = new SurrealTestHarness(); var store = harness.CreatePeerOplogConfirmationStore(); await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote); await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote); await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-1"); await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(90, 0, "source-1"), "hash-old"); await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-2"); var peerConfirmations = (await store.GetConfirmationsForPeerAsync("peer-a")).ToList(); peerConfirmations.Count.ShouldBe(1); peerConfirmations[0].ConfirmedWall.ShouldBe(100); peerConfirmations[0].ConfirmedLogic.ShouldBe(1); peerConfirmations[0].ConfirmedHash.ShouldBe("hash-2"); var all = (await store.ExportAsync()).Where(x => x.PeerNodeId == "peer-a").ToList(); all.Count(x => x.SourceNodeId == "__peer_registration__").ShouldBe(1); await store.RemovePeerTrackingAsync("peer-a"); var activePeers = (await store.GetActiveTrackedPeersAsync()).ToList(); activePeers.ShouldNotContain("peer-a"); var afterDeactivate = (await store.ExportAsync()).Where(x => x.PeerNodeId == "peer-a").ToList(); afterDeactivate.All(x => x.IsActive == false).ShouldBeTrue(); } [Fact] public async Task PeerOplogConfirmationStore_Merge_UsesNewerAndActiveStateSemantics() { await using var harness = new SurrealTestHarness(); var store = harness.CreatePeerOplogConfirmationStore(); await store.EnsurePeerRegisteredAsync("peer-a", "10.0.0.10:5050", PeerType.StaticRemote); await store.UpdateConfirmationAsync("peer-a", "source-1", new HlcTimestamp(100, 1, "source-1"), "hash-1"); var existing = (await store.ExportAsync()) .Single(x => x.PeerNodeId == "peer-a" && x.SourceNodeId == "source-1"); await store.MergeAsync(new[] { new PeerOplogConfirmation { PeerNodeId = "peer-a", SourceNodeId = "source-1", ConfirmedWall = 90, ConfirmedLogic = 0, ConfirmedHash = "hash-old", LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(-5), IsActive = true }, new PeerOplogConfirmation { PeerNodeId = "peer-a", SourceNodeId = "source-1", ConfirmedWall = 130, ConfirmedLogic = 0, ConfirmedHash = "hash-2", LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(5), IsActive = false }, new PeerOplogConfirmation { PeerNodeId = "peer-a", SourceNodeId = "source-2", ConfirmedWall = 50, ConfirmedLogic = 0, ConfirmedHash = "hash-3", LastConfirmedUtc = existing.LastConfirmedUtc.AddMinutes(5), IsActive = true } }); var all = (await store.ExportAsync()) .Where(x => x.PeerNodeId == "peer-a" && x.SourceNodeId != "__peer_registration__") .OrderBy(x => x.SourceNodeId) .ToList(); all.Count.ShouldBe(2); var source1 = all.Single(x => x.SourceNodeId == "source-1"); source1.ConfirmedWall.ShouldBe(130); source1.ConfirmedLogic.ShouldBe(0); source1.ConfirmedHash.ShouldBe("hash-2"); source1.IsActive.ShouldBeFalse(); var source2 = all.Single(x => x.SourceNodeId == "source-2"); source2.ConfirmedWall.ShouldBe(50); source2.ConfirmedHash.ShouldBe("hash-3"); source2.IsActive.ShouldBeTrue(); } } public class SurrealSnapshotMetadataStoreContractTests { [Fact] public async Task SnapshotMetadataStore_InsertUpdateMergeAndHashLookup_Works() { await using var harness = new SurrealTestHarness(); var store = harness.CreateSnapshotMetadataStore(); await store.InsertSnapshotMetadataAsync(new SnapshotMetadata { NodeId = "node-a", TimestampPhysicalTime = 100, TimestampLogicalCounter = 0, Hash = "hash-1" }); var initialHash = await store.GetSnapshotHashAsync("node-a"); initialHash.ShouldBe("hash-1"); await store.UpdateSnapshotMetadataAsync(new SnapshotMetadata { NodeId = "node-a", TimestampPhysicalTime = 120, TimestampLogicalCounter = 1, Hash = "hash-2" }, CancellationToken.None); var updatedHash = await store.GetSnapshotHashAsync("node-a"); updatedHash.ShouldBe("hash-2"); await store.MergeAsync(new[] { new SnapshotMetadata { NodeId = "node-a", TimestampPhysicalTime = 119, TimestampLogicalCounter = 9, Hash = "hash-old" }, new SnapshotMetadata { NodeId = "node-a", TimestampPhysicalTime = 130, TimestampLogicalCounter = 0, Hash = "hash-3" }, new SnapshotMetadata { NodeId = "node-b", TimestampPhysicalTime = 140, TimestampLogicalCounter = 0, Hash = "hash-b" } }); var finalNodeA = await store.GetSnapshotMetadataAsync("node-a"); finalNodeA.ShouldNotBeNull(); finalNodeA.Hash.ShouldBe("hash-3"); finalNodeA.TimestampPhysicalTime.ShouldBe(130); var all = (await store.GetAllSnapshotMetadataAsync()).OrderBy(x => x.NodeId).ToList(); all.Count.ShouldBe(2); all[0].NodeId.ShouldBe("node-a"); all[1].NodeId.ShouldBe("node-b"); } } internal sealed class SurrealTestHarness : IAsyncDisposable { private readonly CBDDCSurrealEmbeddedClient _client; private readonly string _rootPath; private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer; public SurrealTestHarness() { string suffix = Guid.NewGuid().ToString("N"); _rootPath = Path.Combine(Path.GetTempPath(), "cbddc-surreal-tests", suffix); string databasePath = Path.Combine(_rootPath, "rocksdb"); var options = new CBDDCSurrealEmbeddedOptions { Endpoint = "rocksdb://local", DatabasePath = databasePath, Namespace = $"cbddc_tests_{suffix}", Database = $"main_{suffix}" }; _client = new CBDDCSurrealEmbeddedClient(options, NullLogger.Instance); _schemaInitializer = new TestSurrealSchemaInitializer(_client); } public SurrealDocumentMetadataStore CreateDocumentMetadataStore() { return new SurrealDocumentMetadataStore( _client, _schemaInitializer, NullLogger.Instance); } public SurrealOplogStore CreateOplogStore() { return new SurrealOplogStore( _client, _schemaInitializer, Substitute.For(), new LastWriteWinsConflictResolver(), new VectorClockService(), null, NullLogger.Instance); } public SurrealPeerConfigurationStore CreatePeerConfigurationStore() { return new SurrealPeerConfigurationStore( _client, _schemaInitializer, NullLogger.Instance); } public SurrealPeerOplogConfirmationStore CreatePeerOplogConfirmationStore() { return new SurrealPeerOplogConfirmationStore( _client, _schemaInitializer, NullLogger.Instance); } public SurrealSnapshotMetadataStore CreateSnapshotMetadataStore() { return new SurrealSnapshotMetadataStore( _client, _schemaInitializer, NullLogger.Instance); } public async ValueTask DisposeAsync() { await _client.DisposeAsync(); await DeleteDirectoryWithRetriesAsync(_rootPath); } private static async Task DeleteDirectoryWithRetriesAsync(string path) { for (var attempt = 0; attempt < 5; attempt++) try { if (Directory.Exists(path)) Directory.Delete(path, true); return; } catch when (attempt < 4) { await Task.Delay(50); } } } internal sealed class TestSurrealSchemaInitializer : ICBDDCSurrealSchemaInitializer { private readonly ICBDDCSurrealEmbeddedClient _client; private int _initialized; public TestSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client) { _client = client; } public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _initialized, 1) == 1) return; await _client.InitializeAsync(cancellationToken); } }