934909b430
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.
129 lines
5.0 KiB
C#
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);
|
|
}
|
|
}
|