docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
{
/// <summary>Verifies that starting a deployment inserts a row and dispatches to the coordinator.</summary>
[Fact]
public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()
{
@@ -41,6 +42,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
db.ConfigEdits.Single().EntityType.ShouldBe("Deployment");
}
/// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
[Fact]
public void StartDeployment_refuses_when_another_is_in_flight()
{
@@ -21,6 +21,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
SourceNode: NodeId.Parse("node-a"),
CorrelationId: CorrelationId.NewId());
/// <summary>Verifies that buffered events flush when count threshold is reached.</summary>
[Fact]
public void Buffered_events_flush_on_count_threshold()
{
@@ -39,6 +40,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
}, duration: TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that duplicate event IDs within a batch are deduplicated in the buffer.</summary>
[Fact]
public void Duplicate_eventIds_within_a_batch_dedup_in_buffer()
{
@@ -59,6 +61,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
db.ConfigAuditLogs.Count().ShouldBe(100, "in-buffer dedup should collapse duplicate EventIds");
}
/// <summary>Verifies that PostStop flushes the pending buffer.</summary>
[Fact]
public void PostStop_flushes_pending_buffer()
{
@@ -77,6 +80,7 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
db.ConfigAuditLogs.Count().ShouldBe(10);
}
/// <summary>Verifies that EventId and CorrelationId are persisted to dedicated columns.</summary>
[Fact]
public void EventId_and_CorrelationId_are_persisted_to_dedicated_columns()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class ConfigComposerTests : ControlPlaneActorTestBase
{
/// <summary>Verifies that an empty database produces a stable, reproducible hash.</summary>
[Fact]
public async Task Empty_database_produces_stable_hash()
{
@@ -24,6 +25,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
a1.Blob.ShouldBe(a2.Blob);
}
/// <summary>Verifies that insertion order does not affect the configuration hash.</summary>
[Fact]
public async Task Same_rows_in_different_insert_orders_produce_same_hash()
{
@@ -51,6 +53,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
hashAB.ShouldBe(hashBA);
}
/// <summary>Verifies that different database configurations produce different hashes.</summary>
[Fact]
public async Task Different_data_produces_different_hash()
{
@@ -72,6 +75,7 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
hashAB.ShouldNotBe(hashA);
}
/// <summary>Verifies that the revision hash is a 64-character lowercase hexadecimal string.</summary>
[Fact]
public async Task Hash_is_64_lowercase_hex_chars()
{
@@ -13,6 +13,7 @@ public sealed class ConfigPublishCoordinatorTests : ControlPlaneActorTestBase
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('a', 64));
/// <summary>Verifies that deployment seals immediately when no driver-role members are in the cluster.</summary>
[Fact]
public void EmptyCluster_dispatch_seals_immediately()
{
@@ -32,6 +33,7 @@ public sealed class ConfigPublishCoordinatorTests : ControlPlaneActorTestBase
}, duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that stale apply acknowledgments arriving after seal are ignored.</summary>
[Fact]
public void Stale_ApplyAck_after_seal_is_ignored()
{
@@ -15,6 +15,7 @@ public sealed class ConfigPublishCoordinatorTimeoutTests : ControlPlaneActorTest
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('b', 64));
/// <summary>Verifies that DeadlineElapsed marks the current deployment as timed out.</summary>
[Fact]
public void DeadlineElapsed_for_current_deployment_marks_TimedOut()
{
@@ -48,6 +49,7 @@ public sealed class ConfigPublishCoordinatorTimeoutTests : ControlPlaneActorTest
}, duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that stale DeadlineElapsed for other deployments is ignored.</summary>
[Fact]
public void Stale_DeadlineElapsed_for_other_deployment_is_ignored()
{
@@ -66,6 +68,7 @@ public sealed class ConfigPublishCoordinatorTimeoutTests : ControlPlaneActorTest
status.ShouldNotBe(DeploymentStatus.TimedOut);
}
/// <summary>Verifies that PreStart recovers in-flight deployment state.</summary>
[Fact]
public void PreStart_recovers_inflight_deployment_state()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class FleetStatusBroadcasterTests : ControlPlaneActorTestBase
{
/// <summary>Verifies that the self member's join appears in the initial fleet status snapshot.</summary>
[Fact]
public void Self_member_up_lands_in_snapshot()
{
@@ -22,6 +23,7 @@ public sealed class FleetStatusBroadcasterTests : ControlPlaneActorTestBase
snapshot.Nodes.Any(n => n.Health == FleetNodeHealth.Healthy).ShouldBeTrue();
}
/// <summary>Verifies that heartbeat messages update the node revision in the next broadcast.</summary>
[Fact]
public void Heartbeat_updates_revision_in_next_snapshot()
{
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
/// </summary>
public abstract class ControlPlaneActorTestBase : TestKit
{
/// <summary>Gets the Akka configuration for test actors, including cluster and pub-sub extensions.</summary>
protected static string AkkaTestHocon => @"
akka {
loglevel = ""WARNING""
@@ -38,6 +39,7 @@ akka {
}
}";
/// <summary>Initializes a new instance of the test harness; joins the cluster and waits for up status.</summary>
protected ControlPlaneActorTestBase() : base(AkkaTestHocon)
{
// Self-join so the cluster transitions to Up and DistributedPubSub forms.
@@ -47,6 +49,9 @@ akka {
TimeSpan.FromSeconds(5));
}
/// <summary>Creates a new in-memory database context factory with an optional database name.</summary>
/// <param name="dbName">Optional database name; defaults to a random GUID if not provided.</param>
/// <returns>A context factory for creating in-memory config database contexts.</returns>
protected static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory(string? dbName = null)
{
dbName ??= Guid.NewGuid().ToString("N");
@@ -55,6 +60,8 @@ akka {
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new in-memory database context.</summary>
/// <returns>A new OtOpcUaConfigDbContext instance.</returns>
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// </summary>
public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase
{
/// <summary>Verifies that a self-join event triggers RedundancyStateChanged through the broadcast override.</summary>
[Fact]
public void Self_join_triggers_RedundancyStateChanged_via_broadcast_override()
{
@@ -27,6 +28,7 @@ public sealed class RedundancyStateActorTests : ControlPlaneActorTestBase
msg.CorrelationId.Value.ShouldNotBe(Guid.Empty);
}
/// <summary>Verifies that multiple back-to-back events debounce to a single RedundancyStateChanged publication.</summary>
[Fact]
public void Multiple_back_to_back_events_debounce_to_single_publish()
{
@@ -7,6 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
public sealed class ServiceLevelCalculatorTests
{
/// <summary>Verifies that non-Up member statuses return a service level of zero.</summary>
/// <param name="status">The Akka cluster member status to test.</param>
[Theory]
[InlineData(MemberStatus.Down)]
[InlineData(MemberStatus.Removed)]
@@ -19,6 +21,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)0);
}
/// <summary>Verifies that a fully healthy non-leader member returns a service level of 240.</summary>
[Fact]
public void Fully_healthy_non_leader_returns_240()
{
@@ -27,6 +30,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)240);
}
/// <summary>Verifies that a fully healthy role leader returns a service level of 250.</summary>
[Fact]
public void Fully_healthy_role_leader_returns_250()
{
@@ -35,6 +39,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)250);
}
/// <summary>Verifies that a database reachable but stale returns a service level of 200.</summary>
[Fact]
public void Db_reachable_but_stale_returns_200()
{
@@ -43,6 +48,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)200);
}
/// <summary>Verifies that an unreachable and stale database returns a service level of 100.</summary>
[Fact]
public void Db_unreachable_and_stale_returns_100()
{
@@ -51,6 +57,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)100);
}
/// <summary>Verifies that an OPC UA probe failure on a non-stale database returns zero.</summary>
[Fact]
public void Opcua_probe_fail_when_not_stale_returns_zero()
{
@@ -60,6 +67,7 @@ public sealed class ServiceLevelCalculatorTests
sl.ShouldBe((byte)0);
}
/// <summary>Verifies that a Joining member status is treated like Up for service level grading.</summary>
[Fact]
public void Joining_member_is_treated_like_Up_for_grading()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// </summary>
public sealed class ClusterFormationTests
{
/// <summary>Verifies that two nodes form a 2-member cluster.</summary>
[Fact]
public async Task Two_nodes_form_a_2_member_cluster()
{
@@ -27,6 +28,7 @@ public sealed class ClusterFormationTests
aRoles.ShouldContain("driver");
}
/// <summary>Verifies that both nodes see each other as role members.</summary>
[Fact]
public async Task Both_nodes_see_each_other_as_role_members()
{
@@ -18,6 +18,7 @@ public sealed class DeployHappyPathTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that StartDeployment seals after both nodes apply.</summary>
[Fact]
public async Task StartDeployment_seals_after_both_nodes_apply()
{
@@ -53,6 +54,7 @@ public sealed class DeployHappyPathTests
nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
}
/// <summary>Verifies that replaying dispatch to same revision is idempotent and a no-op.</summary>
[Fact]
public async Task Replaying_dispatch_to_same_revision_is_idempotent_no_op()
{
@@ -18,6 +18,7 @@ public sealed class FailoverDuringDeployTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that stopping node B shrinks the cluster to one up member.</summary>
[Fact]
public async Task Stopping_node_b_shrinks_cluster_to_one_up_member()
{
@@ -32,6 +33,7 @@ public sealed class FailoverDuringDeployTests
.Count(m => m.Status == MemberStatus.Up).ShouldBe(1);
}
/// <summary>Verifies that a restarted node B rejoins the cluster on the same port.</summary>
[Fact]
public async Task Restarted_node_b_rejoins_cluster_on_same_port()
{
@@ -48,6 +50,7 @@ public sealed class FailoverDuringDeployTests
.Count(m => m.Status == MemberStatus.Up).ShouldBe(2);
}
/// <summary>Verifies that a deployment started with node B down seals with one-node state.</summary>
[Fact]
public async Task Deployment_started_with_node_b_down_seals_with_one_node_state()
{
@@ -16,6 +16,7 @@ public sealed class FleetDiagnosticsRoundTripTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies that get diagnostics returns a snapshot with the target node ID.</summary>
[Fact]
public async Task GetDiagnostics_returns_snapshot_with_target_NodeId()
{
@@ -39,6 +40,7 @@ public sealed class FleetDiagnosticsRoundTripTests
snapshot.AsOfUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddSeconds(-30));
}
/// <summary>Verifies that get diagnostics after deploy reports the current revision.</summary>
[Fact]
public async Task GetDiagnostics_after_deploy_reports_current_revision()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// </summary>
public sealed class LdapOpcUaUserAuthenticatorTests
{
/// <summary>Verifies that successful LDAP authentication returns Allow result with user roles.</summary>
[Fact]
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
{
@@ -26,6 +27,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.Roles.ShouldBe(new[] { "ConfigEditor" });
}
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
@@ -38,6 +40,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.Error.ShouldBe("Invalid username or password");
}
/// <summary>Verifies that LDAP exceptions are converted to backend error denial results.</summary>
[Fact]
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
{
@@ -51,6 +54,7 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.Error.ShouldContain("backend");
}
/// <summary>Verifies that authentication falls back to username when LDAP omits display name.</summary>
[Fact]
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
{
@@ -63,12 +67,23 @@ public sealed class LdapOpcUaUserAuthenticatorTests
result.DisplayName.ShouldBe("alice");
}
/// <summary>Test fake implementation of LDAP authentication service.</summary>
private sealed class FakeLdap : ILdapAuthService
{
private readonly Func<string, LdapAuthResult> _handler;
/// <summary>Initializes the fake with a fixed result that ignores the username.</summary>
/// <param name="fixed_">The result to return for any authentication attempt.</param>
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
/// <summary>Initializes the fake with a handler function for custom results.</summary>
/// <param name="handler">The handler to invoke with the username to produce a result.</param>
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
/// <summary>Authenticates a user asynchronously via the handler function.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password (ignored by the fake).</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// </summary>
public sealed class RoslynScriptedAlarmEvaluatorTests
{
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
[Fact]
public void Evaluate_predicate_returning_true_reports_Active()
{
@@ -27,6 +28,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
result.Active.ShouldBeTrue();
}
/// <summary>Verifies evaluation of predicate returning false reports Inactive.</summary>
[Fact]
public void Evaluate_predicate_returning_false_reports_Inactive()
{
@@ -41,6 +43,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
result.Active.ShouldBeFalse();
}
/// <summary>Verifies compiled predicates are cached across calls.</summary>
[Fact]
public void Evaluate_caches_compiled_predicate_across_calls()
{
@@ -54,6 +57,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
second.Active.ShouldBeFalse();
}
/// <summary>Verifies compile errors return Failure.</summary>
[Fact]
public void Evaluate_compile_error_returns_Failure()
{
@@ -65,6 +69,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
result.Reason!.ShouldContain("compile");
}
/// <summary>Verifies predicate writing virtual tag returns Failure.</summary>
[Fact]
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
{
@@ -80,6 +85,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
result.Reason!.ShouldContain("threw");
}
/// <summary>Verifies empty predicate returns Failure.</summary>
[Fact]
public void Evaluate_empty_predicate_returns_Failure()
{
@@ -88,6 +94,7 @@ public sealed class RoslynScriptedAlarmEvaluatorTests
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
/// <summary>Verifies evaluation after dispose returns Failure.</summary>
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// </summary>
public sealed class RoslynVirtualTagEvaluatorTests
{
/// <summary>Verifies that simple addition expression is evaluated correctly.</summary>
[Fact]
public void Evaluate_simple_addition_returns_summed_value()
{
@@ -27,6 +28,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
result.Value.ShouldBe(42);
}
/// <summary>Verifies that compiled expressions are cached across multiple calls.</summary>
[Fact]
public void Evaluate_caches_compiled_expression_across_calls()
{
@@ -42,6 +44,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
second.Value.ShouldBe(14);
}
/// <summary>Verifies that compile errors return Failure with a descriptive reason.</summary>
[Fact]
public void Evaluate_compile_error_returns_Failure_with_reason()
{
@@ -54,6 +57,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
result.Reason.ShouldContain("compile");
}
/// <summary>Verifies that runtime exceptions return Failure with a descriptive reason.</summary>
[Fact]
public void Evaluate_runtime_exception_returns_Failure_with_reason()
{
@@ -69,6 +73,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
result.Reason.ShouldContain("threw");
}
/// <summary>Verifies that empty expressions return Failure.</summary>
[Fact]
public void Evaluate_empty_expression_returns_Failure()
{
@@ -78,6 +83,7 @@ public sealed class RoslynVirtualTagEvaluatorTests
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
/// <summary>Verifies that evaluation after disposal returns Failure.</summary>
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
@@ -48,26 +48,35 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
public const string TestRoles = "admin,driver";
/// <summary>Gets the shared database name for both cluster nodes.</summary>
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
/// <summary>Gets the harness mode configuration from environment variables.</summary>
public HarnessMode Mode { get; } = HarnessMode.FromEnvironment();
private string? _sqlDbName;
private string? _sqlConnString;
/// <summary>Gets the first web application node.</summary>
public WebApplication NodeA { get; private set; } = null!;
/// <summary>Gets the second web application node.</summary>
public WebApplication NodeB { get; private set; } = null!;
/// <summary>Gets the Akka port allocated for node A.</summary>
public int NodeAAkkaPort { get; private set; }
/// <summary>Gets the Akka port allocated for node B.</summary>
public int NodeBAkkaPort { get; private set; }
// Both nodes bind to 127.0.0.1 — ClusterRoleInfo + ConfigPublishCoordinator encode
// host:port into NodeId so the cluster membership stays distinct on different ports.
public const string LoopbackHost = "127.0.0.1";
/// <summary>Gets the Akka ActorSystem for node A.</summary>
public ActorSystem NodeASystem => NodeA.Services.GetRequiredService<ActorSystem>();
/// <summary>Gets the Akka ActorSystem for node B.</summary>
public ActorSystem NodeBSystem => NodeB.Services.GetRequiredService<ActorSystem>();
/// <summary>Boots both nodes and waits up to <paramref name="formationTimeout"/> for cluster convergence.</summary>
/// <param name="formationTimeout">Maximum time to wait for cluster formation; defaults to 20 seconds if not provided.</param>
public static async Task<TwoNodeClusterHarness> StartAsync(TimeSpan? formationTimeout = null)
{
var harness = new TwoNodeClusterHarness();
@@ -110,6 +119,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// Rebuilds node B on the same Akka port + same ConfigDb and waits for the cluster
/// to re-converge to 2 Up members. Use after <see cref="StopNodeBAsync"/> to test rejoin.
/// </summary>
/// <param name="formationTimeout">The maximum time to wait for cluster formation; defaults to 20 seconds.</param>
public async Task RestartNodeBAsync(TimeSpan? formationTimeout = null)
{
NodeB = await BuildNodeAsync(this, NodeRole.Joiner);
@@ -124,6 +134,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// Waits for node A's cluster view to reach <paramref name="expectedUpMembers"/> members in
/// <see cref="MemberStatus.Up"/>. Used for asserting shrink-after-stop or grow-after-restart.
/// </summary>
/// <param name="expectedUpMembers">The expected number of Up members in the cluster.</param>
/// <param name="timeout">The maximum time to wait for the expected cluster size.</param>
public async Task WaitForClusterSizeAsync(int expectedUpMembers, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -265,6 +277,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
return port;
}
/// <summary>Asynchronously disposes both nodes and cleans up the SQL database if used.</summary>
public async ValueTask DisposeAsync()
{
if (NodeB is not null) await NodeB.DisposeAsync();
@@ -279,6 +292,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// <summary>Captures the env-var driven harness mode at construction time.</summary>
public sealed record HarnessMode(bool UseSqlServer, bool UseRealLdap)
{
/// <summary>Creates a HarnessMode from environment variable settings.</summary>
/// <returns>A HarnessMode configured from OTOPCUA_HARNESS_USE_SQL and OTOPCUA_HARNESS_USE_LDAP environment variables.</returns>
public static HarnessMode FromEnvironment() => new(
UseSqlServer: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_SQL") == "1",
UseRealLdap: Environment.GetEnvironmentVariable("OTOPCUA_HARNESS_USE_LDAP") == "1");
@@ -286,6 +301,11 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
private sealed class StubLdapAuthService : ILdapAuthService
{
/// <summary>Asynchronously authenticates a user with the stub LDAP service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate against.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the LDAP authentication result.</returns>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(new LdapAuthResult(
Success: password == "valid-password",
@@ -23,6 +23,7 @@ public sealed class DualEndpointTests
private const string NodeAUri = "urn:OtOpcUa.DualEndpoint.NodeA";
private const string NodeBUri = "urn:OtOpcUa.DualEndpoint.NodeB";
/// <summary>Verifies that a client can read the ServerArray containing both redundant node URIs.</summary>
[Fact]
public async Task Client_reads_both_ApplicationUris_from_NodeA_ServerArray()
{
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredAddressSpaceSinkTests
{
/// <summary>Verifies that the default inner is a null sink so calls before SetSink are safe.</summary>
[Fact]
public void Default_inner_is_null_sink_so_calls_before_SetSink_are_safe()
{
@@ -18,6 +19,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.RebuildAddressSpace();
}
/// <summary>Verifies that calls after SetSink are forwarded to the inner sink.</summary>
[Fact]
public void Calls_after_SetSink_are_forwarded_to_the_inner()
{
@@ -32,6 +34,7 @@ public sealed class DeferredAddressSpaceSinkTests
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB" });
}
/// <summary>Verifies that setting sink to null reverts to null sink.</summary>
[Fact]
public void SetSink_to_null_reverts_to_null_sink()
{
@@ -46,6 +49,7 @@ public sealed class DeferredAddressSpaceSinkTests
inner.Calls.Count.ShouldBe(1);
}
/// <summary>Verifies that sink can be swapped between implementations.</summary>
[Fact]
public void SetSink_can_swap_between_implementations()
{
@@ -65,15 +69,21 @@ public sealed class DeferredAddressSpaceSinkTests
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the queue of recorded calls.</summary>
public ConcurrentQueue<string> CallQueue { get; } = new();
/// <summary>Gets the list of recorded calls.</summary>
public List<string> Calls => CallQueue.ToList();
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WV:{nodeId}");
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
}
}
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredServiceLevelPublisherTests
{
/// <summary>Verifies that publish before SetInner is a safe no-op.</summary>
[Fact]
public void Publish_before_SetInner_is_a_safe_noop()
{
@@ -14,6 +15,7 @@ public sealed class DeferredServiceLevelPublisherTests
Should.NotThrow(() => deferred.Publish(123));
}
/// <summary>Verifies that publish after SetInner routes to the inner publisher.</summary>
[Fact]
public void Publish_after_SetInner_routes_to_the_inner()
{
@@ -26,6 +28,7 @@ public sealed class DeferredServiceLevelPublisherTests
recording.LastValue.ShouldBe((byte)200);
}
/// <summary>Verifies that SetInner with null reverts to the null publisher.</summary>
[Fact]
public void SetInner_null_reverts_to_Null_publisher()
{
@@ -42,7 +45,10 @@ public sealed class DeferredServiceLevelPublisherTests
private sealed class RecordingPublisher : IServiceLevelPublisher
{
/// <summary>Gets the last published service level value.</summary>
public byte? LastValue { get; private set; }
/// <summary>Publishes a service level value.</summary>
/// <param name="serviceLevel">The service level to publish.</param>
public void Publish(byte serviceLevel) => LastValue = serviceLevel;
}
}
@@ -17,6 +17,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
/// <summary>Verifies successful UserName token impersonation sets identity and clears validation error.</summary>
[Fact]
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
{
@@ -33,6 +34,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
authenticator.LastPassword.ShouldBe("secret");
}
/// <summary>Verifies failed UserName token impersonation sets validation error and clears identity.</summary>
[Fact]
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
{
@@ -47,6 +49,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
}
/// <summary>Verifies anonymous identity tokens are passed through to the SDK default handler.</summary>
[Fact]
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
{
@@ -61,6 +64,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
}
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
[Fact]
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
{
@@ -74,6 +78,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
[Fact]
public void HandleImpersonation_null_username_treated_as_empty_string()
{
@@ -86,6 +91,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
authenticator.LastUsername.ShouldBe(string.Empty);
}
/// <summary>Verifies NullOpcUaUserAuthenticator always returns denial result.</summary>
[Fact]
public async Task NullOpcUaUserAuthenticator_always_denies()
{
@@ -99,9 +105,16 @@ public sealed class OpcUaApplicationHostImpersonationTests
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
{
/// <summary>Gets the username passed to the last authentication call.</summary>
public string? LastUsername { get; private set; }
/// <summary>Gets the password passed to the last authentication call.</summary>
public string? LastPassword { get; private set; }
/// <summary>Authenticates a username and password, recording them for inspection by tests.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The predefined authentication result.</returns>
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
LastUsername = username;
@@ -110,8 +123,14 @@ public sealed class OpcUaApplicationHostImpersonationTests
}
}
/// <summary>Test authenticator that throws an exception during authentication.</summary>
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
{
/// <summary>Authenticates by throwing the configured exception.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; always throws the configured exception.</returns>
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
=> Task.FromException<OpcUaUserAuthResult>(ex);
}
@@ -20,6 +20,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>
/// Verifies that BuildSecurityPolicies emits all three baseline security profiles.
/// </summary>
[Fact]
public void BuildSecurityPolicies_default_set_emits_all_three_baseline_profiles()
{
@@ -39,6 +42,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
policies[2].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
/// <summary>
/// Verifies that BuildSecurityPolicies deduplicates repeated profiles.
/// </summary>
[Fact]
public void BuildSecurityPolicies_dedupes_repeated_profiles()
{
@@ -54,6 +60,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
policies[1].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
/// <summary>
/// Verifies that BuildSecurityPolicies falls back to None when given empty input.
/// </summary>
[Fact]
public void BuildSecurityPolicies_empty_input_falls_back_to_none()
{
@@ -64,6 +73,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
}
/// <summary>
/// Verifies that BuildUserTokenPolicies emits anonymous and username policies.
/// </summary>
[Fact]
public void BuildUserTokenPolicies_emits_anonymous_and_username()
{
@@ -76,6 +88,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
/// <summary>
/// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles.
/// </summary>
[Fact]
public async Task StartAsync_populates_ServerConfiguration_with_all_enabled_profiles()
{
@@ -111,6 +126,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
modes.ShouldBe(new[] { MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt });
}
/// <summary>
/// Verifies that StartAsync with only SignAndEncrypt omits the None endpoint.
/// </summary>
[Fact]
public async Task StartAsync_with_only_signandencrypt_omits_None_endpoint()
{
@@ -146,6 +164,9 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
return port;
}
/// <summary>
/// Cleans up temporary PKI files.
/// </summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
@@ -17,6 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// </summary>
public sealed class OpcUaApplicationHostServerArrayTests
{
/// <summary>
/// Verifies that ServerArray contains local URI and configured peer URIs after start.
/// </summary>
[Fact]
public async Task ServerArray_contains_local_uri_and_configured_peers_after_start()
{
@@ -18,6 +18,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>Verifies StartAsync creates a self-signed certificate in the PKI own store.</summary>
[Fact]
public async Task StartAsync_creates_application_certificate_in_pki_own()
{
@@ -39,6 +40,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
Directory.EnumerateFiles(ownCerts).ShouldNotBeEmpty("expected a self-signed cert file in the own store");
}
/// <summary>Verifies StartAsync reuses an existing certificate on the second boot.</summary>
[Fact]
public async Task StartAsync_reuses_existing_certificate_on_second_boot()
{
@@ -89,6 +91,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
return port;
}
/// <summary>Cleans up the temporary PKI directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
@@ -22,6 +22,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships.</summary>
[Fact]
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
{
@@ -44,6 +45,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
}
/// <summary>Verifies that orphan equipment without a parent line appears under root.</summary>
[Fact]
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
{
@@ -62,6 +64,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
}
/// <summary>Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.</summary>
[Fact]
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
{
@@ -112,6 +115,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
return port;
}
/// <summary>Disposes of resources allocated by this test class.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
@@ -124,12 +128,28 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
{
private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
/// <summary>Gets the list of EnsureFolder calls recorded by this sink.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
/// <summary>Records a value write (stub implementation for testing).</summary>
/// <param name="nodeId">The node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Records an alarm state write (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
/// <summary>Records a folder creation request.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { }
}
}
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class Phase7ApplierTests
{
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
[Fact]
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
{
@@ -24,6 +25,7 @@ public sealed class Phase7ApplierTests
sink.AlarmWrites.ShouldBeEmpty();
}
/// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
[Fact]
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
{
@@ -40,6 +42,7 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary>
[Fact]
public void Added_equipment_triggers_rebuild_without_alarm_writes()
{
@@ -65,6 +68,7 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
[Fact]
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
{
@@ -93,6 +97,7 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Verifies that sink exceptions in WriteAlarmState do not propagate and rebuild still fires.</summary>
[Fact]
public void Sink_exception_in_WriteAlarmState_does_not_propagate_and_rebuild_still_fires()
{
@@ -124,32 +129,70 @@ public sealed class Phase7ApplierTests
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the queue of alarm state write calls.</summary>
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
/// <summary>Gets the queue of folder creation calls.</summary>
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
public int RebuildCalls;
/// <summary>Gets the list of recorded alarm writes.</summary>
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
/// <summary>Gets the list of recorded folder creation calls.</summary>
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
/// <summary>Records a value write (no-op in this recording sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Records an alarm state write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
{
private readonly bool _throwOnAlarmWrite;
/// <summary>Initializes a new instance of the ThrowingSink class.</summary>
/// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }
/// <summary>Records a value write (no-op in this sink).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
}
}
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class Phase7ComposerPurityTests
{
/// <summary>Verifies empty inputs produce empty result.</summary>
[Fact]
public void Empty_inputs_produce_empty_result()
{
@@ -19,6 +20,7 @@ public sealed class Phase7ComposerPurityTests
result.ScriptedAlarmPlans.ShouldBeEmpty();
}
/// <summary>Verifies same inputs in different order produce structurally equal results.</summary>
[Fact]
public void Same_inputs_in_different_order_produce_structurally_equal_results()
{
@@ -44,6 +46,7 @@ public sealed class Phase7ComposerPurityTests
r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
}
/// <summary>Verifies Compose is pure with identical repeated calls.</summary>
[Fact]
public void Compose_is_pure_repeated_call_returns_element_identical_output()
{
@@ -61,6 +64,7 @@ public sealed class Phase7ComposerPurityTests
r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
}
/// <summary>Verifies output is sorted by natural key.</summary>
[Fact]
public void Output_is_sorted_by_natural_key()
{
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class Phase7PlannerTests
{
/// <summary>Verifies that empty inputs produce an empty plan.</summary>
[Fact]
public void Empty_inputs_produce_empty_plan()
{
@@ -16,6 +17,7 @@ public sealed class Phase7PlannerTests
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies that identical compositions produce an empty plan.</summary>
[Fact]
public void Identical_compositions_produce_empty_plan()
{
@@ -28,6 +30,7 @@ public sealed class Phase7PlannerTests
plan.IsEmpty.ShouldBeTrue();
}
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
[Fact]
public void New_equipment_goes_to_AddedEquipment()
{
@@ -44,6 +47,7 @@ public sealed class Phase7PlannerTests
plan.ChangedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that disappeared equipment goes to the RemovedEquipment list.</summary>
[Fact]
public void Disappeared_equipment_goes_to_RemovedEquipment()
{
@@ -59,6 +63,7 @@ public sealed class Phase7PlannerTests
plan.AddedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that equipment with same id but different display name routes to ChangedEquipment.</summary>
[Fact]
public void Same_id_with_different_display_name_routes_to_ChangedEquipment()
{
@@ -79,6 +84,7 @@ public sealed class Phase7PlannerTests
plan.RemovedEquipment.ShouldBeEmpty();
}
/// <summary>Verifies that driver config changes route to ChangedDrivers.</summary>
[Fact]
public void Driver_config_change_routes_to_ChangedDrivers()
{
@@ -96,6 +102,7 @@ public sealed class Phase7PlannerTests
plan.ChangedDrivers.Single().Current.ConfigJson.ShouldContain("new");
}
/// <summary>Verifies that alarm message template changes route to ChangedAlarms.</summary>
[Fact]
public void Alarm_message_template_change_routes_to_ChangedAlarms()
{
@@ -113,6 +120,7 @@ public sealed class Phase7PlannerTests
plan.ChangedAlarms.Single().Current.MessageTemplate.ShouldBe("new");
}
/// <summary>Verifies that added and removed lists are sorted by id for deterministic ordering.</summary>
[Fact]
public void Added_and_removed_lists_are_sorted_by_id_for_deterministic_ordering()
{
@@ -127,6 +135,7 @@ public sealed class Phase7PlannerTests
plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" });
}
/// <summary>Verifies that mixed changes across all three classes are captured in one pass.</summary>
[Fact]
public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass()
{
@@ -19,6 +19,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Path.GetTempPath(),
$"otopcua-sink-{Guid.NewGuid():N}");
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
[Fact]
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
{
@@ -34,6 +35,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Verifies that WriteAlarmState creates a dedicated node distinct from value writes.</summary>
[Fact]
public async Task WriteAlarmState_creates_dedicated_node_distinct_from_value_writes()
{
@@ -48,6 +50,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
[Fact]
public async Task RebuildAddressSpace_clears_all_registered_variables()
{
@@ -69,6 +72,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Verifies that NullOpcUaAddressSpaceSink does not crash on any call.</summary>
[Fact]
public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
{
@@ -108,6 +112,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
@@ -19,6 +19,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
/// <summary>Verifies that the publisher writes values to the standard Server.ServiceLevel variable.</summary>
[Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
{
@@ -47,6 +48,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
variable.Value.ShouldBe((byte)200);
}
/// <summary>Verifies that publishing service level values is idempotent when called multiple times.</summary>
[Fact]
public async Task Publish_is_idempotent_when_called_multiple_times()
{
@@ -83,6 +85,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
return port;
}
/// <summary>Disposes and cleans up the test resources.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
@@ -8,18 +8,21 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public sealed class DeploymentArtifactTests
{
/// <summary>Verifies that empty blob returns empty list.</summary>
[Fact]
public void Empty_blob_returns_empty_list()
{
DeploymentArtifact.ParseDriverInstances(ReadOnlySpan<byte>.Empty).ShouldBeEmpty();
}
/// <summary>Verifies that malformed JSON returns empty list.</summary>
[Fact]
public void Malformed_json_returns_empty_list()
{
DeploymentArtifact.ParseDriverInstances(Encoding.UTF8.GetBytes("not json")).ShouldBeEmpty();
}
/// <summary>Verifies that snapshot without DriverInstances returns empty.</summary>
[Fact]
public void Snapshot_without_DriverInstances_returns_empty()
{
@@ -27,6 +30,7 @@ public sealed class DeploymentArtifactTests
DeploymentArtifact.ParseDriverInstances(blob).ShouldBeEmpty();
}
/// <summary>Verifies that driver instances are parsed from composer-shaped blob.</summary>
[Fact]
public void Parses_driver_instances_from_composer_shaped_blob()
{
@@ -69,6 +73,7 @@ public sealed class DeploymentArtifactTests
specs[1].Enabled.ShouldBeFalse();
}
/// <summary>Verifies that ParseComposition returns empty for empty blob.</summary>
[Fact]
public void ParseComposition_returns_empty_for_empty_blob()
{
@@ -78,6 +83,7 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.ShouldBeEmpty();
}
/// <summary>Verifies that ParseComposition reads all three entity classes sorted by ID.</summary>
[Fact]
public void ParseComposition_reads_all_three_entity_classes_sorted_by_id()
{
@@ -111,6 +117,7 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
}
/// <summary>Verifies that specs missing required fields are dropped.</summary>
[Fact]
public void Spec_missing_required_fields_is_dropped()
{
@@ -21,6 +21,7 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
/// <summary>Verifies that applying a deployment with driver instances spawns one child per enabled row.</summary>
[Fact]
public void Apply_with_driver_instances_in_artifact_spawns_one_child_per_enabled_row()
{
@@ -43,6 +44,7 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that applying a deployment with unsupported driver type falls back to stub.</summary>
[Fact]
public void Apply_with_unsupported_driver_type_falls_back_to_stub()
{
@@ -72,6 +74,7 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
snap.Drivers[0].State.ShouldBe("Stubbed");
}
/// <summary>Verifies that Galaxy driver on non-Windows is stubbed by ShouldStub check.</summary>
[Fact]
public void Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check()
{
@@ -102,6 +105,7 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
}
}
/// <summary>Verifies that a second apply with removed driver stops the child.</summary>
[Fact]
public void Second_apply_with_removed_driver_stops_the_child()
{
@@ -161,12 +165,20 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
return id;
}
/// <summary>Test double for IDriverFactory that counts driver creation attempts.</summary>
private sealed class CountingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
/// <summary>Gets the number of times TryCreate was called and returned a driver.</summary>
public int CreateCount;
/// <summary>Initializes a new instance with the specified supported driver type.</summary>
/// <param name="supportedType">The driver type this factory supports.</param>
public CountingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>Attempts to create a driver if the type is supported.</summary>
/// <param name="driverType">The driver type to create.</param>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
@@ -174,19 +186,32 @@ public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
return new TestDriver(driverInstanceId, driverType);
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Test double for IDriver with minimal implementation.</summary>
private sealed class TestDriver : IDriver
{
/// <inheritdoc />
public string DriverInstanceId { get; }
/// <inheritdoc />
public string DriverType { get; }
/// <summary>Initializes a new test driver with the specified ID and type.</summary>
/// <param name="id">The driver instance ID.</param>
/// <param name="type">The driver type.</param>
public TestDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -17,6 +17,7 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
/// <summary>Verifies that bootstrap with no prior state enters the Steady state.</summary>
[Fact]
public void Bootstrap_with_no_prior_state_enters_Steady()
{
@@ -33,6 +34,7 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
ack.NodeId.ShouldBe(TestNode);
}
/// <summary>Verifies that dispatching the same revision is acked immediately without apply work.</summary>
[Fact]
public void Same_revision_dispatch_is_acked_immediately_with_no_apply_work()
{
@@ -66,6 +68,7 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
verify.NodeDeploymentStates.Count(s => s.NodeId == TestNode.Value).ShouldBe(1);
}
/// <summary>Verifies that a new revision dispatch writes an Applied NodeDeploymentState.</summary>
[Fact]
public void New_revision_dispatch_writes_Applied_NodeDeploymentState()
{
@@ -89,6 +92,7 @@ public sealed class DriverHostActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that an orphaned Applying row on bootstrap is replayed.</summary>
[Fact]
public void Orphan_Applying_row_on_bootstrap_replays_apply()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public sealed class DriverInstanceActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.</summary>
[Fact]
public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success()
{
@@ -32,6 +33,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
driver.ReinitializeCount.ShouldBe(1);
}
/// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary>
[Fact]
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
{
@@ -45,6 +47,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
AwaitCondition(() => driver.InitializeCount >= 3, TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that writing to a non-IWritable driver returns failure.</summary>
[Fact]
public async Task Write_against_non_IWritable_driver_returns_failure()
{
@@ -62,6 +65,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
reply.Reason!.ShouldContain("IWritable");
}
/// <summary>Verifies that writing to an IWritable driver returns success when status is Good.</summary>
[Fact]
public async Task Write_against_IWritable_returns_success_when_status_is_Good()
{
@@ -80,6 +84,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
driver.Writes.Single().Value.ShouldBe(42);
}
/// <summary>Verifies that write propagates status code on Bad result.</summary>
[Fact]
public async Task Write_propagates_status_code_on_Bad_result()
{
@@ -98,6 +103,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
reply.Reason!.ShouldContain("80340000");
}
/// <summary>Verifies that subscribing to an ISubscribable driver forwards OnDataChange to parent.</summary>
[Fact]
public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent()
{
@@ -122,6 +128,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
published.Quality.ShouldBe(OpcUaQuality.Good);
}
/// <summary>Verifies that subscribe translates OPC UA status severity bits to OpcUaQuality.</summary>
[Fact]
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
{
@@ -145,6 +152,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Bad);
}
/// <summary>Verifies that subscribing to a non-ISubscribable driver replies with failure.</summary>
[Fact]
public async Task Subscribe_against_non_ISubscribable_replies_with_failure()
{
@@ -161,6 +169,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
reply.Reason.ShouldContain("ISubscribable");
}
/// <summary>Verifies that DisconnectObserved detaches subscription handler so late events are dropped.</summary>
[Fact]
public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped()
{
@@ -185,13 +194,21 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
private class StubDriver : IDriver
{
/// <summary>Gets or sets a value indicating whether initialization should throw.</summary>
public bool InitializeShouldThrow { get; set; }
/// <summary>Gets the number of times initialization was called.</summary>
public int InitializeCount;
/// <summary>Gets the number of times reinitialization was called.</summary>
public int ReinitializeCount;
/// <summary>Gets the driver instance ID.</summary>
public string DriverInstanceId => "stub-driver-1";
/// <summary>Gets the driver type.</summary>
public string DriverType => "Stub";
/// <summary>Initializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
@@ -199,23 +216,37 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReinitializeCount);
return Task.CompletedTask;
}
/// <summary>Shuts down the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class WritableStubDriver : StubDriver, IWritable
{
/// <summary>Gets or sets the next status code to return from write operations.</summary>
public uint NextStatusCode { get; set; } = 0u;
/// <summary>Gets the list of write requests received.</summary>
public List<WriteRequest> Writes { get; } = new();
/// <summary>Writes the specified requests.</summary>
/// <param name="writes">The write requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -227,19 +258,32 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
private sealed class SubscribableStubDriver : StubDriver, ISubscribable
{
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
private readonly StubHandle _handle = new();
/// <summary>Gets the number of subscribers to OnDataChange.</summary>
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
=> Task.FromResult<ISubscriptionHandle>(_handle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
/// <param name="handle">The subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Fires a data change event with the specified parameters.</summary>
/// <param name="fullRef">The full reference of the data that changed.</param>
/// <param name="value">The new value.</param>
/// <param name="statusCode">The OPC UA status code.</param>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
@@ -248,6 +292,7 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
}
@@ -9,6 +9,7 @@ public sealed class DriverSpawnPlannerTests
private static DriverInstanceSpec Spec(string id, string type = "Modbus", string config = "{\"host\":\"127.0.0.1\"}", bool enabled = true) =>
new(Guid.NewGuid(), id, id, type, enabled, config);
/// <summary>Verifies that all new drivers are placed in ToSpawn when current is empty.</summary>
[Fact]
public void All_new_drivers_go_into_ToSpawn_when_current_is_empty()
{
@@ -22,6 +23,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that the same configuration yields an empty plan.</summary>
[Fact]
public void Same_config_yields_empty_plan()
{
@@ -38,6 +40,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that different configuration is routed to ApplyDelta.</summary>
[Fact]
public void Different_config_routes_to_ApplyDelta()
{
@@ -54,6 +57,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
[Fact]
public void Removed_driver_routes_to_ToStop()
{
@@ -71,6 +75,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary>
[Fact]
public void Disabled_driver_with_running_child_routes_to_ToStop()
{
@@ -87,6 +92,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToApplyDelta.ShouldBeEmpty();
}
/// <summary>Verifies that disabled new drivers are not spawned.</summary>
[Fact]
public void Disabled_new_driver_is_not_spawned()
{
@@ -100,6 +106,7 @@ public sealed class DriverSpawnPlannerTests
plan.ToStop.ShouldBeEmpty();
}
/// <summary>Verifies that driver type changes trigger stop followed by respawn.</summary>
[Fact]
public void Driver_type_change_triggers_stop_plus_respawn()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// </summary>
public abstract class RuntimeActorTestBase : TestKit
{
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
protected static string AkkaTestHocon => @"
akka {
loglevel = ""WARNING""
@@ -32,6 +33,7 @@ akka {
}
}";
/// <summary>Initializes a new instance of the RuntimeActorTestBase class with Akka test setup.</summary>
protected RuntimeActorTestBase() : base(AkkaTestHocon)
{
var cluster = Akka.Cluster.Cluster.Get(Sys);
@@ -40,6 +42,9 @@ akka {
TimeSpan.FromSeconds(5));
}
/// <summary>Creates a new in-memory database factory for testing.</summary>
/// <param name="dbName">The database name, or null to generate a unique name.</param>
/// <returns>An IDbContextFactory for the in-memory database.</returns>
protected static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory(string? dbName = null)
{
dbName ??= Guid.NewGuid().ToString("N");
@@ -48,6 +53,8 @@ akka {
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new in-memory database context.</summary>
/// <returns>A new OtOpcUaConfigDbContext instance.</returns>
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
public sealed class HealthProbeActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.</summary>
[Fact]
public async Task DbHealthProbeActor_returns_reachable_against_in_memory_db()
{
@@ -28,6 +29,7 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
status.LastError.ShouldBeNull();
}
/// <summary>Verifies that the peer OPC UA probe actor reports Ok true against a live listener.</summary>
[Fact]
public void PeerOpcUaProbeActor_reports_Ok_true_against_a_live_listener()
{
@@ -47,6 +49,7 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that the peer OPC UA probe actor reports Ok false against an unreachable endpoint.</summary>
[Fact]
public void PeerOpcUaProbeActor_reports_Ok_false_against_an_unreachable_endpoint()
{
@@ -63,6 +66,7 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that the historian adapter actor forwards events to the injected sink.</summary>
[Fact]
public void HistorianAdapterActor_forwards_events_to_injected_sink()
{
@@ -87,6 +91,7 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
new[] { "alm-0", "alm-1", "alm-2", "alm-3", "alm-4" });
}
/// <summary>Verifies that the historian adapter actor returns sink status via GetStatus.</summary>
[Fact]
public async Task HistorianAdapterActor_returns_sink_status_via_GetStatus()
{
@@ -107,14 +112,19 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
private sealed class RecordingSink : IAlarmHistorianSink
{
/// <summary>Gets the list of enqueued alarm historian events.</summary>
public ConcurrentBag<AlarmHistorianEvent> Enqueued { get; } = [];
/// <summary>Enqueues an alarm historian event.</summary>
/// <param name="evt">The event to enqueue.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
Enqueued.Add(evt);
return Task.CompletedTask;
}
/// <summary>Gets the current status of the historian sink.</summary>
public HistorianSinkStatus GetStatus() => new(
QueueDepth: Enqueued.Count,
DeadLetterDepth: 0,
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Observability;
/// </summary>
public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
{
/// <summary>Verifies that VirtualTagActor evaluation emits the otopcua_virtualtag_eval counter.</summary>
[Fact]
public void VirtualTagActor_evaluation_emits_otopcua_virtualtag_eval_counter()
{
@@ -34,6 +35,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
recorder.WithTag("outcome", "ok").ShouldBeGreaterThanOrEqualTo(1);
}
/// <summary>Verifies that OpcUaPublishActor AttributeValueUpdate emits the sink write counter.</summary>
[Fact]
public void OpcUaPublishActor_AttributeValueUpdate_emits_sink_write_counter()
{
@@ -58,6 +60,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
});
}
/// <summary>Verifies that RebuildAddressSpace starts an address space rebuild span.</summary>
[Fact]
public void RebuildAddressSpace_starts_an_address_space_rebuild_span()
{
@@ -95,6 +98,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
private readonly List<KeyValuePair<string, object?>[]> _tagSets = new();
private readonly object _gate = new();
/// <summary>Initializes the recorder for the specified instrument name.</summary>
/// <param name="instrumentName">Name of the instrument to listen for.</param>
public MeterRecorder(string instrumentName)
{
_name = instrumentName;
@@ -117,8 +122,12 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
_listener.Start();
}
/// <summary>Gets the total value recorded.</summary>
public long Total { get { lock (_gate) return _total; } }
/// <summary>Gets count of measurements with the specified tag key-value pair.</summary>
/// <param name="key">Tag key.</param>
/// <param name="value">Tag value.</param>
public int WithTag(string key, string value)
{
lock (_gate)
@@ -127,7 +136,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
}
}
public void Dispose() => _listener.Dispose();
/// <summary>Releases the listener.</summary>
public void Dispose() => _listener.Dispose();
}
/// <summary>Listens to a single ActivitySource by name and stores started Activities.</summary>
@@ -138,6 +148,8 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
private readonly List<Activity> _activities = new();
private readonly object _gate = new();
/// <summary>Initializes the recorder for the specified operation name.</summary>
/// <param name="operationName">Name of the operation to listen for.</param>
public ActivityRecorder(string operationName)
{
_operationName = operationName;
@@ -156,23 +168,45 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
ActivitySource.AddActivityListener(_listener);
}
/// <summary>Gets the list of recorded activities.</summary>
public IReadOnlyList<Activity> Activities { get { lock (_gate) return _activities.ToArray(); } }
/// <summary>Releases the listener.</summary>
public void Dispose() => _listener.Dispose();
}
private sealed class ConstEval(object? value) : IVirtualTagEvaluator
{
/// <summary>Evaluates the virtual tag with a constant value.</summary>
/// <param name="virtualTagId">Virtual tag ID.</param>
/// <param name="expression">Expression to evaluate.</param>
/// <param name="dependencies">Dependency values.</param>
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(value);
}
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the write count.</summary>
public int Writes { get; private set; }
/// <summary>Records a value write.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
/// <summary>Records an alarm state write.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
/// <summary>Ensures folder exists (stub implementation).</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ }
}
}
@@ -18,6 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
{
/// <summary>Tests that RebuildAddressSpace with dbFactory loads artifact, composes, and applies.</summary>
[Fact]
public void RebuildAddressSpace_with_dbFactory_loads_artifact_composes_and_applies()
{
@@ -42,6 +43,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(2));
}
/// <summary>Tests that rebuild with no artifact is idempotent no-op.</summary>
[Fact]
public void Rebuild_with_no_artifact_is_idempotent_no_op()
{
@@ -61,6 +63,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>Tests that second rebuild with same artifact is empty plan no-op.</summary>
[Fact]
public void Second_rebuild_with_same_artifact_is_empty_plan_no_op()
{
@@ -81,6 +84,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Tests that rebuild without dbFactory falls back to raw sink rebuild.</summary>
[Fact]
public void Rebuild_without_dbFactory_falls_back_to_raw_sink_rebuild()
{
@@ -133,14 +137,31 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the list of recorded sink calls.</summary>
public ConcurrentQueue<string> Calls { get; } = new();
/// <summary>Gets or sets the count of rebuild address space calls.</summary>
public int RebuildCalls;
/// <summary>Records a value write call.</summary>
/// <param name="nodeId">The OPC UA node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the write.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
=> Calls.Enqueue($"WV:{nodeId}");
/// <summary>Records an alarm state write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="ts">The timestamp of the state change.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}");
/// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> Calls.Enqueue($"EF:{folderNodeId}");
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
}
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that message contracts are accepted without a pinned dispatcher in tests.</summary>
[Fact]
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
{
@@ -24,6 +25,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that production props target the OPC UA synchronized dispatcher.</summary>
[Fact]
public void Production_Props_targets_opcua_synchronized_dispatcher()
{
@@ -31,6 +33,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
props.Dispatcher.ShouldBe(OpcUaPublishActor.DispatcherId);
}
/// <summary>Verifies that AttributeValueUpdate routes to sink WriteValue.</summary>
[Fact]
public void AttributeValueUpdate_routes_to_sink_WriteValue()
{
@@ -50,6 +53,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that AlarmStateUpdate routes to sink WriteAlarmState.</summary>
[Fact]
public void AlarmStateUpdate_routes_to_sink_WriteAlarmState()
{
@@ -67,6 +71,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that RebuildAddressSpace calls sink Rebuild.</summary>
[Fact]
public void RebuildAddressSpace_calls_sink_Rebuild()
{
@@ -78,6 +83,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
[Fact]
public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()
{
@@ -92,6 +98,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that RedundancyStateChanged drives local ServiceLevel publish for primary leader.</summary>
[Fact]
public void RedundancyStateChanged_drives_local_ServiceLevel_publish_for_primary_leader()
{
@@ -115,6 +122,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that RedundancyStateChanged for secondary publishes 100.</summary>
[Fact]
public void RedundancyStateChanged_for_secondary_publishes_100()
{
@@ -135,32 +143,57 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Test implementation of IOpcUaAddressSpaceSink that records calls.</summary>
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
/// <summary>Gets the queue of recorded value updates.</summary>
public ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> ValueQueue { get; } = new();
/// <summary>Gets the queue of recorded alarm state updates.</summary>
public ConcurrentQueue<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> AlarmQueue { get; } = new();
/// <summary>Count of rebuild calls.</summary>
public int RebuildCalls;
/// <summary>Gets the list of recorded value updates.</summary>
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values =>
ValueQueue.ToList();
/// <summary>Gets the list of recorded alarm state updates.</summary>
public List<(string AlarmNodeId, bool Active, bool Acknowledged, DateTime Ts)> Alarms =>
AlarmQueue.ToList();
/// <summary>Records a value update.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The attribute value.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the update.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
ValueQueue.Enqueue((nodeId, value, quality, ts));
/// <summary>Records an alarm state update.</summary>
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="ts">The timestamp of the update.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
/// <summary>Ensures a folder exists (no-op in test).</summary>
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
/// <summary>Records a rebuild call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
}
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
private sealed class RecordingPublisher : IServiceLevelPublisher
{
private readonly ConcurrentQueue<byte> _q = new();
/// <summary>Gets the recorded service levels.</summary>
public byte[] Levels => _q.ToArray();
/// <summary>Records a service level publish.</summary>
/// <param name="serviceLevel">The service level value to publish.</param>
public void Publish(byte serviceLevel) => _q.Enqueue(serviceLevel);
}
}
@@ -24,6 +24,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
{
private static CancellationToken Ct => CancellationToken.None;
/// <summary>Verifies that the primary cluster leader sets Server ServiceLevel to 240.</summary>
[Fact]
public async Task Primary_leader_drives_Server_ServiceLevel_to_240()
{
@@ -63,6 +64,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
}
}
/// <summary>Verifies that the secondary node sets Server ServiceLevel to 100.</summary>
[Fact]
public async Task Secondary_drives_Server_ServiceLevel_to_100()
{
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that full state cycle publishes StateChanged messages to parent at each transition.</summary>
[Fact]
public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
{
@@ -33,6 +34,7 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
}
/// <summary>Verifies that duplicate ConditionMet messages in Active state are ignored.</summary>
[Fact]
public void Duplicate_ConditionMet_in_Active_is_ignored()
{
@@ -46,6 +48,7 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.</summary>
[Fact]
public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic()
{
@@ -82,6 +85,7 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that clear transition publishes Cleared event.</summary>
[Fact]
public void Engine_clear_transition_publishes_Cleared_event()
{
@@ -107,6 +111,7 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(1));
}
/// <summary>Verifies that manual acknowledge emits Acknowledged transition with the user.</summary>
[Fact]
public void Manual_acknowledge_emits_Acknowledged_transition_with_user()
{
@@ -132,10 +137,16 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(1));
}
/// <summary>A threshold-based alarm evaluator for testing.</summary>
private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
{
private readonly double _threshold;
/// <summary>Initializes a new instance of the ThresholdEvaluator class.</summary>
/// <param name="threshold">The threshold value to compare against.</param>
public ThresholdEvaluator(double threshold) { _threshold = threshold; }
/// <inheritdoc />
public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary<string, object?> deps)
{
if (!deps.TryGetValue("temp", out var raw) || raw is null)
@@ -144,10 +155,18 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
}
}
/// <summary>A test publisher that captures published messages.</summary>
private sealed class CapturingPublisher
{
/// <summary>Gets the topics that messages were published to.</summary>
public ConcurrentBag<string> Topics { get; } = new();
/// <summary>Gets the payloads that were published.</summary>
public ConcurrentBag<object> Payloads { get; } = new();
/// <summary>Publishes a message to the specified topic.</summary>
/// <param name="topic">The topic name.</param>
/// <param name="payload">The message payload.</param>
public void Publish(string topic, object payload)
{
Topics.Add(topic);
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
{
/// <summary>Verifies that alarm state transitions write to the state store with the correct lastAckUser value.</summary>
[Fact]
public async Task Transition_writes_to_state_store_with_lastAckUser()
{
@@ -37,6 +38,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that actor restart restores persisted state so pending acknowledgment is not dropped.</summary>
[Fact]
public async Task PreStart_restores_persisted_state_so_restart_does_not_drop_pending_ack()
{
@@ -61,6 +63,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}, duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that alarm boots to inactive state when no persisted state exists.</summary>
[Fact]
public async Task PreStart_with_no_persisted_state_boots_inactive()
{
@@ -76,6 +79,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Verifies that EF-based alarm actor state store correctly persists and restores state through the config database.</summary>
[Fact]
public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb()
{
@@ -113,6 +117,7 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
}
}
/// <summary>Verifies that loading an alarm state for a missing ID returns null.</summary>
[Fact]
public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null()
{
@@ -128,11 +133,20 @@ public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase
private readonly ConcurrentDictionary<string, AlarmActorStateSnapshot> _byId = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<AlarmActorStateSnapshot> _saves = new();
/// <summary>Gets all saved alarm state snapshots in order.</summary>
public List<AlarmActorStateSnapshot> Snapshots => _saves.ToList();
/// <summary>Loads the alarm state snapshot for the specified alarm ID.</summary>
/// <param name="alarmId">The alarm ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The alarm state snapshot if found, null otherwise.</returns>
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
=> Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null);
/// <summary>Saves the alarm state snapshot.</summary>
/// <param name="snapshot">The alarm state snapshot to save.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A completed task.</returns>
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
{
_byId[snapshot.AlarmId] = snapshot;
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
/// </summary>
public sealed class ServiceCollectionExtensionsTests
{
/// <summary>Verifies that WithOtOpcUaRuntimeActors spawns driver host and DB health probe actors.</summary>
[Fact]
public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe()
{
@@ -67,8 +68,10 @@ public sealed class ServiceCollectionExtensionsTests
}
}
/// <summary>In-memory database factory for testing.</summary>
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <summary>Creates a new in-memory database context.</summary>
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
@@ -78,13 +81,25 @@ public sealed class ServiceCollectionExtensionsTests
}
}
/// <summary>Fake cluster role information for testing.</summary>
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
{
/// <summary>Gets the local node ID.</summary>
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
/// <summary>Gets the local roles.</summary>
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
/// <summary>Determines whether the local node has the specified role.</summary>
/// <param name="role">The role to check.</param>
public bool HasRole(string role) => LocalRoles.Contains(role);
/// <summary>Gets the members with the specified role.</summary>
/// <param name="role">The role to query.</param>
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
/// <summary>Gets the leader node for the specified role.</summary>
/// <param name="role">The role to query.</param>
public NodeId? RoleLeader(string role) => null;
/// <summary>Raised when the role leader changes.</summary>
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
{
add { _ = value; }
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
public sealed class DependencyMuxActorTests : RuntimeActorTestBase
{
/// <summary>Verifies that AttributeValuePublished messages are forwarded only to subscribers interested in the specific reference.</summary>
[Fact]
public void AttributeValuePublished_is_forwarded_only_to_subscribers_interested_in_that_ref()
{
@@ -44,6 +45,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
subB.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
}
/// <summary>Verifies that publishes for unregistered references are silently dropped.</summary>
[Fact]
public void Publish_for_unregistered_ref_is_silently_dropped()
{
@@ -56,6 +58,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that unregistering interest stops forwarding to that subscriber.</summary>
[Fact]
public void UnregisterInterest_stops_forwarding()
{
@@ -71,6 +74,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that re-registering a subscriber replaces the prior interest set.</summary>
[Fact]
public void Re_register_replaces_prior_interest_set()
{
@@ -87,6 +91,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("tag-2");
}
/// <summary>Verifies that a virtual tag actor registers dependencies with the mux during PreStart and that evaluation fires end-to-end.</summary>
[Fact]
public void VirtualTagActor_PreStart_registers_deps_with_mux_and_eval_fires_end_to_end()
{
@@ -119,6 +124,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that the DriverHostActor forwards AttributeValuePublished messages through the dependency mux.</summary>
[Fact]
public void DriverHostActor_forwards_AttributeValuePublished_through_to_mux()
{
@@ -145,6 +151,10 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
private sealed class EchoSumEvaluator : ZB.MOM.WW.OtOpcUa.Commons.Engines.IVirtualTagEvaluator
{
/// <summary>Evaluates the expression by summing all dependency values as integers.</summary>
/// <param name="id">The identifier of the virtual tag.</param>
/// <param name="expression">The expression to evaluate.</param>
/// <param name="deps">A dictionary of dependency values keyed by reference.</param>
public ZB.MOM.WW.OtOpcUa.Commons.Engines.VirtualTagEvalResult Evaluate(
string id, string expression, IReadOnlyDictionary<string, object?> deps)
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
public sealed class VirtualTagActorTests : RuntimeActorTestBase
{
/// <summary>Verifies the actor accepts dependency value changes and remains alive.</summary>
[Fact]
public void DependencyValueChanged_is_accepted_and_actor_stays_alive()
{
@@ -21,6 +22,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies evaluator results flow to parent as EvaluationResult.</summary>
[Fact]
public void Evaluator_result_flows_to_parent_as_EvaluationResult()
{
@@ -38,6 +40,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
second.Value.ShouldBe(42);
}
/// <summary>Verifies repeated same values do not emit a second EvaluationResult.</summary>
[Fact]
public void Repeated_same_value_does_not_emit_a_second_EvaluationResult()
{
@@ -53,6 +56,7 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies evaluator failure publishes ScriptLogEntry warning.</summary>
[Fact]
public void Evaluator_failure_publishes_ScriptLogEntry_warning()
{
@@ -79,8 +83,14 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
}
/// <summary>Test evaluator that sums integer dependency values.</summary>
private sealed class SumEvaluator : IVirtualTagEvaluator
{
/// <summary>Evaluates the expression by summing integer dependencies.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>The sum of integer values in the dependencies.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
{
var sum = deps.Values.OfType<int>().Sum();
@@ -88,26 +98,48 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
}
}
/// <summary>Test evaluator that always returns a constant value.</summary>
private sealed class ConstEvaluator : IVirtualTagEvaluator
{
private readonly object _value;
/// <summary>Initializes the constant evaluator with a fixed value.</summary>
/// <param name="value">The constant value to return.</param>
public ConstEvaluator(object value) { _value = value; }
/// <summary>Evaluates the expression by returning the constant value.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>The constant value.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Ok(_value);
}
/// <summary>Test evaluator that always returns a failure.</summary>
private sealed class FailingEvaluator : IVirtualTagEvaluator
{
private readonly string _reason;
/// <summary>Initializes the failing evaluator with a failure reason.</summary>
/// <param name="reason">The reason for the failure.</param>
public FailingEvaluator(string reason) { _reason = reason; }
/// <summary>Evaluates the expression and returns a failure.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A failure result with the specified reason.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Failure(_reason);
}
/// <summary>Test publisher that captures published messages.</summary>
private sealed class CapturingPublisher
{
/// <summary>Gets the list of published topics.</summary>
public ConcurrentBag<string> Topics { get; } = new();
/// <summary>Gets the list of published payloads.</summary>
public ConcurrentBag<object> Payloads { get; } = new();
/// <summary>Publishes a message with a topic and payload.</summary>
/// <param name="topic">The message topic.</param>
/// <param name="payload">The message payload.</param>
public void Publish(string topic, object payload)
{
Topics.Add(topic);
@@ -31,6 +31,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Initializes the test host and server.</summary>
public async ValueTask InitializeAsync()
{
var dbName = $"auth-int-tests-{Guid.NewGuid():N}";
@@ -71,6 +72,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
_server = _host.GetTestServer();
}
/// <summary>Disposes the test host and server.</summary>
public async ValueTask DisposeAsync()
{
await _host.StopAsync(TestContext.Current.CancellationToken);
@@ -79,6 +81,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private HttpClient NewClient() => _server.CreateClient();
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
[Fact]
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
{
@@ -90,6 +93,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
}
/// <summary>Tests that login with invalid credentials returns 401.</summary>
[Fact]
public async Task Login_with_invalid_credentials_returns_401()
{
@@ -100,6 +104,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
/// <summary>Tests that login when LDAP throws returns 503.</summary>
[Fact]
public async Task Login_when_ldap_throws_returns_503()
{
@@ -110,6 +115,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
/// <summary>Tests that ping anonymous returns 401.</summary>
[Fact]
public async Task Ping_anonymous_returns_401()
{
@@ -119,6 +125,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
/// <summary>Tests that ping after cookie login returns 200.</summary>
[Fact]
public async Task Ping_after_cookie_login_returns_200()
{
@@ -133,6 +140,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>Tests that token after cookie login returns jwt.</summary>
[Fact]
public async Task Token_after_cookie_login_returns_jwt()
{
@@ -152,6 +160,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
token!.Split('.').Length.ShouldBe(3);
}
/// <summary>Tests that logout clears the cookie.</summary>
[Fact]
public async Task Logout_clears_the_cookie()
{
@@ -180,6 +189,11 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private sealed class StubLdapAuthService : ILdapAuthService
{
/// <summary>Authenticates a user asynchronously using the stub service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to verify.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The authentication result.</returns>
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (username == "ldap-down")
@@ -20,12 +20,14 @@ public sealed class JwtTokenServiceTests
ExpiryMinutes = expiryMinutes,
}), NullLogger<JwtTokenService>.Instance);
/// <summary>Verifies that a short signing key throws InvalidOperationException.</summary>
[Fact]
public void Short_signing_key_throws()
{
Should.Throw<InvalidOperationException>(() => NewService("too-short"));
}
/// <summary>Verifies that issued tokens validate and preserve claims.</summary>
[Fact]
public void Issue_then_validate_roundtrips_claims()
{
@@ -40,6 +42,7 @@ public sealed class JwtTokenServiceTests
.Select(c => c.Value).ShouldBe(new[] { "ReadOnly", "AlarmAck" }, ignoreOrder: true);
}
/// <summary>Verifies that tampered tokens are rejected during validation.</summary>
[Fact]
public void Tampered_token_is_rejected()
{
@@ -55,6 +58,7 @@ public sealed class JwtTokenServiceTests
principal.ShouldBeNull();
}
/// <summary>Verifies that expired tokens are rejected during validation.</summary>
[Fact]
public void Expired_token_is_rejected()
{
@@ -66,6 +70,7 @@ public sealed class JwtTokenServiceTests
principal.ShouldBeNull();
}
/// <summary>Verifies that tokens signed with a different key are rejected.</summary>
[Fact]
public void Cross_key_token_is_rejected()
{
@@ -6,6 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
public sealed class LdapHelperTests
{
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
/// <param name="input">The input string.</param>
/// <param name="expected">The expected escaped output.</param>
[Theory]
[InlineData("joe", "joe")]
[InlineData("jo*e", "jo\\2ae")]
@@ -17,6 +20,9 @@ public sealed class LdapHelperTests
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
}
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected organizational unit value.</param>
[Theory]
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
@@ -26,6 +32,9 @@ public sealed class LdapHelperTests
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
}
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
/// <param name="dn">The distinguished name.</param>
/// <param name="expected">The expected RDN value.</param>
[Theory]
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
[InlineData("cn=Admins", "Admins")]
@@ -6,6 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
public sealed class RoleMapperTests
{
/// <summary>
/// Verifies that empty mapping returns no roles.
/// </summary>
[Fact]
public void Empty_mapping_returns_empty()
{
@@ -13,6 +16,9 @@ public sealed class RoleMapperTests
.ShouldBeEmpty();
}
/// <summary>
/// Verifies that RoleMapper maps a group to its corresponding role.
/// </summary>
[Fact]
public void Maps_group_to_role()
{
@@ -22,6 +28,9 @@ public sealed class RoleMapperTests
.ShouldBe(new[] { "FleetAdmin" });
}
/// <summary>
/// Verifies that group matching is case-insensitive.
/// </summary>
[Fact]
public void Case_insensitive_group_match()
{
@@ -34,6 +43,9 @@ public sealed class RoleMapperTests
.ShouldBe(new[] { "FleetAdmin" });
}
/// <summary>
/// Verifies that multiple groups are deduplicated to unique roles.
/// </summary>
[Fact]
public void Multiple_groups_dedup_roles()
{