using System; using Shouldly; using Xunit; using ZB.MOM.WW.LmxOpcUa.Historian.Aveva; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Historian; namespace ZB.MOM.WW.LmxOpcUa.Historian.Aveva.Tests { /// /// Verifies Historian data source lifecycle behavior: dispose safety, /// post-dispose rejection, connection failure handling, and reconnect-after-error. /// public class HistorianDataSourceLifecycleTests { private static HistorianConfiguration DefaultConfig => new() { Enabled = true, ServerName = "test-historian", Port = 32568, IntegratedSecurity = true, CommandTimeoutSeconds = 5 }; [Fact] public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException() { var ds = new HistorianDataSource(DefaultConfig); ds.Dispose(); Should.Throw(() => ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult()); } [Fact] public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException() { var ds = new HistorianDataSource(DefaultConfig); ds.Dispose(); Should.Throw(() => ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average") .GetAwaiter().GetResult()); } [Fact] public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException() { var ds = new HistorianDataSource(DefaultConfig); ds.Dispose(); Should.Throw(() => ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow }) .GetAwaiter().GetResult()); } [Fact] public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException() { var ds = new HistorianDataSource(DefaultConfig); ds.Dispose(); Should.Throw(() => ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult()); } [Fact] public void Dispose_CalledTwice_DoesNotThrow() { var ds = new HistorianDataSource(DefaultConfig); ds.Dispose(); Should.NotThrow(() => ds.Dispose()); } [Fact] public void HistorianAggregateMap_UnknownColumn_ReturnsNull() { HistorianAggregateMap.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull(); } [Fact] public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults() { var factory = new FakeHistorianConnectionFactory { ConnectException = new InvalidOperationException("Connection refused") }; var ds = new HistorianDataSource(DefaultConfig, factory); var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); results.Count.ShouldBe(0); factory.ConnectCallCount.ShouldBe(1); } [Fact] public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults() { var factory = new FakeHistorianConnectionFactory { ConnectException = new TimeoutException("Connection timed out") }; var ds = new HistorianDataSource(DefaultConfig, factory); var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); results.Count.ShouldBe(0); } [Fact] public void ReadRawAsync_AfterConnectionError_AttemptsReconnect() { var factory = new FakeHistorianConnectionFactory(); var ds = new HistorianDataSource(DefaultConfig, factory); // First call: factory returns a HistorianAccess that isn't actually connected, // so the query will fail and HandleConnectionError will reset the connection. ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); // Second call: should attempt reconnection via the factory ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); // Factory should have been called twice — once for initial connect, once for reconnect factory.ConnectCallCount.ShouldBe(2); } [Fact] public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState() { var callCount = 0; var factory = new FakeHistorianConnectionFactory { OnConnect = count => { callCount = count; if (count == 1) throw new InvalidOperationException("First connection fails"); // Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing) } }; var ds = new HistorianDataSource(DefaultConfig, factory); // First read: connection fails var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); r1.Count.ShouldBe(0); // Second read: should attempt new connection without throwing from internal state corruption var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); callCount.ShouldBe(2); } [Fact] public void Dispose_DuringConnectionFailure_DoesNotThrow() { var factory = new FakeHistorianConnectionFactory { ConnectException = new InvalidOperationException("Connection refused") }; var ds = new HistorianDataSource(DefaultConfig, factory); // Trigger a failed connection attempt ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100) .GetAwaiter().GetResult(); // Dispose should handle the null connection gracefully Should.NotThrow(() => ds.Dispose()); } } }