64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
161 lines
7.0 KiB
C#
161 lines
7.0 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
|
|
{
|
|
/// <summary>Verifies that ReadRaw throws when no nodes are healthy.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that ReadRaw tries each cluster node in order.</summary>
|
|
[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" });
|
|
}
|
|
|
|
/// <summary>Verifies that failed nodes are marked in cooldown and not retried immediately.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Verifies that ReadEvents uses a separate event connection path.</summary>
|
|
[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
|
|
{
|
|
/// <summary>
|
|
/// Simulates a connection failure by throwing an exception.
|
|
/// </summary>
|
|
/// <param name="config">The historian configuration.</param>
|
|
/// <param name="type">The connection type.</param>
|
|
/// <param name="readOnly">Whether to open a read-only connection.</param>
|
|
public HistorianAccess CreateAndConnect(
|
|
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
|
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
|
}
|
|
|
|
private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory
|
|
{
|
|
/// <summary>Gets the list of node names that were attempted.</summary>
|
|
public List<string> AttemptedNodes { get; } = new();
|
|
/// <summary>Gets the list of connection types that were attempted.</summary>
|
|
public List<HistorianConnectionType> AttemptedTypes { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Tracks connection attempts and simulates a connection failure.
|
|
/// </summary>
|
|
/// <param name="config">The historian configuration.</param>
|
|
/// <param name="type">The connection type.</param>
|
|
/// <param name="readOnly">Whether to open a read-only connection.</param>
|
|
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}");
|
|
}
|
|
}
|
|
}
|