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();
}
}
}