using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// Integration-style tests for the Phase 6.1 Stream D consumption hook — they don't touch /// SQL Server (the real SealedBootstrap does, via sp_GetCurrentGenerationForCluster), but /// they exercise ResilientConfigReader + GenerationSealedCache + StaleConfigFlag end-to-end /// by simulating central-DB outcomes through a direct ReadAsync call. /// [Trait("Category", "Integration")] public sealed class SealedBootstrapIntegrationTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-bootstrap-{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 CentralDbSuccess_SealsSnapshot_And_FlagFresh() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10)); // Simulate the SealedBootstrap fresh-path: central DB returns generation id 42; the // bootstrap seals it + ResilientConfigReader marks the flag fresh. var result = await reader.ReadAsync( "c-a", centralFetch: async _ => { await cache.SealAsync(new GenerationSnapshot { ClusterId = "c-a", GenerationId = 42, CachedAt = DateTime.UtcNow, PayloadJson = "{\"gen\":42}", }, CancellationToken.None); return (long?)42; }, fromSnapshot: snap => (long?)snap.GenerationId, CancellationToken.None); result.ShouldBe(42); flag.IsStale.ShouldBeFalse(); cache.TryGetCurrentGenerationId("c-a").ShouldBe(42); } [Fact] public async Task CentralDbFails_FallsBackToSealedSnapshot_FlagStale() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); // Seed a prior sealed snapshot (simulating a previous successful boot). await cache.SealAsync(new GenerationSnapshot { ClusterId = "c-a", GenerationId = 37, CachedAt = DateTime.UtcNow, PayloadJson = "{\"gen\":37}", }); // Now simulate central DB down → fallback. var result = await reader.ReadAsync( "c-a", centralFetch: _ => throw new InvalidOperationException("SQL dead"), fromSnapshot: snap => (long?)snap.GenerationId, CancellationToken.None); result.ShouldBe(37); flag.IsStale.ShouldBeTrue("cache fallback flips the /healthz flag"); } [Fact] public async Task NoSnapshot_AndCentralDown_Throws_ClearError() { 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( "c-a", centralFetch: _ => throw new InvalidOperationException("SQL dead"), fromSnapshot: snap => (long?)snap.GenerationId, CancellationToken.None); }); } [Fact] public async Task SuccessfulBootstrap_AfterFailure_ClearsStaleFlag() { var cache = new GenerationSealedCache(_root); var flag = new StaleConfigFlag(); var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, timeout: TimeSpan.FromSeconds(10), retryCount: 0); await cache.SealAsync(new GenerationSnapshot { ClusterId = "c-a", GenerationId = 1, CachedAt = DateTime.UtcNow, PayloadJson = "{}", }); // Fallback serves snapshot → flag goes stale. await reader.ReadAsync("c-a", centralFetch: _ => throw new InvalidOperationException("dead"), fromSnapshot: s => (long?)s.GenerationId, CancellationToken.None); flag.IsStale.ShouldBeTrue(); // Subsequent successful bootstrap clears it. await reader.ReadAsync("c-a", centralFetch: _ => ValueTask.FromResult((long?)5), fromSnapshot: s => (long?)s.GenerationId, CancellationToken.None); flag.IsStale.ShouldBeFalse("next successful DB round-trip clears the flag"); } }