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 #668 — OPC UA Part 11 modified-value history: HistoryReadRaw with IsReadModified = true dispatches /// to , projects a with one /// per value, and pages with continuation points keyed on /// (SourceTimeUtc, ModifiedId). Same harness as : a real /// SDK server, a session-less in-process HistoryRead and an , over a /// fake that behaves like the SQL backends (ordered by key, half-open window, cap + exact truncation flag). /// public sealed class NodeManagerHistoryReadModifiedTests : IDisposable { private const string Tag = "WW.Mod"; 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-modified-{Guid.NewGuid():N}"); [Fact] public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos() { var modifiedAt = Epoch.AddDays(2); var trail = new List { Mod(1, seconds: 10, prior: null, HistoryModificationType.Insert, "alice", modifiedAt), Mod(2, seconds: 10, prior: 1.5, HistoryModificationType.Replace, "bob", modifiedAt.AddMinutes(1)), Mod(3, seconds: 20, prior: 7.0, HistoryModificationType.Delete, "carol", modifiedAt.AddMinutes(2)), }; var (host, nm, node, src) = await SetupAsync(trail); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Cp.ShouldBeNull(); src.LastTag.ShouldBe(Tag); src.LastStart.ShouldBe(Epoch); src.LastEnd.ShouldBe(Epoch.AddHours(1)); src.LastMax.ShouldBe(100u); src.LastCursor.ShouldBeNull(); var data = r.Data!; data.DataValues.Count.ShouldBe(3); data.ModificationInfos.Count.ShouldBe(3); data.ModificationInfos.Select(m => m.UpdateType) .ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]); data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]); data.ModificationInfos.Select(m => m.ModificationTime) .ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]); data.DataValues[0].Value.ShouldBeNull(); data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); data.DataValues[1].Value.ShouldBe(1.5); data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10)); data.DataValues[2].Value.ShouldBe(7.0); await host.DisposeAsync(); } [Fact] public async Task An_empty_trail_is_GoodNoData() { var (host, nm, node, _) = await SetupAsync([]); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData); r.Data!.ModificationInfos.ShouldBeEmpty(); await host.DisposeAsync(); } [Fact] public async Task Modified_reads_page_exactly_once_in_order_even_through_same_time_runs() { // 30 modifications; ids 5..9 all share one source time, so a page boundary cuts through the run. var trail = Enumerable.Range(1, 30) .Select(i => Mod(i, seconds: i is >= 5 and <= 9 ? 5 : i, prior: i, HistoryModificationType.Replace, "u", Epoch)) .ToList(); var (host, nm, node, src) = await SetupAsync(trail); var ids = Drain(nm, node, Epoch, Epoch.AddHours(1), max: 4); ids.ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId)); ids.Count.ShouldBe(30); src.Calls.ShouldBe(8); // 7 full pages + a 2-value tail; the exact HasMore flag means no trailing empty page await host.DisposeAsync(); } [Fact] public async Task A_reverse_modified_read_pages_descending() { var trail = Enumerable.Range(1, 11).Select(i => Mod(i, seconds: i / 2, prior: i, HistoryModificationType.Delete, "u", Epoch)).ToList(); var (host, nm, node, src) = await SetupAsync(trail); var ids = Drain(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3); ids.ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId)); ids.First().ShouldBe(11); await host.DisposeAsync(); } [Fact] public async Task An_end_only_modified_read_pages_backward_from_the_end() { var trail = Enumerable.Range(1, 9).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList(); var (host, nm, node, src) = await SetupAsync(trail); // StartTime unset + EndTime set + a cap = "the newest N, walking backward". Drain(nm, node, DateTime.MinValue, Epoch.AddHours(1), max: 4).ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId)); src.LastStart.ShouldBe(Epoch.AddHours(1)); src.LastEnd.ShouldBe(new DateTime(0, DateTimeKind.Utc)); await host.DisposeAsync(); } [Fact] public async Task A_reverse_resume_hands_the_backend_the_last_returned_source_time_and_id() { // Ids 3 and 4 share a source time; page 1 (reverse, cap 3) ends on id 4, then resumes mid-run. var trail = new List { Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch), Mod(2, 2, 2, HistoryModificationType.Update, "u", Epoch), Mod(3, 3, 3, HistoryModificationType.Update, "u", Epoch), Mod(4, 3, 4, HistoryModificationType.Update, "u", Epoch), Mod(5, 5, 5, HistoryModificationType.Update, "u", Epoch), Mod(6, 6, 6, HistoryModificationType.Update, "u", Epoch), }; var (host, nm, node, src) = await SetupAsync(trail); var p1 = Read(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3, cp: null); p1.Ids.ShouldBe([6, 5, 4]); p1.Cp.ShouldNotBeNull(); var p2 = Read(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3, cp: p1.Cp); src.LastCursor.ShouldBe(new HistoryModifiedCursor(Epoch.AddSeconds(3), 4)); p2.Ids.ShouldBe([3, 2, 1]); await host.DisposeAsync(); } [Fact] public async Task The_resume_cursor_uses_the_explicit_source_time_even_when_the_prior_value_carries_none() { // Inserts have no prior value; their snapshot has no source timestamp. The cursor must not fall back to MinValue. var trail = Enumerable.Range(1, 6).Select(i => new HistoryModifiedValue( new DataValueSnapshot(null, StatusCodes.BadNoData, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i, Epoch.AddSeconds(i))).ToList(); var (host, nm, node, src) = await SetupAsync(trail); var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 3, cp: null); p1.Cp.ShouldNotBeNull(); Read(nm, node, Epoch, Epoch.AddHours(1), max: 3, cp: p1.Cp); src.LastCursor.ShouldBe(new HistoryModifiedCursor(Epoch.AddSeconds(3), 3)); await host.DisposeAsync(); } [Fact] public async Task An_open_ended_forward_modified_read_pages_to_the_end() { var trail = Enumerable.Range(1, 9).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList(); var (host, nm, node, src) = await SetupAsync(trail); Drain(nm, node, Epoch, DateTime.MinValue, max: 4).ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId)); src.LastEnd.ShouldBe(new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc)); await host.DisposeAsync(); } [Fact] public async Task An_unbounded_modified_read_never_pages() { var trail = Enumerable.Range(1, 50).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList(); var (host, nm, node, _) = await SetupAsync(trail); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 0, cp: null); r.Data!.DataValues.Count.ShouldBe(50); r.Cp.ShouldBeNull(); await host.DisposeAsync(); } [Fact] public async Task Cursors_are_bound_to_the_read_kind_and_the_node() { var trail = Enumerable.Range(1, 10).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList(); var (host, nm, node, _) = await SetupAsync(trail); // A RAW cursor for the same tag is not a modified cursor. var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4)); Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code .ShouldBe(StatusCodes.BadContinuationPointInvalid); // A MODIFIED cursor presented to a raw read is invalid too. var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); p1.Cp.ShouldNotBeNull(); Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code .ShouldBe(StatusCodes.BadContinuationPointInvalid); // A modified cursor issued for one node is invalid on another node's tag. var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns); var other = nm.TryGetVariable("eq-1/other")!.NodeId; Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code .ShouldBe(StatusCodes.BadContinuationPointInvalid); await host.DisposeAsync(); } [Fact] public async Task A_backend_failure_is_isolated_to_its_node() { var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]); src.Throw = new InvalidOperationException("boom"); var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null); r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); await host.DisposeAsync(); } // --- helpers --------------------------------------------------------------------------------- private static HistoryModifiedValue Mod( long id, int seconds, double? prior, HistoryModificationType type, string user, DateTime modifiedAt) { var t = Epoch.AddSeconds(seconds); var value = prior is null ? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt) : new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t); return new HistoryModifiedValue(value, modifiedAt, type, user, id, t); } private static List Drain(OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max) { var ids = new List(); byte[]? cp = null; var pages = 0; do { var r = Read(nm, node, start, end, max, cp); r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString()); ids.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(60, "paging must terminate"); } while (cp is not null); return ids; } private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List Ids) Read( OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true) { var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified }, TimestampsToReturn.Both, false, new List { new() { NodeId = node, ContinuationPoint = cp } }, results, errors); HistoryModifiedData? data = null; var ids = new List(); if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho) && ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm) { data = hm; // The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail. ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v))); } return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids); } private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync( List trail) { var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.ModifiedHistoryTest", ApplicationUri = $"urn:OtOpcUa.ModifiedHistoryTest:{Guid.NewGuid():N}", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var nm = started.Server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var src = new SqlLikeTrail(trail); nm.HistorianDataSource = src; nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns); return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src); } /// /// Behaves like the SqlServer/QuestDB modified reads: ordered by (SourceTimeUtc, ModifiedId) (descending /// when reversed), half-open window, exclusive cursor, cap + exact . /// To let the test recover each projected value's id, the server timestamp handed out is /// Epoch + id ticks (an otherwise unused field in these tests). /// private sealed class SqlLikeTrail(List trail) : IHistorianDataSource { public string? LastTag { get; private set; } public DateTime LastStart { get; private set; } public DateTime LastEnd { get; private set; } public uint LastMax { get; private set; } public HistoryModifiedCursor? LastCursor { get; private set; } public int Calls { get; private set; } public Exception? Throw { get; set; } public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks; public IEnumerable Ordered(bool reverse) => reverse ? trail.OrderByDescending(m => m.SourceTimeUtc).ThenByDescending(m => m.ModifiedId) : trail.OrderBy(m => m.SourceTimeUtc).ThenBy(m => m.ModifiedId); public async Task ReadModifiedAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter, CancellationToken cancellationToken) { await Task.Delay(1, cancellationToken).ConfigureAwait(false); if (Throw is not null) throw Throw; Calls++; (LastTag, LastStart, LastEnd, LastMax, LastCursor) = (fullReference, startUtc, endUtc, maxValues, resumeAfter); var reverse = startUtc > endUtc; var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc); var rows = Ordered(reverse).Where(m => { var t = m.SourceTimeUtc; var inWindow = startUtc == endUtc ? t == lo : reverse ? t > lo && t <= hi : t >= lo && t < hi; if (!inWindow) return false; if (resumeAfter is null) return true; var c = t.CompareTo(resumeAfter.SourceTimeUtc); return reverse ? c < 0 || (c == 0 && m.ModifiedId < resumeAfter.ModifiedId) : c > 0 || (c == 0 && m.ModifiedId > resumeAfter.ModifiedId); }).Select(m => m with { Value = m.Value with { ServerTimestampUtc = Epoch.AddTicks(m.ModifiedId) } }).ToList(); var take = maxValues == 0 ? rows.Count : (int)Math.Min(maxValues, (uint)rows.Count); return new HistoryModifiedReadResult(rows.Take(take).ToList(), rows.Count > take); } 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 Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, 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 */ } } } }