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; /// /// End-to-end tests for the game's event endpoints, exercising the real /// EventsAPI controller against an in-memory database. /// 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 CreateV2EventAsync(RecDBContext db, long roomId) { var createController = CreateController( db, CreatorId, $$"""{ "Name": "Club meetup", "RoomId": {{roomId}} }"""); var create = Assert.IsType(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 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(await controller.UpdateEventV2(eventId)); return System.Text.Json.JsonDocument.Parse(System.Text.Json.JsonSerializer.Serialize(result.Value)); } private static async Task 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 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(result.Result); return Assert.IsType(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(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(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(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(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(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(found.Result); Assert.Equal(created.PlayerEventId, Assert.IsType(ok.Value).PlayerEventId); var missing = await controller.GetEvent(9999); Assert.IsType(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(Assert.IsType(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() .Single(); Assert.Equal("v1/-1/responses", route.Template); var responses = Assert.IsType(controller.GetLegacyPlaceholderResponses()); var list = Assert.IsAssignableFrom>(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>( Assert.IsType(responses.Result).Value); Assert.Equal(2, list.Count); var attendees = await creator.GetAttendees(created.PlayerEventId); var ids = Assert.IsType>( Assert.IsType(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(forbidden); Assert.NotNull(await db.PlayerEvents.FindAsync(created.PlayerEventId)); var ok = await creator.DeleteEvent(created.PlayerEventId); Assert.IsType(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(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(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(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(); 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(create).Value))) createdId = doc.RootElement.GetProperty("PlayerEvent").GetProperty("PlayerEventId").GetInt64(); var delete = await controller.DeleteEventV2(createdId); Assert.IsType(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(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(await stranger.DeleteEventV2(createdId)); Assert.NotNull(await db.PlayerEvents.FindAsync(createdId)); } private static async Task 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(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(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(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(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(byId.Result); Assert.Equal(1234, Assert.IsType(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(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() .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))); } }