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 */ }
}
}
}