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. } } }