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 { /// /// Confirms that the initial browsed hierarchy matches the seeded Galaxy model. /// [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(); } } /// /// Confirms that adding a Galaxy object and rebuilding exposes the new node to OPC UA clients. /// [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(); } } /// /// Confirms that removing a Galaxy object and rebuilding removes the node from the OPC UA hierarchy. /// [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(); } } /// /// Confirms that subscriptions on deleted nodes receive a bad-quality notification after rebuild. /// [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(); } } /// /// Confirms that subscriptions on surviving nodes continue to work after a partial rebuild. /// [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(); } } /// /// Confirms that adding a Galaxy attribute and rebuilding exposes a new OPC UA variable. /// [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(); } } /// /// Confirms that removing a Galaxy attribute and rebuilding removes the OPC UA variable. /// [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(); } } /// /// Confirms that rebuilds preserve subscription bookkeeping for nodes that survive the metadata refresh. /// [Fact] public async Task Rebuild_PreservesSubscriptionBookkeeping_ForSurvivingNodes() { var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); await fixture.InitializeAsync(); try { var nodeManager = fixture.Service.NodeManagerInstance!; var mxClient = fixture.MxAccessClient!; nodeManager.SubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(1); fixture.Service.TriggerRebuild(); await Task.Delay(200); mxClient.ActiveSubscriptionCount.ShouldBe(1); nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(0); } finally { await fixture.DisposeAsync(); } } /// /// Confirms that transferred monitored items recreate MXAccess subscriptions when the service has no local subscription state. /// [Fact] public async Task TransferSubscriptions_RestoresMxAccessSubscriptionState_WhenLocalStateIsMissing() { var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); await fixture.InitializeAsync(); try { var nodeManager = fixture.Service.NodeManagerInstance!; var mxClient = fixture.MxAccessClient!; nodeManager.RestoreTransferredSubscriptions(new[] { "TestMachine_001.MachineID", "TestMachine_001.MachineID" }); await Task.Delay(100); mxClient.ActiveSubscriptionCount.ShouldBe(1); nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(1); nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(0); } finally { await fixture.DisposeAsync(); } } /// /// Confirms that transferring monitored items does not double-count subscriptions already tracked in memory. /// [Fact] public async Task TransferSubscriptions_DoesNotDoubleCount_WhenSubscriptionAlreadyTracked() { var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); await fixture.InitializeAsync(); try { var nodeManager = fixture.Service.NodeManagerInstance!; var mxClient = fixture.MxAccessClient!; nodeManager.SubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(1); nodeManager.RestoreTransferredSubscriptions(new[] { "TestMachine_001.MachineID" }); await Task.Delay(100); mxClient.ActiveSubscriptionCount.ShouldBe(1); nodeManager.UnsubscribeTag("TestMachine_001.MachineID"); mxClient.ActiveSubscriptionCount.ShouldBe(0); } finally { await fixture.DisposeAsync(); } } } }