using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using SdkHistoryReadResult = Opc.Ua.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder /// event-notifier nodes. Boots a real (the same harness Task 3 uses), /// wires a recording fake BEFORE materialising an alarm condition /// (so the alarm-owning equipment folder is promoted to an event notifier WITH the HistoryRead bit), /// then invokes the node manager's PUBLIC HistoryRead(OperationContext, …) with a /// . The base CustomNodeManager2 builds the node handles + dispatches to /// the protected HistoryReadEvents override, so this exercises the real dispatch path /// in-process — fast + deterministic, no client socket. /// public sealed class NodeManagerHistoryReadEventsTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-historyreadevents-{Guid.NewGuid():N}"); /// Happy path: the fake receives (sourceName == the equipment-folder id, StartTime, EndTime, /// maxEvents), and each returned event decodes to a HistoryEventFieldList whose EventFields are in /// SelectClause ORDER with correctly-typed Variants (EventId ByteString, SourceName string, Time /// DateTime, ReceiveTime DateTime, Message LocalizedText, Severity UInt16). StatusCode is Good when /// events are present. [Fact] public async Task Events_dispatches_to_source_and_projects_fields_in_select_order() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var fake = new RecordingHistorianDataSource(); // Wire the source BEFORE materialising the alarm so the folder gets the HistoryRead bit. nm.HistorianDataSource = fake; const string equipmentId = "eq-evt"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); var rcvTime = new DateTime(2026, 6, 14, 10, 0, 1, DateTimeKind.Utc); fake.EventsResult = new HistoricalEventsResult( new[] { new HistoricalEvent("evt-42", "Pump_001", evtTime, rcvTime, "Pump tripped", 700) }, null); var start = DateTime.UtcNow.AddHours(-1); var end = DateTime.UtcNow; var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = 50, // ReceiveTime is included to verify it projects evt.ReceivedTimeUtc at index 3. Filter = SelectFilter("EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"), }; var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); // The source saw the equipment/folder id as the sourceName + the request window + cap. fake.LastSourceName.ShouldBe(equipmentId); fake.LastStart.ShouldBe(start); fake.LastEnd.ShouldBe(end); fake.LastMaxEvents.ShouldBe(50); errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); history.Events.Count.ShouldBe(1); var fields = history.Events[0].EventFields; fields.Count.ShouldBe(6); // EventId ⇒ ByteString (UTF-8 of "evt-42"). fields[0].Value.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42")); // SourceName ⇒ string. fields[1].Value.ShouldBe("Pump_001"); // Time ⇒ DateTime (event occurrence time). fields[2].Value.ShouldBe(evtTime); // ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc). fields[3].Value.ShouldBe(rcvTime); // Message ⇒ LocalizedText. fields[4].Value.ShouldBeOfType().Text.ShouldBe("Pump tripped"); // Severity ⇒ UInt16. fields[5].Value.ShouldBe((ushort)700); await host.DisposeAsync(); } /// An unsupported select operand (BrowsePath ["EventType"]) projects to Variant.Null — a field /// the server can't supply is null (spec-conformant) — while supported siblings still project. [Fact] public async Task Events_unsupported_select_field_projects_null() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var fake = new RecordingHistorianDataSource(); nm.HistorianDataSource = fake; const string equipmentId = "eq-unsupported"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; fake.EventsResult = new HistoricalEventsResult( new[] { new HistoricalEvent("evt-1", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 500) }, null); var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, // EventType is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is. Filter = SelectFilter("EventType", "EventId"), }; var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); var fields = history.Events[0].EventFields; fields.Count.ShouldBe(2); // EventType ⇒ Variant.Null. fields[0].Value.ShouldBeNull(); // EventId still projects to a ByteString. fields[1].Value.ShouldBeOfType().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1")); await host.DisposeAsync(); } /// Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window /// just held no events). [Fact] public async Task Events_empty_yields_GoodNoData() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var fake = new RecordingHistorianDataSource { EventsResult = new HistoricalEventsResult(Array.Empty(), null), }; nm.HistorianDataSource = fake; const string equipmentId = "eq-empty"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, Filter = SelectFilter("EventId"), }; var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData); var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); history.Events.ShouldBeEmpty(); await host.DisposeAsync(); } /// A folder promoted to an event notifier while NO historian was wired (Null source at /// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields /// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked. [Fact] public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // Materialise the alarm while the source is still the Null default — the folder is promoted to // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. const string equipmentId = "eq-nosrc"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. var fake = new RecordingHistorianDataSource(); nm.HistorianDataSource = fake; var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, Filter = SelectFilter("EventId"), }; var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); fake.LastSourceName.ShouldBeNull(); // source never reached fake.EventsCalled.ShouldBeFalse(); await host.DisposeAsync(); } /// A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported; /// the source is never invoked. /// /// NOTE — what this test actually pins: the SDK base (CustomNodeManager2.HistoryRead) filters /// event-history reads by the EventNotifier.HistoryRead bit, NOT by /// AccessLevel.HistoryRead. A variable node carries AccessLevel.HistoryRead (for /// variable-history reads) but EventNotifier = None (no event-notifier bits at all). The /// SDK base therefore rejects it and does NOT pass it to our HistoryReadEvents override; /// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins /// that base-level rejection of variable nodes for event reads. /// The override's own source-guard (miss in _eventNotifierSources) is exercised by the /// Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported test instead. /// /// [Fact] public async Task Events_non_source_node_yields_BadHistoryOperationUnsupported() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var fake = new RecordingHistorianDataSource(); nm.HistorianDataSource = fake; // A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but // EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs. nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", writable: false, historianTagname: "WW.Temp"); var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, Filter = SelectFilter("EventId"), }; var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); fake.EventsCalled.ShouldBeFalse(); await host.DisposeAsync(); } /// A backend that throws ⇒ that node's error is Bad and no exception escapes the /// HistoryRead call. The fake source MUST be invoked (proving we reached the bridge) and threw; /// the production catch now sets BOTH errors and results explicitly, so both are asserted here /// rather than relying on the SDK base pre-seeding results[i]. [Fact] public async Task Events_backend_throw_yields_bad_status_and_does_not_escape() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; var fake = new RecordingHistorianDataSource { ThrowOnRead = true }; nm.HistorianDataSource = fake; const string equipmentId = "eq-boom"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, Filter = SelectFilter("EventId"), }; // The call must not throw even though the backend does. var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); // Authoritative per-node signal: errors[0] must be Bad. ServiceResult.IsBad(errors[0]).ShouldBeTrue(); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); // The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding). results[0].ShouldNotBeNull(); results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); // The source WAS entered (proving the override reached the bridge and the throw was swallowed). fake.EventsEntered.ShouldBeTrue(); // ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path. fake.EventsCalled.ShouldBeFalse(); await host.DisposeAsync(); } /// Build a HistoryReadEvents-style event filter whose select clauses are single-element /// BrowsePaths over the supplied BaseEventType leaf field names, in order. private static EventFilter SelectFilter(params string[] leafFieldNames) { var filter = new EventFilter(); foreach (var name in leafFieldNames) { filter.SelectClauses.Add( new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name)) { AttributeId = Attributes.Value, }); } return filter; } /// Invoke the node manager's public HistoryRead with a single node, returning the filled /// results + errors — same session-less pattern as Task 3's tests. private static (IList Results, IList Errors) InvokeHistoryRead( OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var nodesToRead = new List { new() { NodeId = nodeId } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead( context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); return (results, errors); } /// A recording fake historian source — captures the last ReadEventsAsync call's arguments and /// returns a configured result (or throws when is set). The Raw/Processed/ /// AtTime reads delegate to the Null source (unused by these tests). private sealed class RecordingHistorianDataSource : IHistorianDataSource { public bool ThrowOnRead { get; init; } public HistoricalEventsResult EventsResult { get; set; } = new(Array.Empty(), null); /// Set on every ReadEventsAsync entry, even when ThrowOnRead causes it to throw /// before EventsCalled is set — proves the override reached the bridge. public bool EventsEntered { get; private set; } public bool EventsCalled { get; private set; } public string? LastSourceName { get; private set; } public DateTime LastStart { get; private set; } public DateTime LastEnd { get; private set; } public int LastMaxEvents { get; private set; } public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken); public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) { EventsEntered = true; if (ThrowOnRead) throw new InvalidOperationException("backend boom for events"); EventsCalled = true; LastSourceName = sourceName; LastStart = startUtc; LastEnd = endUtc; LastMaxEvents = maxEvents; return Task.FromResult(EventsResult); } public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.HistoryReadEventsTest", ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }