using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests.Fakes; namespace ZB.MOM.WW.LmxOpcUa.Client.Shared.Tests; /// /// Verifies the shared OPC UA client service behaviors for connection management, browsing, subscriptions, history, alarms, and redundancy. /// public class OpcUaClientServiceTests : IDisposable { private readonly FakeApplicationConfigurationFactory _configFactory = new(); private readonly FakeEndpointDiscovery _endpointDiscovery = new(); private readonly OpcUaClientService _service; private readonly FakeSessionFactory _sessionFactory = new(); public OpcUaClientServiceTests() { _service = new OpcUaClientService(_configFactory, _endpointDiscovery, _sessionFactory); } /// /// Releases the shared client service after each test so session and subscription state do not leak between scenarios. /// public void Dispose() { _service.Dispose(); } private ConnectionSettings ValidSettings(string url = "opc.tcp://localhost:4840") { return new ConnectionSettings { EndpointUrl = url, SessionTimeoutSeconds = 60 }; } // --- Connection tests --- /// /// Verifies that a valid connection request returns populated connection metadata and marks the client as connected. /// [Fact] public async Task ConnectAsync_ValidSettings_ReturnsConnectionInfo() { var info = await _service.ConnectAsync(ValidSettings()); info.ShouldNotBeNull(); info.EndpointUrl.ShouldBe("opc.tcp://localhost:4840"); _service.IsConnected.ShouldBeTrue(); _service.CurrentConnectionInfo.ShouldBe(info); } /// /// Verifies that invalid connection settings fail validation before any OPC UA session is created. /// [Fact] public async Task ConnectAsync_InvalidSettings_ThrowsBeforeCreatingSession() { var settings = new ConnectionSettings { EndpointUrl = "" }; await Should.ThrowAsync(() => _service.ConnectAsync(settings)); _sessionFactory.CreateCallCount.ShouldBe(0); _service.IsConnected.ShouldBeFalse(); } /// /// Verifies that server and security details from the session are copied into the exposed connection info. /// [Fact] public async Task ConnectAsync_PopulatesConnectionInfo() { var session = new FakeSessionAdapter { ServerName = "MyServer", SecurityMode = "Sign", SecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256", SessionId = "ns=0;i=999", SessionName = "TestSession" }; _sessionFactory.EnqueueSession(session); var info = await _service.ConnectAsync(ValidSettings()); info.ServerName.ShouldBe("MyServer"); info.SecurityMode.ShouldBe("Sign"); info.SecurityPolicyUri.ShouldBe("http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"); info.SessionId.ShouldBe("ns=0;i=999"); info.SessionName.ShouldBe("TestSession"); } /// /// Verifies that connection-state transitions are raised for the connecting and connected phases. /// [Fact] public async Task ConnectAsync_RaisesConnectionStateChangedEvents() { var events = new List(); _service.ConnectionStateChanged += (_, e) => events.Add(e); await _service.ConnectAsync(ValidSettings()); events.Count.ShouldBe(2); events[0].OldState.ShouldBe(ConnectionState.Disconnected); events[0].NewState.ShouldBe(ConnectionState.Connecting); events[1].OldState.ShouldBe(ConnectionState.Connecting); events[1].NewState.ShouldBe(ConnectionState.Connected); } /// /// Verifies that a failed session creation leaves the client in the disconnected state. /// [Fact] public async Task ConnectAsync_SessionFactoryFails_TransitionsToDisconnected() { _sessionFactory.ThrowOnCreate = true; var events = new List(); _service.ConnectionStateChanged += (_, e) => events.Add(e); await Should.ThrowAsync(() => _service.ConnectAsync(ValidSettings())); _service.IsConnected.ShouldBeFalse(); events.Last().NewState.ShouldBe(ConnectionState.Disconnected); } /// /// Verifies that username and password settings are passed through to the session-creation pipeline. /// [Fact] public async Task ConnectAsync_WithUsername_PassesThroughToFactory() { var settings = ValidSettings(); settings.Username = "admin"; settings.Password = "secret"; await _service.ConnectAsync(settings); _configFactory.LastSettings!.Username.ShouldBe("admin"); _configFactory.LastSettings!.Password.ShouldBe("secret"); } // --- Disconnect tests --- /// /// Verifies that disconnect closes the active session and clears exposed connection state. /// [Fact] public async Task DisconnectAsync_WhenConnected_ClosesSession() { await _service.ConnectAsync(ValidSettings()); var session = _sessionFactory.CreatedSessions[0]; await _service.DisconnectAsync(); session.Closed.ShouldBeTrue(); _service.IsConnected.ShouldBeFalse(); _service.CurrentConnectionInfo.ShouldBeNull(); } /// /// Verifies that disconnect is safe to call when no server session is active. /// [Fact] public async Task DisconnectAsync_WhenNotConnected_IsIdempotent() { await _service.DisconnectAsync(); // Should not throw _service.IsConnected.ShouldBeFalse(); } /// /// Verifies that repeated disconnect calls do not throw after cleanup has already run. /// [Fact] public async Task DisconnectAsync_CalledTwice_IsIdempotent() { await _service.ConnectAsync(ValidSettings()); await _service.DisconnectAsync(); await _service.DisconnectAsync(); // Should not throw } // --- Read tests --- /// /// Verifies that a connected client can read the current value of a node through the session adapter. /// [Fact] public async Task ReadValueAsync_WhenConnected_ReturnsValue() { var session = new FakeSessionAdapter { ReadResponse = new DataValue(new Variant(42), StatusCodes.Good) }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode")); result.Value.ShouldBe(42); session.ReadCount.ShouldBe(1); } /// /// Verifies that reads are rejected when the client is not connected to a server. /// [Fact] public async Task ReadValueAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"))); } /// /// Verifies that session-level read failures are surfaced to callers instead of being swallowed. /// [Fact] public async Task ReadValueAsync_SessionThrows_PropagatesException() { var session = new FakeSessionAdapter { ThrowOnRead = true }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await Should.ThrowAsync(() => _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"))); } // --- Write tests --- /// /// Verifies that writes succeed through the session adapter when the client is connected. /// [Fact] public async Task WriteValueAsync_WhenConnected_WritesValue() { var session = new FakeSessionAdapter { ReadResponse = new DataValue(new Variant(0), StatusCodes.Good), WriteResponse = StatusCodes.Good }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42); result.ShouldBe(StatusCodes.Good); session.WriteCount.ShouldBe(1); } /// /// Verifies that string inputs are coerced to the node's current data type before writing. /// [Fact] public async Task WriteValueAsync_StringValue_CoercesToTargetType() { var session = new FakeSessionAdapter { ReadResponse = new DataValue(new Variant(0), StatusCodes.Good) // int type }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"); session.WriteCount.ShouldBe(1); session.ReadCount.ShouldBe(1); // Read for type inference } /// /// Verifies that non-string values are written directly without an extra type-inference read. /// [Fact] public async Task WriteValueAsync_NonStringValue_WritesDirectly() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42); session.WriteCount.ShouldBe(1); session.ReadCount.ShouldBe(0); // No read for non-string values } /// /// Verifies that writes are rejected when the client is disconnected. /// [Fact] public async Task WriteValueAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42)); } // --- Browse tests --- /// /// Verifies that browse results are mapped into the client browse model used by CLI and UI consumers. /// [Fact] public async Task BrowseAsync_WhenConnected_ReturnsMappedResults() { var session = new FakeSessionAdapter { BrowseResponse = [ new ReferenceDescription { NodeId = new ExpandedNodeId("ns=2;s=Child1"), DisplayName = new LocalizedText("Child1"), NodeClass = NodeClass.Variable } ] }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var results = await _service.BrowseAsync(); results.Count.ShouldBe(1); results[0].DisplayName.ShouldBe("Child1"); results[0].NodeClass.ShouldBe("Variable"); results[0].HasChildren.ShouldBeFalse(); // Variable nodes don't check HasChildren } /// /// Verifies that a null browse root defaults to the OPC UA Objects folder. /// [Fact] public async Task BrowseAsync_NullParent_UsesObjectsFolder() { var session = new FakeSessionAdapter { BrowseResponse = [] }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.BrowseAsync(); session.BrowseCount.ShouldBe(1); } /// /// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches. /// [Fact] public async Task BrowseAsync_ObjectNode_ChecksHasChildren() { var session = new FakeSessionAdapter { BrowseResponse = [ new ReferenceDescription { NodeId = new ExpandedNodeId("ns=2;s=Folder1"), DisplayName = new LocalizedText("Folder1"), NodeClass = NodeClass.Object } ], HasChildrenResponse = true }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var results = await _service.BrowseAsync(); results[0].HasChildren.ShouldBeTrue(); session.HasChildrenCount.ShouldBe(1); } /// /// Verifies that browse continuation points are followed so multi-page address-space branches are fully returned. /// [Fact] public async Task BrowseAsync_WithContinuationPoint_FollowsIt() { var session = new FakeSessionAdapter { BrowseResponse = [ new ReferenceDescription { NodeId = new ExpandedNodeId("ns=2;s=A"), DisplayName = new LocalizedText("A"), NodeClass = NodeClass.Variable } ], BrowseContinuationPoint = [1, 2, 3], BrowseNextResponse = [ new ReferenceDescription { NodeId = new ExpandedNodeId("ns=2;s=B"), DisplayName = new LocalizedText("B"), NodeClass = NodeClass.Variable } ], BrowseNextContinuationPoint = null }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var results = await _service.BrowseAsync(); results.Count.ShouldBe(2); session.BrowseNextCount.ShouldBe(1); } /// /// Verifies that browse requests are rejected when the client is disconnected. /// [Fact] public async Task BrowseAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.BrowseAsync()); } // --- Subscribe tests --- /// /// Verifies that subscribing to a node creates a monitored item on a data-change subscription. /// [Fact] public async Task SubscribeAsync_CreatesSubscription() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500); session.CreatedSubscriptions.Count.ShouldBe(1); session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1); } /// /// Verifies that duplicate subscribe requests for the same node do not create duplicate monitored items. /// [Fact] public async Task SubscribeAsync_DuplicateNode_IsIdempotent() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")); await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")); // duplicate session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1); } /// /// Verifies that data-change notifications from the subscription are raised through the shared client event. /// [Fact] public async Task SubscribeAsync_RaisesDataChangedEvent() { var fakeSub = new FakeSubscriptionAdapter(); var session = new FakeSessionAdapter { NextSubscription = fakeSub }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); DataChangedEventArgs? received = null; _service.DataChanged += (_, e) => received = e; await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500); // Simulate data change var handle = fakeSub.ActiveHandles.First(); fakeSub.SimulateDataChange(handle, new DataValue(new Variant(99), StatusCodes.Good)); received.ShouldNotBeNull(); received!.NodeId.ShouldBe("ns=2;s=MyNode"); received.Value.Value.ShouldBe(99); } /// /// Verifies that unsubscribing removes the corresponding monitored item from the active subscription. /// [Fact] public async Task UnsubscribeAsync_RemovesMonitoredItem() { var fakeSub = new FakeSubscriptionAdapter(); var session = new FakeSessionAdapter { NextSubscription = fakeSub }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode")); await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode")); fakeSub.RemoveCount.ShouldBe(1); } /// /// Verifies that unsubscribing an unknown node is treated as a safe no-op. /// [Fact] public async Task UnsubscribeAsync_WhenNotSubscribed_DoesNotThrow() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed")); // Should not throw } /// /// Verifies that data subscriptions cannot be created while the client is disconnected. /// [Fact] public async Task SubscribeAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"))); } // --- Alarm subscription tests --- /// /// Verifies that alarm subscription requests create an event monitored item on the session. /// [Fact] public async Task SubscribeAlarmsAsync_CreatesEventSubscription() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAlarmsAsync(); session.CreatedSubscriptions.Count.ShouldBe(1); session.CreatedSubscriptions[0].AddEventCount.ShouldBe(1); } /// /// Verifies that duplicate alarm-subscription requests do not create duplicate event subscriptions. /// [Fact] public async Task SubscribeAlarmsAsync_Duplicate_IsIdempotent() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAlarmsAsync(); await _service.SubscribeAlarmsAsync(); // duplicate session.CreatedSubscriptions.Count.ShouldBe(1); } /// /// Verifies that OPC UA event notifications are mapped into the shared client alarm event model. /// [Fact] public async Task SubscribeAlarmsAsync_RaisesAlarmEvent() { var fakeSub = new FakeSubscriptionAdapter(); var session = new FakeSessionAdapter { NextSubscription = fakeSub }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); AlarmEventArgs? received = null; _service.AlarmEvent += (_, e) => received = e; await _service.SubscribeAlarmsAsync(); // Simulate alarm event with proper field count var handle = fakeSub.ActiveHandles.First(); var fields = new EventFieldList { EventFields = [ new Variant(new byte[] { 1, 2, 3 }), // 0: EventId new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType new Variant("Source1"), // 2: SourceName new Variant(DateTime.UtcNow), // 3: Time new Variant(new LocalizedText("High temp")), // 4: Message new Variant((ushort)500), // 5: Severity new Variant("HighTemp"), // 6: ConditionName new Variant(true), // 7: Retain new Variant(false), // 8: AckedState new Variant(true), // 9: ActiveState new Variant(true), // 10: EnabledState new Variant(false) ] }; fakeSub.SimulateEvent(handle, fields); received.ShouldNotBeNull(); received!.SourceName.ShouldBe("Source1"); received.ConditionName.ShouldBe("HighTemp"); received.Severity.ShouldBe((ushort)500); received.Message.ShouldBe("High temp"); received.Retain.ShouldBeTrue(); received.ActiveState.ShouldBeTrue(); received.AckedState.ShouldBeFalse(); } /// /// Verifies that removing alarm monitoring deletes the underlying event subscription. /// [Fact] public async Task UnsubscribeAlarmsAsync_DeletesSubscription() { var fakeSub = new FakeSubscriptionAdapter(); var session = new FakeSessionAdapter { NextSubscription = fakeSub }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAlarmsAsync(); await _service.UnsubscribeAlarmsAsync(); fakeSub.Deleted.ShouldBeTrue(); } /// /// Verifies that removing alarms is safe even when no alarm subscription exists. /// [Fact] public async Task UnsubscribeAlarmsAsync_WhenNoSubscription_DoesNotThrow() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.UnsubscribeAlarmsAsync(); // Should not throw } /// /// Verifies that condition refresh requests are forwarded to the active alarm subscription. /// [Fact] public async Task RequestConditionRefreshAsync_CallsAdapter() { var fakeSub = new FakeSubscriptionAdapter(); var session = new FakeSessionAdapter { NextSubscription = fakeSub }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await _service.SubscribeAlarmsAsync(); await _service.RequestConditionRefreshAsync(); fakeSub.ConditionRefreshCalled.ShouldBeTrue(); } /// /// Verifies that condition refresh fails fast when no alarm subscription is active. /// [Fact] public async Task RequestConditionRefreshAsync_NoAlarmSubscription_Throws() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await Should.ThrowAsync(() => _service.RequestConditionRefreshAsync()); } /// /// Verifies that alarm subscriptions cannot be created while disconnected. /// [Fact] public async Task SubscribeAlarmsAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.SubscribeAlarmsAsync()); } // --- History read tests --- /// /// Verifies that raw history reads return the session-provided values. /// [Fact] public async Task HistoryReadRawAsync_ReturnsValues() { var expectedValues = new List { new(new Variant(1.0), StatusCodes.Good), new(new Variant(2.0), StatusCodes.Good) }; var session = new FakeSessionAdapter { HistoryReadRawResponse = expectedValues }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var results = await _service.HistoryReadRawAsync( new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow); results.Count.ShouldBe(2); session.HistoryReadRawCount.ShouldBe(1); } /// /// Verifies that raw history reads are rejected while disconnected. /// [Fact] public async Task HistoryReadRawAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); } /// /// Verifies that raw-history failures from the session are propagated to callers. /// [Fact] public async Task HistoryReadRawAsync_SessionThrows_PropagatesException() { var session = new FakeSessionAdapter { ThrowOnHistoryReadRaw = true }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await Should.ThrowAsync(() => _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow)); } /// /// Verifies that aggregate history reads return the processed values from the session adapter. /// [Fact] public async Task HistoryReadAggregateAsync_ReturnsValues() { var expectedValues = new List { new(new Variant(1.5), StatusCodes.Good) }; var session = new FakeSessionAdapter { HistoryReadAggregateResponse = expectedValues }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var results = await _service.HistoryReadAggregateAsync( new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average); results.Count.ShouldBe(1); session.HistoryReadAggregateCount.ShouldBe(1); } /// /// Verifies that aggregate history reads are rejected while disconnected. /// [Fact] public async Task HistoryReadAggregateAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.HistoryReadAggregateAsync( new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average)); } /// /// Verifies that aggregate-history failures from the session are propagated to callers. /// [Fact] public async Task HistoryReadAggregateAsync_SessionThrows_PropagatesException() { var session = new FakeSessionAdapter { ThrowOnHistoryReadAggregate = true }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); await Should.ThrowAsync(() => _service.HistoryReadAggregateAsync( new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average)); } // --- Redundancy tests --- /// /// Verifies that redundancy mode, service level, and server URIs are read from the standard OPC UA redundancy nodes. /// [Fact] public async Task GetRedundancyInfoAsync_ReturnsInfo() { var session = new FakeSessionAdapter { ReadResponseFunc = nodeId => { if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport) return new DataValue(new Variant((int)RedundancySupport.Warm), StatusCodes.Good); if (nodeId == VariableIds.Server_ServiceLevel) return new DataValue(new Variant((byte)200), StatusCodes.Good); if (nodeId == VariableIds.Server_ServerRedundancy_ServerUriArray) return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good); if (nodeId == VariableIds.Server_ServerArray) return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good); return new DataValue(StatusCodes.BadNodeIdUnknown); } }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var info = await _service.GetRedundancyInfoAsync(); info.Mode.ShouldBe("Warm"); info.ServiceLevel.ShouldBe((byte)200); info.ServerUris.ShouldBe(["urn:server1", "urn:server2"]); info.ApplicationUri.ShouldBe("urn:server1"); } /// /// Verifies that missing optional redundancy arrays do not prevent a redundancy snapshot from being returned. /// [Fact] public async Task GetRedundancyInfoAsync_MissingOptionalArrays_ReturnsGracefully() { var readCallIndex = 0; var session = new FakeSessionAdapter { ReadResponseFunc = nodeId => { if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport) return new DataValue(new Variant((int)RedundancySupport.None), StatusCodes.Good); if (nodeId == VariableIds.Server_ServiceLevel) return new DataValue(new Variant((byte)100), StatusCodes.Good); // Throw for optional reads throw new ServiceResultException(StatusCodes.BadNodeIdUnknown); } }; _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); var info = await _service.GetRedundancyInfoAsync(); info.Mode.ShouldBe("None"); info.ServiceLevel.ShouldBe((byte)100); info.ServerUris.ShouldBeEmpty(); info.ApplicationUri.ShouldBeEmpty(); } /// /// Verifies that redundancy inspection is rejected while disconnected. /// [Fact] public async Task GetRedundancyInfoAsync_WhenDisconnected_Throws() { await Should.ThrowAsync(() => _service.GetRedundancyInfoAsync()); } // --- Failover tests --- /// /// Verifies that a keep-alive failure moves the client to a configured failover endpoint. /// [Fact] public async Task KeepAliveFailure_TriggersFailover() { var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" }; var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" }; _sessionFactory.EnqueueSession(session1); _sessionFactory.EnqueueSession(session2); var settings = ValidSettings("opc.tcp://primary:4840"); settings.FailoverUrls = ["opc.tcp://backup:4840"]; var stateChanges = new List(); _service.ConnectionStateChanged += (_, e) => stateChanges.Add(e); await _service.ConnectAsync(settings); // Simulate keep-alive failure session1.SimulateKeepAlive(false); // Give async failover time to complete await Task.Delay(200); // Should have reconnected stateChanges.ShouldContain(e => e.NewState == ConnectionState.Reconnecting); stateChanges.ShouldContain(e => e.NewState == ConnectionState.Connected && e.EndpointUrl == "opc.tcp://backup:4840"); } /// /// Verifies that connection metadata is refreshed to reflect the newly active failover endpoint. /// [Fact] public async Task KeepAliveFailure_UpdatesConnectionInfo() { var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" }; var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840", ServerName = "BackupServer" }; _sessionFactory.EnqueueSession(session1); _sessionFactory.EnqueueSession(session2); var settings = ValidSettings("opc.tcp://primary:4840"); settings.FailoverUrls = ["opc.tcp://backup:4840"]; await _service.ConnectAsync(settings); session1.SimulateKeepAlive(false); await Task.Delay(200); _service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840"); _service.CurrentConnectionInfo.ServerName.ShouldBe("BackupServer"); } /// /// Verifies that the client falls back to disconnected when every failover endpoint is unreachable. /// [Fact] public async Task KeepAliveFailure_AllEndpointsFail_TransitionsToDisconnected() { var session1 = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session1); await _service.ConnectAsync(ValidSettings()); // After the first session, make factory fail _sessionFactory.ThrowOnCreate = true; session1.SimulateKeepAlive(false); await Task.Delay(200); _service.IsConnected.ShouldBeFalse(); } // --- Dispose tests --- /// /// Verifies that dispose releases the underlying session and clears exposed connection state. /// [Fact] public async Task Dispose_CleansUpResources() { var session = new FakeSessionAdapter(); _sessionFactory.EnqueueSession(session); await _service.ConnectAsync(ValidSettings()); _service.Dispose(); session.Disposed.ShouldBeTrue(); _service.CurrentConnectionInfo.ShouldBeNull(); } /// /// Verifies that dispose is safe to call even when no connection was established. /// [Fact] public void Dispose_WhenNotConnected_DoesNotThrow() { _service.Dispose(); // Should not throw } /// /// Verifies that public operations reject use after the shared client has been disposed. /// [Fact] public async Task OperationsAfterDispose_Throw() { _service.Dispose(); await Should.ThrowAsync(() => _service.ConnectAsync(ValidSettings())); await Should.ThrowAsync(() => _service.ReadValueAsync(new NodeId("ns=2;s=X"))); } // --- Factory tests --- /// /// Verifies that the factory creates a usable shared OPC UA client service instance. /// [Fact] public void OpcUaClientServiceFactory_CreatesService() { var factory = new OpcUaClientServiceFactory(); var service = factory.Create(); service.ShouldNotBeNull(); service.ShouldBeAssignableTo(); service.Dispose(); } }