Add runtime address space rebuild integration tests

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-25 06:32:31 -04:00
parent 44177acf64
commit e4aaee10f7
5 changed files with 448 additions and 3 deletions

View File

@@ -267,6 +267,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
}
/// <summary>
/// Triggers an address space rebuild from the current Galaxy repository data. For testing.
/// </summary>
internal void TriggerRebuild() => OnGalaxyChanged();
// Accessors for testing
internal IMxAccessClient? MxClient => (IMxAccessClient?)_mxAccessClient ?? _mxAccessClientForWiring;
internal PerformanceMetrics? Metrics => _metrics;

View File

@@ -25,15 +25,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
public int OpcUaPort { get; }
public string EndpointUrl => $"opc.tcp://localhost:{OpcUaPort}/LmxOpcUa";
/// <summary>
/// The fake Galaxy repository injected into the service. Mutate Hierarchy/Attributes
/// then call Service.TriggerRebuild() to simulate a Galaxy redeployment.
/// </summary>
public FakeGalaxyRepository? GalaxyRepository { get; }
/// <summary>
/// The fake MxAccess client injected into the service (when using WithFakeMxAccessClient).
/// </summary>
public FakeMxAccessClient? MxAccessClient { get; }
/// <summary>
/// The fake MxProxy injected into the service (when using WithFakes).
/// </summary>
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;
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -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()

View File

@@ -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
{
/// <summary>
/// OPC UA client helper for integration tests. Connects to a test server,
/// browses, reads, and subscribes to nodes programmatically.
/// </summary>
internal class OpcUaTestClient : IDisposable
{
private Session? _session;
public Session Session => _session ?? throw new InvalidOperationException("Not connected");
/// <summary>
/// Resolves the namespace index for a given namespace URI (e.g., "urn:TestGalaxy:LmxOpcUa").
/// </summary>
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;
}
/// <summary>
/// Creates a NodeId in the LmxOpcUa namespace using the server's actual namespace index.
/// </summary>
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);
}
/// <summary>
/// Browse children of a node. Returns list of (DisplayName, NodeId, NodeClass).
/// </summary>
public async Task<List<(string Name, NodeId NodeId, NodeClass NodeClass)>> 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;
}
/// <summary>
/// Read a node's value.
/// </summary>
public DataValue Read(NodeId nodeId)
{
return Session.ReadValue(nodeId);
}
/// <summary>
/// Create a subscription with a monitored item on the given node.
/// Returns the subscription and monitored item for inspection.
/// </summary>
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();
}
}
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<MonitoredItemNotification>();
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();
}
}
}
}

View File

@@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126" />
</ItemGroup>
<ItemGroup>