using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; [Trait("Category", "Unit")] public sealed class ResilientConfigReaderTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}"); public void Dispose() { try { if (!Directory.Exists(_root)) return; foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) File.SetAttributes(f, FileAttributes.Normal); Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } } [Fact] public async Task CentralDbSucceeds_ReturnsValue_MarksFresh() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag { }; flag.MarkStale(); // pre-existing stale state var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance); var result = await reader.ReadAsync( "cluster-a", _ => ValueTask.FromResult("fresh-from-db"), _ => "from-cache", CancellationToken.None); result.ShouldBe("fresh-from-db"); flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag"); } [Fact] public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale() { var cache = new GenerationSealedCache(_root); await cache.SealAsync(new GenerationSnapshot { ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow, PayloadJson = "{\"cached\":true}", }); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 2); var attempts = 0; var result = await reader.ReadAsync( "cluster-a", _ => { attempts++; throw new InvalidOperationException("SQL dead"); #pragma warning disable CS0162 return ValueTask.FromResult("never"); #pragma warning restore CS0162 }, snap => snap.PayloadJson, CancellationToken.None); attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts"); result.ShouldBe("{\"cached\":true}"); flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true"); } [Fact] public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); await Should.ThrowAsync(async () => { await reader.ReadAsync( "cluster-a", _ => throw new InvalidOperationException("SQL dead"), _ => "never", CancellationToken.None); }); flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was"); } [Fact] public async Task Cancellation_NotRetried() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 5); using var cts = new CancellationTokenSource(); cts.Cancel(); var attempts = 0; await Should.ThrowAsync(async () => { await reader.ReadAsync( "cluster-a", ct => { attempts++; ct.ThrowIfCancellationRequested(); return ValueTask.FromResult("ok"); }, _ => "cache", cts.Token); }); attempts.ShouldBeLessThanOrEqualTo(1); } } [Trait("Category", "Unit")] public sealed class StaleConfigFlagTests { [Fact] public void Default_IsFresh() { new StaleConfigFlag().IsStale.ShouldBeFalse(); } [Fact] public void MarkStale_ThenFresh_Toggles() { var flag = new StaleConfigFlag(); flag.MarkStale(); flag.IsStale.ShouldBeTrue(); flag.MarkFresh(); flag.IsStale.ShouldBeFalse(); } [Fact] public void ConcurrentWrites_Converge() { var flag = new StaleConfigFlag(); Parallel.For(0, 1000, i => { if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh(); }); flag.MarkFresh(); flag.IsStale.ShouldBeFalse(); } }