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");
}
}