Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
@@ -0,0 +1,299 @@
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<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
public RecDBContext CreateDbContext() => new(options);
}
public sealed class RecordingClient : ISingleClientProxy
{
public List<string> Notifications { get; } = new();
public Func<Task>? BeforeSend { get; set; }
public Task<T> InvokeCoreAsync<T>(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<string>(args[0]));
}
}
public class RecordingHubClients : DispatchProxy
{
public Dictionary<string, RecordingClient> 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<NotifyHub>
{
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<object, object?> Items { get; } = new Dictionary<object, object?>();
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<IHubClients, RecordingHubClients>();
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<IHubClients, RecordingHubClients>();
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;
}
}
}