using System.Security.Claims; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class ConnectionRecoveryTests { [Theory] [InlineData(true)] [InlineData(false)] public async Task ReconnectReplaysActiveBanWithoutRoomPresence(bool active) { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; await using var db = new RecDBContext(options); db.Moderation.Add(new RadicadeServer.RecNet.Moderation.ModerationDB { PlayerId = 987654322, IsBan = true, Message = "test ban", ExpireDuration = DateTime.UtcNow.AddHours(active ? 1 : -1) }); await db.SaveChangesAsync(); var proxy = new RecordingClient(); var clients = System.Reflection.DispatchProxy.Create(); ((CallerClientsProxy)(object)clients).Client = proxy; var previousContext = Notify.Context; var previousFactory = Notify.DbFactory; try { Notify.DbFactory = null!; var hub = new NotifyHub(previousContext, db) { Context = new TestHubContext(987654322), Clients = clients }; await hub.OnConnectedAsync(); Assert.Equal(active ? 1 : 0, proxy.Messages.Count); if (active) { Assert.Equal("Notification", proxy.Messages[0].Method); // The replayed kick must carry the client enum's NUMBER (22) inside a JSON // string; the name parses and lands on no handler, and a bare number does not // parse at all - both leave a banned player kicked with no ban screen. Assert.Contains("\"Id\":\"22\"", proxy.Messages[0].Json); Assert.DoesNotContain("ModerationKick", proxy.Messages[0].Json); Assert.Contains("test ban", proxy.Messages[0].Json); } } finally { lock (Notify.ClientsLock) Notify.Clients.RemoveAll(c => c.AccountId == 987654322); Notify.Context = previousContext; Notify.DbFactory = previousFactory; } } public class CallerClientsProxy : System.Reflection.DispatchProxy { public IClientProxy Client = null!; protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args) => method?.Name == "get_Caller" ? Client : throw new NotSupportedException(); } private sealed class RecordingClient : ISingleClientProxy { public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public List<(string Method, string Json)> Messages { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Messages.Add((method, Assert.IsType(args[0]))); return Task.CompletedTask; } } [Fact] public async Task NotificationDisconnectDoesNotChangeGamePresence() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; await using var db = new RecDBContext(options); var presence = new RealPresence { playerId = 987654321, isOnline = true, roomInstance = 42 }; db.Presences.Add(presence); await db.SaveChangesAsync(); var hub = new NotifyHub(Notify.Context, db) { Context = new TestHubContext(presence.playerId) }; await hub.OnDisconnectedAsync(new IOException("Transport interrupted")); await db.Entry(presence).ReloadAsync(); Assert.Equal(42L, presence.roomInstance); Assert.True(presence.isOnline); } private sealed class TestHubContext(long accountId) : HubCallerContext { public override string ConnectionId => "connection-recovery-test"; public override string? UserIdentifier => accountId.ToString(); public override ClaimsPrincipal User => new(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, accountId.ToString())], "test")); public override IDictionary Items { get; } = new Dictionary(); public override IFeatureCollection Features { get; } = new FeatureCollection(); public override CancellationToken ConnectionAborted => CancellationToken.None; public override void Abort() { } } }