using System.Reflection; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Config; using RadicadeServer.API.Events; using RadicadeServer.API.Matchmaking; using RadicadeServer.API.Notifications; using RadicadeServer.API.Relationships; using RadicadeServer.API.Relationships.V2; using RadicadeServer.Clubs; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Events; using RadicadeServer.RecNet.Matchmaking; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Relationships; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RoomServer; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class InviteFlowTests { private const long CreatorId = 42; private const long InviteeId = 43; private const long StrangerId = 44; private const long RoomId = 45; private const long EventId = 46; private const long ClubId = 47; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } private sealed record FactorySnapshot( IDbContextFactory Notify, IDbContextFactory Room, IDbContextFactory Matchmaking); private static FactorySnapshot SetFactories(IDbContextFactory factory) { var previous = new FactorySnapshot(Notify.DbFactory, RoomUtils.DbFactory, MatchmakingUtils.DbFactory); Notify.DbFactory = factory; RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return previous; } private static void RestoreFactories(FactorySnapshot previous) { Notify.DbFactory = previous.Notify; RoomUtils.DbFactory = previous.Room; MatchmakingUtils.DbFactory = previous.Matchmaking; } private static async Task<(RecDBContext Db, FactorySnapshot Previous)> CreateContextAsync() { var factory = new TestDbContextFactory(); var previous = SetFactories(factory); var db = factory.CreateDbContext(); db.Profiles.AddRange(ProfileFor(CreatorId), ProfileFor(InviteeId), ProfileFor(StrangerId)); db.Players.AddRange( new PlayerData { AccountId = CreatorId, Hashword = "test" }, new PlayerData { AccountId = InviteeId, Hashword = "test" }, new PlayerData { AccountId = StrangerId, Hashword = "test" }); await db.SaveChangesAsync(); return (db, previous); } private static void SetUser(Controller controller, long userId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static void SetFormBody(Controller controller, string body) { var request = controller.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); request.ContentLength = request.Body.Length; } private static Profile ProfileFor(long id) => new() { accountId = id, username = $"player{id}", displayName = $"Player {id}", profileImage = string.Empty, createdAt = DateTime.UtcNow }; private static async Task SeedRoomAsync(RecDBContext db, Accessibility accessibility = Accessibility.Public) { db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "Invite test room", CreatorAccountId = CreatorId, Accessibility = accessibility, MaxPlayers = 8, CreatedAt = DateTime.UtcNow }); db.SubRooms.Add(new SubRoom { RoomId = RoomId, SubRoomId = 1, Name = "Main", MaxPlayers = 8, Accessibility = Accessibility.Public, UnitySceneId = "invite-test-scene" }); await db.SaveChangesAsync(); } private static async Task SeedEventAsync( RecDBContext db, Accessibility eventAccess = Accessibility.Private, Accessibility roomAccess = Accessibility.Public) { await SeedRoomAsync(db, roomAccess); var ev = new PlayerEventDB { PlayerEventId = EventId, Name = "Invite test event", CreatorPlayerId = CreatorId, RoomId = RoomId, StartTime = DateTime.UtcNow.AddMinutes(-10), EndTime = DateTime.UtcNow.AddHours(1), Accessibility = eventAccess }; db.PlayerEvents.Add(ev); db.Presences.Add(new RealPresence { playerId = CreatorId, isOnline = true }); db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); return ev; } [Fact] public async Task BulkEventInvitePersistsPendingResponseAndMessageAndAllowsPrivateEventJoin() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db, Accessibility.Private, Accessibility.Private); var controller = new EventsAPI(db); SetUser(controller, CreatorId); SetFormBody(controller, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}"); var result = await controller.BulkInvite(); Assert.IsType(result); var response = await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId); Assert.Equal(EventResponses.Pending, response.Type); var message = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.PlayerEventInvitation, message.Type); Assert.Equal(EventId, message.PlayerEventId); Assert.Equal(CreatorId, message.FromPlayerId); Assert.Equal(EventResponses.Pending, (await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId)).Type); var invitee = new EventsAPI(db); SetUser(invitee, InviteeId); Assert.IsType((await invitee.RespondToEventV2(EventId)).Result); var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.Equal(EventId, match.roomInstance?.eventId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task PrivateEventRejectsUninvitedRsvpAndDeclinedInviteeCannotReenter() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db); var strangerRsvp = new EventsAPI(db); SetUser(strangerRsvp, StrangerId); Assert.IsType((await strangerRsvp.RespondToEventV2(EventId)).Result); var invitedRsvp = new EventsAPI(db); SetUser(invitedRsvp, InviteeId); Assert.IsType((await invitedRsvp.RespondToEventV2(EventId)).Result); db.Messages.Add(new Message { ToPlayerId = InviteeId, FromPlayerId = CreatorId, Type = MessageType.PlayerEventInvitation, PlayerEventId = EventId }); db.PlayerEventResponses.Add(new PlayerEventResponse { PlayerEventId = EventId, PlayerId = InviteeId, Type = EventResponses.No }); await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.EventIsPrivate, match.errorCode); var resend = new EventsAPI(db); SetUser(resend, CreatorId); SetFormBody(resend, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}"); Assert.IsType(await resend.BulkInvite()); Assert.Equal(2, await db.Messages.CountAsync(x => x.ToPlayerId == InviteeId && x.PlayerEventId == EventId)); var response = await db.PlayerEventResponses.SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == InviteeId); Assert.Equal(EventResponses.Pending, response.Type); var renewedRsvp = new EventsAPI(db); SetUser(renewedRsvp, InviteeId); Assert.IsType((await renewedRsvp.RespondToEventV2(EventId)).Result); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task GameInviteCarriesInstanceAndRoomIdsAndGrantsOnlyRecipientPrivateAccess() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "private-instance" }; db.RoomInstances.Add(instance); await db.SaveChangesAsync(); db.Presences.Add(new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }); db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); var inviter = new MatchmakingAPI(db); SetUser(inviter, CreatorId); Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.GameInviteV2, invite.Type); Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite)); Assert.Equal(RoomId, invite.RoomId); // The client's V2 parser requires this JSON string before its button can join. using var data = JsonDocument.Parse(invite.Data); Assert.Equal(invite.Id, data.RootElement.GetProperty("InviteId").GetInt64()); Assert.Equal("Invite test room", data.RootElement.GetProperty("Name").GetString()); Assert.Equal(0, data.RootElement.GetProperty("InviteMode").GetInt32()); var inbox = new RadicadeServer.API.Messages.MessagesAPI(db); SetUser(inbox, InviteeId); await inbox.DeleteMessage(new DeleteMessageRequestDTO { MessageIds = new() { invite.Id } }); Assert.Empty((await inbox.GetMessages())!); Assert.True((await db.Messages.SingleAsync(x => x.Id == invite.Id)).IsDismissed); var joiner = new MatchmakingAPI(db); SetUser(joiner, InviteeId); var accepted = await joiner.AcceptInviteByMessageId( data.RootElement.GetProperty("InviteId").GetInt64(), null); Assert.Equal(JoinStatus.Success, accepted!.errorCode); Assert.Equal(instance.photonRoomId, accepted.roomInstance!.photonRoomId); var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null); Assert.Equal(JoinStatus.Success, joined.errorCode); Assert.Equal(instance.roomInstanceId, (await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance); var forged = new Message { FromPlayerId = StrangerId, ToPlayerId = StrangerId, Type = MessageType.GameInvite, Data = instance.roomInstanceId.ToString(), RoomId = RoomId }; db.Messages.Add(forged); await db.SaveChangesAsync(); var forgedJoiner = new MatchmakingAPI(db); SetUser(forgedJoiner, StrangerId); var forgedDenied = await forgedJoiner.GoToRoomInstance(instance.roomInstanceId, null); Assert.Equal(JoinStatus.RoomInstanceIsPrivate, forgedDenied.errorCode); db.Messages.Remove(invite); db.Messages.Remove(forged); await db.SaveChangesAsync(); var nextPlayer = new MatchmakingAPI(db); SetUser(nextPlayer, StrangerId); var denied = await nextPlayer.GoToRoomInstance(instance.roomInstanceId, null); Assert.Equal(JoinStatus.RoomInstanceIsPrivate, denied.errorCode); var uninvited = new MatchmakingAPI(db); SetUser(uninvited, StrangerId); var unauthorizedInvite = await uninvited.InvitePlayer(InviteeId, instance.roomInstanceId); Assert.False(unauthorizedInvite.success); } finally { RestoreFactories(previousFactories); } } /// /// The room half of an invite. Answering a game invite starts with fetching the room it /// names (`GET rooms/rooms/{id}?include=5421...` and `rooms/rooms/bulk?id=` for its card), /// and a private room used to answer 404 / [] to everyone but its owner - so the one player /// who had just been invited could not see the room they were invited to, and the prompt had /// nothing to join from. The invite is the access it grants and nothing more: a stranger /// still gets 404, and a sender who has left the room stops granting it. /// [Fact] public async Task InviteAlsoMakesThePrivateRoomFetchableForTheInviteeOnly() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); // A dorm is the shape the live report has: the friend invited from their own dorm, // and that is the private room a stranger's fetch answers 404 for. var room = await db.Rooms.SingleAsync(x => x.RoomId == RoomId); room.IsDorm = true; await db.SaveChangesAsync(); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "private-instance" }; db.RoomInstances.Add(instance); await db.SaveChangesAsync(); db.Presences.Add(new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }); db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true }); db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true }); await db.SaveChangesAsync(); var inviter = new MatchmakingAPI(db); SetUser(inviter, CreatorId); Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success); // The room itself, through the same handler the client's fetch lands on. var inviteeRooms = new RoomServerAPI(db); SetUser(inviteeRooms, InviteeId); Assert.IsType((await inviteeRooms.GetRoomById(RoomId)).Result); var strangerRooms = new RoomServerAPI(db); SetUser(strangerRooms, StrangerId); Assert.IsType((await strangerRooms.GetRoomById(RoomId)).Result); // The card the prompt renders from is the same rule, so the bulk answer carries the // room for the invitee and stays empty for everyone else. var inviteeBulk = await inviteeRooms.GetBulkRooms(null, new List { RoomId }); Assert.Single(inviteeBulk!); var strangerBulk = await strangerRooms.GetBulkRooms(null, new List { RoomId }); Assert.Empty(strangerBulk!); // The inviter leaving takes the invite's worth with it: there is no instance left to // be placed in, so the room goes back to being invisible. (await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = null; await db.SaveChangesAsync(); Assert.IsType((await inviteeRooms.GetRoomById(RoomId)).Result); } finally { RestoreFactories(previousFactories); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task AcceptedInviteKeepsDormLoadableAfterMessageDeletion(bool chatInvite) { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var room = await db.Rooms.SingleAsync(); room.IsDorm = true; var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "invite-room-load" }; db.RoomInstances.Add(instance); await db.SaveChangesAsync(); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); var inviter = new MatchmakingAPI(db); SetUser(inviter, CreatorId); Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success); var invite = await db.Messages.SingleAsync(); var accept = new MatchmakingAPI(db); SetUser(accept, InviteeId); if (chatInvite) { db.Chats.Add(new ChatThread { chatThreadId = 70, chatThreadName = "", playerIds = $"[{InviteeId},{CreatorId}]", messages = JsonSerializer.Serialize(new[] { new ChatMessage { chatMessageId = 2, chatThreadeId = 70, senderPlayerId = CreatorId, timeSent = DateTime.UtcNow, contents = "{\"Type\":1,\"Version\":1,\"Data\":\"Party invite\"}" } }) }); await db.SaveChangesAsync(); Assert.Equal(JoinStatus.Success, (await accept.MatchmakeChatInvite(70, 2, null))!.errorCode); } else Assert.Equal(JoinStatus.Success, (await accept.AcceptInviteByMessageId(invite.Id, null))!.errorCode); // Inbox cleanup and the host leaving must not undo an accepted room admission. db.Messages.Remove(invite); (await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = null; await db.SaveChangesAsync(); var rooms = new RoomServerAPI(db); SetUser(rooms, InviteeId); var loaded = Assert.IsType((await rooms.GetRoomById(RoomId)).Result); Assert.NotEmpty(Assert.IsType(loaded.Value).SubRooms); Assert.Single((await rooms.GetBulkRooms(null, new List { RoomId }))!); SetUser(rooms, StrangerId); Assert.IsType((await rooms.GetRoomById(RoomId)).Result); var presence = await db.Presences.SingleAsync(x => x.playerId == InviteeId); presence.isOnline = false; await db.SaveChangesAsync(); SetUser(rooms, InviteeId); Assert.IsType((await rooms.GetRoomById(RoomId)).Result); presence.isOnline = true; presence.roomInstance = null; await db.SaveChangesAsync(); Assert.IsType((await rooms.GetRoomById(RoomId)).Result); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task ExistingMemberCanForwardPrivateRoomInviteAndTheInviteeCanJoin() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "forwarded-private-instance" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = StrangerId, isOnline = true }); await db.SaveChangesAsync(); db.Messages.Add(new Message { ToPlayerId = InviteeId, FromPlayerId = CreatorId, Type = MessageType.GameInvite, Data = instance.roomInstanceId.ToString(), RoomId = RoomId }); await db.SaveChangesAsync(); var inviter = new MatchmakingAPI(db); SetUser(inviter, InviteeId); Assert.True((await inviter.InvitePlayer(StrangerId, instance.roomInstanceId)).success); var forwardedInvite = await db.Messages.SingleAsync(x => x.ToPlayerId == StrangerId); Assert.Equal(InviteeId, forwardedInvite.FromPlayerId); var joiner = new MatchmakingAPI(db); SetUser(joiner, StrangerId); var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null); Assert.Equal(JoinStatus.Success, joined.errorCode); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task PlayerInPrivateInstanceOfPublicRoomCanInvitePartyMemberToJoin() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Public); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "player-private-instance" }; db.RoomInstances.Add(instance); db.Presences.Add(new RealPresence { playerId = StrangerId, roomInstance = instance.roomInstanceId, isOnline = true }); db.Presences.Add(new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); var inviter = new MatchmakingAPI(db); SetUser(inviter, StrangerId); Assert.True((await inviter.InvitePlayer(InviteeId, instance.roomInstanceId)).success); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(StrangerId, invite.FromPlayerId); Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite)); Assert.Equal(RoomId, invite.RoomId); var joiner = new MatchmakingAPI(db); SetUser(joiner, InviteeId); var joined = await joiner.GoToRoomInstance(instance.roomInstanceId, null); Assert.Equal(JoinStatus.Success, joined.errorCode); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task CoOwnerRoleInviteCanBeAcceptedByInviteeButNotForgedByAnotherPlayer() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db); var api = new RoomServerAPI(db); SetUser(api, CreatorId); SetFormBody(api, "role=30"); var inviteResult = await api.InvitePlayerToRole(RoomId, InviteeId); Assert.True(inviteResult!.success); var inviteMessage = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.RoomCoOwnerInvited, inviteMessage.Type); Assert.Equal(RoomId, inviteMessage.RoomId); Assert.Equal(RoleType.None, (await db.RoomRoles.SingleAsync(x => x.RoomId == RoomId && x.AccountId == InviteeId)).Role); var forged = new RoomServerAPI(db); SetUser(forged, StrangerId); SetFormBody(forged, "role=30"); Assert.False((await forged.SetPlayerRole(RoomId, InviteeId))!.success); var acceptance = new RoomServerAPI(db); SetUser(acceptance, InviteeId); SetFormBody(acceptance, ""); Assert.True((await acceptance.SetPlayerRole(RoomId, InviteeId))!.success); Assert.Equal(RoleType.CoOwner, (await db.RoomRoles.SingleAsync(x => x.RoomId == RoomId && x.AccountId == InviteeId)).Role); Assert.Contains(await db.Messages.ToListAsync(), x => x.ToPlayerId == InviteeId && x.Type == MessageType.RoomCoOwnerAdded); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task ClubInviteAddsConfiguredMembershipAndRequiresCoownerAuthority() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await ClubStore.SaveAsync(db, new List { new() { clubId = ClubId, ownerPlayerId = CreatorId, name = "Test club" } }); var unauthorized = new ClubsAPI(db); SetUser(unauthorized, InviteeId); SetFormBody(unauthorized, $"accountId={StrangerId}&membershipType=10"); var denied = await unauthorized.InviteMember(ClubId); Assert.IsType(denied); Assert.Equal(StatusCodes.Status403Forbidden, ((ObjectResult)denied).StatusCode); var api = new ClubsAPI(db); SetUser(api, CreatorId); SetFormBody(api, $"accountId={InviteeId}&membershipType=20"); var result = await api.InviteMember(ClubId); Assert.IsType(result); var updated = await ClubStore.GetAsync(db, ClubId); Assert.Equal(ClubMembershipTypes.PendingInvited, ClubStore.GetMembershipType(updated, InviteeId)); Assert.Equal(1, ClubStore.GetMemberCount(updated)); var pendingMember = Assert.Single(ClubStore.GetMembers(updated), member => member.AccountId == InviteeId); Assert.Equal(CreatorId, pendingMember.InviterAccountId); Assert.Equal(ClubMembershipTypes.PendingInvited, pendingMember.MembershipType); var pendingWireMember = Assert.IsType>( ClubStore.BuildMember(pendingMember, updated, ProfileFor(InviteeId))); Assert.Equal(0, pendingWireMember["membershipStatus"]); Assert.Equal(CreatorId, pendingWireMember["inviterAccountId"]); var message = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.ClubModeratorInvited, message.Type); Assert.Equal(ClubId.ToString(), message.Data); Assert.Equal(ClubMembershipTypes.Moderator.ToString(), message.Details); // Only the invitee can activate the stored tier; before acceptance the // pending row isn't counted as a member or given the invited role. var forgedAcceptance = new ClubsAPI(db); SetUser(forgedAcceptance, StrangerId); Assert.IsType(await forgedAcceptance.AcceptClubInvitation(ClubId)); var acceptance = new ClubsAPI(db); SetUser(acceptance, InviteeId); var accepted = Assert.IsType(await acceptance.AcceptClubInvitation(ClubId)); var acceptedClub = await ClubStore.GetAsync(db, ClubId); Assert.Equal(ClubMembershipTypes.Moderator, ClubStore.GetMembershipType(acceptedClub, InviteeId)); Assert.Equal(2, ClubStore.GetMemberCount(acceptedClub)); var activeMember = Assert.Single(ClubStore.GetMembers(acceptedClub), member => member.AccountId == InviteeId); var activeWireMember = Assert.IsType>( ClubStore.BuildMember(activeMember, acceptedClub, ProfileFor(InviteeId))); Assert.Equal(1, activeWireMember["membershipStatus"]); Assert.Contains("Moderator", JsonSerializer.Serialize(accepted.Value)); } finally { RestoreFactories(previousFactories); } } // ---- The spellings a 20230831 client can post --------------------------------- /// /// The invite surface is pinned by shape because a 404 is what the player reads as /// `Invite Failed!`: the bare form spelling the September client provably posts (its own /// log shows `POST /matchmaking/invite` with a form body on an Invite press), the path /// spelling beside it for a build that puts the target in the path, and the offline variant /// beside the api routes. /// /// The client's `matchmake/invite/{0}` is NOT a send spelling. It sits in the contiguous /// `matchmake/*` JOIN family, so it serves the accept-by-invite-id spelling /// (), beside the chat-party /// acceptance `matchmake/chatinvite/{threadId}/{messageId}`. Treating the path as a send /// would invite whatever account happened to hold the same number as an invite's own /// message id. /// [Fact] public void InviteIsServedUnderEverySpellingTheClientsPost() { var sends = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.Invite))! .GetCustomAttributes() .Select(attribute => attribute.Template!) .ToArray(); Assert.Contains("invite", sends); Assert.Contains("invite/{playerId:long}", sends); Assert.DoesNotContain(sends, route => route.StartsWith("matchmake/", StringComparison.Ordinal)); Assert.Equal("matchmake/invite/{inviteId:long}", Assert.Single(typeof(MatchmakingAPI) .GetMethod(nameof(MatchmakingAPI.AcceptInviteByMessageId))! .GetCustomAttributes()).Template); Assert.Equal("matchmake/chatinvite/{threadId:long}/{messageId:long}", Assert.Single(typeof(MatchmakingAPI) .GetMethod(nameof(MatchmakingAPI.MatchmakeChatInvite))! .GetCustomAttributes()).Template); var offline = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.SendOfflineInvite))!; Assert.Equal("~/api/offlineinvite/v1/send", Assert.Single(offline.GetCustomAttributes()).Template); // The party-invite link lifetime rides the settings routes, as a bare single-key object: // the api host the sender's client reads, and the chat host the receiver's card fetches // when a party invite message arrives. var partyInviteRoutes = typeof(PlayerSettingsAPI) .GetMethod(nameof(PlayerSettingsAPI.GetPartyInviteSettings))! .GetCustomAttributes() .Select(attribute => attribute.Template!) .ToArray(); Assert.Contains("partyinvite", partyInviteRoutes); Assert.Contains("~/chat/settings/partyinvite", partyInviteRoutes); Assert.Contains("api/settings", typeof(PlayerSettingsAPI) .GetCustomAttributes().Select(attribute => attribute.Template)); } [Fact] public void PartyInviteSettingsAnswerTheBareSingleKeyObject() { using var db = new RecDBContext(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); var result = Assert.IsType(new PlayerSettingsAPI(db).GetPartyInviteSettings()); using var json = JsonDocument.Parse(JsonSerializer.Serialize(result.Value)); var property = Assert.Single(json.RootElement.EnumerateObject()); Assert.Equal("InviteLinkLifetimeInMinutes", property.Name); Assert.Equal( JsonValueKind.Number, property.Value.ValueKind); } /// /// The path spelling carries no instance at all, so the invite has to mean "the instance I am /// standing in" - the caller's own presence row, which is the same row the invite insists the /// inviter be live in. /// [Fact] public async Task PathShapedInviteWithoutAnInstanceInvitesIntoTheCallersCurrentInstance() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "path-shaped-invite" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, CreatorId); api.Request.RouteValues = new RouteValueDictionary { ["playerId"] = InviteeId.ToString() }; var result = await api.Invite(); Assert.True(result!.success); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.GameInviteV2, invite.Type); Assert.Equal(CreatorId, invite.FromPlayerId); Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite)); Assert.Equal(RoomId, invite.RoomId); } finally { RestoreFactories(previousFactories); } } /// /// No instance in the request AND none on the caller is the one case worth refusing loudly: /// the invite would otherwise be stored pointing at nothing, and the recipient's client would /// read a join prompt for a room that does not exist. /// [Fact] public async Task InviteWithNoInstanceAndNoPresenceIsRefusedWithoutStoringAnything() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var api = new MatchmakingAPI(db); SetUser(api, CreatorId); api.Request.RouteValues = new RouteValueDictionary { ["playerId"] = InviteeId.ToString() }; var result = await api.Invite(); Assert.False(result!.success); Assert.Empty(await db.Messages.ToListAsync()); } finally { RestoreFactories(previousFactories); } } /// /// An offline invite is the same stored game invite: the target simply has no hub frame to /// receive, and reads the stored message when they next sign in. Nothing about the invite is /// weakened for them - the instance and the room still name where to go. /// [Fact] public async Task OfflineInviteStoresTheSameGameInviteForATargetWithNoPresence() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = false, maxCapacity = 8, photonRoomId = "offline-invite" }; db.RoomInstances.Add(instance); db.Presences.Add(new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, CreatorId); SetFormBody(api, $"invitedPlayerId={InviteeId}&roomInstanceId={instance.roomInstanceId}"); var result = await api.SendOfflineInvite(); Assert.True(result.success); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.GameInviteV2, invite.Type); Assert.Equal(CreatorId, invite.FromPlayerId); Assert.Equal(instance.roomInstanceId, RoomInviteDetails.GetInstanceId(invite)); Assert.Equal(RoomId, invite.RoomId); } finally { RestoreFactories(previousFactories); } } // ---- Accepting --------------------------------------------------------------- /// /// The client's accept spelling for a game invite is its own `matchmake/invite/{0}`, the id /// being the invite message the hub pushed. The invite authorizes that exact instance for /// its recipient - a private instance is what an invite exists to get into - and the move /// is the same presence write a join performs, so the client's follow-up sees the recipient /// standing in the sender's instance. /// [Fact] public async Task AcceptingAStoredGameInviteMovesTheInviteeIntoTheInviterInstance() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "accepted-game-invite" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); db.Messages.Add(new Message { FromPlayerId = CreatorId, ToPlayerId = InviteeId, Type = MessageType.GameInvite, Data = instance.roomInstanceId.ToString(), RoomId = RoomId }); await db.SaveChangesAsync(); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); var accept = new MatchmakingAPI(db); SetUser(accept, InviteeId); var result = await accept.AcceptInviteByMessageId(invite.Id, null); Assert.NotNull(result); Assert.Equal(JoinStatus.Success, result!.errorCode); Assert.Equal(instance.roomInstanceId, result.roomInstance?.roomInstanceId); var presence = await db.Presences.SingleAsync(x => x.playerId == InviteeId); Assert.Equal(instance.roomInstanceId, presence.roomInstance); Assert.True(presence.isOnline); Assert.Equal(JoinStatus.Success, presence.errorCode); } finally { RestoreFactories(previousFactories); } } /// /// Every way a game invite can be stale or forged is answered with the code the client /// already has for it, rather than with a status it reads as a network failure: someone /// else's invite is expired, a note the caller wrote themselves is not an invite, a sender /// with no live session is not online, a sender who moved on makes the invite stale, and an /// instance that is gone names nothing to join. /// [Fact] public async Task AcceptingAGameInviteRefusesStaleForgedAndOfflineInvitesByName() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "refused-game-invite" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); Message Store(long to, long from, string data) { var message = new Message { FromPlayerId = from, ToPlayerId = to, Type = MessageType.GameInvite, Data = data, RoomId = RoomId }; db.Messages.Add(message); db.SaveChanges(); return message; } async Task AcceptAs(long caller, long inviteId) { var api = new MatchmakingAPI(db); SetUser(api, caller); return await api.AcceptInviteByMessageId(inviteId, null); } // Addressed to someone else: the caller has no such invite. var foreign = Store(StrangerId, CreatorId, instance.roomInstanceId.ToString()); var foreignResult = await AcceptAs(InviteeId, foreign.Id); Assert.Equal(JoinStatus.RoomInviteExpired, foreignResult!.errorCode); Assert.Null(foreignResult.roomInstance); // Written by the caller themselves. var selfSent = Store(InviteeId, InviteeId, instance.roomInstanceId.ToString()); Assert.Equal(JoinStatus.ChatMessageNotAnInvite, (await AcceptAs(InviteeId, selfSent.Id))!.errorCode); // The sender has no live session to be standing in the instance. var fromOffline = Store(InviteeId, StrangerId, instance.roomInstanceId.ToString()); Assert.Equal(JoinStatus.PlayerNotOnline, (await AcceptAs(InviteeId, fromOffline.Id))!.errorCode); // The sender moved to another instance, so the invite no longer names where they are. var elsewhere = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "somewhere-else" }; db.RoomInstances.Add(elsewhere); await db.SaveChangesAsync(); (await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = elsewhere.roomInstanceId; await db.SaveChangesAsync(); var moved = Store(InviteeId, CreatorId, instance.roomInstanceId.ToString()); Assert.Equal(JoinStatus.RoomInviteExpired, (await AcceptAs(InviteeId, moved.Id))!.errorCode); // The named instance is no longer live. (await db.Presences.SingleAsync(x => x.playerId == CreatorId)).roomInstance = 999_999; await db.SaveChangesAsync(); var gone = Store(InviteeId, CreatorId, "999999"); Assert.Equal(JoinStatus.RoomIsNotActive, (await AcceptAs(InviteeId, gone.Id))!.errorCode); } finally { RestoreFactories(previousFactories); } } /// /// The chat-party acceptance the client spells `matchmake/chatinvite/{threadId}/{messageId}`: /// when the message is stored, its sender is the inviter and their live instance is the /// destination - the same stored thread the chat surface serves, read here for who wrote /// the invite rather than for what it says. /// [Fact] public async Task AcceptingAChatInviteResolvesTheInviterFromTheStoredMessage() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "accepted-chat-invite" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); const long ThreadId = 70; db.Chats.Add(new ChatThread { chatThreadId = ThreadId, chatThreadName = "", playerIds = $"[{InviteeId},{CreatorId}]", messages = JsonSerializer.Serialize(new List { new() { chatMessageId = 2, chatThreadeId = ThreadId, senderPlayerId = CreatorId, timeSent = DateTime.UtcNow, contents = "\"come hang out\"" } }) }); await db.SaveChangesAsync(); var accept = new MatchmakingAPI(db); SetUser(accept, InviteeId); var result = await accept.MatchmakeChatInvite(ThreadId, 2, null); Assert.NotNull(result); Assert.Equal(JoinStatus.Success, result!.errorCode); Assert.Equal(instance.roomInstanceId, result.roomInstance?.roomInstanceId); Assert.Equal(instance.roomInstanceId, (await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance); } finally { RestoreFactories(previousFactories); } } /// /// A message id this server never stored falls back to the thread's single other member - /// the only person a party invite in that conversation could be from. A caller who is not /// in the thread is refused by name, and a stored message that is too old (or written by /// the caller) is refused with the code the client already has for it. /// [Fact] public async Task ChatInviteAcceptFallsBackToTheThreadsOtherMemberAndRefusesTheRest() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedRoomAsync(db, Accessibility.Private); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, isPrivate = true, maxCapacity = 8, photonRoomId = "chat-invite-fallback" }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CreatorId, roomInstance = instance.roomInstanceId, isOnline = true }, new RealPresence { playerId = InviteeId, isOnline = true }); await db.SaveChangesAsync(); const long ThreadId = 71; const long ExpiredThreadId = 72; const long ForeignThreadId = 73; db.Chats.AddRange( new ChatThread { chatThreadId = ThreadId, chatThreadName = "", playerIds = $"[{InviteeId},{CreatorId}]", messages = "[]" }, new ChatThread { chatThreadId = ExpiredThreadId, chatThreadName = "", playerIds = $"[{InviteeId},{CreatorId}]", messages = JsonSerializer.Serialize(new List { new() { chatMessageId = 5, chatThreadeId = ExpiredThreadId, senderPlayerId = CreatorId, timeSent = DateTime.UtcNow.AddMinutes(-(PlayerSettingsAPI.InviteLinkLifetimeInMinutes + 5)), contents = "\"too old\"" } }) }, new ChatThread { chatThreadId = ForeignThreadId, chatThreadName = "", playerIds = $"[{CreatorId},{StrangerId}]", messages = "[]" }); await db.SaveChangesAsync(); // Not stored, exactly one other member: they are the inviter. var fallback = new MatchmakingAPI(db); SetUser(fallback, InviteeId); var fallbackResult = await fallback.MatchmakeChatInvite(ThreadId, 77, null); Assert.Equal(JoinStatus.Success, fallbackResult!.errorCode); Assert.Equal(instance.roomInstanceId, (await db.Presences.SingleAsync(x => x.playerId == InviteeId)).roomInstance); var expired = new MatchmakingAPI(db); SetUser(expired, InviteeId); Assert.Equal(JoinStatus.RoomInviteExpired, (await expired.MatchmakeChatInvite(ExpiredThreadId, 5, null))!.errorCode); var foreign = new MatchmakingAPI(db); SetUser(foreign, InviteeId); Assert.Equal(JoinStatus.ChatPartyInviteNotFound, (await foreign.MatchmakeChatInvite(ForeignThreadId, 1, null))!.errorCode); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task FriendInviteCreatesReciprocalPendingRowsAndDurableMessage() { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var sender = new RelationshipsV2(db); SetUser(sender, CreatorId); Assert.IsType(await sender.SendFriendRequest(InviteeId)); Assert.Equal(RelationshipType.FriendRequestSent, (await db.Relationships.SingleAsync(x => x.AccountId == CreatorId && x.PlayerId == InviteeId)).RelationshipType); Assert.Equal(RelationshipType.FriendRequestReceived, (await db.Relationships.SingleAsync(x => x.AccountId == InviteeId && x.PlayerId == CreatorId)).RelationshipType); var invite = await db.Messages.SingleAsync(x => x.ToPlayerId == InviteeId); Assert.Equal(MessageType.FriendInvite, invite.Type); Assert.Equal(CreatorId, invite.FromPlayerId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task TheBroadcasterKeepsTheirOwnInstanceAndAViewerIsMatchedElsewhere() { // The instance a broadcaster registers is their own room. A viewer who may join the // event is still not placed in it - a broadcast advertises the session, it does not // open it - so matchmaking hands them the event's ordinary instance instead. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = true; var ordinary = new RoomInstance { roomInstanceId = 500, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; db.RoomInstances.AddRange(ordinary, broadcast); await db.SaveChangesAsync(); ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId; await db.SaveChangesAsync(); var host = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId); Assert.Equal(JoinStatus.Success, host.errorCode); Assert.Equal(broadcast.roomInstanceId, host.roomInstance?.roomInstanceId); var viewer = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.Success, viewer.errorCode); Assert.NotEqual(broadcast.roomInstanceId, viewer.roomInstance?.roomInstanceId); Assert.Equal(ordinary.roomInstanceId, viewer.roomInstance?.roomInstanceId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task AnInvitedViewerIsStillNotPlacedInTheBroadcastersInstance() { // An invitation grants the event, not a place in the broadcaster's own room: an invited // viewer is matched into an ordinary instance exactly like an uninvited one. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = true; db.PlayerEventResponses.Add(new PlayerEventResponse { PlayerEventId = EventId, PlayerId = InviteeId, Type = EventResponses.Yes }); var ordinary = new RoomInstance { roomInstanceId = 500, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; db.RoomInstances.AddRange(ordinary, broadcast); await db.SaveChangesAsync(); ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId; await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.Equal(ordinary.roomInstanceId, match.roomInstance?.roomInstanceId); Assert.NotEqual(broadcast.roomInstanceId, match.roomInstance?.roomInstanceId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task AViewerGetsTheirOwnInstanceWhenTheBroadcastIsTheOnlyOne() { // With nothing else to share, the viewer is given a fresh instance of the event's room - // the broadcast instance is never reused - and it is public, so the next viewer lands // beside them instead of causing a third instance. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = true; db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true }); var broadcast = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; db.RoomInstances.Add(broadcast); await db.SaveChangesAsync(); ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId; await db.SaveChangesAsync(); var first = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.Success, first.errorCode); Assert.NotNull(first.roomInstance); Assert.NotEqual(broadcast.roomInstanceId, first.roomInstance!.roomInstanceId); Assert.Equal(EventId, first.roomInstance.eventId); Assert.False(first.roomInstance.isPrivate); var second = await MatchmakingUtils.MatchmakeEvent(StrangerId, EventId); Assert.Equal(JoinStatus.Success, second.errorCode); Assert.Equal(first.roomInstance.roomInstanceId, second.roomInstance?.roomInstanceId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task AViewerIsNotPlacedWithTheBroadcasterWhenTheRegisteredInstanceIsStale() { // Re-entering the event hands the broadcaster a fresh instance and nothing re-registers // it, so the id the event still carries can name a room nobody is in. The instance the // broadcaster is actually standing in is off-limits for the same reason the registered // one is - otherwise the protection lapses the first time they walk back in. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = true; var registered = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; var standingIn = new RoomInstance { roomInstanceId = 502, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; db.RoomInstances.AddRange(registered, standingIn); await db.SaveChangesAsync(); ev.BroadcastingRoomInstanceId = registered.roomInstanceId; // The broadcaster has since re-entered the event: their presence names 502 while the // event still advertises the instance they registered, 501. db.Presences.Single(x => x.playerId == CreatorId).roomInstance = standingIn.roomInstanceId; await db.SaveChangesAsync(); var viewer = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.Success, viewer.errorCode); Assert.NotNull(viewer.roomInstance); Assert.NotEqual(registered.roomInstanceId, viewer.roomInstance!.roomInstanceId); Assert.NotEqual(standingIn.roomInstanceId, viewer.roomInstance.roomInstanceId); Assert.Equal(EventId, viewer.roomInstance.eventId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task CreatorCanJoinOwnEventLongBeforeItStarts() { // The host walks into their own event whenever they want, so the room they are // advertising is already stood up (and they can be standing in it) hours early. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db); ev.StartTime = DateTime.UtcNow.AddHours(6); ev.EndTime = DateTime.UtcNow.AddHours(7); await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.Equal(EventId, match.roomInstance?.eventId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task NonCreatorIsStillHeldToTheEventStartTime() { // The early gate is the host's alone. An invited guest who shows up before the // start time is turned away rather than dropped into an event that has not begun. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.StartTime = DateTime.UtcNow.AddHours(6); ev.EndTime = DateTime.UtcNow.AddHours(7); await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent(InviteeId, EventId); Assert.Equal(JoinStatus.EventNotStarted, match.errorCode); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task CreatorCannotJoinAnEventThatHasAlreadyFinished() { // The exemption opens the door early, not indefinitely: once the window closes the // event is closed to its own creator too. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db); ev.StartTime = DateTime.UtcNow.AddHours(-2); ev.EndTime = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId); Assert.Equal(JoinStatus.EventAlreadyFinished, match.errorCode); } finally { RestoreFactories(previousFactories); } } [Theory] [InlineData(false, 3)] [InlineData(true, 6)] public async Task EventInstanceWireTypeMatchesMultiInstanceSetting(bool multiInstance, int wireType) { var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = multiInstance; db.RoomInstances.Add(new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8, roomInstanceType = RadicadeServer.RecNet.Enums.Rooms.RoomInstanceType.Event }); await db.SaveChangesAsync(); // Reusing a room created before multi-instance was enabled must repair its type. var existing = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId); Assert.Equal(JoinStatus.Success, existing.errorCode); Assert.Equal(501, existing.roomInstance!.roomInstanceId); Assert.Equal(wireType, (int)existing.roomInstance.roomInstanceType); db.ChangeTracker.Clear(); Assert.Equal(wireType, (int)(await db.RoomInstances.SingleAsync()).roomInstanceType); // Fresh private event instances need the same type; privacy is a separate flag. var created = await MatchmakingUtils.MatchmakeEvent(CreatorId, EventId, JoinMode.PrivateNewInstance); Assert.Equal(JoinStatus.Success, created.errorCode); Assert.True(created.roomInstance!.isPrivate); var json = System.Text.Json.JsonSerializer.SerializeToElement(created.roomInstance); Assert.Equal(wireType, json.GetProperty("roomInstanceType").GetInt32()); } finally { RestoreFactories(previousFactories); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task PrivateModeOnAnEventCreatesThePlayersOwnPrivateInstance(bool sourceStillOccupied) { // PRIVATE MODE on the event screen asks for the player's own copy of the event's // room. It has to be created even while the event has a shared instance and a live // broadcast instance to hand out - and it must be neither of those, or pressing the // button would move the player into somebody else's room. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { var ev = await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); ev.IsMultiInstance = true; var shared = new RoomInstance { roomInstanceId = 501, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; var broadcast = new RoomInstance { roomInstanceId = 502, roomId = RoomId, subRoomId = 1, eventId = EventId, maxCapacity = 8 }; db.RoomInstances.AddRange(shared, broadcast); await db.SaveChangesAsync(); ev.BroadcastingRoomInstanceId = broadcast.roomInstanceId; if (sourceStillOccupied) { var presenter = await db.Presences.SingleAsync(p => p.playerId == InviteeId); presenter.isOnline = true; presenter.roomInstance = broadcast.roomInstanceId; } await db.SaveChangesAsync(); var match = await MatchmakingUtils.MatchmakeEvent( CreatorId, EventId, JoinMode.PrivateNewInstance); Assert.Equal(JoinStatus.Success, match.errorCode); Assert.NotNull(match.roomInstance); Assert.NotEqual(shared.roomInstanceId, match.roomInstance.roomInstanceId); Assert.NotEqual(broadcast.roomInstanceId, match.roomInstance.roomInstanceId); Assert.Equal(EventId, match.roomInstance.eventId); // Event rooms stay event instances; `isPrivate` is what makes this one the // player's own. Assert.True(match.roomInstance.isPrivate); // Matchmaking joined the player through its own context, so read the presence back // fresh rather than through this context's tracked copy. db.ChangeTracker.Clear(); Assert.Equal(sourceStillOccupied ? (long?)broadcast.roomInstanceId : null, (await db.PlayerEvents.SingleAsync(e => e.PlayerEventId == EventId)).BroadcastingRoomInstanceId); Assert.Equal(match.roomInstance.roomInstanceId, (await db.Presences.AsNoTracking().SingleAsync(x => x.playerId == CreatorId)).roomInstance); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task EachPlayersPrivateModeGetsItsOwnInstance() { // Two people pressing PRIVATE MODE on the same event do not end up together: every // request is a new room, so neither is handed the other's private instance. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); var host = await MatchmakingUtils.MatchmakeEvent( CreatorId, EventId, JoinMode.PrivateNewInstance); var guest = await MatchmakingUtils.MatchmakeEvent( InviteeId, EventId, JoinMode.PrivateNewInstance); Assert.Equal(JoinStatus.Success, host.errorCode); Assert.Equal(JoinStatus.Success, guest.errorCode); Assert.NotNull(host.roomInstance); Assert.NotNull(guest.roomInstance); Assert.True(host.roomInstance!.isPrivate); Assert.True(guest.roomInstance!.isPrivate); Assert.NotEqual(host.roomInstance.roomInstanceId, guest.roomInstance.roomInstanceId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task PrivateModeLeavesNoInstanceBehindForSomeoneWhoMayNotBeInTheRoom() { // The event's room is private and this player is not the host, not a co-owner and has // no invitation, so PRIVATE MODE is refused - refused before anything is created, so // a refused press does not leave an empty private instance in the room. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db, Accessibility.Public, Accessibility.Private); db.Presences.Add(new RealPresence { playerId = StrangerId, isOnline = true }); await db.SaveChangesAsync(); var before = await db.RoomInstances.CountAsync(); var match = await MatchmakingUtils.MatchmakeEvent( StrangerId, EventId, JoinMode.PrivateNewInstance); Assert.Equal(JoinStatus.RoomIsPrivate, match.errorCode); Assert.Equal(before, await db.RoomInstances.CountAsync()); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task EventRouteHonoursTheClientsPrivateMode() { // Both event routes take the same GoToRequest as every other go-to call, and the join // mode on it is what the event screen's PRIVATE MODE button sends. Without it the // route answered with a shared instance and the client waited on a private room that // was never created. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db, Accessibility.Public, Accessibility.Public); var controller = new MatchmakingAPI(db); SetUser(controller, CreatorId); var privateJoin = await controller.GoToEvent(EventId, new GoToRequest { JoinMode = JoinMode.PrivateNewInstance }); Assert.Equal(JoinStatus.Success, privateJoin!.errorCode); Assert.True(privateJoin.roomInstance!.isPrivate); // And the ordinary join is still the shared one: the private instance above is // never handed out by the plain lookup. var ordinaryJoin = await controller.GoToEvent(EventId, new GoToRequest { JoinMode = JoinMode.PublicMatchmaking }); Assert.Equal(JoinStatus.Success, ordinaryJoin!.errorCode); Assert.False(ordinaryJoin.roomInstance!.isPrivate); Assert.NotEqual(privateJoin.roomInstance.roomInstanceId, ordinaryJoin.roomInstance.roomInstanceId); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task EventParticipantsMayInviteAndAStrangerIsRefusedWithTheCreatorNamed() { // The gate used to be creator-only. The client offers the invite button to players who are // already on the event, so an attendee was answered an anonymous 403 for a press the client // had invited them to make. const long OutsiderId = 99; var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db); db.PlayerEventResponses.Add(new PlayerEventResponse { PlayerEventId = EventId, PlayerId = InviteeId, Type = EventResponses.Yes }); await db.SaveChangesAsync(); var participant = new EventsAPI(db); SetUser(participant, InviteeId); SetFormBody(participant, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={StrangerId}"); Assert.IsType(await participant.BulkInvite()); Assert.Equal(EventResponses.Pending, (await db.PlayerEventResponses .SingleAsync(x => x.PlayerEventId == EventId && x.PlayerId == StrangerId)).Type); var outsider = new EventsAPI(db); SetUser(outsider, OutsiderId); SetFormBody(outsider, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}"); var refused = Assert.IsType(await outsider.BulkInvite()); Assert.Equal(StatusCodes.Status403Forbidden, refused.StatusCode); Assert.Contains($"\"CreatorPlayerId\":{CreatorId}", JsonSerializer.Serialize(refused.Value)); } finally { RestoreFactories(previousFactories); } } [Fact] public async Task AnEventWhoseCreatorIsGoneMayBeInvitedToByAnyone() { // A wiped-and-reseeded database leaves events whose creating account no longer exists. Nobody // is left to invite for them, so refusing every caller made the event permanently unusable. var (db, previousFactories) = await CreateContextAsync(); await using var disposeDb = db; try { await SeedEventAsync(db); var orphan = await db.PlayerEvents.SingleAsync(x => x.PlayerEventId == EventId); orphan.CreatorPlayerId = 987654; await db.SaveChangesAsync(); var controller = new EventsAPI(db); SetUser(controller, StrangerId); SetFormBody(controller, $"PlayerEventId={EventId}&InvitedPlayerIds%5B%5D={InviteeId}"); Assert.IsType(await controller.BulkInvite()); } finally { RestoreFactories(previousFactories); } } }