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 #669 — server-side continuation-point paging for HistoryRead-Events. Same harness as /// : a real SDK server, a session-less in-process /// HistoryRead and an , over a fake source that behaves like /// the SQL backends (ordered by (time, id), half-open window, cap + truncation flag). /// public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-eventpaging-{Guid.NewGuid():N}"); [Fact] public async Task Events_page_across_continuation_points_exactly_once_in_order() { var (host, nm, node, src) = await SetupAsync(Distinct(250)); var ids = DrainAsync(nm, node, max: 100); ids.ShouldBe(src.All.Select(e => e.EventId)); src.Calls.ShouldBeGreaterThan(2); await host.DisposeAsync(); } [Fact] public async Task Events_tie_at_page_boundary_is_neither_duplicated_nor_skipped() { // Indices 2,3,4 share a timestamp; cap 4 cuts through the cluster. var events = new List { Ev("e0", 0), Ev("e1", 1), Ev("e2", 2), Ev("e3", 2), Ev("e4", 2), Ev("e5", 3), }; var (host, nm, node, _) = await SetupAsync(events); DrainAsync(nm, node, max: 4).ShouldBe(["e0", "e1", "e2", "e3", "e4", "e5"]); await host.DisposeAsync(); } [Fact] public async Task Events_tie_cluster_larger_than_the_cap_pages_within_the_cluster() { var events = new List { Ev("a0", 0) }; for (var i = 0; i < 7; i++) events.Add(Ev($"t{i}", 5)); events.Add(Ev("z0", 9)); var (host, nm, node, _) = await SetupAsync(events); DrainAsync(nm, node, max: 3).ShouldBe( ["a0", "t0", "t1", "t2", "t3", "t4", "t5", "t6", "z0"]); await host.DisposeAsync(); } [Fact] public async Task Events_tie_cluster_beyond_the_overfetch_bound_fails_loudly() { var events = new List(); for (var i = 0; i < 10; i++) events.Add(Ev($"t{i:00}", 5)); var (host, nm, node, _) = await SetupAsync(events); nm.MaxTieClusterOverfetch = 4; var p1 = ReadEvents(nm, node, max: 3, inboundCp: null); p1.Cp.ShouldNotBeNull(); var p2 = ReadEvents(nm, node, max: 3, inboundCp: p1.Cp); p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); await host.DisposeAsync(); } [Fact] public async Task Events_unknown_continuation_point_is_BadContinuationPointInvalid() { var (host, nm, node, _) = await SetupAsync(Distinct(5)); var r = ReadEvents(nm, node, max: 2, inboundCp: Guid.NewGuid().ToByteArray()); r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); await host.DisposeAsync(); } [Fact] public async Task Events_release_drops_the_cursor() { var (host, nm, node, _) = await SetupAsync(Distinct(10)); var p1 = ReadEvents(nm, node, max: 4, inboundCp: null); p1.Cp.ShouldNotBeNull(); InvokeRelease(nm, node, p1.Cp!); ReadEvents(nm, node, max: 4, inboundCp: p1.Cp).Error.StatusCode.Code .ShouldBe(StatusCodes.BadContinuationPointInvalid); await host.DisposeAsync(); } [Fact] public async Task Events_cap_sized_complete_page_emits_no_continuation_point() { var (host, nm, node, _) = await SetupAsync(Distinct(4)); var r = ReadEvents(nm, node, max: 4, inboundCp: null); r.Ids.Count.ShouldBe(4); r.Cp.ShouldBeNull("the source did not flag truncation, so a cap-sized page is not a paged one"); await host.DisposeAsync(); } [Fact] public async Task Events_unbounded_request_never_pages() { var (host, nm, node, _) = await SetupAsync(Distinct(300)); var r = ReadEvents(nm, node, max: 0, inboundCp: null); r.Ids.Count.ShouldBe(300); r.Cp.ShouldBeNull(); await host.DisposeAsync(); } [Fact] public async Task A_raw_cursor_presented_to_an_events_read_is_invalid() { var (host, nm, node, _) = await SetupAsync(Distinct(10)); var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState("t", Epoch, Epoch.AddDays(1), 0, 4)); ReadEvents(nm, node, max: 4, inboundCp: raw).Error.StatusCode.Code .ShouldBe(StatusCodes.BadContinuationPointInvalid); await host.DisposeAsync(); } // --- helpers --------------------------------------------------------------------------------- private static HistoricalEvent Ev(string id, int seconds) => new(id, "src", Epoch.AddSeconds(seconds), Epoch.AddSeconds(seconds), "m", 500); private static List Distinct(int n) => Enumerable.Range(0, n).Select(i => Ev($"e{i:0000}", i)).ToList(); private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeEventSource Src)> SetupAsync( List events) { var applicationUri = $"urn:OtOpcUa.EventPagingTest:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.EventPagingTest", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var nm = started.Server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var src = new SqlLikeEventSource(events); nm.HistorianDataSource = src; const string equipmentId = "eq-evt"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); return (started.Host, nm, nm.TryGetFolder(equipmentId)!.NodeId, src); } private static List DrainAsync(OtOpcUaNodeManager nm, NodeId node, uint max) { var all = new List(); byte[]? cp = null; var pages = 0; do { var r = ReadEvents(nm, node, max, cp); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); all.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(60, "paging must terminate"); } while (cp is not null); return all; } private static (List Ids, ServiceResult Error, byte[]? Cp) ReadEvents( OtOpcUaNodeManager nm, NodeId node, uint max, byte[]? inboundCp) { 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 = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), 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)) { var he = (HistoryEvent)ExtensionObject.ToEncodeable(ho); foreach (var e in he.Events) ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)); } return (ids, errors[0], results[0]?.ContinuationPoint); } private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId node, byte[] cp) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var nodesToRead = new List { new() { NodeId = node, ContinuationPoint = cp } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, new ReadEventDetails { StartTime = Epoch, EndTime = Epoch.AddDays(1), NumValuesPerNode = 4 }, TimestampsToReturn.Both, true, nodesToRead, results, errors); } /// Behaves like the SqlServer/QuestDB event reads: (time, id) order, [start, end), cap, truncation flag. private sealed class SqlLikeEventSource(List events) : IHistorianDataSource { public IReadOnlyList All { get; } = events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList(); public int Calls { get; private set; } public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) { Calls++; var window = All.Where(e => e.EventTimeUtc >= startUtc && e.EventTimeUtc < endUtc).ToList(); var cap = maxEvents <= 0 ? 1000 : maxEvents; var truncated = window.Count > cap; return Task.FromResult(new HistoricalEventsResult( window.Take(cap).ToList(), truncated ? new byte[] { 1 } : null)); } 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() { } } public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best effort */ } } } }