using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Connections.Features; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// Regression tests for arch-review 2026-08-19 P6-6 on the hub side. SubscribeInstance /// awaits StartStreamAsync — up to 30s — and only then stores the session id on the /// connection. A client that disconnects inside that window ran OnDisconnectedAsync /// while there was still no session id to release, so the session stayed registered in the /// singleton and its keepalive renewed it forever (the /// orphan reaper only collects UNregistered sessions). The fix publishes the id and /// re-checks connection liveness under the per-connection Items monitor, so exactly /// one side tears the session down. /// public class DebugStreamHubSubscribeRaceTests { private sealed class GatedDebugStreamService : DebugStreamService { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List _stopped = new(); public GatedDebugStreamService() : base( new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance), new ServiceCollection().BuildServiceProvider(), new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance), NullLogger.Instance) { } public string SessionId => "session-under-test"; /// Completes once has been entered. public Task Entered => _entered.Task; /// Session ids passed to , in call order. public IReadOnlyList Stopped { get { lock (_stopped) { return _stopped.ToList(); } } } public void ReleaseStart() => _gate.TrySetResult(); public override async Task StartStreamAsync( int instanceId, Action onEvent, Action onTerminated, CancellationToken ct = default) { _entered.TrySetResult(); await _gate.Task; return new DebugStreamSession( SessionId, new DebugViewSnapshot( "Inst-1", Array.Empty(), Array.Empty(), DateTimeOffset.UtcNow)); } public override void StopStream(string sessionId) { lock (_stopped) { _stopped.Add(sessionId); } } } private sealed class TestHubCallerContext : HubCallerContext { private readonly FeatureCollection _features = new(); private readonly CancellationTokenSource _aborted = new(); public TestHubCallerContext(HttpContext http) => _features.Set(new TestHttpContextFeature { HttpContext = http }); public override string ConnectionId => "test-conn"; public override string? UserIdentifier => null; public override System.Security.Claims.ClaimsPrincipal? User => null; public override IDictionary Items { get; } = new Dictionary(); public override IFeatureCollection Features => _features; public override CancellationToken ConnectionAborted => _aborted.Token; public override void Abort() => _aborted.Cancel(); private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } } private const string SessionIdKey = "DebugStreamSessionId"; private static (DebugStreamHub Hub, TestHubCallerContext Context, GatedDebugStreamService Stream) CreateHub() { var repo = Substitute.For(); repo.GetInstanceByIdAsync(7).Returns(new Instance("Inst-1") { Id = 7, SiteId = 1 }); var services = new ServiceCollection(); services.AddSingleton(repo); var http = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; var stream = new GatedDebugStreamService(); var hub = new DebugStreamHub( stream, Substitute.For>(), NullLogger.Instance) { Clients = Substitute.For() }; var context = new TestHubCallerContext(http); hub.Context = context; // No roles / no permitted sites on the connection == system-wide scope, so the // authorization branch is not what these tests are exercising. return (hub, context, stream); } [Fact] public async Task SubscribeInstance_ClientDisconnectsMidStart_StopsTheJustCreatedSession() { var (hub, context, stream) = CreateHub(); var subscribe = hub.SubscribeInstance(7); await stream.Entered.WaitAsync(TimeSpan.FromSeconds(5)); // The client goes away while the start is still in flight — the disconnect path // finds nothing registered yet. await hub.OnDisconnectedAsync(null); Assert.Empty(stream.Stopped); stream.ReleaseStart(); await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal(new[] { stream.SessionId }, stream.Stopped); Assert.False(context.Items.ContainsKey(SessionIdKey)); } [Fact] public async Task SubscribeInstance_ConnectionAbortedMidStart_StopsTheJustCreatedSession() { // Same window, but the abort token trips without OnDisconnectedAsync having run yet // (SignalR aborts the connection before dispatching the disconnect handler). var (hub, context, stream) = CreateHub(); var subscribe = hub.SubscribeInstance(7); await stream.Entered.WaitAsync(TimeSpan.FromSeconds(5)); context.Abort(); stream.ReleaseStart(); await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal(new[] { stream.SessionId }, stream.Stopped); Assert.False(context.Items.ContainsKey(SessionIdKey)); } [Fact] public async Task SubscribeInstance_ThenDisconnect_StopsTheSessionExactlyOnce() { var (hub, context, stream) = CreateHub(); stream.ReleaseStart(); await hub.SubscribeInstance(7); Assert.Empty(stream.Stopped); Assert.Equal(stream.SessionId, context.Items[SessionIdKey]); await hub.OnDisconnectedAsync(null); Assert.Equal(new[] { stream.SessionId }, stream.Stopped); Assert.False(context.Items.ContainsKey(SessionIdKey)); // A second release (an explicit unsubscribe racing the disconnect) is a no-op. await hub.UnsubscribeInstance(); Assert.Equal(new[] { stream.SessionId }, stream.Stopped); } }