using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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; /// /// Gitea #694 item 2 (live gate F4): a walked-back events read on the Gateway proves "nothing older" with one forward read /// down to the Unix epoch (#691) — ~3.8 s live on Runtime.dbo.Events, and an unfiltered deep read hit the gateway's /// 30 s SQL timeout. The opt-in ServerHistorian:EventReadHorizon () /// bounds every events read at now − horizon, in both directions; off (the default) nothing changes. The backend is /// shaped like the Gateway's SQL event read: forward only, [start, end] inclusive, refuses start > end /// (counted), records every requested window. /// public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly DateTime Now = new(2026, 10, 13, 12, 0, 0, DateTimeKind.Utc); private static readonly TimeSpan Horizon = TimeSpan.FromDays(30); private static readonly DateTime Floor = Now - Horizon; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-eventhorizon-{Guid.NewGuid():N}"); /// Five recent events (the last five minutes) — a source with little history, as in the live gate. private static List Recent() => Enumerable.Range(0, 5).Select(i => Ev($"r{i}", Now.AddMinutes(-5 + i))).ToList(); [Fact] public async Task Without_a_horizon_a_walked_reverse_read_proves_the_rest_empty_down_to_the_epoch() { // The positive control: today's exact behaviour, and the cost the horizon removes. var (host, nm, node, gw) = await SetupAsync(Recent()); try { var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]); gw.Requests.Min(q => q.Start).ShouldBe(DateTime.UnixEpoch); gw.BadRequests.ShouldBe(0); } finally { await host.DisposeAsync(); } } [Fact] public async Task With_a_horizon_the_walk_never_reads_before_it_and_answers_the_same() { var (host, nm, node, gw) = await SetupAsync(Recent()); try { nm.EventReadHorizon = Horizon; var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]); r.Cp.ShouldBeNull(); // Requests are rounded outward to the millisecond, so the earliest one may start up to 1 ms before the floor. gw.Requests.Min(q => q.Start).ShouldBeGreaterThanOrEqualTo(Floor.AddMilliseconds(-1)); gw.BadRequests.ShouldBe(0); } finally { await host.DisposeAsync(); } } [Fact] public async Task Events_older_than_the_horizon_are_not_returned_in_either_direction() { var events = new List { Ev("old", Floor.AddDays(-10)), Ev("edge", Floor), Ev("new", Now.AddDays(-1)) }; var (host, nm, node, gw) = await SetupAsync(events); try { nm.EventReadHorizon = Horizon; var reverse = ReadEvents(nm, node, max: 10, start: Now, end: DateTime.UnixEpoch); reverse.Ids.ShouldBe(["new", "edge"]); // the floor instant itself is inside var forward = ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now); forward.Ids.ShouldBe(["edge", "new"]); gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1)); nm.EventReadHorizon = TimeSpan.Zero; // off again: exact to the window ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now).Ids.ShouldBe(["old", "edge", "new"]); } finally { await host.DisposeAsync(); } } [Fact] public async Task A_window_wholly_older_than_the_horizon_is_GoodNoData_and_never_sent() { var (host, nm, node, gw) = await SetupAsync([Ev("old", Floor.AddDays(-10))]); try { nm.EventReadHorizon = Horizon; var forward = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-20), end: Floor.AddDays(-1)); forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); forward.Status.Code.ShouldBe(StatusCodes.GoodNoData); forward.Ids.ShouldBeEmpty(); var reverse = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-1), end: Floor.AddDays(-20)); reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData); gw.Requests.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } [Fact] public async Task A_page_through_under_a_horizon_keeps_its_floor_across_continuation_points() { var events = Enumerable.Range(0, 25).Select(i => Ev($"e{i:00}", Now.AddMinutes(-i))).ToList(); events.Add(Ev("old", Floor.AddDays(-1))); var (host, nm, node, gw) = await SetupAsync(events); try { nm.EventReadHorizon = Horizon; var all = new List(); byte[]? cp = null; var pages = 0; do { var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now, inboundCp: cp); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); all.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(10); } while (cp is not null); all.ShouldBe(Enumerable.Range(0, 25).Select(i => $"e{i:00}")); gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1)); } finally { await host.DisposeAsync(); } } // ---- helpers --------------------------------------------------------------------------------------------------------- private static HistoricalEvent Ev(string id, DateTime at) => new(id, "src", at, at, "m", 500); private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, GatewayShapedEventSource Gw)> SetupAsync( List events) { var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.EventHorizonTest", ApplicationUri = $"urn:OtOpcUa.EventHorizonTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var nm = started.Server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.Clock = new FixedClock(Now); var gw = new GatewayShapedEventSource(events); nm.HistorianDataSource = gw; nm.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alarm-1", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); return (started.Host, nm, nm.TryGetFolder("eq-evt")!.NodeId, gw); } private static (List Ids, ServiceResult Error, StatusCode Status, byte[]? Cp) ReadEvents( OtOpcUaNodeManager nm, NodeId node, uint max, DateTime start, DateTime end, byte[]? inboundCp = null) { var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var filter = new EventFilter(); filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value, }); var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, Filter = filter }; var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = inboundCp } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors); var ids = new List(); if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)).Events) ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint); } /// The Gateway's SQL event read: forward only, [start, end] inclusive, time-ordered, capped + flag. private sealed class GatewayShapedEventSource(List events) : IHistorianDataSource { public List<(DateTime Start, DateTime End, int Max)> Requests { get; } = []; public int BadRequests { get; private set; } public bool SupportsReverseEvents => false; public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) { Requests.Add((s, e, m)); if (s > e) BadRequests++; // the gateway's ValidateRange var window = events.Where(x => x.EventTimeUtc >= s && x.EventTimeUtc <= e).OrderBy(x => x.EventTimeUtc).ToList(); var cap = m <= 0 ? int.MaxValue : m; return Task.FromResult(new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null)); } public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) => reverse ? throw new NotSupportedException("forward only") : ReadEventsAsync(n, s, e, m, ct); public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } private sealed class FixedClock(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(now); } public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } } } }