using Akka.Actor; using Akka.TestKit.Xunit2; using NSubstitute; using Opc.Ua; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// /// Arch-review 2026-08-19 P2-4 (classification half) — how a thrown /// out of a batch subscribe is classified. /// /// /// The gap these pin. IsConnectionLevelFailure knew only about /// InvalidOperationException / socket / timeout / IO faults, so the OPC UA SDK's own way of /// reporting a dead session — a ServiceResultException carrying a bad-session status — /// was treated as a per-tag resolution failure for the WHOLE chunk. Every tag went unresolved and /// the backoff probe re-issued the same batch against the same dead session forever, growing the /// adapter's monitored-item bookkeeping each round, instead of reconnecting once. /// /// /// /// The classification must stay narrow: a per-node status (BadNodeIdUnknown) is exactly the /// "device still booting / wrong tag path" case the resolution timer exists for, and must NOT put /// the connection into a reconnect loop. /// /// public class DataConnectionOpcUaFaultClassificationTests : TestKit { private readonly ISiteHealthCollector _health = Substitute.For(); private readonly IDataConnectionFactory _factory = Substitute.For(); private static DataConnectionOptions Options() => new() { ReconnectInterval = TimeSpan.FromMilliseconds(100), TagResolutionRetryInterval = TimeSpan.FromMilliseconds(150), TagResolutionRetryMaxInterval = TimeSpan.FromMilliseconds(600), WriteTimeout = TimeSpan.FromSeconds(5), SeedReadMaxAttempts = 1, SeedReadRetryDelay = TimeSpan.FromMilliseconds(10), SubscribeBatchSize = 5, SubscribeBatchDelay = TimeSpan.FromMilliseconds(50), SeedReadBatchSize = 4, SeedReadMaxParallelism = 2, SeedOverallTimeout = TimeSpan.FromSeconds(30), QualityFlushInterval = TimeSpan.FromMilliseconds(200) }; private IActorRef CreateActor(FakeBatchDataConnection adapter, string name) => Sys.ActorOf(Props.Create(() => new DataConnectionActor( name, adapter, Options(), _health, _factory, "OpcUa")), name); private int HealthTransitions(string connectionName, ConnectionHealth health) => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteHealthCollector.UpdateConnectionHealth) && c.GetArguments() is [string name, ConnectionHealth reported, ..] && name == connectionName && reported == health); /// /// A bad-session fault is a CONNECTION fault: the request fails and the actor enters /// Reconnecting (which pushes bad quality for the connection). /// /// Fails before the fix: the chunk was treated per-tag, so the request was acked /// Success and the connection stayed nominally up on a dead session. /// [Fact] public void BadSessionServiceResultException_IsConnectionLevel_AndDrivesReconnect() { const string name = "opcua-bad-session"; var adapter = new FakeBatchDataConnection { BatchSubscribeThrows = () => new ServiceResultException(StatusCodes.BadSessionIdInvalid, "session is no longer valid") }; var actor = CreateActor(adapter, name); actor.Tell(new SubscribeTagsRequest("c1", "inst1", name, ["tag1", "tag2"], DateTimeOffset.UtcNow)); ExpectMsg(m => !m.Success && m.ErrorMessage != null, TimeSpan.FromSeconds(5)); ExpectMsg(q => q.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5)); } /// /// The narrowness control. BadNodeIdUnknown is a per-node fault: the tags go unresolved /// (bad quality, retried on the backoff timer) and the connection is NOT torn down. /// [Fact] public void BadNodeIdServiceResultException_StaysPerTag_AndDoesNotTearDownTheConnection() { const string name = "opcua-bad-nodeid"; var adapter = new FakeBatchDataConnection { BatchSubscribeThrows = () => new ServiceResultException(StatusCodes.BadNodeIdUnknown, "no such node") }; var actor = CreateActor(adapter, name); actor.Tell(new SubscribeTagsRequest("c1", "inst1", name, ["tag1", "tag2"], DateTimeOffset.UtcNow)); // Per-tag treatment: bad quality per tag, and the request itself is still acked Success // (the tags are registered and retried, exactly as an unresolved tag path is). FishForMessage(m => m.Success, TimeSpan.FromSeconds(5)); Assert.Equal(0, HealthTransitions(name, ConnectionHealth.Disconnected)); } }