Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceConnectFailoverTests.cs
Joseph Doherty 1f29b215c8 fix(driver-historian-wonderware): resolve Low code-review findings (Driver.Historian.Wonderware-004,005,007,008,010,011,012)
- Driver.Historian.Wonderware-004: ToHistorianEvent synthesises a fresh
  Guid when the upstream EventId is unparseable and logs the substitution
  instead of writing the historian with Guid.Empty.
- Driver.Historian.Wonderware-005: GetHealthSnapshot derives the
  connection-open booleans from the active-node fields so the snapshot
  is self-consistent without depending on the secondary lock.
- Driver.Historian.Wonderware-007: SID-mismatch branch in PipeServer now
  sends a HelloAck { Accepted=false, RejectReason } so the client sees a
  symmetric rejection.
- Driver.Historian.Wonderware-008: classify StartQuery failures —
  connection-class codes drop the connection, query-class codes throw
  QueryClassStartQueryException so the IPC layer surfaces Success=false.
- Driver.Historian.Wonderware-010: RequestTimeoutSeconds now enforced
  via BuildRequestCts linked to the caller's CancellationToken.
- Driver.Historian.Wonderware-011: refreshed XML docs to describe the
  current sidecar / named-pipe architecture (Galaxy.Host / Proxy
  references reframed as historical context).
- Driver.Historian.Wonderware-012: pinned the previously-uncovered
  HistorianDataSource behaviours with five new test files; also removed
  the stale empty tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
  directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:18:10 -04:00

143 lines
5.9 KiB
C#

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;
/// <summary>
/// Driver.Historian.Wonderware-012 coverage — pins <see cref="HistorianDataSource"/>'s
/// connect-failover / cooldown loop via a fake <see cref="IHistorianConnectionFactory"/>.
/// A live <see cref="HistorianAccess"/> is never instantiated; the fake throws on every
/// attempt so the read path surfaces the connect failure without touching the SDK.
/// </summary>
[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<string> { "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<Exception>(() => 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<string> { "node-a", "node-b", "node-c" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var factory = new TrackingThrowingConnectionFactory();
var ds = new HistorianDataSource(cfg, factory);
await Should.ThrowAsync<Exception>(() => 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<string> { "node-a", "node-b" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
await Should.ThrowAsync<Exception>(() => 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<string> { "node-a" },
FailureCooldownSeconds = 60,
RequestTimeoutSeconds = 0,
};
var factory = new TrackingThrowingConnectionFactory();
var ds = new HistorianDataSource(cfg, factory);
await Should.ThrowAsync<Exception>(() => 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<string> AttemptedNodes { get; } = new();
public List<HistorianConnectionType> 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}");
}
}
}