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 @@
+