Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcRedundancyFixture.cs
2026-04-26 10:05:05 -04:00

96 lines
3.8 KiB
C#

using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Multi-endpoint fixture for upstream-redundancy smoke tests (PR-14, issue #286).
/// Probes both <c>opc-plc</c> instances from the docker-compose stack —
/// <c>opc-plc</c> on 50000 + <c>opc-plc-secondary</c> on 50002 — and exposes
/// a <see cref="SkipReason"/> when either is unreachable. Tests use the pair to
/// drive a ServiceLevel drop on the primary and assert the driver fails over
/// to the secondary mid-session.
/// </summary>
/// <remarks>
/// The primary endpoint URL can be overridden via <c>OPCUA_SIM_ENDPOINT</c> + the
/// secondary via <c>OPCUA_SIM_ENDPOINT_SECONDARY</c> for runs against real
/// redundant servers. Defaults assume the docker-compose stack is up locally
/// (<c>docker compose -f Docker/docker-compose.yml up opc-plc opc-plc-secondary</c>).
/// </remarks>
public sealed class OpcPlcRedundancyFixture : IAsyncDisposable
{
private const string DefaultPrimary = "opc.tcp://localhost:50000";
private const string DefaultSecondary = "opc.tcp://localhost:50002";
private const string PrimaryEnvVar = "OPCUA_SIM_ENDPOINT";
private const string SecondaryEnvVar = "OPCUA_SIM_ENDPOINT_SECONDARY";
public string PrimaryEndpointUrl { get; }
public string SecondaryEndpointUrl { get; }
public string? SkipReason { get; }
public OpcPlcRedundancyFixture()
{
PrimaryEndpointUrl = Environment.GetEnvironmentVariable(PrimaryEnvVar) ?? DefaultPrimary;
SecondaryEndpointUrl = Environment.GetEnvironmentVariable(SecondaryEnvVar) ?? DefaultSecondary;
if (!ProbeTcp(PrimaryEndpointUrl, out var primaryReason))
{
SkipReason = primaryReason;
return;
}
if (!ProbeTcp(SecondaryEndpointUrl, out var secondaryReason))
{
SkipReason = secondaryReason;
return;
}
}
private static bool ProbeTcp(string endpointUrl, out string? skipReason)
{
skipReason = null;
var (host, port) = ParseHostPort(endpointUrl);
try
{
using var client = new TcpClient(AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(host)
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
skipReason = $"opc-plc instance at {host}:{port} did not accept a TCP connection within 2s. " +
"Start it (`docker compose -f Docker/docker-compose.yml up opc-plc opc-plc-secondary`).";
return false;
}
return true;
}
catch (Exception ex)
{
skipReason = $"opc-plc instance at {host}:{port} unreachable: {ex.GetType().Name}: {ex.Message}.";
return false;
}
}
private static (string Host, int Port) ParseHostPort(string endpointUrl)
{
const string scheme = "opc.tcp://";
var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
? endpointUrl[scheme.Length..]
: endpointUrl;
var slash = body.IndexOf('/');
if (slash >= 0) body = body[..slash];
var colon = body.IndexOf(':');
if (colon < 0) return (body, 4840);
var host = body[..colon];
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class OpcPlcRedundancyCollection : Xunit.ICollectionFixture<OpcPlcRedundancyFixture>
{
public const string Name = "OpcPlcRedundancy";
}