Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
T
Joseph Doherty 934909b430 fix(historian): #691 review — clamp Gateway at-or-after read, GoodNoData when nothing exists, HonorReturnBounds kill switch
1. The at-or-after bound read on a forward-only backend (Gateway) ends at
   now + ReverseRawReader.LookAhead instead of DateTime.MaxValue (the gateway
   only checks start <= end, so a year-9999 end reached AVEVA); a bound beyond
   the horizon is not found without a read. SqlServer/QuestDB keep MaxValue:
   they bind it safely and may hold samples further ahead. The Gateway fake now
   refuses far-future ends, so the missing-end-bound test is no longer vacuous.
3. No data and no bound found (whole read empty) => GoodNoData, no values —
   the pre-bounds answer (6.5.3.2: success only if one or both bounds exist).
   A one-value page whose start bound is missing first checks there is data or
   a found end bound.
4. ServerHistorian:HonorReturnBounds (default true); false ignores ReturnBounds
   as before #691, including the modified-read Bad_InvalidArgument.
2026-10-12 17:52:34 -04:00

129 lines
5.0 KiB
C#

using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Gitea #674 review — pins what the hosted service wires onto the node manager for HistoryUpdate: the Primary gate
/// from <see cref="IHistoryUpdateRoleView"/> (read live on every call), and the future-skew bound + clock from
/// <c>ServerHistorian</c> options. Asserted through <see cref="OtOpcUaServerHostedService.BuildHistoryUpdatePosture"/>
/// (what <c>StartAsync</c> applies) so the SDK need not boot.
/// </summary>
public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
{
[Fact]
public void A_registered_view_wires_a_gate_that_reads_it_live()
{
var view = new FlippableView { Serve = false };
var posture = Service(view).BuildHistoryUpdatePosture();
posture.PrimaryGate.ShouldNotBeNull();
posture.PrimaryGate().ShouldBeFalse();
view.Serve = true;
posture.PrimaryGate().ShouldBeTrue("the gate must read the view on every call, not capture its value");
}
/// <summary>The gate comes from the HistoryUpdate view, NOT the data-plane source — a source saying "Primary" must not
/// open it while the HistoryUpdate verdict is closed (the boot-window distinction).</summary>
[Fact]
public void The_gate_ignores_the_data_plane_primary_source()
{
var posture = Service(new FlippableView { Serve = false }, new AlwaysPrimary()).BuildHistoryUpdatePosture();
posture.PrimaryGate!().ShouldBeFalse();
}
/// <summary>No view (an admin-only node: no redundancy manager) ⇒ no gate ⇒ HistoryUpdate allowed.</summary>
[Fact]
public void No_view_wires_no_gate()
{
Service(view: null).BuildHistoryUpdatePosture().PrimaryGate.ShouldBeNull();
}
[Fact]
public void Skew_and_clock_come_from_options_and_the_host_clock()
{
var clock = new FixedClock();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:07:00",
}).Build();
var posture = Service(view: null, config: config, clock: clock).BuildHistoryUpdatePosture();
posture.MaxFutureSkew.ShouldBe(TimeSpan.FromMinutes(7));
posture.Clock.ShouldBeSameAs(clock);
}
[Fact]
public void An_absent_section_keeps_the_one_day_default()
{
Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
}
/// <summary>#691: the ReturnBounds kill switch is bound from <c>ServerHistorian:HonorReturnBounds</c>, default on.</summary>
[Fact]
public void The_return_bounds_kill_switch_comes_from_options_and_defaults_on()
{
Service(view: null).HonorReturnBounds.ShouldBeTrue();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:HonorReturnBounds"] = "false",
}).Build();
Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse();
}
private static OtOpcUaServerHostedService Service(
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
TimeProvider? clock = null) => new(
Options.Create(new OpcUaApplicationHostOptions()),
new DeferredAddressSpaceSink(),
new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance,
NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance,
config ?? new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no ActorSystem is needed for the HistoryUpdate posture"),
new ActorRegistry(),
dataPlaneAuthorizer: null,
Options.Create(new AclOptions()),
clock ?? TimeProvider.System,
NullLoggerFactory.Instance,
primaryRoleSource: primary,
historyUpdateRoleView: view);
private sealed class FlippableView : IHistoryUpdateRoleView
{
public volatile bool Serve;
public bool ShouldServeHistoryUpdate => Serve;
public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => Serve = shouldServeHistoryUpdate;
}
private sealed class AlwaysPrimary : IDriverPrimaryRoleSource
{
public bool ShouldActAsPrimary => true;
public void PublishPrimaryRole(bool shouldActAsPrimary)
{
}
}
private sealed class FixedClock : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2026, 10, 11, 0, 0, 0, TimeSpan.Zero);
}
}