Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.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

155 lines
4.9 KiB
C#

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<ResilientConfigReader>.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<ResilientConfigReader>.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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
{
await reader.ReadAsync<string>(
"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<ResilientConfigReader>.Instance,
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
using var cts = new CancellationTokenSource();
cts.Cancel();
var attempts = 0;
await Should.ThrowAsync<OperationCanceledException>(async () =>
{
await reader.ReadAsync<string>(
"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();
}
}