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}"); } // ------------------------------------------------------------------------------------ // Configuration-005 — concurrent PutAsync for the same (ClusterId, GenerationId) must // not produce duplicate rows. The original find-then-insert was non-atomic so two racing // callers could both observe `existing is null` and both Insert. // ------------------------------------------------------------------------------------ [Fact] public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate() { using var cache = new LiteDbConfigCache(_dbPath); // Pre-seed gen=99 so prune keepLatest:1 has a sentinel that survives independent of // any potential duplicate (gen=42) row count. await cache.PutAsync(Snapshot("c-1", 99)); // Many parallel writes for the same key. Without serialization, racing find-then-insert // would Insert multiple rows for the same (ClusterId, GenerationId=42). var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(async () => { var s = Snapshot("c-1", 42); await cache.PutAsync(s); })).ToArray(); await Task.WhenAll(tasks); // Count rows for gen=42 directly by inspecting the LiteDB file via a fresh handle. cache.Dispose(); using var verify = new LiteDB.LiteDatabase(_dbPath); var col = verify.GetCollection("generations"); var gen42Count = col.Find(s => s.ClusterId == "c-1" && s.GenerationId == 42).Count(); gen42Count.ShouldBe(1, $"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts"); } [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)); } }