using System.Net; using System.Net.Sockets; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// /// v3 Batch 4 (B4-WP4) — the HEADLINE over-the-wire proof for the multi-notifier native alarm (Wave C /// review M1): a SINGLE native transition on a condition wired to MULTIPLE notifier roots (its raw /// device-folder parent + N referencing equipment folders) delivers exactly one event copy to a /// Server-object event subscriber — the Part 9 shared-InstanceStateSnapshot dedup /// (MonitoredItem.QueueEventIsEventContainedInQueue), NOT one copy per root. This is the /// regression guard the design's "duplicate-report fallback rejected" decision rests on, and it is exactly /// the multi-root topology (2 equipment folders → 2 notifier paths up to Server) where a per-root /// re-report would surface as double delivery. /// /// Boots the real exactly as SubscriptionSurvivalTests does, /// materialises the raw condition + two referencing equipment folders + the notifier wiring through the /// production , opens a real client subscription with event monitored /// items (captured via the subscription , keyed by /// ClientHandle), fires ONE transition via , /// and counts the delivered events. Heavy in-process server+client integration — runs in the serial /// integration pass, not the lightweight unit sweep. /// public sealed class NativeAlarmMultiNotifierEventDeliveryTests { private const string ServerUri = "urn:OtOpcUa.MultiNotifierEventDelivery"; // The raw device-oriented topology (ns=Raw) + two referencing equipment folders (ns=UNS). private const string RawDeviceFolder = "Plant/Modbus/dev1"; private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; private const string Equip1 = "EQ-filling-line1-station1"; private const string Equip2 = "EQ-filling-line2-station2"; private const string AlarmMessage = "over-temperature (multi-notifier test)"; // Index of the Message select clause in the event filter (see BuildEventFilter): [0]=EventId, [1]=Message. private const int MessageFieldIndex = 1; /// One native transition on a condition wired to two equipment folders delivers EXACTLY ONE event /// to a Server-object subscriber (not one per notifier root) — the shared-snapshot queue dedup. [Fact] public async Task Single_transition_delivers_exactly_one_event_at_the_Server_object() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-evt-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); WireSingleConditionToTwoEquipment(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var collector = new EventCollector(); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); var serverItem = AddEventItem(subscription, ObjectIds.Server); await subscription.ApplyChangesAsync(ct); // Fire ONE genuine transition (inactive+acked → active+unacked). No ConditionRefresh is issued, so // the ONLY event is this transition — a per-root re-report would show up as a second copy. sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5)); // Settle past the publishing interval so any (erroneous) duplicate would have arrived. await Task.Delay(TimeSpan.FromMilliseconds(700), ct); collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "the single condition must deliver exactly ONE event to the Server object despite two notifier " + "roots (2 equipment folders) — a per-root re-report would deliver 2."); await subscription.DeleteAsync(true, ct); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Overlapping subscriptions: event monitored items at the Server object AND an equipment folder /// each receive EXACTLY ONE copy of the one transition — the multi-root topology does not multiply delivery /// to any single subscriber, and the equipment-folder subscriber (ns=UNS) sees the native alarm without /// touching ns=Raw. [Fact] public async Task Overlapping_folder_and_server_subscriptions_each_receive_exactly_one_copy() { var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-ovl-{Guid.NewGuid():N}"); var port = AllocateFreePort(); var ct = TestContext.Current.CancellationToken; try { var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Overlap", ct); await using var _ = host; var sink = new SdkAddressSpaceSink(server.NodeManager!); WireSingleConditionToTwoEquipment(sink); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); unsNs.ShouldBeGreaterThan((ushort)0); var collector = new EventCollector(); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; subscription.FastEventCallback = collector.OnEvents; session.AddSubscription(subscription); await subscription.CreateAsync(ct); var serverItem = AddEventItem(subscription, ObjectIds.Server); var equipItem = AddEventItem(subscription, new NodeId(Equip1, unsNs)); await subscription.ApplyChangesAsync(ct); sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1 && collector.CountFor(equipItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5)); await Task.Delay(TimeSpan.FromMilliseconds(700), ct); // Each subscriber gets exactly one copy — the shared snapshot is deduped per monitored item; the // multi-root topology never multiplies delivery. collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "Server-object subscriber"); collector.CountFor(equipItem.ClientHandle).ShouldBe(1, "equipment-folder subscriber (ns=UNS) sees the native alarm"); await subscription.DeleteAsync(true, ct); } finally { SafeDelete(pkiRoot + "-srv"); } } /// Materialise the raw device folder + the single native condition at its RawPath + two referencing /// equipment folders (UNS), then wire the condition as an event notifier of both equipment folders. private static void WireSingleConditionToTwoEquipment(SdkAddressSpaceSink sink) { sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); sink.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns); sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); } private static AlarmConditionSnapshot ActiveSnapshot() => new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage); private static MonitoredItem AddEventItem(Subscription subscription, NodeId source) { var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = source, AttributeId = Attributes.EventNotifier, Filter = BuildEventFilter(), QueueSize = 100, SamplingInterval = 0, }; subscription.AddItem(item); return item; } /// An EventFilter selecting [0]=EventId (dedup identity) and [1]=Message (our discriminator). private static EventFilter BuildEventFilter() { var filter = new EventFilter(); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); return filter; } /// Collects events delivered to a subscription's , /// tallying per monitored-item ClientHandle and filtering to our unique alarm Message (so unrelated server /// events / refresh brackets never count). private sealed class EventCollector { private readonly object _gate = new(); private readonly Dictionary _byHandle = new(); public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) { lock (_gate) { foreach (var e in notification.Events) { if (e.EventFields.Count > MessageFieldIndex && e.EventFields[MessageFieldIndex].Value is LocalizedText lt && lt.Text == AlarmMessage) { _byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1; } } } } public int CountFor(uint clientHandle) { lock (_gate) return _byHandle.GetValueOrDefault(clientHandle); } } private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( int port, string pkiRoot, string appUri, CancellationToken ct) { var options = new OpcUaApplicationHostOptions { ApplicationName = appUri, ApplicationUri = appUri, OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = pkiRoot, EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, AutoAcceptUntrustedClientCertificates = true, }; var server = new OtOpcUaSdkServer(); var host = new OpcUaApplicationHost(options, NullLogger.Instance); await host.StartAsync(server, ct); return (server, host); } private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) { var appConfig = new ApplicationConfiguration { ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient", ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}", ApplicationType = ApplicationType.Client, SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, }; await appConfig.ValidateAsync(ApplicationType.Client, ct); appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; var endpoint = await CoreClientUtils.SelectEndpointAsync( appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); var endpointConfiguration = EndpointConfiguration.Create(appConfig); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000, identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(50); } condition().ShouldBeTrue("condition not met within timeout"); } private static int AllocateFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static void SafeDelete(string dir) { if (Directory.Exists(dir)) { try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } } } }