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
@@ -0,0 +1,41 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPartitionMaintenanceOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the maintenance
/// hosted service; a zero look-ahead leaves the partition function without a
/// future monthly boundary, risking inserts into the unbounded tail partition.
/// </summary>
public class AuditLogPartitionMaintenanceOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalSeconds_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroLookaheadMonths_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { LookaheadMonths = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.LookaheadMonths), StringComparison.Ordinal));
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPurgeOptions"/>
/// (arch-review 08 round 2 NF4). The class self-clamps at resolve time, but a
/// plainly-wrong config (zero cadence, zero batch, zero command timeout) should
/// still fail the boot so the operator learns immediately.
/// <see cref="AuditLogPurgeOptions.IntervalOverride"/> is test-only and left
/// unvalidated.
/// </summary>
public class AuditLogPurgeOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPurgeOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalHours_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null, new AuditLogPurgeOptions { IntervalHours = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
}
[Fact]
public void ZeroChannelPurgeBatchSize_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { ChannelPurgeBatchSizeConfigured = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
}
[Fact]
public void ZeroMaintenanceCommandTimeout_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { MaintenanceCommandTimeoutMinutes = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes), StringComparison.Ordinal));
}
}
@@ -0,0 +1,54 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditReconciliationOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the reconciliation
/// singleton, a zero batch pulls nothing, and a zero stalled-cycle threshold
/// would fire the stalled signal on the first non-draining cycle.
/// <see cref="SiteAuditReconciliationOptions.ReconciliationIntervalOverride"/>
/// is test-only and left unvalidated.
/// </summary>
public class SiteAuditReconciliationOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditReconciliationOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
}
[Fact]
public void ZeroReconciliationInterval_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { ReconciliationIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroStalledCycles_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { StalledAfterNonDrainingCycles = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles), StringComparison.Ordinal));
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditRetentionOptions"/>
/// (arch-review 08 round 2 NF4). Although the option class self-clamps at
/// resolve time, the validator still fails a boot with a plainly-wrong config
/// (zero retention window, zero/negative purge period, negative initial delay)
/// so the operator learns at startup rather than trusting a silent clamp.
/// <c>PurgeIntervalOverride</c> is test-only and left unvalidated.
/// </summary>
public class SiteAuditRetentionOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditRetentionOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditRetentionOptions()).Succeeded);
}
[Fact]
public void ZeroRetentionDays_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null, new SiteAuditRetentionOptions { RetentionDays = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.RetentionDays), StringComparison.Ordinal));
}
[Fact]
public void ZeroPurgeInterval_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null,
new SiteAuditRetentionOptions { PurgeInterval = TimeSpan.Zero });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.PurgeInterval), StringComparison.Ordinal));
}
[Fact]
public void NegativeInitialDelay_Fails()
{
var validator = new SiteAuditRetentionOptionsValidator();
var result = validator.Validate(null,
new SiteAuditRetentionOptions { InitialDelay = TimeSpan.FromSeconds(-1) });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditRetentionOptions.InitialDelay), StringComparison.Ordinal));
}
}
@@ -0,0 +1,61 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditTelemetryOptions"/>
/// (arch-review 08 round 2 NF4). The drain-loop cadences are used as raw
/// scheduling delays in <c>SiteAuditTelemetryActor</c>; a zero interval spins
/// the drain and a zero batch drains nothing. Idle must back off at least as
/// far as busy (idle &lt; busy inverts the actor's intent).
/// </summary>
public class SiteAuditTelemetryOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditTelemetryOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditTelemetryOptions()).Succeeded);
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroBusyInterval_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { BusyIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.BusyIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroIdleInterval_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null, new SiteAuditTelemetryOptions { IdleIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void IdleShorterThanBusy_Fails()
{
var validator = new SiteAuditTelemetryOptionsValidator();
var result = validator.Validate(null,
new SiteAuditTelemetryOptions { BusyIntervalSeconds = 30, IdleIntervalSeconds = 5 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditTelemetryOptions.IdleIntervalSeconds), StringComparison.Ordinal));
}
}
@@ -0,0 +1,72 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Eager startup validation for <see cref="SqliteAuditWriterOptions"/>
/// (arch-review 08 round 2 NF4). The site hot-path writer's channel/batch/flush
/// knobs must be positive or the background writer task cannot make progress;
/// an empty <c>DatabasePath</c> would leave the SQLite writer with nowhere to go.
/// <c>BacklogPollIntervalSeconds</c> is deliberately NOT validated — a
/// non-positive value has a documented fall-back-to-30s contract.
/// </summary>
public class SqliteAuditWriterOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SqliteAuditWriterOptionsValidator();
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
}
[Fact]
public void EmptyDatabasePath_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { DatabasePath = "" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.DatabasePath), StringComparison.Ordinal));
}
[Fact]
public void ZeroChannelCapacity_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { ChannelCapacity = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroFlushInterval_Fails()
{
var validator = new SqliteAuditWriterOptionsValidator();
var result = validator.Validate(null, new SqliteAuditWriterOptions { FlushIntervalMs = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveBacklogPollInterval_StillValid(int value)
{
// Documented fall-back-to-30s contract — must NOT be rejected.
var validator = new SqliteAuditWriterOptionsValidator();
Assert.True(validator.Validate(null,
new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = value }).Succeeded);
}
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Locks the config section <see cref="ServiceCollectionExtensions.AddCommunication"/>
/// binds (arch-review 08 round 2 NF8). Every real appsettings nests this section under
/// <c>ScadaBridge:</c>; binding a root-level <c>Communication</c> section reads config
/// that no deployment writes — the drift the Host duplicate was silently masking.
/// AddCommunication alone must bind the canonical <c>ScadaBridge:Communication</c> section.
/// </summary>
public class CommunicationOptionsBindingTests
{
[Fact]
public void AddCommunication_BindsTheScadaBridgeCommunicationSection()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Communication:DeploymentTimeout"] = "00:01:23",
}).Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddCommunication();
using var sp = services.BuildServiceProvider();
Assert.Equal(TimeSpan.FromSeconds(83),
sp.GetRequiredService<IOptions<CommunicationOptions>>().Value.DeploymentTimeout);
}
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// Locks the config section <see cref="ServiceCollectionExtensions.AddDataConnectionLayer"/>
/// binds (arch-review 08 round 2 NF8). Every real appsettings nests this section under
/// <c>ScadaBridge:</c>; binding a root-level <c>DataConnectionLayer</c> section reads config
/// that no deployment writes — the drift the Host duplicate was silently masking.
/// AddDataConnectionLayer alone must bind the canonical <c>ScadaBridge:DataConnection</c> section.
/// </summary>
public class DataConnectionOptionsBindingTests
{
[Fact]
public void AddDataConnectionLayer_BindsTheScadaBridgeDataConnectionSection()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:DataConnection:ReconnectInterval"] = "00:00:42",
}).Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddDataConnectionLayer();
using var sp = services.BuildServiceProvider();
Assert.Equal(TimeSpan.FromSeconds(42),
sp.GetRequiredService<IOptions<DataConnectionOptions>>().Value.ReconnectInterval);
}
}
@@ -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));
}
}
@@ -0,0 +1,40 @@
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Tracking;
/// <summary>
/// Eager startup validation for <see cref="OperationTrackingOptions"/>
/// (arch-review 08 round 2 NF4). The store opens the SQLite connection string at
/// construction, so an empty connection string must fail the host at boot; the
/// retention window must be positive or terminal-row purge would delete
/// everything.
/// </summary>
public class OperationTrackingOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new OperationTrackingOptionsValidator();
Assert.True(validator.Validate(null, new OperationTrackingOptions()).Succeeded);
}
[Fact]
public void EmptyConnectionString_Fails()
{
var validator = new OperationTrackingOptionsValidator();
var result = validator.Validate(null, new OperationTrackingOptions { ConnectionString = "" });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(OperationTrackingOptions.ConnectionString), StringComparison.Ordinal));
}
[Fact]
public void ZeroRetentionDays_Fails()
{
var validator = new OperationTrackingOptionsValidator();
var result = validator.Validate(null, new OperationTrackingOptions { RetentionDays = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(OperationTrackingOptions.RetentionDays), StringComparison.Ordinal));
}
}