using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; [Trait("Category", "Unit")] public sealed class GenerationSealedCacheTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}"); public void Dispose() { try { if (!Directory.Exists(_root)) return; // Remove ReadOnly attribute first so Directory.Delete can clean sealed files. foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) File.SetAttributes(f, FileAttributes.Normal); Directory.Delete(_root, recursive: true); } catch { /* best-effort cleanup */ } } private GenerationSnapshot MakeSnapshot(string clusterId, long generationId, string payload = "{\"sample\":true}") => new() { ClusterId = clusterId, GenerationId = generationId, CachedAt = DateTime.UtcNow, PayloadJson = payload, }; [Fact] public async Task FirstBoot_NoSnapshot_ReadThrows() { var cache = new GenerationSealedCache(_root); await Should.ThrowAsync( () => cache.ReadCurrentAsync("cluster-a")); } [Fact] public async Task SealThenRead_RoundTrips() { var cache = new GenerationSealedCache(_root); var snapshot = MakeSnapshot("cluster-a", 42, "{\"hello\":\"world\"}"); await cache.SealAsync(snapshot); var read = await cache.ReadCurrentAsync("cluster-a"); read.GenerationId.ShouldBe(42); read.ClusterId.ShouldBe("cluster-a"); read.PayloadJson.ShouldBe("{\"hello\":\"world\"}"); } [Fact] public async Task SealedFile_IsReadOnly_OnDisk() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 5)); var sealedPath = Path.Combine(_root, "cluster-a", "5.db"); File.Exists(sealedPath).ShouldBeTrue(); var attrs = File.GetAttributes(sealedPath); attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only"); } [Fact] public async Task SealingTwoGenerations_PointerAdvances_ToLatest() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 1)); await cache.SealAsync(MakeSnapshot("cluster-a", 2)); cache.TryGetCurrentGenerationId("cluster-a").ShouldBe(2); var read = await cache.ReadCurrentAsync("cluster-a"); read.GenerationId.ShouldBe(2); } [Fact] public async Task PriorGenerationFile_Survives_AfterNewSeal() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 1)); await cache.SealAsync(MakeSnapshot("cluster-a", 2)); File.Exists(Path.Combine(_root, "cluster-a", "1.db")).ShouldBeTrue( "prior generations preserved for audit; pruning is separate"); File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue(); } [Fact] public async Task CorruptSealedFile_ReadFailsClosed() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 7)); // Corrupt the sealed file: clear read-only, truncate, leave pointer intact. var sealedPath = Path.Combine(_root, "cluster-a", "7.db"); File.SetAttributes(sealedPath, FileAttributes.Normal); File.WriteAllBytes(sealedPath, [0x00, 0x01, 0x02]); await Should.ThrowAsync( () => cache.ReadCurrentAsync("cluster-a")); } [Fact] public async Task MissingSealedFile_ReadFailsClosed() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 3)); // Delete the sealed file but leave the pointer — corruption scenario. var sealedPath = Path.Combine(_root, "cluster-a", "3.db"); File.SetAttributes(sealedPath, FileAttributes.Normal); File.Delete(sealedPath); await Should.ThrowAsync( () => cache.ReadCurrentAsync("cluster-a")); } [Fact] public async Task CorruptPointerFile_ReadFailsClosed() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 9)); var pointerPath = Path.Combine(_root, "cluster-a", "CURRENT"); File.WriteAllText(pointerPath, "not-a-number"); await Should.ThrowAsync( () => cache.ReadCurrentAsync("cluster-a")); } [Fact] public async Task SealSameGenerationTwice_IsIdempotent() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 11)); await cache.SealAsync(MakeSnapshot("cluster-a", 11, "{\"v\":2}")); var read = await cache.ReadCurrentAsync("cluster-a"); read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops"); } [Fact] public async Task IndependentClusters_DoNotInterfere() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(MakeSnapshot("cluster-a", 1)); await cache.SealAsync(MakeSnapshot("cluster-b", 10)); (await cache.ReadCurrentAsync("cluster-a")).GenerationId.ShouldBe(1); (await cache.ReadCurrentAsync("cluster-b")).GenerationId.ShouldBe(10); } }