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,427 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Events;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Events;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using Xunit;
|
||||
|
||||
namespace RadicadeServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A created event has to reach the creator's own open screen, not just the database.
|
||||
///
|
||||
/// The client fills its event list once at login from `api/playerevents/v1/all` and never
|
||||
/// refetches after a write - so an event created without a `PlayerEventCreated` (80) frame is
|
||||
/// stored, acknowledged with a toast, and still absent from Watch > Events until the player
|
||||
/// relogs. These tests pin the frames, their numeric ids (the client subscribes the decimal
|
||||
/// string, not the enum name) and the payload shape the frame decoder reads.
|
||||
/// </summary>
|
||||
[Collection(DbFactoryCollection.Name)]
|
||||
public class PlayerEventNotificationTests
|
||||
{
|
||||
private const long CreatorId = 42;
|
||||
|
||||
[Fact]
|
||||
public void CreatedAndUpdatedFramesUseTheNumericIdsTheClientSubscribesTo()
|
||||
{
|
||||
var ev = Event();
|
||||
|
||||
var created = PlayerEventNotifications.Created(ev);
|
||||
var updated = PlayerEventNotifications.Updated(ev);
|
||||
|
||||
Assert.Equal(80, Convert.ToInt64(created.Id, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(81, Convert.ToInt64(updated.Id, System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
// The id goes out as the decimal string: the client's dispatcher drops any frame whose
|
||||
// Id is not the exact key its handler registered under, and "PlayerEventCreated" has no
|
||||
// subscriber of its own.
|
||||
Assert.Equal("\"80\"", Notify.WireId(created.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCreatedFrameCarriesTheCamelCaseEventTheFrameDecoderReads()
|
||||
{
|
||||
var created = PlayerEventNotifications.Created(Event());
|
||||
var msg = JsonSerializer.SerializeToElement(created.Msg);
|
||||
|
||||
// Every key the frame decoder looks for, in the casing the notification channel uses.
|
||||
foreach (var key in new[]
|
||||
{
|
||||
"tags", "playerEventId", "creatorPlayerId", "roomId", "subRoomId", "clubId",
|
||||
"name", "description", "imageName", "startTime", "endTime", "attendeeCount",
|
||||
"accessibility", "isMultiInstance", "supportMultiInstanceRoomChat",
|
||||
"defaultBroadcastPermissions", "canRequestBroadcastPermissions",
|
||||
"broadcastingRoomInstanceId"
|
||||
})
|
||||
Assert.True(msg.TryGetProperty(key, out _), $"the frame is missing '{key}'");
|
||||
|
||||
// The stored-only fields the read shapes serve are not part of the frame.
|
||||
Assert.False(msg.TryGetProperty("State", out _));
|
||||
Assert.False(msg.TryGetProperty("PlayerEventId", out _));
|
||||
|
||||
Assert.Equal(7, msg.GetProperty("playerEventId").GetInt64());
|
||||
Assert.Equal(CreatorId, msg.GetProperty("creatorPlayerId").GetInt64());
|
||||
Assert.Equal(102, msg.GetProperty("roomId").GetInt64());
|
||||
Assert.Equal(3, msg.GetProperty("attendeeCount").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Null, msg.GetProperty("broadcastingRoomInstanceId").ValueKind);
|
||||
|
||||
var tag = Assert.Single(msg.GetProperty("tags").EnumerateArray());
|
||||
Assert.Equal("workshops", tag.GetProperty("tag").GetString());
|
||||
Assert.Equal(0, tag.GetProperty("type").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFramesTimestampsSerializeTheSameWayAnEventReadDoes()
|
||||
{
|
||||
// The event the frame adds is displayed with the time it carries, so a frame timestamp
|
||||
// must look like the one the same event shows when it arrives from v1/all - a local
|
||||
// wall-clock pick time round-trips through this server without a zone.
|
||||
var read = JsonSerializer.SerializeToElement(new PlayerEvent
|
||||
{
|
||||
StartTime = new DateTime(2026, 10, 8, 19, 10, 0, DateTimeKind.Unspecified),
|
||||
EndTime = new DateTime(2026, 10, 8, 19, 40, 0, DateTimeKind.Unspecified)
|
||||
});
|
||||
var frame = JsonSerializer.SerializeToElement(PlayerEventNotifications.Created(Event()).Msg);
|
||||
|
||||
Assert.Equal(read.GetProperty("StartTime").GetString(), frame.GetProperty("startTime").GetString());
|
||||
Assert.Equal(read.GetProperty("EndTime").GetString(), frame.GetProperty("endTime").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BroadcastStateRidesAlongOnTheUpdatedFrame()
|
||||
{
|
||||
var ev = Event();
|
||||
ev.BroadcastingRoomInstanceId = 30848;
|
||||
|
||||
var msg = JsonSerializer.SerializeToElement(PlayerEventNotifications.Updated(ev).Msg);
|
||||
|
||||
Assert.Equal(30848, msg.GetProperty("broadcastingRoomInstanceId").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletedFrameCarriesTheBareIdUnderTheClientsOwnCasing()
|
||||
{
|
||||
var deleted = PlayerEventNotifications.Deleted(7);
|
||||
var msg = JsonSerializer.SerializeToElement(deleted.Msg);
|
||||
|
||||
Assert.Equal(82, Convert.ToInt64(deleted.Id, System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
// The client's deleted-event model names the field camelCase (`playerEventId`), the same
|
||||
// spelling every other frame payload uses. A frame that carries only PascalCase binds
|
||||
// nothing there, so the card survives the delete; the PascalCase key stays as an alias.
|
||||
Assert.Equal(7, msg.GetProperty("playerEventId").GetInt64());
|
||||
Assert.Equal(7, msg.GetProperty("PlayerEventId").GetInt64());
|
||||
|
||||
// Nothing else rides along: the client has no use for the rest of the row it deleted.
|
||||
Assert.All(msg.EnumerateObject(),
|
||||
property => Assert.Contains(property.Name, new[] { "playerEventId", "PlayerEventId" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseChangedFrameCarriesBothNestedObjects()
|
||||
{
|
||||
// The client's handlers dereference one level down with no null guard, so a frame with
|
||||
// only half the payload is worse than no frame at all.
|
||||
var frame = PlayerEventNotifications.ResponseChanged(Event(), new PlayerEventResponse
|
||||
{
|
||||
PlayerEventResponseId = 11,
|
||||
PlayerEventId = 7,
|
||||
PlayerId = 55,
|
||||
Type = EventResponses.Pending,
|
||||
CreatedAt = new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
|
||||
var msg = JsonSerializer.SerializeToElement(frame.Msg);
|
||||
|
||||
Assert.Equal(83, Convert.ToInt64(frame.Id, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(7, msg.GetProperty("PlayerEvent").GetProperty("playerEventId").GetInt64());
|
||||
var response = msg.GetProperty("PlayerEventResponse");
|
||||
Assert.Equal(11, response.GetProperty("PlayerEventResponseId").GetInt64());
|
||||
Assert.Equal(55, response.GetProperty("PlayerId").GetInt64());
|
||||
Assert.Equal((int)EventResponses.Pending, response.GetProperty("Type").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatingAnEventPushesTheCreatedFrameToTheCreatorAndNoOneElse()
|
||||
{
|
||||
var factory = new TestDbContextFactory();
|
||||
await using var db = factory.CreateDbContext();
|
||||
var room = new RoomData { Name = "Broadcast Room" };
|
||||
db.Rooms.Add(room);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var oldContext = Notify.Context;
|
||||
var hubClients = DispatchProxy.Create<IHubClients, RecordingHubClients>();
|
||||
var recording = (RecordingHubClients)(object)hubClients;
|
||||
recording.ClientsById["creator-socket"] = new RecordingClient();
|
||||
recording.ClientsById["bystander-socket"] = new RecordingClient();
|
||||
|
||||
lock (Notify.ClientsLock)
|
||||
{
|
||||
Notify.Clients.RemoveAll(client =>
|
||||
client.ConnectionId is "creator-socket" or "bystander-socket");
|
||||
Notify.Clients.AddRange(new[]
|
||||
{
|
||||
new HubClient { AccountId = CreatorId, ConnectionId = "creator-socket" },
|
||||
new HubClient { AccountId = 99, ConnectionId = "bystander-socket" }
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Notify.Context = new TestHubContext(hubClients);
|
||||
|
||||
var controller = new EventsAPI(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString())], "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
var body = $$"""{ "Name": "Watch me", "RoomId": {{room.RoomId}}, "Tags": [{ "Tag": "workshops", "Type": 0 }] }""";
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(body);
|
||||
controller.HttpContext.Request.Body = new MemoryStream(bytes);
|
||||
controller.HttpContext.Request.ContentType = "application/json";
|
||||
controller.HttpContext.Request.ContentLength = bytes.Length;
|
||||
|
||||
var result = await controller.CreateEventV2();
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
|
||||
var sent = Assert.Single(recording.ClientsById["creator-socket"].Notifications);
|
||||
Assert.Empty(recording.ClientsById["bystander-socket"].Notifications);
|
||||
|
||||
using var frame = JsonDocument.Parse(sent);
|
||||
Assert.Equal("80", frame.RootElement.GetProperty("Id").GetString());
|
||||
|
||||
var playerEvent = frame.RootElement.GetProperty("Msg");
|
||||
var stored = await db.PlayerEvents.SingleAsync();
|
||||
Assert.Equal(stored.PlayerEventId, playerEvent.GetProperty("playerEventId").GetInt64());
|
||||
Assert.Equal("Watch me", playerEvent.GetProperty("name").GetString());
|
||||
Assert.Equal("workshops", Assert.Single(playerEvent.GetProperty("tags").EnumerateArray())
|
||||
.GetProperty("tag").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (Notify.ClientsLock)
|
||||
Notify.Clients.RemoveAll(client =>
|
||||
client.ConnectionId is "creator-socket" or "bystander-socket");
|
||||
Notify.Context = oldContext;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingAnEventPushesTheDeletedFrameToTheCreator()
|
||||
{
|
||||
var factory = new TestDbContextFactory();
|
||||
await using var db = factory.CreateDbContext();
|
||||
var stored = Event();
|
||||
db.PlayerEvents.Add(stored);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var oldContext = Notify.Context;
|
||||
var hubClients = DispatchProxy.Create<IHubClients, RecordingHubClients>();
|
||||
var recording = (RecordingHubClients)(object)hubClients;
|
||||
recording.ClientsById["creator-socket"] = new RecordingClient();
|
||||
|
||||
lock (Notify.ClientsLock)
|
||||
{
|
||||
Notify.Clients.RemoveAll(client => client.ConnectionId == "creator-socket");
|
||||
Notify.Clients.Add(new HubClient { AccountId = CreatorId, ConnectionId = "creator-socket" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Notify.Context = new TestHubContext(hubClients);
|
||||
|
||||
var controller = new EventsAPI(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString())], "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.DeleteEventV2(stored.PlayerEventId);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
|
||||
var sent = Assert.Single(recording.ClientsById["creator-socket"].Notifications);
|
||||
using var frame = JsonDocument.Parse(sent);
|
||||
Assert.Equal("82", frame.RootElement.GetProperty("Id").GetString());
|
||||
Assert.Equal(stored.PlayerEventId,
|
||||
frame.RootElement.GetProperty("Msg").GetProperty("playerEventId").GetInt64());
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (Notify.ClientsLock)
|
||||
Notify.Clients.RemoveAll(client => client.ConnectionId == "creator-socket");
|
||||
Notify.Context = oldContext;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BroadcastChangesReachEveryEchoInstanceButNotOtherEventsOrOfflinePlayers()
|
||||
{
|
||||
var factory = new TestDbContextFactory();
|
||||
await using var db = factory.CreateDbContext();
|
||||
var ev = Event();
|
||||
ev.IsMultiInstance = true;
|
||||
db.PlayerEvents.Add(ev);
|
||||
db.RoomInstances.AddRange(
|
||||
new RoomInstance { roomInstanceId = 100, roomId = ev.RoomId, eventId = ev.PlayerEventId },
|
||||
new RoomInstance { roomInstanceId = 101, roomId = ev.RoomId, eventId = ev.PlayerEventId },
|
||||
new RoomInstance { roomInstanceId = 102, roomId = ev.RoomId, eventId = ev.PlayerEventId },
|
||||
new RoomInstance { roomInstanceId = 103, roomId = ev.RoomId, eventId = 999 });
|
||||
db.Presences.AddRange(
|
||||
new RadicadeServer.RecNet.Accounts.RealPresence { playerId = CreatorId, roomInstance = 100, isOnline = true },
|
||||
new RadicadeServer.RecNet.Accounts.RealPresence { playerId = 51, roomInstance = 101, isOnline = true },
|
||||
new RadicadeServer.RecNet.Accounts.RealPresence { playerId = 52, roomInstance = 102, isOnline = true },
|
||||
new RadicadeServer.RecNet.Accounts.RealPresence { playerId = 53, roomInstance = 103, isOnline = true },
|
||||
new RadicadeServer.RecNet.Accounts.RealPresence { playerId = 54, roomInstance = 101, isOnline = false });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var oldContext = Notify.Context;
|
||||
var hubClients = DispatchProxy.Create<IHubClients, RecordingHubClients>();
|
||||
var recording = (RecordingHubClients)(object)hubClients;
|
||||
var clients = new[] { CreatorId, 51L, 52L, 53L, 54L }.Select(id => new HubClient
|
||||
{
|
||||
AccountId = id, ConnectionId = $"broadcast-test-{id}"
|
||||
}).ToArray();
|
||||
foreach (var client in clients)
|
||||
recording.ClientsById[client.ConnectionId] = new RecordingClient();
|
||||
lock (Notify.ClientsLock)
|
||||
Notify.Clients.AddRange(clients);
|
||||
|
||||
try
|
||||
{
|
||||
Notify.Context = new TestHubContext(hubClients);
|
||||
// Start, change the source instance, then stop while viewers stay in their rooms.
|
||||
foreach (long? source in new long?[] { 100, 102, null })
|
||||
{
|
||||
var controller = new EventsAPI(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString())], "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
PlayerEventId = ev.PlayerEventId, RoomInstanceId = source
|
||||
});
|
||||
controller.Request.Body = new MemoryStream(body);
|
||||
controller.Request.ContentType = "application/json";
|
||||
controller.Request.ContentLength = body.Length;
|
||||
Assert.IsType<OkObjectResult>(await controller.BroadcastRoomInstance());
|
||||
Assert.Equal(source, (await db.PlayerEvents.SingleAsync()).BroadcastingRoomInstanceId);
|
||||
|
||||
foreach (var client in clients)
|
||||
{
|
||||
var notifications = recording.ClientsById[client.ConnectionId].Notifications;
|
||||
if (client.AccountId is 53 or 54)
|
||||
Assert.Empty(notifications);
|
||||
else
|
||||
{
|
||||
using var frame = JsonDocument.Parse(Assert.Single(notifications));
|
||||
Assert.Equal("81", frame.RootElement.GetProperty("Id").GetString());
|
||||
var payload = frame.RootElement.GetProperty("Msg");
|
||||
Assert.Equal(ev.PlayerEventId, payload.GetProperty("playerEventId").GetInt64());
|
||||
var broadcast = payload.GetProperty("broadcastingRoomInstanceId");
|
||||
Assert.Equal(source, broadcast.ValueKind == JsonValueKind.Null ? (long?)null : broadcast.GetInt64());
|
||||
}
|
||||
notifications.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (Notify.ClientsLock)
|
||||
foreach (var client in clients)
|
||||
Notify.Clients.Remove(client);
|
||||
Notify.Context = oldContext;
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerEventDB Event() => new()
|
||||
{
|
||||
// The id is assigned by the store; a test that reads it back after a save uses the
|
||||
// generated one.
|
||||
PlayerEventId = 7,
|
||||
Name = "Launch Party",
|
||||
Description = "Big launch",
|
||||
RoomId = 102,
|
||||
CreatorPlayerId = CreatorId,
|
||||
AttendeeCount = 3,
|
||||
StartTime = new DateTime(2026, 10, 8, 19, 10, 0, DateTimeKind.Unspecified),
|
||||
EndTime = new DateTime(2026, 10, 8, 19, 40, 0, DateTimeKind.Unspecified),
|
||||
Accessibility = Accessibility.Public,
|
||||
TagsJson = """[{"Tag":"workshops","Type":0}]"""
|
||||
};
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
||||
{
|
||||
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
public RecDBContext CreateDbContext() => new(options);
|
||||
}
|
||||
|
||||
private sealed class RecordingClient : ISingleClientProxy
|
||||
{
|
||||
public List<string> Notifications { get; } = new();
|
||||
|
||||
public Task<T> InvokeCoreAsync<T>(string method, object?[] args,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Assert.Equal("Notification", method);
|
||||
Notifications.Add(Assert.IsType<string>(args[0]));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user