using Mbproxy.Admin; using Shouldly; using Xunit; namespace Mbproxy.Tests.Admin; /// /// Unit tests for — group joins and subscription tracking. /// Capture arming is the broadcaster's job; the hub only mutates the /// . Uses hand-written SignalR test doubles /// (see ); no SignalR host is started. /// [Trait("Category", "Unit")] public sealed class StatusHubTests { private static StatusHub MakeHub( string connectionId, PlcSubscriptionTracker tracker, out FakeGroupManager groups) { groups = new FakeGroupManager(); return new StatusHub(tracker) { Context = new FakeHubCallerContext(connectionId), Groups = groups, }; } [Fact] public async Task SubscribeFleet_JoinsFleetGroup() { var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), out var groups); await hub.SubscribeFleet(); groups.Added.ShouldContain(("conn-1", StatusHub.FleetGroup)); } [Fact] public async Task SubscribePlc_JoinsPlcGroup_AndTracksViewer() { var tracker = new PlcSubscriptionTracker(); var hub = MakeHub("conn-1", tracker, out var groups); await hub.SubscribePlc("plc-1", "tab-A"); groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("plc-1"))); tracker.ActivePlcs().ShouldContain("plc-1"); } [Fact] public async Task Reconnect_SameTab_NewConnection_DoesNotLeakViewer() { // A transport reconnect: the same browser tab acquires a new ConnectionId and // re-subscribes; the old connection's OnDisconnectedAsync then fires late. The // PLC must not be left with a stranded viewer once the tab finally closes. var tracker = new PlcSubscriptionTracker(); var first = MakeHub("conn-old", tracker, out _); await first.SubscribePlc("plc-1", "tab-A"); var second = MakeHub("conn-new", tracker, out _); await second.SubscribePlc("plc-1", "tab-A"); await first.OnDisconnectedAsync(null); // late disconnect of the old connection tracker.ActivePlcs().ShouldContain("plc-1", "the tab is still open on the reconnected connection"); await second.OnDisconnectedAsync(null); // the tab finally closes tracker.ActivePlcs().ShouldBeEmpty("no viewer may be stranded after the tab closes"); } [Fact] public async Task Reconnect_SameTab_NewConnectionDisconnectsFirst_DoesNotLeakViewer() { // Mirror of the reconnect test above with the disconnect ordering reversed: the // NEW connection's OnDisconnectedAsync arrives before the old one's. SignalR does // not guarantee the order, so the tracker must be correct either way. var tracker = new PlcSubscriptionTracker(); var first = MakeHub("conn-old", tracker, out _); await first.SubscribePlc("plc-1", "tab-A"); var second = MakeHub("conn-new", tracker, out _); await second.SubscribePlc("plc-1", "tab-A"); await second.OnDisconnectedAsync(null); // the new connection drops first tracker.ActivePlcs().ShouldContain("plc-1", "the tab is still open on the original connection"); await first.OnDisconnectedAsync(null); tracker.ActivePlcs().ShouldBeEmpty("no viewer may be stranded after the tab closes"); } [Fact] public async Task TwoTabs_FirstCloseKeepsActive_LastCloseClears() { var tracker = new PlcSubscriptionTracker(); var tabA = MakeHub("conn-a", tracker, out _); var tabB = MakeHub("conn-b", tracker, out _); await tabA.SubscribePlc("plc-1", "tab-A"); await tabB.SubscribePlc("plc-1", "tab-B"); await tabA.OnDisconnectedAsync(null); tracker.ActivePlcs().ShouldContain("plc-1", "a second tab is still viewing the PLC"); await tabB.OnDisconnectedAsync(null); tracker.ActivePlcs().ShouldBeEmpty(); } [Fact] public async Task SubscribePlc_UnknownPlc_DoesNotThrow() { var hub = MakeHub("conn-1", new PlcSubscriptionTracker(), out var groups); await Should.NotThrowAsync(async () => await hub.SubscribePlc("ghost", "tab-A")); groups.Added.ShouldContain(("conn-1", StatusHub.PlcGroup("ghost"))); } }