using System.Reflection; 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 RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class NotificationSubscriptionTests { private const long TargetId = 200; private const long SubscriberId = 100; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } public sealed class RecordingClient : ISingleClientProxy { public List Notifications { get; } = new(); public Func? BeforeSend { get; set; } public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public async Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { if (BeforeSend != null) await BeforeSend(); Assert.Equal("Notification", method); Notifications.Add(Assert.IsType(args[0])); } } public class RecordingHubClients : DispatchProxy { public Dictionary ClientsById { get; } = new(StringComparer.Ordinal); protected override object? Invoke(MethodInfo? method, object?[]? args) { if (method?.Name == "Client" && args is [string connectionId]) return ClientsById[connectionId]; throw new NotSupportedException($"Unexpected hub-client member: {method?.Name}"); } } private sealed class TestHubContext : IHubContext { public IHubClients Clients { get; } public IGroupManager Groups => null!; public TestHubContext(IHubClients clients) => Clients = clients; } private sealed class TestHubCallerContext(long accountId, string connectionId) : HubCallerContext { public override string ConnectionId => connectionId; 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() { } } [Fact] public async Task SlowOrFailedSocketDoesNotDelayOtherSubscribersAndLogoutStillPushesOffline() { var factory = new TestDbContextFactory(); await using var db = factory.CreateDbContext(); db.Presences.Add(new RealPresence { playerId = TargetId, isOnline = true }); await db.SaveChangesAsync(); var oldFactory = Notify.DbFactory; var oldContext = Notify.Context; var clients = DispatchProxy.Create(); var recording = (RecordingHubClients)(object)clients; var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); recording.ClientsById["presence-slow"] = new RecordingClient { BeforeSend = () => release.Task }; recording.ClientsById["presence-fast"] = new RecordingClient { BeforeSend = () => { delivered.TrySetResult(); return Task.CompletedTask; } }; recording.ClientsById["presence-owner"] = new RecordingClient { BeforeSend = () => Task.FromException(new IOException("Socket closed")) }; Task? pending = null; try { Notify.DbFactory = factory; Notify.Context = new TestHubContext(clients); lock (Notify.ClientsLock) Notify.Clients.AddRange(new[] { new HubClient { AccountId = SubscriberId, ConnectionId = "presence-slow", HasSubscriptions = true, Subscriptions = [TargetId] }, new HubClient { AccountId = 101, ConnectionId = "presence-fast", HasSubscriptions = true, Subscriptions = [TargetId] }, new HubClient { AccountId = TargetId, ConnectionId = "presence-owner" } }); pending = Notify.UpdateSubscriptions(TargetId); await delivered.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(pending.IsCompleted); release.SetException(new IOException("Subscriber disconnected")); await pending; Assert.Single(recording.ClientsById["presence-fast"].Notifications); var api = new RadicadeServer.API.Matchmaking.MatchmakingPlayerAPI(db) { ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, TargetId.ToString())], "test")) } } }; await api.Logout(); using var offline = System.Text.Json.JsonDocument.Parse(recording.ClientsById["presence-fast"].Notifications.Last()); Assert.False(offline.RootElement.GetProperty("Msg").GetProperty("isOnline").GetBoolean()); } finally { release.TrySetResult(); if (pending != null) await pending; lock (Notify.ClientsLock) Notify.Clients.RemoveAll(client => client.ConnectionId.StartsWith("presence-")); Notify.DbFactory = oldFactory; Notify.Context = oldContext; } } [Fact] public async Task UpdatesReachOnlySocketsSubscribedToTheChangedPlayer() { var factory = new TestDbContextFactory(); var db = factory.CreateDbContext(); await using var _db = db; var oldFactory = Notify.DbFactory; var oldContext = Notify.Context; var oldRoomFactory = RoomUtils.DbFactory; var hubClients = DispatchProxy.Create(); var connections = (RecordingHubClients)(object)hubClients; foreach (var id in new[] { "target", "subscriber-one", "subscriber-two", "other-subscriber" }) connections.ClientsById[id] = new RecordingClient(); var hubContext = new TestHubContext(hubClients); db.Profiles.Add(new Profile { accountId = TargetId, profileImage = "profile", username = "target", displayName = "Target", createdAt = DateTime.UtcNow }); db.Rooms.Add(new RoomData { RoomId = 50, Name = "Public room", CreatorAccountId = 1, Accessibility = Accessibility.Public }); db.RoomInstances.Add(new RoomInstance { roomInstanceId = 500, roomId = 50, subRoomId = 51, photonRegion = "us", photonRoomId = "secret-photon-room", dataBlob = "secret-room-save", roomCode = "secret-room-code" }); db.Players.Add(new PlayerData { AccountId = TargetId, AppVersion = "20230825" }); db.Presences.Add(new RealPresence { playerId = TargetId, isOnline = true, roomInstance = 500 }); await db.SaveChangesAsync(); lock (Notify.ClientsLock) { Notify.Clients.RemoveAll(client => client.AccountId is TargetId or SubscriberId or 101); Notify.Clients.AddRange(new[] { new HubClient { AccountId = TargetId, ConnectionId = "target" }, new HubClient { AccountId = SubscriberId, ConnectionId = "subscriber-one" }, new HubClient { AccountId = SubscriberId, ConnectionId = "subscriber-two" }, new HubClient { AccountId = 101, ConnectionId = "other-subscriber" } }); } try { Notify.Context = hubContext; Notify.DbFactory = factory; RoomUtils.DbFactory = factory; var firstSocket = new NotifyHub(hubContext, db) { Context = new TestHubCallerContext(SubscriberId, "subscriber-one") }; await firstSocket.Subscribe(new SubscriptionUpdate { PlayerIds = [TargetId] }); // A second socket for the same account replaces only its own list; it does not // inherit the first socket's subscription. var secondSocket = new NotifyHub(hubContext, db) { Context = new TestHubCallerContext(SubscriberId, "subscriber-two") }; await secondSocket.Subscribe(new SubscriptionUpdate { PlayerIds = [] }); var otherSocket = new NotifyHub(hubContext, db) { Context = new TestHubCallerContext(101, "other-subscriber") }; await otherSocket.Subscribe(new SubscriptionUpdate { PlayerIds = [TargetId] }); await Notify.UpdateSubscriptions(TargetId); await Notify.UpdateSubscriptions(TargetId, Notifs.SubscriptionUpdateProfile, new { accountId = TargetId }); Assert.Empty(connections.ClientsById["target"].Notifications); Assert.Empty(connections.ClientsById["subscriber-two"].Notifications); Assert.Equal(4, connections.ClientsById["subscriber-one"].Notifications.Count); Assert.Equal(4, connections.ClientsById["other-subscriber"].Notifications.Count); using var presence = System.Text.Json.JsonDocument.Parse(connections.ClientsById["subscriber-one"].Notifications[0]); Assert.Equal("PresenceUpdate", presence.RootElement.GetProperty("Id").GetString()); var roomInstance = presence.RootElement.GetProperty("Msg").GetProperty("roomInstance"); Assert.Equal(string.Empty, roomInstance.GetProperty("photonRoomId").GetString()); Assert.Equal(string.Empty, roomInstance.GetProperty("dataBlob").GetString()); Assert.Equal(string.Empty, roomInstance.GetProperty("roomCode").GetString()); using var profile = System.Text.Json.JsonDocument.Parse(connections.ClientsById["subscriber-one"].Notifications[3]); Assert.Equal("AccountUpdate", profile.RootElement.GetProperty("Id").GetString()); // Appearing offline hides the room in pushes, without ending the real session. var statusApi = new RadicadeServer.API.Matchmaking.MatchmakingPlayerAPI(db) { ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, TargetId.ToString())], "test")) } } }; await statusApi.SetStatusVisibility(RadicadeServer.RecNet.Enums.Players.StatusVisibility.Offline); using var hidden = System.Text.Json.JsonDocument.Parse(connections.ClientsById["subscriber-one"].Notifications.Last()); Assert.False(hidden.RootElement.GetProperty("Msg").GetProperty("isOnline").GetBoolean()); Assert.Equal(System.Text.Json.JsonValueKind.Null, hidden.RootElement.GetProperty("Msg").GetProperty("roomInstance").ValueKind); Assert.True((await db.Presences.SingleAsync()).isOnline); await statusApi.Heartbeat(); Assert.Equal(RadicadeServer.RecNet.Enums.Players.StatusVisibility.Offline, (await db.Presences.SingleAsync()).statusVisibility); // A second live connection keeps the account online; closing the last one does not. lock (Notify.ClientsLock) Notify.Clients.Add(new HubClient { AccountId = TargetId, ConnectionId = "target-second" }); var targetHub = new NotifyHub(hubContext, db) { Context = new TestHubCallerContext(TargetId, "target") }; await targetHub.OnDisconnectedAsync(null!); Assert.True((await db.Presences.SingleAsync()).isOnline); targetHub.Context = new TestHubCallerContext(TargetId, "target-second"); await targetHub.OnDisconnectedAsync(null!); Assert.False((await db.Presences.SingleAsync()).isOnline); Assert.Equal(500, (await db.Presences.SingleAsync()).roomInstance); } finally { lock (Notify.ClientsLock) Notify.Clients.RemoveAll(client => client.AccountId is TargetId or SubscriberId or 101); Notify.Context = oldContext; Notify.DbFactory = oldFactory; RoomUtils.DbFactory = oldRoomFactory; } } }