diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs new file mode 100644 index 0000000..cafa479 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/ServiceLevelEndToEndTests.cs @@ -0,0 +1,147 @@ +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Task 60 / #81 — verifies the full path from cluster redundancy state to OPC UA +/// Server.ServiceLevel visible on the wire. Boots a real , +/// wires into a +/// (the production binding pattern), spawns against the +/// deferred publisher, and sends a snapshot. Asserts +/// ServerObject.ServiceLevel.Value reflects the role-derived byte. +/// +public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase +{ + private static CancellationToken Ct => CancellationToken.None; + + [Fact] + public async Task Primary_leader_drives_Server_ServiceLevel_to_240() + { + var pkiRoot = AllocatePkiRoot(); + try + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + BuildOptions("PrimaryLeader", pkiRoot), + NullLogger.Instance); + await host.StartAsync(server, Ct); + + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance)); + + var localNode = NodeId.Parse("node-A"); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + serviceLevel: deferred, + subscribeRedundancyTopic: false, + localNode: localNode)); + + actor.Tell(new RedundancyStateChanged( + Nodes: new[] + { + new NodeRedundancyState(localNode, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId: CorrelationId.NewId())); + + AwaitAssertion(() => + server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240)); + } + finally + { + DeletePkiRoot(pkiRoot); + } + } + + [Fact] + public async Task Secondary_drives_Server_ServiceLevel_to_100() + { + var pkiRoot = AllocatePkiRoot(); + try + { + var server = new StandardServer(); + await using var host = new OpcUaApplicationHost( + BuildOptions("Secondary", pkiRoot), + NullLogger.Instance); + await host.StartAsync(server, Ct); + + var deferred = new DeferredServiceLevelPublisher(); + deferred.SetInner(new SdkServiceLevelPublisher( + server.CurrentInstance, + NullLogger.Instance)); + + var localNode = NodeId.Parse("node-B"); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + serviceLevel: deferred, + subscribeRedundancyTopic: false, + localNode: localNode)); + + actor.Tell(new RedundancyStateChanged( + Nodes: new[] + { + new NodeRedundancyState(localNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId: CorrelationId.NewId())); + + AwaitAssertion(() => + server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)100)); + } + finally + { + DeletePkiRoot(pkiRoot); + } + } + + private static OpcUaApplicationHostOptions BuildOptions(string name, string pkiRoot) => + new() + { + ApplicationName = $"OtOpcUa.E2E.{name}", + ApplicationUri = $"urn:OtOpcUa.E2E.{name}:{Guid.NewGuid():N}", + OpcUaPort = AllocateFreePort(), + PublicHostname = "localhost", + PkiStoreRoot = pkiRoot, + }; + + private static string AllocatePkiRoot() => + Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}"); + + private static void DeletePkiRoot(string root) + { + if (Directory.Exists(root)) + { + try { Directory.Delete(root, recursive: true); } + catch { /* best-effort */ } + } + } + + private static int AllocateFreePort() + { + using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private void AwaitAssertion(Action assertion) + { + var deadline = DateTime.UtcNow.AddSeconds(3); + Exception? last = null; + while (DateTime.UtcNow < deadline) + { + try { assertion(); return; } + catch (Exception ex) { last = ex; Thread.Sleep(30); } + } + if (last is not null) throw last; + } +}