using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
/// Unit tests for . Covers the offline-determinable failure
/// paths (invalid JSON, missing host/port, unreachable closed port) plus the degrade path:
/// on a host with no FANUC FWLIB native library present (this dev box / CI Linux containers),
/// the cnc_allclibhndl3 P/Invoke throws at JIT bind
/// time, so a TCP-reachable target must still report Ok=true with a "FWLIB absent"
/// note — never worse than the pre-Phase-5 TCP-only probe.
///
/// Live-verify DEFERRED. The happy path (a real CNC answers cnc_allclibhndl3
/// with EW_OK → "FOCAS handle OK") and the CNC-error path (FWLIB present but the
/// remote returns e.g. EW_SOCKET/EW_PROTOCOL → "FOCAS handshake failed:
/// focas_rc=...") cannot run on this rig: there is neither a FANUC CNC nor the FWLIB native
/// library available. Those two paths are verified manually against a real Windows+FWLIB host.
///
///
[Trait("Category", "Unit")]
public sealed class FocasDriverProbeTests
{
private static readonly FocasDriverProbe Probe = new();
// -------------------------------------------------------------------------
// 1. Invalid JSON
// -------------------------------------------------------------------------
/// Invalid JSON returns Ok=false with a message containing "invalid".
[Fact]
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
{
var result = await Probe.ProbeAsync(
"not-valid-json{{{",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ToLowerInvariant().ShouldContain("invalid");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 2. Config with no device / no host
// -------------------------------------------------------------------------
/// A config with an empty Devices list returns Ok=false with a "no host/port" message.
[Fact]
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
{
var result = await Probe.ProbeAsync(
"{\"devices\":[]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
///
/// A config whose first device carries a malformed host address (not focas://) returns
/// Ok=false with a "no host/port" message because
/// returns null for the address.
///
[Fact]
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
{
// "not-a-focas-url" is not a focas:// URL — TryParse returns null.
var result = await Probe.ProbeAsync(
"{\"devices\":[{\"hostAddress\":\"not-a-focas-url\"}]}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("no host/port");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 3. Unreachable target — TCP connect fails at preflight
// -------------------------------------------------------------------------
///
/// A closed port causes ConnectAsync to throw ; the
/// probe must return Ok=false with a "Connect failed" message. The port is reserved then
/// released so the OS refuses the connection immediately (no black-hole delay needed).
///
[Fact]
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
{
// Reserve an ephemeral port then release it so the port is definitely closed.
var reserved = new TcpListener(IPAddress.Loopback, 0);
reserved.Start();
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
reserved.Stop();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
cts.Token);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("Connect failed");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 4. Degrade path — TCP reachable, FWLIB absent (the key test)
// -------------------------------------------------------------------------
///
/// Against an in-process that accepts the connection, the TCP
/// preflight succeeds. On this box the FANUC FWLIB native library is absent, so the
/// cnc_allclibhndl3 P/Invoke throws (or a
/// related load failure). The probe MUST degrade gracefully — return Ok=true with
/// a "FWLIB absent ... TCP reachability only" note — proving no regression versus the
/// pre-Phase-5 TCP-only probe on FWLIB-less hosts.
///
[Fact]
public async Task TcpReachable_FwlibAbsent_Degrades_To_OkTrue_WithReachabilityNote()
{
// Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
// Keep accepting so the connect always completes; ignore the accepted socket.
_ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"focas://127.0.0.1:{port}\"}}]}}";
var result = await Probe.ProbeAsync(
configJson,
TimeSpan.FromSeconds(3),
cts.Token);
// No FWLIB here → degrade, never worse than TCP-only.
result.Ok.ShouldBeTrue(
$"Expected degrade to Ok=true on an FWLIB-less host but got: {result.Message}");
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("FWLIB absent");
result.Message!.ShouldContain("TCP reachability only");
result.Latency.ShouldNotBeNull();
}
finally
{
listener.Stop();
}
}
private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var socket = await listener.AcceptSocketAsync(ct);
// Drop the connection immediately — we only need the TCP handshake to complete.
socket.Dispose();
}
}
catch
{
// Listener stopped / token cancelled — expected at test teardown.
}
}
}