Files
Radicade/ServerTests/PlayerEventNotificationTests.cs
T
2026-10-16 23:33:13 -07:00

428 lines
19 KiB
C#

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;
}
}