mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
428 lines
19 KiB
C#
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;
|
|
}
|
|
}
|