using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text.Json; using Mbproxy.Options; using Mbproxy.Proxy; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; using Shouldly; using Xunit; namespace Mbproxy.Tests.Admin; /// /// End-to-end test of the SignalR live feed: a real against /// the live Kestrel admin host. Exercises the whole push path that no other test covers — /// group joins, the MapHub wiring, the /// , and the broadcaster loop — /// confirming that SubscribeFleet yields a "fleet" message and /// SubscribePlc yields a "plc" message. /// [Trait("Category", "E2E")] public sealed class HubStatusE2ETests { [Fact(Timeout = 15_000)] public async Task SubscribeFleet_ReceivesFleetSnapshot() { int adminPort = PickFreePort(); int proxyPort = PickFreePort(); await using var host = BuildHost(adminPort, proxyPort); using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await host.Host.StartAsync(startCts.Token); await WaitForAdminAsync(adminPort); await using var connection = new HubConnectionBuilder() .WithUrl($"http://127.0.0.1:{adminPort}/hub/status") .Build(); var fleet = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); connection.On("fleet", payload => fleet.TrySetResult(payload)); await connection.StartAsync(TestContext.Current.CancellationToken); await connection.InvokeAsync("SubscribeFleet", TestContext.Current.CancellationToken); var snapshot = await fleet.Task.WaitAsync( TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken); // The fleet payload is a StatusResponse — assert a couple of its known fields. snapshot.TryGetProperty("service", out _).ShouldBeTrue("fleet payload must carry 'service'"); snapshot.TryGetProperty("plcs", out var plcs).ShouldBeTrue("fleet payload must carry 'plcs'"); plcs.ValueKind.ShouldBe(JsonValueKind.Array); } [Fact(Timeout = 15_000)] public async Task SubscribePlc_ReceivesDetailSnapshot() { int adminPort = PickFreePort(); int proxyPort = PickFreePort(); await using var host = BuildHost(adminPort, proxyPort); using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await host.Host.StartAsync(startCts.Token); await WaitForAdminAsync(adminPort); await using var connection = new HubConnectionBuilder() .WithUrl($"http://127.0.0.1:{adminPort}/hub/status") .Build(); var detail = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); connection.On("plc", payload => detail.TrySetResult(payload)); await connection.StartAsync(TestContext.Current.CancellationToken); // tabId is a stable per-page-load identifier the real client generates. await connection.InvokeAsync("SubscribePlc", "TestPLC", "tab-e2e", TestContext.Current.CancellationToken); var snapshot = await detail.Task.WaitAsync( TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken); // The detail payload is a PlcDetailResponse { plc, debug }. snapshot.TryGetProperty("debug", out var debug).ShouldBeTrue("detail payload must carry 'debug'"); debug.TryGetProperty("captureArmed", out _).ShouldBeTrue("debug must carry 'captureArmed'"); debug.TryGetProperty("tags", out var tags).ShouldBeTrue("debug must carry 'tags'"); tags.ValueKind.ShouldBe(JsonValueKind.Array); } // ── Helpers ─────────────────────────────────────────────────────────────── private static HostHandle BuildHost(int adminPort, int proxyPort) { var config = new Dictionary { ["Mbproxy:AdminPort"] = adminPort.ToString(), // Fast push cadence so the subscribed client sees a message promptly. ["Mbproxy:AdminPushIntervalMs"] = "100", ["Mbproxy:Plcs:0:Name"] = "TestPLC", ["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(), ["Mbproxy:Plcs:0:Host"] = "127.0.0.1", ["Mbproxy:Plcs:0:Port"] = "502", ["Mbproxy:Connection:BackendConnectTimeoutMs"] = "500", ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "500", }; var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); builder.Services.AddSerilog( new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false); builder.AddMbproxyOptions(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.AddMbproxyAdmin(); return new HostHandle(builder.Build()); } private static async Task WaitForAdminAsync(int adminPort) { using var http = new HttpClient(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); while (!cts.IsCancellationRequested) { try { var r = await http.GetAsync($"http://127.0.0.1:{adminPort}/status.json", cts.Token); if (r.StatusCode == HttpStatusCode.OK) return; } catch { } await Task.Delay(100, cts.Token).ConfigureAwait(false); } throw new TimeoutException($"Admin endpoint on port {adminPort} did not start in time."); } private static int PickFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); int port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } private sealed class HostHandle(IHost host) : IAsyncDisposable { public IHost Host { get; } = host; public async ValueTask DisposeAsync() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await Host.StopAsync(cts.Token); } catch { } Host.Dispose(); } } }