using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// Multi-endpoint fixture for upstream-redundancy smoke tests (PR-14, issue #286). /// Probes both opc-plc instances from the docker-compose stack — /// opc-plc on 50000 + opc-plc-secondary on 50002 — and exposes /// a 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. /// /// /// The primary endpoint URL can be overridden via OPCUA_SIM_ENDPOINT + the /// secondary via OPCUA_SIM_ENDPOINT_SECONDARY for runs against real /// redundant servers. Defaults assume the docker-compose stack is up locally /// (docker compose -f Docker/docker-compose.yml up opc-plc opc-plc-secondary). /// 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 { public const string Name = "OpcPlcRedundancy"; }