diff --git a/Tests/Resgrid.Tests/Web/Eventing/ClosedConnectionHubTests.cs b/Tests/Resgrid.Tests/Web/Eventing/ClosedConnectionHubTests.cs
new file mode 100644
index 000000000..dd516054c
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Eventing/ClosedConnectionHubTests.cs
@@ -0,0 +1,136 @@
+extern alias Eventing;
+
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.AspNetCore.SignalR;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+using EventingHubs = Eventing::Resgrid.Web.Eventing.Hubs;
+using ServicesHubs = Resgrid.Web.Services.Hubs;
+
+namespace Resgrid.Tests.Web.Eventing
+{
+ ///
+ /// SignalR does not wait for in-flight hub calls before disconnecting, and on the Redis backplane a group
+ /// change for a connection no server holds waits 30 seconds for an ack and then throws. A hub call that
+ /// finds its connection closed skips the group change.
+ ///
+ [TestFixture]
+ public class ClosedConnectionHubTests
+ {
+ private const int DepartmentId = 7;
+ private const int LinkId = 3;
+ private const int CallId = 5;
+ private const string ChannelId = "ch-1";
+ private const string UserId = "b0000000-0000-0000-0000-000000000001";
+
+ private static readonly Func NoArrange = _ => Task.CompletedTask;
+
+ private static readonly TestCaseData[] GroupChangingCalls =
+ {
+ Case("Eventing.Connect", h => h.Eventing.Connect(DepartmentId)),
+ Case("Eventing.SubscribeToDepartmentLink", h => h.Eventing.SubscribeToDepartmentLink(LinkId)),
+ Case("Eventing.UnsubscribeToDepartmentLink", h => h.Eventing.UnsubscribeToDepartmentLink(LinkId)),
+ Case("Eventing.SubscribeToCall", h => h.Eventing.SubscribeToCall(CallId)),
+ Case("Eventing.UnsubscribeToCall", h => h.Eventing.UnsubscribeToCall(CallId)),
+ Case("Services.Connect", h => h.Services.Connect(DepartmentId)),
+ Case("Services.SubscribeToDepartmentLink", h => h.Services.SubscribeToDepartmentLink(LinkId)),
+ Case("Services.UnsubscribeToDepartmentLink", h => h.Services.UnsubscribeToDepartmentLink(LinkId)),
+ Case("Chat.Connect", h => h.Chat.Connect()),
+ Case("Chat.JoinChannel", h => h.Chat.JoinChannel(ChannelId)),
+ Case("Chat.LeaveChannel", h => h.Chat.LeaveChannel(ChannelId), arrange: h => h.Chat.JoinChannel(ChannelId))
+ };
+
+ [TestCaseSource(nameof(GroupChangingCalls))]
+ public async Task Open_connection_changes_groups(Func arrange, Func act)
+ {
+ var harness = new Harness();
+ await arrange(harness);
+ harness.Groups.Invocations.Clear();
+
+ await act(harness);
+
+ harness.Groups.Invocations.Should().NotBeEmpty("otherwise the closed-connection case proves nothing");
+ }
+
+ [TestCaseSource(nameof(GroupChangingCalls))]
+ public async Task Closed_connection_leaves_groups_alone(Func arrange, Func act)
+ {
+ var harness = new Harness();
+ await arrange(harness);
+ harness.Groups.Invocations.Clear();
+ harness.CloseConnection();
+
+ await act(harness);
+
+ harness.Groups.Invocations.Should().BeEmpty();
+ }
+
+ private static TestCaseData Case(string name, Func act, Func arrange = null) =>
+ new TestCaseData(arrange ?? NoArrange, act).SetArgDisplayNames(name);
+
+ public sealed class Harness
+ {
+ private readonly CancellationTokenSource _connectionAborted = new CancellationTokenSource();
+
+ public Harness()
+ {
+ var context = new Mock();
+ context.SetupGet(x => x.ConnectionId).Returns("conn-1");
+ context.SetupGet(x => x.ConnectionAborted).Returns(_connectionAborted.Token);
+ context.SetupGet(x => x.Items).Returns(new Dictionary