merge(r2): r2-plan08
# Conflicts: # src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs
This commit is contained in:
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
+54
@@ -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));
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
+61
@@ -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 < 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));
|
||||
}
|
||||
}
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+40
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user