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
+781
View File
@@ -0,0 +1,781 @@
using System.IO;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Events;
using RadicadeServer.DB;
using RadicadeServer.RecNet.Enums;
using RadicadeServer.RecNet.Events;
using RadicadeServer.RecNet.Rooms;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// End-to-end tests for the game's event endpoints, exercising the real
/// EventsAPI controller against an in-memory database.
/// </summary>
public class EventsApiTests
{
private const long CreatorId = 42;
private static RecDBContext CreateDb()
{
var options = TestDbOptions.Create()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new RecDBContext(options);
}
private static EventsAPI CreateController(RecDBContext db, long? userId = CreatorId)
{
var identity = new ClaimsIdentity("TestAuth");
if (userId.HasValue)
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString()));
return new EventsAPI(db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
}
};
}
private static EventsAPI CreateController(RecDBContext db, long? userId, string jsonBody)
{
var controller = CreateController(db, userId);
SetJsonBody(controller, jsonBody);
return controller;
}
private static void SetJsonBody(EventsAPI controller, string jsonBody)
{
var bytes = Encoding.UTF8.GetBytes(jsonBody);
controller.HttpContext.Request.Body = new MemoryStream(bytes);
controller.HttpContext.Request.ContentType = "application/json";
controller.HttpContext.Request.ContentLength = bytes.Length;
}
private static async Task<long> CreateV2EventAsync(RecDBContext db, long roomId)
{
var createController = CreateController(
db, CreatorId,
$$"""{ "Name": "Club meetup", "RoomId": {{roomId}} }""");
var create = Assert.IsType<OkObjectResult>(await createController.CreateEventV2());
using var document = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(create.Value));
return document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64();
}
private static async Task<System.Text.Json.JsonDocument> UpdateEventV2Async(
RecDBContext db, long eventId, string route, string body)
{
var controller = CreateController(db);
SetJsonBody(controller, body);
controller.HttpContext.Request.Path = $"/api/playerevents/v2/{eventId}/{route}";
var result = Assert.IsType<OkObjectResult>(await controller.UpdateEventV2(eventId));
return System.Text.Json.JsonDocument.Parse(System.Text.Json.JsonSerializer.Serialize(result.Value));
}
private static async Task<long> SeedRoomAsync(RecDBContext db, string name = "Test Room")
{
var room = new RoomData { Name = name };
db.Rooms.Add(room);
await db.SaveChangesAsync();
return room.RoomId;
}
private static async Task<PlayerEvent> CreateEventAsync(
EventsAPI controller,
long roomId,
string name = "Launch Party")
{
var result = await controller.CreateEvent(
name, "A test event", roomId,
DateTime.UtcNow.AddHours(1), null, null, null);
var ok = Assert.IsType<OkObjectResult>(result.Result);
return Assert.IsType<PlayerEvent>(ok.Value);
}
[Fact]
public async Task CreateEvent_ThenAppearsInSearchAndRoomEvents()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(controller, roomId, "Launch Party"); var search = await controller.SearchEvents("launch", null);
Assert.Single(search);
Assert.Contains(search, e => e.PlayerEventId == created.PlayerEventId);
var roomEvents = await controller.GetEventsInRoom(roomId);
Assert.Contains(roomEvents, e => e.PlayerEventId == created.PlayerEventId);
}
[Fact]
public async Task SearchEvents_OnlyReturnsPublicUpcomingEvents()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
await CreateEventAsync(controller, roomId, "Public Upcoming");
db.PlayerEvents.Add(new PlayerEventDB
{
Name = "Ended",
Description = "",
ImageName = "",
StartTime = DateTime.UtcNow.AddHours(-4),
EndTime = DateTime.UtcNow.AddHours(-2),
CreatorPlayerId = CreatorId,
AttendeeCount = 1,
RoomId = roomId,
Accessibility = Accessibility.Public
});
db.PlayerEvents.Add(new PlayerEventDB
{
Name = "Private",
Description = "",
ImageName = "",
StartTime = DateTime.UtcNow.AddHours(1),
EndTime = DateTime.UtcNow.AddHours(3),
CreatorPlayerId = CreatorId,
AttendeeCount = 1,
RoomId = roomId,
Accessibility = Accessibility.Private
});
await db.SaveChangesAsync(); var search = await controller.SearchEvents(null, null);
Assert.Single(search);
Assert.Equal("Public Upcoming", search[0].Name);
}
[Fact]
public async Task CreateEvent_BlankName_ReturnsBadRequest()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var result = await controller.CreateEvent(" ", null, roomId, null, null, null, null);
Assert.IsType<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task CreateEvent_NonPositiveRoomId_ReturnsBadRequest()
{
var db = CreateDb();
var controller = CreateController(db);
var result = await controller.CreateEvent("Launch", null, 0, null, null, null, null);
Assert.IsType<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task CreateEvent_UnknownRoom_ReturnsBadRequest()
{
var db = CreateDb();
var controller = CreateController(db);
var result = await controller.CreateEvent("Launch", null, 9999, null, null, null, null);
Assert.IsType<BadRequestObjectResult>(result.Result);
}
[Fact]
public async Task CreateEvent_NoUser_ReturnsUnauthorized()
{
var db = CreateDb();
var controller = CreateController(db, userId: null);
var roomId = await SeedRoomAsync(db);
var result = await controller.CreateEvent("Launch", null, roomId, null, null, null, null);
Assert.IsType<UnauthorizedResult>(result.Result);
}
[Fact]
public async Task CreateEvent_ImplicitlyRSVPsCreatorYes()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(controller, roomId);
var response = await db.PlayerEventResponses
.SingleAsync(r => r.PlayerEventId == created.PlayerEventId);
Assert.Equal(CreatorId, response.PlayerId);
Assert.Equal(EventResponses.Yes, response.Type);
Assert.Equal(1, created.AttendeeCount);
}
[Fact]
public async Task GetCreatedEvents_ReturnsOnlyTheUsersEvents()
{
var db = CreateDb();
var controller = CreateController(db, userId: 42);
var otherController = CreateController(db, userId: 43);
var roomId = await SeedRoomAsync(db);
await CreateEventAsync(controller, roomId, "Mine");
await CreateEventAsync(otherController, roomId, "Theirs");
var mine = await controller.GetCreatedEvents();
Assert.Single(mine);
Assert.Equal("Mine", mine[0].Name);
}
[Fact]
public async Task GetAllEvents_ReturnsCreatedAndNoWireResponses()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
await CreateEventAsync(controller, roomId, "Mine");
var all = await controller.GetAllEvents();
Assert.Single(all.Created);
// The 2023 client parses Responses entries with its typed MessagePack
// deserializer and a shape it does not fully understand deserializes to
// null, failing the boot-time events refresh. The RecFlare reference
// server always serves an empty Responses list, so Radicade does too.
Assert.Empty(all.Responses);
}
[Fact]
public async Task GetAllEvents_NeverServesAnEmptyImageName()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
db.PlayerEvents.Add(new PlayerEventDB
{
Name = "No image",
Description = "",
ImageName = "",
StartTime = DateTime.UtcNow.AddHours(1),
EndTime = DateTime.UtcNow.AddHours(2),
CreatorPlayerId = CreatorId,
AttendeeCount = 0,
RoomId = roomId,
Accessibility = Accessibility.Public
});
await db.SaveChangesAsync();
var all = await controller.GetAllEvents();
var created = Assert.Single(all.Created);
// The 2023 client's RRUI events cache resolves an image for every event it
// lists and throws on an empty name before making any request, which fails
// the whole refresh and makes the client retry it in a tight loop.
Assert.Equal("default", created.ImageName);
}
[Fact]
public async Task ClubEventFeedsReturnTheExpectedDifferentShapes()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var now = DateTime.UtcNow;
db.PlayerEvents.AddRange(
new PlayerEventDB
{
Name = "Club 12 event", Description = "", ImageName = "", RoomId = roomId,
CreatorPlayerId = CreatorId, StartTime = now.AddHours(1), EndTime = now.AddHours(2),
ClubId = 12, Accessibility = Accessibility.Public
},
new PlayerEventDB
{
Name = "Club 13 event", Description = "", ImageName = "", RoomId = roomId,
CreatorPlayerId = CreatorId, StartTime = now.AddHours(2), EndTime = now.AddHours(3),
ClubId = 13, Accessibility = Accessibility.Public
},
new PlayerEventDB
{
Name = "Unattached event", Description = "", ImageName = "", RoomId = roomId,
CreatorPlayerId = CreatorId, StartTime = now.AddHours(3), EndTime = now.AddHours(4),
Accessibility = Accessibility.Public
});
await db.SaveChangesAsync();
var clubs = await controller.GetEventsByClubs([12, 13]);
Assert.Equal(new[] { "Club 12 event", "Club 13 event" }, clubs.Select(e => e.Name));
Assert.Equal(12, clubs[0].ClubId);
var single = Assert.IsType<OkObjectResult>(await controller.GetEventsByClub(12));
using var json = System.Text.Json.JsonDocument.Parse(System.Text.Json.JsonSerializer.Serialize(single.Value));
Assert.Equal(string.Empty, json.RootElement.GetProperty("ContinuationToken").GetString());
Assert.Equal("Club 12 event", json.RootElement.GetProperty("Events")[0].GetProperty("Name").GetString());
}
[Fact]
public async Task GetEvent_ReturnsEventOrNotFound()
{
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(controller, roomId);
var found = await controller.GetEvent(created.PlayerEventId);
var ok = Assert.IsType<OkObjectResult>(found.Result);
Assert.Equal(created.PlayerEventId, Assert.IsType<PlayerEvent>(ok.Value).PlayerEventId);
var missing = await controller.GetEvent(9999);
Assert.IsType<NotFoundResult>(missing.Result);
}
[Fact]
public async Task GetEvent_SerializesAllClientRequiredFields()
{
// The client deserializes every key of its PlayerEvent model and logs
// "Failed to find key 'Tags'" (then "Malformed Response") when one is
// missing. Guard the full wire shape here.
var db = CreateDb();
var controller = CreateController(db);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(controller, roomId);
var json = System.Text.Json.JsonSerializer.Serialize(created);
Assert.Contains("\"Tags\"", json);
Assert.Contains("\"ClubId\"", json);
Assert.Contains("\"State\"", json);
Assert.Contains("\"CreatedAt\"", json);
}
[Fact]
public void GetEventTagFilters_ReturnsUsableTagCatalog()
{
// The client refuses to save an event while every tag group is empty, so the
// endpoint serves the reference server's lowercase catalog instead of three
// empty sets. TrendingFilters is null exactly as the reference serves it -
// the client renders no trending row for null.
var db = CreateDb();
var controller = CreateController(db);
var filters = controller.GetEventTagFilters();
Assert.NotNull(filters.PinnedFilters);
Assert.NotNull(filters.PopularFilters);
Assert.Null(filters.TrendingFilters);
Assert.NotEmpty(filters.PinnedFilters);
Assert.NotEmpty(filters.PopularFilters);
Assert.All(filters.PinnedFilters, t => Assert.Equal(t, t.ToLowerInvariant()));
Assert.All(filters.PopularFilters, t => Assert.Equal(t, t.ToLowerInvariant()));
Assert.Contains("game", filters.PopularFilters);
Assert.Contains("meetup", filters.PinnedFilters);
}
[Fact]
public async Task SetResponse_AddsRsvpAndUpdatesAttendeeCount()
{
var db = CreateDb();
var creator = CreateController(db, userId: 42);
var guest = CreateController(db, userId: 43);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(creator, roomId);
var yes = await guest.SetResponse(created.PlayerEventId, EventResponses.Yes);
Assert.Equal(
EventResponses.Yes,
Assert.IsType<EventResponse>(Assert.IsType<OkObjectResult>(yes.Result).Value).Type);
var ev = await db.PlayerEvents.SingleAsync(e => e.PlayerEventId == created.PlayerEventId);
Assert.Equal(2, ev.AttendeeCount);
await guest.SetResponse(created.PlayerEventId, EventResponses.No);
ev = await db.PlayerEvents.SingleAsync(e => e.PlayerEventId == created.PlayerEventId);
Assert.Equal(1, ev.AttendeeCount);
}
[Fact]
public void GetLegacyPlaceholderResponses_ReturnsAnEmptyList()
{
var controller = CreateController(CreateDb());
var route = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetLegacyPlaceholderResponses))!
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.HttpGetAttribute), false)
.Cast<Microsoft.AspNetCore.Mvc.HttpGetAttribute>()
.Single();
Assert.Equal("v1/-1/responses", route.Template);
var responses = Assert.IsType<OkObjectResult>(controller.GetLegacyPlaceholderResponses());
var list = Assert.IsAssignableFrom<IEnumerable<RadicadeServer.RecNet.Events.EventResponse>>(responses.Value);
Assert.Empty(list);
}
[Fact]
public async Task GetResponsesAndAttendees_ReflectRsvps()
{
var db = CreateDb();
var creator = CreateController(db, userId: 42);
var guest = CreateController(db, userId: 43);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(creator, roomId);
await guest.SetResponse(created.PlayerEventId, EventResponses.Yes);
var responses = await creator.GetResponses(created.PlayerEventId);
var list = Assert.IsType<List<EventResponse>>(
Assert.IsType<OkObjectResult>(responses.Result).Value);
Assert.Equal(2, list.Count);
var attendees = await creator.GetAttendees(created.PlayerEventId);
var ids = Assert.IsType<List<long>>(
Assert.IsType<OkObjectResult>(attendees.Result).Value);
Assert.Contains(CreatorId, ids);
Assert.Contains(43, ids);
}
[Fact]
public async Task DeleteEvent_CreatorCanDelete_NonCreatorCannot()
{
var db = CreateDb();
var creator = CreateController(db, userId: 42);
var stranger = CreateController(db, userId: 99);
var roomId = await SeedRoomAsync(db);
var created = await CreateEventAsync(creator, roomId);
var forbidden = await stranger.DeleteEvent(created.PlayerEventId);
Assert.IsType<ForbidResult>(forbidden);
Assert.NotNull(await db.PlayerEvents.FindAsync(created.PlayerEventId));
var ok = await creator.DeleteEvent(created.PlayerEventId);
Assert.IsType<OkResult>(ok);
Assert.Null(await db.PlayerEvents.FindAsync(created.PlayerEventId));
Assert.Empty(await db.PlayerEventResponses
.Where(r => r.PlayerEventId == created.PlayerEventId).ToListAsync());
}
[Fact]
public async Task DeleteEvent_MissingEvent_ReturnsNotFound()
{
var db = CreateDb();
var controller = CreateController(db);
var result = await controller.DeleteEvent(9999);
Assert.IsType<NotFoundResult>(result);
}
[Fact]
public async Task CreateEventV2_CreatesEventAndReturnsWrapper()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var body =
$$"""
{
"Name": "Launch Party",
"Description": "Big launch",
"RoomId": {{roomId}},
"StartTime": "{{DateTime.UtcNow.AddHours(1):o}}",
"EndTime": "{{DateTime.UtcNow.AddHours(3):o}}",
"Accessibility": "public",
"SubRoomId": null,
"ImageName": ""
}
""";
var controller = CreateController(db, CreatorId, body);
var result = await controller.CreateEventV2();
var ok = Assert.IsType<OkObjectResult>(result);
var json = System.Text.Json.JsonSerializer.Serialize(ok.Value);
using var doc = System.Text.Json.JsonDocument.Parse(json);
// The write envelope: { PlayerEvent, Result, TagModifyResult } (the shape the
// reference server answers and the client's typed deserializer requires).
Assert.Equal(0, doc.RootElement.GetProperty("Result").GetInt32());
var tagModify = doc.RootElement.GetProperty("TagModifyResult");
Assert.Equal(0, tagModify.GetProperty("Result").GetInt32());
Assert.Equal(System.Text.Json.JsonValueKind.Array, tagModify.GetProperty("Tags").ValueKind);
var created = doc.RootElement.GetProperty("PlayerEvent");
Assert.Equal("Launch Party", created.GetProperty("Name").GetString());
Assert.Equal(roomId, created.GetProperty("RoomId").GetInt64());
Assert.Equal(Accessibility.Public, (Accessibility)created.GetProperty("Accessibility").GetInt32());
var eventId = created.GetProperty("PlayerEventId").GetInt64();
var ev = await db.PlayerEvents.SingleAsync(e => e.PlayerEventId == eventId);
Assert.Equal(1, ev.AttendeeCount);
var rsvp = await db.PlayerEventResponses.SingleAsync(r => r.PlayerEventId == eventId);
Assert.Equal(CreatorId, rsvp.PlayerId);
Assert.Equal(EventResponses.Yes, rsvp.Type);
}
[Fact]
public async Task CreateEventV2_UnknownRoom_ReturnsBadRequest()
{
var db = CreateDb();
var body = """{ "Name": "Launch", "RoomId": 9999 }""";
var controller = CreateController(db, CreatorId, body);
var result = await controller.CreateEventV2();
Assert.IsType<BadRequestObjectResult>(result);
}
[Fact]
public async Task UpdateEventV2StoresClubAndEventSettings()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await CreateV2EventAsync(db, roomId);
var payload = """{ "ClubId": 27 }""";
using var updatedJson = await UpdateEventV2Async(db, eventId, "club", payload);
var playerEvent = updatedJson.RootElement.GetProperty("PlayerEvent");
Assert.Equal(27, playerEvent.GetProperty("ClubId").GetInt64());
Assert.False(playerEvent.GetProperty("IsMultiInstance").GetBoolean());
var routeAttributes = typeof(EventsAPI).GetMethod(nameof(EventsAPI.UpdateEventV2))!
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), false)
.Cast<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>();
Assert.Contains(routeAttributes, attribute => attribute.Template == "v2/{eventId:long}/club");
}
[Fact]
public async Task UpdateEventV2ClubRouteSupportsNullableClubId()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await CreateV2EventAsync(db, roomId);
using var assigned = await UpdateEventV2Async(db, eventId, "club", """{ "ClubId": 27 }""");
using var cleared = await UpdateEventV2Async(db, eventId, "club", """{ "ClubId": null }""");
Assert.Equal(27, assigned.RootElement.GetProperty("PlayerEvent").GetProperty("ClubId").GetInt64());
Assert.Equal(System.Text.Json.JsonValueKind.Null,
cleared.RootElement.GetProperty("PlayerEvent").GetProperty("ClubId").ValueKind);
}
[Fact]
public async Task UpdateEventV2MultiInstanceRouteReadsItsFocusedPayload()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await CreateV2EventAsync(db, roomId);
var body = """{ "IsMultiInstance": true, "SupportMultiInstanceRoomChat": true, "DefaultBroadcastPermissions": 3, "CanRequestBroadcastPermissions": 7 }""";
using var updated = await UpdateEventV2Async(db, eventId, "multiinstance", body);
var playerEvent = updated.RootElement.GetProperty("PlayerEvent");
Assert.True(playerEvent.GetProperty("IsMultiInstance").GetBoolean());
Assert.True(playerEvent.GetProperty("SupportMultiInstanceRoomChat").GetBoolean());
Assert.Equal(3, playerEvent.GetProperty("DefaultBroadcastPermissions").GetInt32());
Assert.Equal(7, playerEvent.GetProperty("CanRequestBroadcastPermissions").GetInt32());
}
[Fact]
public async Task UpdateEventV2StoresStructuredTagsInTheClientShape()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await CreateV2EventAsync(db, roomId);
var body = """{ "Tags": [{ "Tag": "meetup", "Type": 1 }] }""";
using var updated = await UpdateEventV2Async(db, eventId, "tags", body);
var tag = updated.RootElement.GetProperty("PlayerEvent").GetProperty("Tags")[0];
Assert.Equal("meetup", tag.GetProperty("Tag").GetString());
Assert.Equal(1, tag.GetProperty("Type").GetInt32());
Assert.Equal("[{\"Tag\":\"meetup\",\"Type\":1}]",
(await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == eventId)).TagsJson);
}
[Fact]
public async Task DeleteEventV2_RemovesTheEvent()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var controller = CreateController(
db, CreatorId, $$"""{ "Name": "To Delete", "RoomId": {{roomId}} }""");
var create = await controller.CreateEventV2();
long createdId;
using (var doc = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(Assert.IsType<OkObjectResult>(create).Value)))
createdId = doc.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64();
var delete = await controller.DeleteEventV2(createdId);
Assert.IsType<OkObjectResult>(delete);
Assert.Null(await db.PlayerEvents.FindAsync(createdId));
Assert.Empty(await db.PlayerEventResponses
.Where(r => r.PlayerEventId == createdId).ToListAsync());
}
[Fact]
public async Task DeleteEventV2_AlreadyGoneEvent_AnswersSuccessInsteadOfNotFound()
{
// The client clears a card only when the 82 frame says so, so a list still showing an
// event whose row is gone is otherwise stuck: every retry is a 404 it can only render as
// an error and no frame is ever pushed again. A repeat delete answers success and puts
// the frame back on the wire, which is what lets the retry clear the card.
var db = CreateDb();
var controller = CreateController(db);
var result = await controller.DeleteEventV2(9999);
var ok = Assert.IsType<OkObjectResult>(result);
using var document = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(ok.Value));
Assert.Equal(0, document.RootElement.GetProperty("Result").GetInt32());
Assert.Equal(9999,
document.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64());
}
[Fact]
public async Task DeleteEventV2_OtherPlayersEvent_IsStillForbidden()
{
// Answering "already gone" must not widen who may delete a row that is still there.
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var createdId = await CreateV2EventAsync(db, roomId);
var stranger = CreateController(db, userId: 99);
Assert.IsType<ForbidResult>(await stranger.DeleteEventV2(createdId));
Assert.NotNull(await db.PlayerEvents.FindAsync(createdId));
}
private static async Task<long> MakeMultiInstanceAsync(RecDBContext db, long eventId)
{
using var updated = await UpdateEventV2Async(
db, eventId, "multiinstance", """{ "IsMultiInstance": true }""");
return eventId;
}
[Fact]
public async Task BroadcastRoomInstance_SetsAndClearsTheBroadcastingInstance()
{
// The client posts `BroadcastRoomInstanceRequest { PlayerEventId, RoomInstanceId }`
// to api/playerevents/v1/broadcast when a broadcaster goes live, and again with a
// null instance when they stop. The server never had the route, so the call 404ed.
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await MakeMultiInstanceAsync(db, await CreateV2EventAsync(db, roomId));
var instance = new RoomInstance { roomId = roomId, subRoomId = 1, eventId = eventId };
db.RoomInstances.Add(instance);
await db.SaveChangesAsync();
var start = CreateController(db, CreatorId,
$$"""{ "PlayerEventId": {{eventId}}, "RoomInstanceId": {{instance.roomInstanceId}} }""");
var started = Assert.IsType<OkObjectResult>(await start.BroadcastRoomInstance());
using (var json = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(started.Value)))
{
Assert.Equal(0, json.RootElement.GetProperty("Result").GetInt32());
Assert.Equal(instance.roomInstanceId,
json.RootElement.GetProperty("PlayerEvent").GetProperty("BroadcastingRoomInstanceId").GetInt64());
}
Assert.Equal(instance.roomInstanceId,
(await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == eventId)).BroadcastingRoomInstanceId);
var stop = CreateController(db, CreatorId,
$$"""{ "PlayerEventId": {{eventId}}, "RoomInstanceId": null }""");
var stopped = Assert.IsType<OkObjectResult>(await stop.BroadcastRoomInstance());
using (var json = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(stopped.Value)))
{
Assert.Equal(System.Text.Json.JsonValueKind.Null,
json.RootElement.GetProperty("PlayerEvent").GetProperty("BroadcastingRoomInstanceId").ValueKind);
}
Assert.Null((await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == eventId)).BroadcastingRoomInstanceId);
}
[Fact]
public async Task BroadcastRoomInstance_NonMultiInstanceEvent_AnswersNotMultiInstance()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await CreateV2EventAsync(db, roomId);
var controller = CreateController(db, CreatorId,
$$"""{ "PlayerEventId": {{eventId}}, "RoomInstanceId": 7 }""");
var result = Assert.IsType<OkObjectResult>(await controller.BroadcastRoomInstance());
using var json = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(result.Value));
Assert.Equal(27, json.RootElement.GetProperty("Result").GetInt32());
Assert.Equal(System.Text.Json.JsonValueKind.Null,
json.RootElement.GetProperty("PlayerEvent").GetProperty("BroadcastingRoomInstanceId").ValueKind);
Assert.Null((await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == eventId)).BroadcastingRoomInstanceId);
}
[Fact]
public async Task BroadcastRoomInstance_NonCreator_IsForbidden()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await MakeMultiInstanceAsync(db, await CreateV2EventAsync(db, roomId));
var stranger = CreateController(db, userId: 99,
$$"""{ "PlayerEventId": {{eventId}}, "RoomInstanceId": 7 }""");
Assert.IsType<ForbidResult>(await stranger.BroadcastRoomInstance());
}
[Fact]
public async Task BroadcastRoomInstance_RoundTripsThroughTheReadShapes()
{
// Viewers find the broadcast from the event feed, so the persisted instance has to
// survive into every read, not just the write envelope.
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var eventId = await MakeMultiInstanceAsync(db, await CreateV2EventAsync(db, roomId));
db.PlayerEvents.Single(x => x.PlayerEventId == eventId).BroadcastingRoomInstanceId = 1234;
await db.SaveChangesAsync();
var feed = await CreateController(db).GetBrowseFeed();
var feedEvent = Assert.Single(feed, e => e.PlayerEventId == eventId);
Assert.Equal(1234, feedEvent.BroadcastingRoomInstanceId);
var byId = await CreateController(db).GetEvent(eventId);
var ok = Assert.IsType<OkObjectResult>(byId.Result);
Assert.Equal(1234, Assert.IsType<PlayerEvent>(ok.Value).BroadcastingRoomInstanceId);
}
[Fact]
public async Task DeleteEventResponse_RemovesTheCallersRsvpAndRecomputesAttendees()
{
var db = CreateDb();
var roomId = await SeedRoomAsync(db);
var creator = CreateController(db, CreatorId);
var created = await CreateEventAsync(creator, roomId);
var guest = CreateController(db, userId: 43);
await guest.SetResponse(created.PlayerEventId, EventResponses.Yes);
Assert.Equal(2, (await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == created.PlayerEventId)).AttendeeCount);
var delete = CreateController(db, userId: 43,
$$"""{ "PlayerEventId": {{created.PlayerEventId}} }""");
var result = Assert.IsType<OkObjectResult>(await delete.DeleteEventResponse());
using var json = System.Text.Json.JsonDocument.Parse(
System.Text.Json.JsonSerializer.Serialize(result.Value));
Assert.Equal(0, json.RootElement.GetProperty("Result").GetInt32());
Assert.False(await db.PlayerEventResponses
.AnyAsync(r => r.PlayerEventId == created.PlayerEventId && r.PlayerId == 43));
Assert.Equal(1, (await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == created.PlayerEventId)).AttendeeCount);
}
[Fact]
public void BroadcastAndDeleteResponseRoutes_MatchTheClientRouteTable()
{
// The 2023 client's route table (global-metadata literals #39422 and #39427) posts to
// api/playerevents/v1/broadcast and api/playerevents/v1/deleteResponse. Both routes
// used to 404, which is why event broadcasting never started.
static IEnumerable<(string Method, string Template)> Routes(string methodName)
=> typeof(EventsAPI).GetMethod(methodName)!
.GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), false)
.Cast<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
.SelectMany(a => a.HttpMethods.Select(m => (m, a.Template)));
Assert.Contains(("POST", "v1/broadcast"), Routes(nameof(EventsAPI.BroadcastRoomInstance)));
Assert.Contains(("POST", "v1/deleteResponse"), Routes(nameof(EventsAPI.DeleteEventResponse)));
}
}