using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; [Trait("Category", "Unit")] public sealed class LiteDbConfigCacheTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db"); public void Dispose() { if (File.Exists(_dbPath)) File.Delete(_dbPath); } private GenerationSnapshot Snapshot(string cluster, long gen) => new() { ClusterId = cluster, GenerationId = gen, CachedAt = DateTime.UtcNow, PayloadJson = $"{{\"g\":{gen}}}", }; [Fact] public async Task Roundtrip_preserves_payload() { using var cache = new LiteDbConfigCache(_dbPath); var put = Snapshot("c-1", 42); await cache.PutAsync(put); var got = await cache.GetMostRecentAsync("c-1"); got.ShouldNotBeNull(); got!.GenerationId.ShouldBe(42); got.PayloadJson.ShouldBe(put.PayloadJson); } [Fact] public async Task GetMostRecent_returns_latest_when_multiple_generations_present() { using var cache = new LiteDbConfigCache(_dbPath); foreach (var g in new long[] { 10, 20, 15 }) await cache.PutAsync(Snapshot("c-1", g)); var got = await cache.GetMostRecentAsync("c-1"); got!.GenerationId.ShouldBe(20); } [Fact] public async Task GetMostRecent_returns_null_for_unknown_cluster() { using var cache = new LiteDbConfigCache(_dbPath); (await cache.GetMostRecentAsync("ghost")).ShouldBeNull(); } [Fact] public async Task Prune_keeps_latest_N_and_drops_older() { using var cache = new LiteDbConfigCache(_dbPath); for (long g = 1; g <= 15; g++) await cache.PutAsync(Snapshot("c-1", g)); await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10); (await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15); // Drop them one by one and count — should be exactly 10 remaining var count = 0; while (await cache.GetMostRecentAsync("c-1") is not null) { count++; await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count)); if (count > 20) break; // safety } count.ShouldBe(10); } [Fact] public async Task Put_same_cluster_generation_twice_replaces_not_duplicates() { using var cache = new LiteDbConfigCache(_dbPath); var first = Snapshot("c-1", 1); first.PayloadJson = "{\"v\":1}"; await cache.PutAsync(first); var second = Snapshot("c-1", 1); second.PayloadJson = "{\"v\":2}"; await cache.PutAsync(second); (await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}"); } [Fact] public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException() { // Write a file large enough to look like a LiteDB page but with garbage contents so page // deserialization fails on the first read probe. File.WriteAllBytes(_dbPath, new byte[8192]); Array.Fill(File.ReadAllBytes(_dbPath), 0xAB); using (var fs = File.OpenWrite(_dbPath)) { fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray()); } Should.Throw(() => new LiteDbConfigCache(_dbPath)); } }