using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ArchestrA; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend; /// /// Driver.Historian.Wonderware-012 coverage — pins 's /// connect-failover / cooldown loop via a fake . /// A live is never instantiated; the fake throws on every /// attempt so the read path surfaces the connect failure without touching the SDK. /// [Trait("Category", "Unit")] public sealed class HistorianDataSourceConnectFailoverTests { [Fact] public async Task ReadRaw_when_no_nodes_are_healthy_throws_so_IPC_surfaces_Success_false() { var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new List { "node-a" }, FailureCooldownSeconds = 60, // Disable the outer request timeout so the test doesn't race the connect failure // against the timeout (we want the connect failure path, not a TimeoutException). RequestTimeoutSeconds = 0, }; var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory()); // Read methods used to swallow the connect exception and return an empty list with // Success=true; the fix re-throws so the IPC layer surfaces Success=false. The // exception must therefore propagate. await Should.ThrowAsync(() => ds.ReadRawAsync( "Tank.Level", new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc), maxValues: 100, CancellationToken.None)); } [Fact] public async Task ReadRaw_tries_each_cluster_node_in_order_until_one_succeeds_or_all_fail() { var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new List { "node-a", "node-b", "node-c" }, FailureCooldownSeconds = 60, RequestTimeoutSeconds = 0, }; var factory = new TrackingThrowingConnectionFactory(); var ds = new HistorianDataSource(cfg, factory); await Should.ThrowAsync(() => ds.ReadRawAsync( "Tank.Level", new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc), maxValues: 100, CancellationToken.None)); // All three candidates must be attempted in the configured order before the // connect-loop gives up. factory.AttemptedNodes.ShouldBe(new[] { "node-a", "node-b", "node-c" }); } [Fact] public async Task ReadRaw_marks_failed_nodes_in_cooldown_so_a_subsequent_call_sees_no_healthy_nodes() { var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new List { "node-a", "node-b" }, FailureCooldownSeconds = 60, RequestTimeoutSeconds = 0, }; var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory()); await Should.ThrowAsync(() => ds.ReadRawAsync( "Tank.Level", DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, maxValues: 100, CancellationToken.None)); var snap = ds.GetHealthSnapshot(); snap.NodeCount.ShouldBe(2); snap.HealthyNodeCount.ShouldBe(0, "both nodes failed and entered cooldown after the connect attempts"); snap.ProcessConnectionOpen.ShouldBeFalse(); snap.ActiveProcessNode.ShouldBeNull(); } [Fact] public async Task ReadEvents_uses_a_separate_event_connection_path() { // ReadEventsAsync uses _eventConnection / EnsureEventConnected — a different // codepath than ReadRawAsync. Symmetric test to pin the dual-connection design. var cfg = new HistorianConfiguration { Enabled = true, ServerNames = new List { "node-a" }, FailureCooldownSeconds = 60, RequestTimeoutSeconds = 0, }; var factory = new TrackingThrowingConnectionFactory(); var ds = new HistorianDataSource(cfg, factory); await Should.ThrowAsync(() => ds.ReadEventsAsync( sourceName: "Tank.HiHi", DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, maxEvents: 100, CancellationToken.None)); factory.AttemptedTypes.ShouldContain(HistorianConnectionType.Event, "event reads must open an Event-typed connection"); factory.AttemptedNodes.ShouldBe(new[] { "node-a" }); } // ── helpers ────────────────────────────────────────────────────────── private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory { public HistorianAccess CreateAndConnect( HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) => throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); } private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory { public List AttemptedNodes { get; } = new(); public List AttemptedTypes { get; } = new(); public HistorianAccess CreateAndConnect( HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true) { AttemptedNodes.Add(config.ServerName); AttemptedTypes.Add(type); throw new InvalidOperationException($"simulated connect failure to {config.ServerName}"); } } }