mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user