merge(r2): r2-plan08

# Conflicts:
#	src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
This commit is contained in:
Joseph Doherty
2026-07-13 11:10:15 -04:00
42 changed files with 1094 additions and 79 deletions
@@ -48,6 +48,7 @@ public class CentralActorPathTests : IAsyncLifetime
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:25510",
@@ -171,6 +172,7 @@ public class SiteActorPathTests : IAsyncLifetime
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
@@ -114,6 +114,7 @@ public class CentralAuditWiringTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -294,6 +295,7 @@ public class SiteAuditWiringTests : IDisposable
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
@@ -108,6 +108,7 @@ public class CentralCompositionRootTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -338,21 +339,28 @@ public class SiteCompositionRootTests : IDisposable
{
private readonly WebApplication _host;
private readonly string _tempDbPath;
private readonly string _tempTrackingDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{Guid.NewGuid()}.db");
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Node:GrpcPort"] = "0",
["ScadaBridge:Database:SiteDbPath"] = _tempDbPath,
// Point the site-local operation-tracking SQLite store (the ctor opens/creates
// the file eagerly) at a temp path so resolving IOperationTrackingStore in the
// composition-root test does not litter site-tracking.db in the test cwd.
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
// ClusterOptions requires at least one seed node (ClusterOptionsValidator).
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
@@ -375,6 +383,20 @@ public class SiteCompositionRootTests : IDisposable
{
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
try { File.Delete(_tempTrackingDbPath); } catch { /* best effort */ }
}
[Fact]
public void Site_Resolves_IOperationTrackingStore()
{
// Site Call Audit's site-local source of truth. AkkaHostedService and
// ScriptRuntimeContext resolve this with GetService and silently degrade
// when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) —
// so its presence on the SITE composition root must be locked here
// (arch-review 08 round 2 NF4/T8: AddSiteRuntime never registered it,
// despite two comments claiming it did).
var store = _host.Services.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>();
Assert.NotNull(store);
}
// --- Singletons ---
@@ -44,6 +44,7 @@ public class HealthCheckTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -41,6 +41,7 @@ public class HostStartupTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -74,6 +75,7 @@ public class HostStartupTests : IDisposable
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
@@ -103,6 +105,7 @@ public class HostStartupTests : IDisposable
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:Role"] = "Site",
["ScadaBridge:Node:NodeName"] = "node-a",
["ScadaBridge:Node:NodeHostname"] = "test-site",
["ScadaBridge:Node:SiteId"] = "TestSite",
["ScadaBridge:Node:RemotingPort"] = "0",
@@ -40,6 +40,7 @@ public class MetricsEndpointTests : IDisposable
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
@@ -0,0 +1,107 @@
using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Eager startup validation for the shared Host options (arch-review 08 round 2
/// NF4). Covers <see cref="NodeOptionsValidator"/>,
/// <see cref="DatabaseOptionsValidator"/>, and
/// <see cref="LoggingOptionsValidator"/> in one file. The headline case: an
/// empty <see cref="NodeOptions.NodeName"/> normalises to a NULL SourceNode
/// audit column, so it must fail the host at boot rather than silently degrade
/// the audit trail.
/// </summary>
public class NodeOptionsValidatorTests
{
// ---- NodeOptions ----
[Fact]
public void Node_DefaultsWithNodeName_AreValid()
{
var validator = new NodeOptionsValidator();
Assert.True(validator.Validate(null, new NodeOptions { NodeName = "node-a" }).Succeeded);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Node_EmptyOrWhitespaceNodeName_Fails(string nodeName)
{
var validator = new NodeOptionsValidator();
var result = validator.Validate(null, new NodeOptions { NodeName = nodeName });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(NodeOptions.NodeName), StringComparison.Ordinal));
}
[Fact]
public void Node_ZeroRemotingPort_IsValid()
{
// CompositionRootTests uses RemotingPort = 0 to request a dynamic port.
var validator = new NodeOptionsValidator();
Assert.True(validator.Validate(null,
new NodeOptions { NodeName = "node-a", RemotingPort = 0 }).Succeeded);
}
[Theory]
[InlineData(-1)]
[InlineData(65_536)]
public void Node_PortOutOfRange_Fails(int port)
{
var validator = new NodeOptionsValidator();
var result = validator.Validate(null,
new NodeOptions { NodeName = "node-a", GrpcPort = port });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(NodeOptions.GrpcPort), StringComparison.Ordinal));
}
// ---- DatabaseOptions ----
[Fact]
public void Database_AllNull_IsValid()
{
// All three fields are nullable and role-dependent; null stays valid.
var validator = new DatabaseOptionsValidator();
Assert.True(validator.Validate(null, new DatabaseOptions()).Succeeded);
}
[Fact]
public void Database_WhitespaceValue_Fails()
{
var validator = new DatabaseOptionsValidator();
var result = validator.Validate(null, new DatabaseOptions { ConfigurationDb = " " });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(DatabaseOptions.ConfigurationDb), StringComparison.Ordinal));
}
// ---- LoggingOptions ----
[Fact]
public void Logging_DefaultInformation_IsValid()
{
var validator = new LoggingOptionsValidator();
Assert.True(validator.Validate(null, new LoggingOptions()).Succeeded);
}
[Theory]
[InlineData("Verbose")]
[InlineData("debug")]
[InlineData("FATAL")]
public void Logging_RecognisedLevel_IsValid(string level)
{
var validator = new LoggingOptionsValidator();
Assert.True(validator.Validate(null, new LoggingOptions { MinimumLevel = level }).Succeeded);
}
[Fact]
public void Logging_UnknownLevel_Fails()
{
var validator = new LoggingOptionsValidator();
var result = validator.Validate(null, new LoggingOptions { MinimumLevel = "Chatty" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(LoggingOptions.MinimumLevel), StringComparison.Ordinal));
}
}