using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
///
/// Tests for . Each test spins up a real
/// on 127.0.0.1 port 0 (OS-assigned) so the port is
/// guaranteed available. Server-side behaviour is driven by a background task so
/// the accept is always awaited before the probe fires.
///
[Trait("Category", "Unit")]
public sealed class ModbusDriverProbeTests
{
private readonly ModbusDriverProbe _probe = new();
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
// ── helpers ──────────────────────────────────────────────────────────────
/// Allocates a TcpListener on an OS-assigned port and returns it started.
private static TcpListener StartListener()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
return l;
}
/// Returns the port assigned to .
private static int ListenerPort(TcpListener l)
=> ((IPEndPoint)l.LocalEndpoint).Port;
///
/// Builds a config JSON for 127.0.0.1 at with optional unitId.
///
private static string Config(int port, byte unitId = 1)
=> $@"{{""host"":""127.0.0.1"",""port"":{port},""unitId"":{unitId}}}";
///
/// Writes a well-formed MBAP FC03 response to , echoing the
/// transaction-id from the request's first two bytes. Reads and discards the full
/// request first so the client's send doesn't block.
///
private static async Task WriteHappyFc03ResponseAsync(NetworkStream stream, CancellationToken ct)
{
// Read the MBAP header (7 bytes) + PDU (5 bytes for FC03 qty=1) = 12 bytes total.
var req = new byte[12];
var read = 0;
while (read < req.Length)
{
var n = await stream.ReadAsync(req.AsMemory(read), ct);
if (n == 0) return; // client closed early
read += n;
}
// Echo TxId from request bytes [0..1].
var txHi = req[0];
var txLo = req[1];
var unitId = req[6];
// Response PDU: FC=0x03, ByteCount=2, Data=0x00 0x00
var pdu = new byte[] { 0x03, 0x02, 0x00, 0x00 };
// MBAP: TxId(2) + Proto=0(2) + Len(2) + UnitId(1) + PDU
// Length field = unitId(1) + pdu.Length
var respLen = (ushort)(1 + pdu.Length);
var resp = new byte[7 + pdu.Length];
resp[0] = txHi;
resp[1] = txLo;
// resp[2..3] = 0 (protocol id)
resp[4] = (byte)(respLen >> 8);
resp[5] = (byte)(respLen & 0xFF);
resp[6] = unitId;
Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length);
await stream.WriteAsync(resp.AsMemory(), ct);
await stream.FlushAsync(ct);
}
///
/// Writes a Modbus exception PDU response (function 0x83 + exception code 0x02)
/// echoing the TxId from the request.
///
private static async Task WriteExceptionPduResponseAsync(NetworkStream stream, CancellationToken ct)
{
// Read request
var req = new byte[12];
var read = 0;
while (read < req.Length)
{
var n = await stream.ReadAsync(req.AsMemory(read), ct);
if (n == 0) return;
read += n;
}
var txHi = req[0];
var txLo = req[1];
var unitId = req[6];
// Exception PDU: error function = 0x83 (0x03 | 0x80), exception code = 0x02
var pdu = new byte[] { 0x83, 0x02 };
var respLen = (ushort)(1 + pdu.Length);
var resp = new byte[7 + pdu.Length];
resp[0] = txHi;
resp[1] = txLo;
resp[4] = (byte)(respLen >> 8);
resp[5] = (byte)(respLen & 0xFF);
resp[6] = unitId;
Buffer.BlockCopy(pdu, 0, resp, 7, pdu.Length);
await stream.WriteAsync(resp.AsMemory(), ct);
await stream.FlushAsync(ct);
}
// ── Test 1: invalid JSON ──────────────────────────────────────────────────
/// Invalid config JSON returns Ok=false with a message containing "invalid".
[Fact]
public async Task InvalidJson_returns_false_with_invalid_message()
{
var result = await _probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("invalid", Case.Insensitive);
}
// ── Test 2: no host/port ─────────────────────────────────────────────────
/// Config with empty host returns Ok=false with a "no host/port" message.
[Fact]
public async Task Config_with_no_host_returns_false_with_no_host_port_message()
{
// host defaults to "127.0.0.1" in ModbusDriverOptions, but port=0 is treated as invalid.
var result = await _probe.ProbeAsync(@"{""host"":"""",""port"":0}", QuickTimeout, CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("no host/port", Case.Insensitive);
}
// ── Test 3: unreachable (closed port) ────────────────────────────────────
/// A probe against a closed port returns Ok=false (Connect failed / refused).
[Fact]
public async Task Unreachable_closed_port_returns_false()
{
// Grab an ephemeral port then immediately stop the listener so nothing is listening.
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Latency.ShouldBeNull();
}
// ── Test 4: TCP accepts then immediately closes (no Modbus reply) ─────────
///
/// When the server accepts the TCP connection and then closes it without sending any
/// Modbus bytes, ProbeAsync returns Ok=false with a "handshake failed" message.
///
[Fact]
public async Task Tcp_connect_then_immediate_close_returns_handshake_failed()
{
using var listener = StartListener();
var port = ListenerPort(listener);
using var cts = new CancellationTokenSource(QuickTimeout);
// Background: accept the TCP client and immediately close it without replying.
var serverTask = Task.Run(async () =>
{
var client = await listener.AcceptTcpClientAsync(cts.Token);
client.Close(); // close without sending any bytes
}, cts.Token);
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
// The server task should have completed (or close enough).
await serverTask.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("handshake failed", Case.Insensitive);
result.Latency.ShouldBeNull();
}
// ── Test 5: canned MBAP happy path ───────────────────────────────────────
///
/// A listener that accepts, reads the FC03 request, and replies with a valid MBAP
/// FC03 response causes ProbeAsync to return Ok=true with message "Modbus FC03 OK".
///
[Fact]
public async Task Happy_path_returns_ok_true_with_FC03_OK_message()
{
using var listener = StartListener();
var port = ListenerPort(listener);
using var cts = new CancellationTokenSource(QuickTimeout);
var serverTask = Task.Run(async () =>
{
var client = await listener.AcceptTcpClientAsync(cts.Token);
await using var stream = client.GetStream();
await WriteHappyFc03ResponseAsync(stream, cts.Token);
}, cts.Token);
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
result.Ok.ShouldBeTrue();
result.Message.ShouldBe("Modbus FC03 OK");
result.Latency.ShouldNotBeNull();
result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
}
// ── Test 6: Modbus exception PDU ─────────────────────────────────────────
///
/// When the server replies with a Modbus exception PDU (function 0x83 + exception code),
/// the probe still returns Ok=true because a real Modbus device answered.
///
[Fact]
public async Task Exception_pdu_response_returns_ok_true_with_exception_pdu_message()
{
using var listener = StartListener();
var port = ListenerPort(listener);
using var cts = new CancellationTokenSource(QuickTimeout);
var serverTask = Task.Run(async () =>
{
var client = await listener.AcceptTcpClientAsync(cts.Token);
await using var stream = client.GetStream();
await WriteExceptionPduResponseAsync(stream, cts.Token);
}, cts.Token);
var result = await _probe.ProbeAsync(Config(port), QuickTimeout, CancellationToken.None);
await serverTask.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
result.Ok.ShouldBeTrue();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("exception PDU", Case.Insensitive);
result.Latency.ShouldNotBeNull();
result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
}
}