Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/SealedBootstrapIntegrationTests.cs
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

134 lines
5.2 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
/// <summary>
/// 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.
/// </summary>
[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<ResilientConfigReader>.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<ResilientConfigReader>.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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
{
await reader.ReadAsync<long?>(
"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<ResilientConfigReader>.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");
}
}