e6ec0ad8be
- HistoryReadEvents miss path + catch path now both set results[handle.Index] explicitly
(new SdkHistoryReadResult { StatusCode = BadHistoryOperationUnsupported }) — don't rely on
base pre-seeding results[i] so every path sets BOTH errors and results coherently (#1)
- ProjectEventField: SourceName null now emits Variant.Null instead of a String-typed null
variant (evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName)) (#3)
- Comment near the HistoryRead dispatcher block updated: all four arms (Raw/Processed/AtTime
+ Events/Task 4) are now overridden — "left to the base" wording was stale (#5)
- Happy-path test adds ReceiveTime to select clauses and asserts it projects ReceivedTimeUtc
as a DateTime Variant at the correct select-order position (#4)
- Backend-throw test hardened: asserts errors[0] via ServiceResult.IsBad + explicit code,
asserts results[0] is non-null with the Bad code (no longer relies on base seeding),
and asserts EventsEntered to prove the override reached the bridge before the throw (#1)
- RecordingHistorianDataSource gains EventsEntered flag (set before ThrowOnRead check) (#1)
- Events_non_source_node test gains clarifying doc comment explaining the SDK base rejects
variable nodes (EventNotifier=None) for event reads before our override runs; the
override's source-guard is exercised by the promoted-without-source test instead (#2)
428 lines
20 KiB
C#
428 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder
|
|
/// event-notifier nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness Task 3 uses),
|
|
/// wires a recording fake <see cref="IHistorianDataSource"/> 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 <c>HistoryRead(OperationContext, …)</c> with a
|
|
/// <see cref="ReadEventDetails"/>. The base CustomNodeManager2 builds the node handles + dispatches to
|
|
/// the protected <c>HistoryReadEvents</c> override, so this exercises the real dispatch path
|
|
/// in-process — fast + deterministic, no client socket.
|
|
/// </summary>
|
|
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}");
|
|
|
|
/// <summary>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.</summary>
|
|
[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<byte[]>().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<LocalizedText>().Text.ShouldBe("Pump tripped");
|
|
// Severity ⇒ UInt16.
|
|
fields[5].Value.ShouldBe((ushort)700);
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
|
|
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window
|
|
/// just held no events).</summary>
|
|
[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<HistoricalEvent>(), 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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported;
|
|
/// the source is never invoked.
|
|
/// <para>
|
|
/// NOTE — what this test actually pins: the SDK base (CustomNodeManager2.HistoryRead) filters
|
|
/// event-history reads by the <c>EventNotifier.HistoryRead</c> bit, NOT by
|
|
/// <c>AccessLevel.HistoryRead</c>. A variable node carries <c>AccessLevel.HistoryRead</c> (for
|
|
/// variable-history reads) but <c>EventNotifier = None</c> (no event-notifier bits at all). The
|
|
/// SDK base therefore rejects it and does NOT pass it to our <c>HistoryReadEvents</c> 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 <c>_eventNotifierSources</c>) is exercised by the
|
|
/// <c>Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported</c> test instead.
|
|
/// </para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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].</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Build a HistoryReadEvents-style event filter whose select clauses are single-element
|
|
/// BrowsePaths over the supplied BaseEventType leaf field names, in order.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
|
|
/// results + errors — same session-less <see cref="OperationContext"/> pattern as Task 3's tests.</summary>
|
|
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> 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<HistoryReadValueId> { new() { NodeId = nodeId } };
|
|
var results = new List<SdkHistoryReadResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
|
|
nm.HistoryRead(
|
|
context,
|
|
details,
|
|
TimestampsToReturn.Both,
|
|
releaseContinuationPoints: false,
|
|
nodesToRead,
|
|
results,
|
|
errors);
|
|
|
|
return (results, errors);
|
|
}
|
|
|
|
/// <summary>A recording fake historian source — captures the last ReadEventsAsync call's arguments and
|
|
/// returns a configured result (or throws when <see cref="ThrowOnRead"/> is set). The Raw/Processed/
|
|
/// AtTime reads delegate to the Null source (unused by these tests).</summary>
|
|
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
|
{
|
|
public bool ThrowOnRead { get; init; }
|
|
public HistoricalEventsResult EventsResult { get; set; } =
|
|
new(Array.Empty<HistoricalEvent>(), null);
|
|
|
|
/// <summary>Set on every ReadEventsAsync entry, even when ThrowOnRead causes it to throw
|
|
/// before EventsCalled is set — proves the override reached the bridge.</summary>
|
|
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<HistorianRead> ReadRawAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
|
CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
|
|
|
|
public Task<HistorianRead> ReadProcessedAsync(
|
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
|
|
|
public Task<HistorianRead> ReadAtTimeAsync(
|
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
|
|
|
public Task<HistoricalEventsResult> 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<OpcUaApplicationHost>.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;
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|