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; /// /// Gitea #674 review — pins what the hosted service wires onto the node manager for HistoryUpdate: the Primary gate /// from (read live on every call), and the future-skew bound + clock from /// ServerHistorian options. Asserted through /// (what StartAsync applies) so the SDK need not boot. /// 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"); } /// 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). [Fact] public void The_gate_ignores_the_data_plane_primary_source() { var posture = Service(new FlippableView { Serve = false }, new AlwaysPrimary()).BuildHistoryUpdatePosture(); posture.PrimaryGate!().ShouldBeFalse(); } /// No view (an admin-only node: no redundancy manager) ⇒ no gate ⇒ HistoryUpdate allowed. [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 { ["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)); } 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); } }