using System.Text.Json; using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Gitea #670 — upstream session loss must reach the driver's health and its subscribers. Before the fix a bad /// keep-alive only flipped the host status: the subscribed values stayed Good forever, health stayed Healthy (so the /// #527 comms-loss watchdog could never trip — its first conjunct is "non-Healthy"), and LastSuccessfulRead /// only moved on init / ReadAsync, which a subscription-only driver never calls. /// [Trait("Category", "Unit")] public sealed class OpcUaClientSessionLossTests { private const uint BadNoCommunication = 0x80310000u; private const string SpeedRef = "Plant/dev/Speed"; private const string CounterRef = "Plant/dev/Counter"; private sealed class ManualClock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now; public void Advance(TimeSpan by) => Now += by; } private static RawTagEntry Tag(string rawPath, string nodeId) => new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true); private static readonly IReadOnlyList Tags = [ Tag(SpeedRef, "ns=0;s=Speed"), Tag(CounterRef, "ns=0;s=Counter"), ]; private static Mock NewSession() { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext) .Returns(new ServiceMessageContext(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } private static (OpcUaClientDriver Driver, Mock Session, ManualClock Clock) NewConnectedDriver() { var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-670"); var session = NewSession(); var clock = new ManualClock(new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero)); drv.Clock = clock; drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, clock.Now.UtcDateTime, null)); return (drv, session, clock); } private static DataValue Good(object value, DateTime ts) => new(new Variant(value), StatusCodes.Good, ts, ts); private static void SetupRead(Mock session, params object[] values) => session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => new ReadResponse { ResponseHeader = new ResponseHeader(), Results = [.. nodes.Select((_, i) => new DataValue(new Variant(values[i]), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], DiagnosticInfos = [], }); private static async Task WaitForAsync(Func condition, string because) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(10); } condition().ShouldBeTrue(because); } [Fact] public void A_bad_keep_alive_degrades_health_and_preserves_the_last_successful_read() { var (drv, _, clock) = NewConnectedDriver(); using var _ = drv; var before = drv.GetHealth().LastSuccessfulRead; clock.Advance(TimeSpan.FromSeconds(5)); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); var h = drv.GetHealth(); h.State.ShouldNotBe(DriverState.Healthy, "a lost session must be visible to the #527 watchdog's first conjunct"); h.State.ShouldBe(DriverState.Degraded); h.LastSuccessfulRead.ShouldBe(before, "the watchdog measures silence from this stamp, so loss must not move it"); h.LastError.ShouldNotBeNull().ShouldContain("keep-alive"); } [Fact] public void A_bad_keep_alive_publishes_BadNoCommunication_once_per_ref_retaining_the_value() { var (drv, _, clock) = NewConnectedDriver(); using var _ = drv; var handle = drv.AddSubscriptionForTest([SpeedRef, CounterRef]); var t0 = clock.Now.UtcDateTime; drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, t0)); drv.DeliverNotificationForTest(handle, CounterRef, Good(7, t0)); var seen = new List(); drv.OnDataChange += (_, e) => seen.Add(e); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); seen.Count.ShouldBe(2, "exactly one Bad per subscribed ref"); var speed = seen.Single(e => e.FullReference == SpeedRef); speed.Snapshot.StatusCode.ShouldBe(BadNoCommunication); speed.Snapshot.Value.ShouldBe(42.5f, "the value is retained — only the quality changes (as the host does)"); speed.SubscriptionHandle.ShouldBe(handle); seen.Single(e => e.FullReference == CounterRef).Snapshot.Value.ShouldBe(7); // The SDK re-raises a bad keep-alive every interval while the channel stays down: no further publishes. drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); drv.RaiseKeepAliveForTest(StatusCodes.BadConnectionClosed); seen.Count.ShouldBe(2, "a second bad keep-alive in the same outage must publish nothing"); } [Fact] public void Good_keep_alives_and_notifications_advance_the_last_successful_read() { var (drv, _, clock) = NewConnectedDriver(); using var _ = drv; var handle = drv.AddSubscriptionForTest([SpeedRef]); clock.Advance(TimeSpan.FromSeconds(30)); drv.RaiseKeepAliveForTest(StatusCodes.Good); drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime); clock.Advance(TimeSpan.FromSeconds(30)); drv.DeliverNotificationForTest(handle, SpeedRef, Good(1.0f, clock.Now.UtcDateTime)); drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// Falsification control for the R2 half of the fix: a long-running, idle, subscription-only driver whose /// server is perfectly alive must never look dead to the #527 watchdog (non-Healthy AND silent ≥ 90 s). /// Without good keep-alives advancing LastSuccessfulRead, fixing R1 alone would trip it on the first /// bad tick of any connection older than 90 s. /// [Fact] public void An_idle_healthy_subscription_driver_never_looks_dead_to_the_watchdog() { var (drv, _, clock) = NewConnectedDriver(); using var _ = drv; drv.AddSubscriptionForTest([SpeedRef]); // static value — no notifications ever arrive for (var i = 0; i < 60; i++) // 5 minutes of 5 s keep-alives { clock.Advance(TimeSpan.FromSeconds(5)); drv.RaiseKeepAliveForTest(StatusCodes.Good); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); (clock.Now.UtcDateTime - h.LastSuccessfulRead!.Value).ShouldBeLessThan(TimeSpan.FromSeconds(90)); } } [Fact] public async Task Recovery_on_the_same_session_republishes_the_identical_value_Good() { var (drv, session, clock) = NewConnectedDriver(); using var _ = drv; var handle = drv.AddSubscriptionForTest([SpeedRef]); drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, clock.Now.UtcDateTime)); SetupRead(session, 42.5f); // the server still holds the SAME value — it will not resend it var seen = new List(); drv.OnDataChange += (_, e) => { lock (seen) seen.Add(e); }; drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); clock.Advance(TimeSpan.FromSeconds(20)); drv.RaiseKeepAliveForTest(StatusCodes.Good); drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now.UtcDateTime); await WaitForAsync(() => { lock (seen) return seen.Count >= 2; }, "the recovery read must publish"); lock (seen) { seen.Count.ShouldBe(2); seen[0].Snapshot.StatusCode.ShouldBe(BadNoCommunication); seen[1].FullReference.ShouldBe(SpeedRef); seen[1].SubscriptionHandle.ShouldBe(handle); seen[1].Snapshot.StatusCode.ShouldBe(0u); seen[1].Snapshot.Value.ShouldBe(42.5f, "the IDENTICAL value must come back Good — a (value,status) dedup lets it through"); } } [Fact] public async Task A_notification_during_recovery_wins_over_the_recovery_read() { var (drv, session, clock) = NewConnectedDriver(); using var _ = drv; var handle = drv.AddSubscriptionForTest([SpeedRef]); drv.DeliverNotificationForTest(handle, SpeedRef, Good(1.0f, clock.Now.UtcDateTime)); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); // A fresh notification arrives before the keep-alive recovers: the read must not overwrite it. drv.DeliverNotificationForTest(handle, SpeedRef, Good(2.0f, clock.Now.UtcDateTime)); SetupRead(session, 1.0f); var seen = new List(); drv.OnDataChange += (_, e) => { lock (seen) seen.Add(e); }; drv.RaiseKeepAliveForTest(StatusCodes.Good); await Task.Delay(200, TestContext.Current.CancellationToken); lock (seen) seen.ShouldBeEmpty(); } [Fact] public void A_bad_keep_alive_annotates_subscribed_alarm_conditions_quality_only_once() { var (drv, _, _) = NewConnectedDriver(); using var _ = drv; drv.AddAlarmSubscriptionForTest([SpeedRef]); var seen = new List(); drv.OnAlarmEvent += (_, e) => seen.Add(e); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); var e = seen.ShouldHaveSingleItem(); e.ConditionId.ShouldBe(SpeedRef); e.IsQualityOnly.ShouldBeTrue("an outage says nothing about the alarm state — quality only, no transition"); e.QualityStatusCode.ShouldBe(BadNoCommunication); } [Fact] public async Task Recovery_restores_alarm_condition_quality_Good() { var (drv, session, _) = NewConnectedDriver(); using var _ = drv; SetupRead(session); drv.AddAlarmSubscriptionForTest([SpeedRef]); var seen = new List(); drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); }; drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); drv.RaiseKeepAliveForTest(StatusCodes.Good); await WaitForAsync(() => { lock (seen) return seen.Count >= 2; }, "recovery must re-annotate Good"); lock (seen) { seen[1].IsQualityOnly.ShouldBeTrue(); seen[1].QualityStatusCode.ShouldBe(0u); } } [Fact] public void A_keep_alive_from_a_superseded_session_is_ignored() { var (drv, _, _) = NewConnectedDriver(); using var _ = drv; var stale = NewSession(); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication, stale.Object); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } [Fact] public async Task Shutdown_clears_the_outage_so_a_fresh_session_reports_its_own_loss() { var (drv, _, _) = NewConnectedDriver(); using var _ = drv; drv.AddSubscriptionForTest([SpeedRef]); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); await drv.ShutdownAsync(TestContext.Current.CancellationToken); // As after a watchdog reinit: a new session + subscription, and a new outage must be reported again. var fresh = NewSession(); drv.SetSessionForTest(fresh.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); drv.AddSubscriptionForTest([SpeedRef]); var seen = new List(); drv.OnDataChange += (_, e) => seen.Add(e); drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); seen.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(BadNoCommunication); } }