From e4aaee10f7fe4b22f0d12470ce092145d946a552 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Mar 2026 06:32:31 -0400 Subject: [PATCH] Add runtime address space rebuild integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify nodes can be added/removed from the OPC UA server at runtime by mutating FakeGalaxyRepository and triggering a rebuild. Uses real OPC UA client sessions to browse, subscribe, and observe changes. Tests cover: - Browse initial hierarchy via OPC UA client - Add object at runtime → new node appears on browse - Remove object → node disappears from browse - Subscribe to node, then remove it → publishes Bad quality - Surviving nodes still browsable after partial rebuild - Add/remove individual attributes at runtime Infrastructure: - OpcUaTestClient helper for programmatic OPC UA client connections - OpcUaServerFixture updated with GalaxyRepository/MxProxy accessors - OpcUaService.TriggerRebuild() exposed for test-driven rebuilds - Namespace index resolved dynamically via session namespace table Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs | 5 + .../Helpers/OpcUaServerFixture.cs | 28 +- .../Helpers/OpcUaTestClient.cs | 154 ++++++++++ .../Integration/AddressSpaceRebuildTests.cs | 263 ++++++++++++++++++ .../ZB.MOM.WW.LmxOpcUa.Tests.csproj | 1 + 5 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AddressSpaceRebuildTests.cs diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index 25fcd3a..40979aa 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -267,6 +267,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating); } + /// + /// Triggers an address space rebuild from the current Galaxy repository data. For testing. + /// + internal void TriggerRebuild() => OnGalaxyChanged(); + // Accessors for testing internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring; internal PerformanceMetrics? Metrics => _metrics; diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs index c66dee5..bb4bbba 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs @@ -25,15 +25,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers public int OpcUaPort { get; } public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa"; + /// + /// The fake Galaxy repository injected into the service. Mutate Hierarchy/Attributes + /// then call Service.TriggerRebuild() to simulate a Galaxy redeployment. + /// + public FakeGalaxyRepository? GalaxyRepository { get; } + + /// + /// The fake MxAccess client injected into the service (when using WithFakeMxAccessClient). + /// + public FakeMxAccessClient? MxAccessClient { get; } + + /// + /// The fake MxProxy injected into the service (when using WithFakes). + /// + public FakeMxProxy? MxProxy { get; } + private readonly OpcUaServiceBuilder _builder; private bool _started; - public OpcUaServerFixture(OpcUaServiceBuilder builder) + private OpcUaServerFixture(OpcUaServiceBuilder builder, + FakeGalaxyRepository? repo = null, + FakeMxAccessClient? mxClient = null, + FakeMxProxy? mxProxy = null) { OpcUaPort = Interlocked.Increment(ref _nextPort); _builder = builder; _builder.WithOpcUaPort(OpcUaPort); _builder.DisableDashboard(); + GalaxyRepository = repo; + MxAccessClient = mxClient; + MxProxy = mxProxy; } /// @@ -56,7 +78,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers .WithGalaxyRepository(r) .WithGalaxyName("TestGalaxy"); - return new OpcUaServerFixture(builder); + return new OpcUaServerFixture(builder, repo: r, mxProxy: p); } /// @@ -79,7 +101,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers .WithGalaxyRepository(r) .WithGalaxyName("TestGalaxy"); - return new OpcUaServerFixture(builder); + return new OpcUaServerFixture(builder, repo: r, mxClient: client); } public Task InitializeAsync() diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs new file mode 100644 index 0000000..7d6ec60 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers +{ + /// + /// OPC UA client helper for integration tests. Connects to a test server, + /// browses, reads, and subscribes to nodes programmatically. + /// + internal class OpcUaTestClient : IDisposable + { + private Session? _session; + + public Session Session => _session ?? throw new InvalidOperationException("Not connected"); + + /// + /// Resolves the namespace index for a given namespace URI (e.g., "urn:TestGalaxy:LmxOpcUa"). + /// + public ushort GetNamespaceIndex(string galaxyName = "TestGalaxy") + { + var nsUri = $"urn:{galaxyName}:LmxOpcUa"; + var idx = Session.NamespaceUris.GetIndex(nsUri); + if (idx < 0) throw new InvalidOperationException($"Namespace '{nsUri}' not found on server"); + return (ushort)idx; + } + + /// + /// Creates a NodeId in the LmxOpcUa namespace using the server's actual namespace index. + /// + public NodeId MakeNodeId(string identifier, string galaxyName = "TestGalaxy") + { + return new NodeId(identifier, GetNamespaceIndex(galaxyName)); + } + + public async Task ConnectAsync(string endpointUrl) + { + var config = new ApplicationConfiguration + { + ApplicationName = "OpcUaTestClient", + ApplicationUri = "urn:localhost:OpcUaTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "own") + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "issuer") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "trusted") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(Path.GetTempPath(), "OpcUaTestClient", "pki", "rejected") + }, + AutoAcceptUntrustedCertificates = true + }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 30000 }, + TransportQuotas = new TransportQuotas() + }; + + await config.Validate(ApplicationType.Client); + config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false); + var endpointConfig = EndpointConfiguration.Create(config); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); + + _session = await Session.Create( + config, configuredEndpoint, false, + "OpcUaTestClient", 30000, null, null); + } + + /// + /// Browse children of a node. Returns list of (DisplayName, NodeId, NodeClass). + /// + public async Task> BrowseAsync(NodeId nodeId) + { + var results = new List<(string, NodeId, NodeClass)>(); + var browser = new Browser(Session) + { + NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + BrowseDirection = BrowseDirection.Forward + }; + + var refs = browser.Browse(nodeId); + foreach (var rd in refs) + { + results.Add((rd.DisplayName.Text, ExpandedNodeId.ToNodeId(rd.NodeId, Session.NamespaceUris), rd.NodeClass)); + } + return results; + } + + /// + /// Read a node's value. + /// + public DataValue Read(NodeId nodeId) + { + return Session.ReadValue(nodeId); + } + + /// + /// Create a subscription with a monitored item on the given node. + /// Returns the subscription and monitored item for inspection. + /// + public async Task<(Subscription Sub, MonitoredItem Item)> SubscribeAsync( + NodeId nodeId, int intervalMs = 250) + { + var subscription = new Subscription(Session.DefaultSubscription) + { + PublishingInterval = intervalMs, + DisplayName = "TestSubscription" + }; + + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, + DisplayName = nodeId.ToString(), + SamplingInterval = intervalMs + }; + + subscription.AddItem(item); + Session.AddSubscription(subscription); + await subscription.CreateAsync(); + + return (subscription, item); + } + + public void Dispose() + { + if (_session != null) + { + try { _session.Close(); } + catch { /* ignore */ } + _session.Dispose(); + } + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AddressSpaceRebuildTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AddressSpaceRebuildTests.cs new file mode 100644 index 0000000..9609b82 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/AddressSpaceRebuildTests.cs @@ -0,0 +1,263 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; +using ZB.MOM.WW.LmxOpcUa.Tests.Helpers; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration +{ + /// + /// Integration tests verifying dynamic address space changes via a real OPC UA client. + /// Tests browse, subscribe, add/remove nodes at runtime, and subscription quality changes. + /// + public class AddressSpaceRebuildTests + { + [Fact] + public async Task Browse_ReturnsInitialHierarchy() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Browse from ZB root + var zbNode = client.MakeNodeId("ZB"); + var children = await client.BrowseAsync(zbNode); + + children.ShouldContain(c => c.Name == "DEV"); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Browse_AfterAddingObject_NewNodeAppears() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Verify initial state — browse TestMachine_001 + var machineNode = client.MakeNodeId("TestMachine_001"); + var initialChildren = await client.BrowseAsync(machineNode); + initialChildren.ShouldNotContain(c => c.Name == "NewReceiver"); + + // Add a new object to the hierarchy + fixture.GalaxyRepository!.Hierarchy.Add(new GalaxyObjectInfo + { + GobjectId = 100, TagName = "NewReceiver_001", + ContainedName = "NewReceiver", BrowseName = "NewReceiver", + ParentGobjectId = 3, IsArea = false // parent = TestMachine_001 + }); + fixture.GalaxyRepository.Attributes.Add(new GalaxyAttributeInfo + { + GobjectId = 100, TagName = "NewReceiver_001", + AttributeName = "NewAttr", FullTagReference = "NewReceiver_001.NewAttr", + MxDataType = 5, IsArray = false + }); + + // Trigger rebuild + fixture.Service.TriggerRebuild(); + await Task.Delay(500); // allow rebuild to complete + + // Browse again — new node should appear + var updatedChildren = await client.BrowseAsync(machineNode); + updatedChildren.ShouldContain(c => c.Name == "NewReceiver"); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Browse_AfterRemovingObject_NodeDisappears() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Verify MESReceiver exists initially + var machineNode = client.MakeNodeId("TestMachine_001"); + var initialChildren = await client.BrowseAsync(machineNode); + initialChildren.ShouldContain(c => c.Name == "MESReceiver"); + + // Remove MESReceiver and its attributes from hierarchy + fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); + fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); + + // Trigger rebuild + fixture.Service.TriggerRebuild(); + await Task.Delay(500); + + // Browse again — MESReceiver should be gone + var updatedChildren = await client.BrowseAsync(machineNode); + updatedChildren.ShouldNotContain(c => c.Name == "MESReceiver"); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Subscribe_RemovedNode_PublishesBadQuality() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Subscribe to an attribute that will be removed + var nodeId = client.MakeNodeId("MESReceiver_001.MoveInBatchID"); + var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100); + + // Collect notifications + var notifications = new List(); + item.Notification += (_, e) => + { + if (e.NotificationValue is MonitoredItemNotification n) + notifications.Add(n); + }; + + await Task.Delay(500); // let initial subscription settle + + // Remove MESReceiver and its attributes + fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); + fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); + + // Trigger rebuild — nodes get deleted + fixture.Service.TriggerRebuild(); + + // Wait for publish cycle to deliver Bad status + await Task.Delay(2000); + + // The subscription should have received a Bad quality notification + // after the node was deleted during rebuild + notifications.ShouldContain(n => StatusCode.IsBad(n.Value.StatusCode)); + + await sub.DeleteAsync(true); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Subscribe_SurvivingNode_StillWorksAfterRebuild() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + // Subscribe to an attribute that will survive the rebuild + var nodeId = client.MakeNodeId("TestMachine_001.MachineID"); + var (sub, item) = await client.SubscribeAsync(nodeId, intervalMs: 100); + + await Task.Delay(500); + + // Remove only MESReceiver (MachineID on TestMachine_001 survives) + fixture.GalaxyRepository!.Hierarchy.RemoveAll(h => h.TagName == "MESReceiver_001"); + fixture.GalaxyRepository.Attributes.RemoveAll(a => a.TagName == "MESReceiver_001"); + + fixture.Service.TriggerRebuild(); + await Task.Delay(1000); + + // The surviving node should still be browsable + var machineNode = client.MakeNodeId("TestMachine_001"); + var children = await client.BrowseAsync(machineNode); + children.ShouldContain(c => c.Name == "MachineID"); + + await sub.DeleteAsync(true); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Browse_AddAttribute_NewVariableAppears() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + var machineNode = client.MakeNodeId("TestMachine_001"); + var initialChildren = await client.BrowseAsync(machineNode); + initialChildren.ShouldNotContain(c => c.Name == "NewSensor"); + + // Add a new attribute + fixture.GalaxyRepository!.Attributes.Add(new GalaxyAttributeInfo + { + GobjectId = 3, TagName = "TestMachine_001", + AttributeName = "NewSensor", FullTagReference = "TestMachine_001.NewSensor", + MxDataType = 4, IsArray = false // Double + }); + + fixture.Service.TriggerRebuild(); + await Task.Delay(500); + + var updatedChildren = await client.BrowseAsync(machineNode); + updatedChildren.ShouldContain(c => c.Name == "NewSensor"); + } + finally + { + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Browse_RemoveAttribute_VariableDisappears() + { + var fixture = OpcUaServerFixture.WithFakes(); + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + var machineNode = client.MakeNodeId("TestMachine_001"); + var initialChildren = await client.BrowseAsync(machineNode); + initialChildren.ShouldContain(c => c.Name == "MachineCode"); + + // Remove MachineCode attribute + fixture.GalaxyRepository!.Attributes.RemoveAll( + a => a.TagName == "TestMachine_001" && a.AttributeName == "MachineCode"); + + fixture.Service.TriggerRebuild(); + await Task.Delay(500); + + var updatedChildren = await client.BrowseAsync(machineNode); + updatedChildren.ShouldNotContain(c => c.Name == "MachineCode"); + } + finally + { + await fixture.DisposeAsync(); + } + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj b/tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj index 3602e39..a23ba39 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/ZB.MOM.WW.LmxOpcUa.Tests.csproj @@ -22,6 +22,7 @@ +