Files
lmxopcua/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaTestClient.cs
Joseph Doherty e4aaee10f7 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>
2026-03-25 06:32:31 -04:00

155 lines
5.9 KiB
C#

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();
}
}
}
}