@@ -0,0 +1,95 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user