using System.Net; using Mbproxy.Admin; using Mbproxy.Options; using Mbproxy.Proxy; using Mbproxy.Proxy.Supervision; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Serilog; using Shouldly; using Xunit; namespace Mbproxy.Tests.Admin; /// /// Unit tests for . /// All tests use a real in-process host with and /// in-memory configuration. No network I/O is required. /// [Trait("Category", "Unit")] public sealed class StatusSnapshotBuilderTests { // ── 1. No PLCs configured → empty PLC list ──────────────────────────────── [Fact] public async Task Build_NoPlcsConfigured_ReturnsEmptyPlcList() { var (host, builder) = await BuildAsync([]); await using var _ = new AsyncHostDispose(host); var result = builder.Build(); result.Plcs.ShouldBeEmpty(); result.Listeners.Configured.ShouldBe(0); result.Listeners.Bound.ShouldBe(0); } // ── 2. One PLC bound → state is "bound" ─────────────────────────────────── [Fact] public async Task Build_OnePlcBound_PopulatesListenerState_Bound() { int port = PickFreePort(); var (host, builder) = await BuildAsync([("PLC-A", port)]); await using var _ = new AsyncHostDispose(host); // Wait for the listener to bind. await WaitForAsync( () => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A listener should bind"); var result = builder.Build(); var plc = result.Plcs.ShouldHaveSingleItem(); plc.Name.ShouldBe("PLC-A"); plc.Listener.State.ShouldBe("bound"); plc.Listener.LastBindError.ShouldBeNull(); } // ── 3. PLC recovering → state + last error + attempts ──────────────────── [Fact] public async Task Build_PlcRecovering_PopulatesLastBindError_AndAttempts() { // Bind the occupier on ANY so the proxy (also ANY) cannot rebind the same port. var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0); occupier.Server.SetSocketOption( System.Net.Sockets.SocketOptionLevel.Socket, System.Net.Sockets.SocketOptionName.ExclusiveAddressUse, true); occupier.Start(); int port = ((IPEndPoint)occupier.LocalEndpoint).Port; try { var (host, builder) = await BuildAsync([("PLC-A", port)], startupWaitMs: 500); await using var _ = new AsyncHostDispose(host); // Give the supervisor time to attempt and fail (it enters Recovering state). await Task.Delay(300, TestContext.Current.CancellationToken); var result = builder.Build(); var plc = result.Plcs.ShouldHaveSingleItem(); plc.Listener.State.ShouldBe("recovering"); } finally { occupier.Stop(); } } // ── 4. Aggregate bound/configured ──────────────────────────────────────── [Fact] public async Task Build_AggregatesListenersBoundAndConfigured() { int portA = PickFreePort(); // Occupy portB on ANY with exclusive address use so the proxy cannot rebind it. var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0); occupier.Server.SetSocketOption( System.Net.Sockets.SocketOptionLevel.Socket, System.Net.Sockets.SocketOptionName.ExclusiveAddressUse, true); occupier.Start(); int portB = ((IPEndPoint)occupier.LocalEndpoint).Port; try { var (host, builder) = await BuildAsync([("PLC-A", portA), ("PLC-B", portB)], startupWaitMs: 400); await using var _ = new AsyncHostDispose(host); await WaitForAsync( () => CanConnect(portA), TimeSpan.FromSeconds(5), "PLC-A should bind"); // Give portB's supervisor time to make its first (failing) attempt. await Task.Delay(200, TestContext.Current.CancellationToken); var result = builder.Build(); result.Listeners.Configured.ShouldBe(2); result.Listeners.Bound.ShouldBe(1); // only PLC-A is bound } finally { occupier.Stop(); } } // ── 5. Per-client snapshot populated after connection ──────────────────── [Fact] public async Task Build_PerClientSnapshot_Includes_RemoteAndConnectedAt_AndPduCount() { int proxyPort = PickFreePort(); // Start a "fake backend" listener so the multiplexer's backend-connect succeeds. var fakeBackend = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); fakeBackend.Start(); int backendPort = ((IPEndPoint)fakeBackend.LocalEndpoint).Port; // Track accepted sockets so we can hold them open while the test runs. var acceptedSockets = new System.Collections.Generic.List(); // Accept connections in the background and keep them open. var backendAcceptTask = Task.Run(async () => { while (true) { try { var accepted = await fakeBackend.AcceptSocketAsync(CancellationToken.None); lock (acceptedSockets) acceptedSockets.Add(accepted); } catch { break; } } }, CancellationToken.None); try { var (host, builder) = await BuildAsync( [("PLC-A", proxyPort)], backendPort: backendPort); await using var hostDispose = new AsyncHostDispose(host); await WaitForAsync( () => CanConnect(proxyPort), TimeSpan.FromSeconds(5), "PLC-A should bind"); // Connect a TCP client to the proxy's listen port. using var client = new System.Net.Sockets.TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); // Give the listener a moment to register the pair. await Task.Delay(200, TestContext.Current.CancellationToken); var result = builder.Build(); var plc = result.Plcs.ShouldHaveSingleItem(); plc.Clients.Connected.ShouldBe(1); var clientSnap = plc.Clients.RemoteEndpoints.ShouldHaveSingleItem(); clientSnap.Remote.ShouldNotBeNullOrEmpty(); // ConnectedAtUtc should be recent (within 10 s). (DateTimeOffset.UtcNow - clientSnap.ConnectedAtUtc).TotalSeconds.ShouldBeLessThan(10); } finally { lock (acceptedSockets) foreach (var s in acceptedSockets) try { s.Dispose(); } catch { } fakeBackend.Stop(); try { await backendAcceptTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); } catch { } } } // ── 6. Service fields: uptime, version, last-reload ────────────────────── [Fact] public async Task Build_ServiceFields_IncludeUptime_Version_AndLastReload() { var (host, builder) = await BuildAsync([]); await using var _ = new AsyncHostDispose(host); var counters = host.Services.GetRequiredService(); var now = DateTimeOffset.UtcNow; counters.RecordReloadApplied(now); var result = builder.Build(); result.Service.UptimeSeconds.ShouldBeGreaterThanOrEqualTo(0); result.Service.Version.ShouldNotBeNullOrEmpty(); result.Service.ConfigLastReloadUtc.ShouldNotBeNull(); result.Service.ConfigReloadCount.ShouldBe(1); } // ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ──────────────── [Fact] public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot() { var (host, builder) = await BuildAsync([]); await using var _ = new AsyncHostDispose(host); var debug = builder.BuildDebug("no-such-plc"); debug.CaptureArmed.ShouldBeFalse(); debug.Tags.ShouldBeEmpty(); } // ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ────── [Fact] public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault() { int port = PickFreePort(); var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072); await using var _ = new AsyncHostDispose(host); await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind"); var debug = builder.BuildDebug("PLC-A"); debug.CaptureArmed.ShouldBeFalse(); // no detail page open var tag = debug.Tags.ShouldHaveSingleItem(); tag.Address.ShouldBe(1072); tag.Width.ShouldBe(16); tag.HasValue.ShouldBeFalse(); tag.RawHex.ShouldBe("—"); } // ── Helpers ─────────────────────────────────────────────────────────────── private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync( (string name, int port)[] plcs, int startupWaitMs = 200, int backendPort = 502, int? bcd16Address = null) { var config = new Dictionary { ["Mbproxy:AdminPort"] = "0", // disable admin for unit tests }; if (bcd16Address is { } addr) { config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString(); config["Mbproxy:BcdTags:Global:0:Width"] = "16"; } for (int i = 0; i < plcs.Length; i++) { config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name; config[$"Mbproxy:Plcs:{i}:ListenPort"] = plcs[i].port.ToString(); config[$"Mbproxy:Plcs:{i}:Host"] = "127.0.0.1"; config[$"Mbproxy:Plcs:{i}:Port"] = backendPort.ToString(); } var hostBuilder = Host.CreateApplicationBuilder(); hostBuilder.Configuration.AddInMemoryCollection(config); hostBuilder.Services.AddSerilog( new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false); hostBuilder.AddMbproxyOptions(); hostBuilder.Services.AddSingleton(); // Register ProxyWorker as singleton so StatusSnapshotBuilder can resolve it by type. hostBuilder.Services.AddSingleton(); hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService()); // Admin support singletons (no AdminEndpointHost — keep unit tests lean). hostBuilder.Services.AddSingleton(); hostBuilder.Services.AddSingleton(); var host = hostBuilder.Build(); using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await host.StartAsync(startCts.Token); await Task.Delay(startupWaitMs, TestContext.Current.CancellationToken); var snapshotBuilder = host.Services.GetRequiredService(); return (host, snapshotBuilder); } private static int PickFreePort() { var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); l.Start(); int port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } private static async Task WaitForAsync(Func predicate, TimeSpan timeout, string msg) { using var cts = new CancellationTokenSource(timeout); while (!predicate() && !cts.IsCancellationRequested) await Task.Delay(50, cts.Token).ConfigureAwait(false); predicate().ShouldBeTrue(msg); } private static bool CanConnect(int port) { try { using var c = new System.Net.Sockets.TcpClient(); c.Connect("127.0.0.1", port); return true; } catch { return false; } } private sealed class AsyncHostDispose : IAsyncDisposable { private readonly IHost _host; public AsyncHostDispose(IHost host) => _host = host; public async ValueTask DisposeAsync() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await _host.StopAsync(cts.Token); } catch { } _host.Dispose(); } } }