using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Matchmaking; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Rooms; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// `POST matchmaking/matchmake/instance/{instanceId}` is how the 2023 client re-enters one /// specific live instance - the private session its owner just left, picked out of /// `GET matchmaking/room/{roomId}/instances`. Radicade had no route for it at all, so the /// call 404'd and "get back into my private instance" had no path except public /// matchmaking, which hands out a different instance. /// /// The route assertion exists because a controller-level test cannot see a missing route - /// that is the failure being fixed. The rest pin the owner gate: entering one's own /// instance (and *only* one's own) has to work, and a private instance must not be refused /// on this path the way `goto/instance/{id}` refuses it for everyone else. /// [Collection(DbFactoryCollection.Name)] public class MatchmakeInstanceRouteTests { private const long RoomId = 3; private const long CreatorId = 1; private const long CoOwnerId = 2; private const long StrangerId = 9; private const long SubRoomId = 3; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; } public RecDBContext CreateDbContext() => new(options); } private static void SetUser(Controller controller, long accountId) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; } private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return (factory, factory.CreateDbContext()); } /// /// A room with one live private instance - the shape an owner's own RecCenter session /// has - plus a presence row for every account the test needs to move. /// private static async Task SeedRoomWithPrivateInstanceAsync( RecDBContext db, bool isPrivate = true, long instanceRoomId = RoomId, params long[] presences) { db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "RecCenter", CreatorAccountId = CreatorId, Accessibility = Accessibility.Public, MaxPlayers = 100, CreatedAt = DateTime.UtcNow, }); db.SubRooms.Add(new SubRoom { SubRoomId = SubRoomId, RoomId = RoomId, Name = "Home", UnitySceneId = "cbad71af-0831-44d8-b8ef-69edafa841f6", MaxPlayers = 100, Accessibility = Accessibility.Public, }); var instance = new RoomInstance { roomId = instanceRoomId, subRoomId = SubRoomId, roomInstanceType = RoomInstanceType.Private, isPrivate = isPrivate, maxCapacity = 100, name = "^RecCenter", location = "cbad71af-0831-44d8-b8ef-69edafa841f6", }; db.RoomInstances.Add(instance); foreach (var accountId in presences) { db.Presences.Add(new RealPresence { playerId = accountId, isOnline = true, roomInstance = null, errorCode = JoinStatus.Success, }); } await db.SaveChangesAsync(); return instance.roomInstanceId; } [Fact] public void SupportsTheSpecificInstanceJoinRoute() { var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.MatchmakeInstance))!; var attributes = method .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), false) .Cast() .ToList(); Assert.Contains(attributes, x => x.Template == "matchmake/instance/{instanceId}"); Assert.Contains(attributes, x => x is HttpPostAttribute); } [Fact] public async Task TheRoomCreatorCanEnterTheirOwnPrivateInstance() { var (factory, db) = CreateContext(); await using var _db = db; var instanceId = await SeedRoomWithPrivateInstanceAsync(db, presences: CreatorId); var api = new MatchmakingAPI(db); SetUser(api, CreatorId); MatchResponse? match = await api.MatchmakeInstance(instanceId, null); Assert.NotNull(match); Assert.Equal(JoinStatus.Success, match!.errorCode); Assert.NotNull(match.roomInstance); Assert.Equal(instanceId, match.roomInstance!.roomInstanceId); // A private instance is the whole point of this route: it must not come back // RoomInstanceIsPrivate the way goto/instance/{id} answers. Assert.True(match.roomInstance.isPrivate); await using var verify = factory.CreateDbContext(); var presence = await verify.Presences.FirstAsync(x => x.playerId == CreatorId); Assert.Equal(instanceId, presence.roomInstance); Assert.Equal(JoinStatus.Success, presence.errorCode); } [Fact] public async Task ACoOwnerCanEnterTheRoomsPrivateInstance() { var (factory, db) = CreateContext(); await using var _db = db; var instanceId = await SeedRoomWithPrivateInstanceAsync(db, presences: CoOwnerId); db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = CoOwnerId, Role = RoleType.CoOwner, InvitedRole = RoleType.None, }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, CoOwnerId); MatchResponse? match = await api.MatchmakeInstance(instanceId, null); Assert.NotNull(match); Assert.Equal(JoinStatus.Success, match!.errorCode); await using var verify = factory.CreateDbContext(); Assert.Equal(instanceId, (await verify.Presences.FirstAsync(x => x.playerId == CoOwnerId)).roomInstance); } [Fact] public async Task APlayerWhoDoesNotManageTheRoomGetsNoSuchRoomAndStaysPut() { var (factory, db) = CreateContext(); await using var _db = db; var instanceId = await SeedRoomWithPrivateInstanceAsync(db, presences: StrangerId); // A Moderator is not enough: the Photon room id is the join coordinate, so only // the room's creator and its co-owners may be handed it. db.RoomRoles.Add(new RoomRole { RoomId = RoomId, AccountId = StrangerId, Role = RoleType.Moderator, InvitedRole = RoleType.None, }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, StrangerId); MatchResponse? match = await api.MatchmakeInstance(instanceId, null); Assert.NotNull(match); Assert.Equal(JoinStatus.NoSuchRoom, match!.errorCode); Assert.Null(match.roomInstance); await using var verify = factory.CreateDbContext(); Assert.Null((await verify.Presences.FirstAsync(x => x.playerId == StrangerId)).roomInstance); } [Fact] public async Task AnUnknownInstanceIsNoSuchRoom() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomWithPrivateInstanceAsync(db, presences: CreatorId); var api = new MatchmakingAPI(db); SetUser(api, CreatorId); MatchResponse? match = await api.MatchmakeInstance(9876543, null); Assert.NotNull(match); Assert.Equal(JoinStatus.NoSuchRoom, match!.errorCode); Assert.Null(match.roomInstance); await using var verify = factory.CreateDbContext(); Assert.Null((await verify.Presences.FirstAsync(x => x.playerId == CreatorId)).roomInstance); } [Fact] public void SupportsTheRoomInstanceBrowserRoute() { var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.GetRoomInstancesInRoom))!; var attributes = method .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), false) .Cast() .ToList(); Assert.Contains(attributes, x => x.Template == "room/{roomId}/instances"); Assert.Contains(attributes, x => x is HttpGetAttribute); } [Fact] public async Task TheInstanceBrowserAnswersAnArrayToAPlayerWhoDoesNotModerateTheRoom() { var (factory, db) = CreateContext(); await using var _db = db; var instanceId = await SeedRoomWithPrivateInstanceAsync(db, isPrivate: false, presences: StrangerId); var presence = await db.Presences.FirstAsync(x => x.playerId == StrangerId); presence.roomInstance = instanceId; await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, StrangerId); // The browser is the watch menu every player opens for the room they are in. It used to // require RoleType.Moderator and answer null to everyone else, which serialises to 204 // No Content - the client's "Failed to get room instance browser: Response was empty". var instances = await api.GetRoomInstancesInRoom(RoomId); Assert.NotNull(instances); var summary = Assert.IsType>(Assert.Single(instances!)); Assert.Equal(instanceId, summary["roomInstanceId"]); Assert.Equal(RoomId, summary["roomId"]); Assert.Equal(new[] { StrangerId }, Assert.IsAssignableFrom>(summary["playerIds"])); } [Fact] public async Task TheInstanceBrowserAnswersAnEmptyArrayToACallerWithNoIdentity() { var (factory, db) = CreateContext(); await using var _db = db; await SeedRoomWithPrivateInstanceAsync(db, presences: CreatorId); var api = new MatchmakingAPI(db); api.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; var instances = await api.GetRoomInstancesInRoom(RoomId); Assert.NotNull(instances); Assert.Empty(instances!); } [Fact] public async Task AnInstanceWhoseRoomIsGoneIsNoSuchRoom() { var (factory, db) = CreateContext(); await using var _db = db; // The instance survives a deleted room: it must not be entered through a room // row that no longer exists (its subroom blob is unreachable anyway). var instanceId = await SeedRoomWithPrivateInstanceAsync( db, instanceRoomId: 4242, presences: CreatorId); var api = new MatchmakingAPI(db); SetUser(api, CreatorId); MatchResponse? match = await api.MatchmakeInstance(instanceId, null); Assert.NotNull(match); Assert.Equal(JoinStatus.NoSuchRoom, match!.errorCode); } }