using System.Reflection; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Matchmaking; using RadicadeServer.Clubs; 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 System.Text.Json; using Xunit; namespace RadicadeServer.Tests; [Collection(DbFactoryCollection.Name)] public class ClubMatchmakingRouteTests { private const long CallerId = 101; private const long FriendId = 202; private const long StrangerId = 303; private const long RoomId = 404; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; public RecDBContext CreateDbContext() => new(options); } private static (TestDbContextFactory Factory, RecDBContext Db) CreateContext() { var factory = new TestDbContextFactory(); RoomUtils.DbFactory = factory; MatchmakingUtils.DbFactory = factory; return (factory, factory.CreateDbContext()); } 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 void SetFormBody(Controller controller, string body) { var request = controller.HttpContext.Request; request.ContentType = "application/x-www-form-urlencoded"; request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(body)); request.ContentLength = request.Body.Length; } [Fact] public void SupportsLegacyAndNewWatchClubJoinAliases() { var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.GoToClub))!; var routes = method.GetCustomAttributes(typeof(HttpMethodAttribute), false) .Cast() .Select(x => x.Template ?? string.Empty) .ToList(); Assert.Contains("goto/club/{clubId}", routes); Assert.Contains("matchmake/club/{clubId}", routes); } [Fact] public void RegistersFriendFollowingRoute() { var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.MatchmakePlayer))!; var routes = method.GetCustomAttributes(typeof(HttpPostAttribute), false) .Cast() .Select(attribute => attribute.Template); Assert.Contains("matchmake/player/{targetId:long}", routes); } [Fact] public async Task FollowRequiresFriendshipAndEntersTheFriendsCurrentInstance() { var (factory, db) = CreateContext(); await using var _db = db; db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "Friend Room", CreatorAccountId = FriendId, Accessibility = Accessibility.Public, CreatedAt = DateTime.UtcNow, }); var instance = new RoomInstance { roomId = RoomId, subRoomId = 1, roomInstanceType = RoomInstanceType.Public, maxCapacity = 10, photonRoomId = "friend-photon-room", }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CallerId }, new RealPresence { playerId = FriendId, isOnline = true, roomInstance = instance.roomInstanceId }, new RealPresence { playerId = StrangerId, isOnline = true, roomInstance = instance.roomInstanceId }); db.Relationships.Add(new RadicadeServer.RecNet.Relationships.RealRelationship { AccountId = FriendId, PlayerId = CallerId, RelationshipType = RadicadeServer.RecNet.Relationships.RelationshipType.Friend, }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, CallerId); var followed = await api.MatchmakePlayer(FriendId, null); Assert.NotNull(followed); Assert.Equal(JoinStatus.Success, followed!.errorCode); Assert.Equal(instance.roomInstanceId, followed.roomInstance?.roomInstanceId); Assert.Equal("friend-photon-room", followed.roomInstance?.photonRoomId); await using var verify = factory.CreateDbContext(); Assert.Equal(instance.roomInstanceId, (await verify.Presences.SingleAsync(x => x.playerId == CallerId)).roomInstance); var nonFriendApi = new MatchmakingAPI(verify); SetUser(nonFriendApi, CallerId); var refused = await nonFriendApi.MatchmakePlayer(StrangerId, null); Assert.NotNull(refused); Assert.Equal(JoinStatus.NoSuchRoom, refused!.errorCode); Assert.Null(refused.roomInstance); var selfFollow = await nonFriendApi.MatchmakePlayer(CallerId, null); Assert.NotNull(selfFollow); Assert.Equal(JoinStatus.NoSuchRoom, selfFollow!.errorCode); Assert.Null(selfFollow.roomInstance); } [Fact] public async Task FollowRefusesRoomBans() { var (factory, db) = CreateContext(); await using var _db = db; db.Rooms.Add(new RoomData { RoomId = RoomId, Name = "Friend Room", CreatorAccountId = FriendId, Accessibility = Accessibility.Public, CreatedAt = DateTime.UtcNow, }); var instance = new RoomInstance { roomId = RoomId, maxCapacity = 10 }; db.RoomInstances.Add(instance); db.Presences.AddRange( new RealPresence { playerId = CallerId }, new RealPresence { playerId = FriendId, isOnline = true, roomInstance = instance.roomInstanceId }); db.Relationships.Add(new RadicadeServer.RecNet.Relationships.RealRelationship { AccountId = CallerId, PlayerId = FriendId, RelationshipType = RadicadeServer.RecNet.Relationships.RelationshipType.Friend, }); db.RoomBans.Add(new RoomBan { roomId = RoomId, bannedPlayerId = CallerId, actorId = FriendId }); await db.SaveChangesAsync(); var api = new MatchmakingAPI(db); SetUser(api, CallerId); var refused = await api.MatchmakePlayer(FriendId, null); Assert.NotNull(refused); Assert.Equal(JoinStatus.BannedFromRoom, refused!.errorCode); Assert.Null(refused.roomInstance); await using var verify = factory.CreateDbContext(); Assert.Null((await verify.Presences.SingleAsync(x => x.playerId == CallerId)).roomInstance); } [Fact] public async Task ClubOwnerIsCreatorMemberAndLegacyClubRoutesAreRegistered() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); var club = await ClubStore.CreateAsync(db, CallerId, "My club", "Description"); var creator = Assert.Single(ClubStore.GetMembers(club), member => member.AccountId == CallerId); Assert.Equal(ClubMembershipTypes.Creator, creator.MembershipType); Assert.Equal(club.createdAt, creator.CreatedAt); var leave = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.LeaveClub))!; var leaveRoutes = leave.GetCustomAttributes() .Select(attribute => attribute.Template) .ToArray(); Assert.Contains("{clubId:long}/members/leave", leaveRoutes); Assert.DoesNotContain("~/clubs/club/{clubId:long}/members/leave", leaveRoutes); var minLevel = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.SetMinimumLevel))!; var minLevelRoutes = minLevel.GetCustomAttributes() .Select(attribute => attribute.Template) .ToArray(); Assert.Contains("{clubId:long}/minlevel", minLevelRoutes); // The class route "clubs/club" already answers clubs/club/{clubId}/minlevel. The absolute // copy of that same path registered the template twice, so every call to it threw // AmbiguousMatchException (500) instead of reaching the handler. Assert.DoesNotContain("~/clubs/club/{clubId:long}/minlevel", minLevelRoutes); await ClubStore.SetMemberTypeAsync(db, club.clubId, FriendId, ClubMembershipTypes.Member, CallerId); var leaveApi = new ClubsAPI(db); SetUser(leaveApi, FriendId); var left = Assert.IsType(await leaveApi.LeaveClub(club.clubId)); Assert.NotNull(left.Value); Assert.DoesNotContain(ClubStore.GetMembers(await ClubStore.GetAsync(db, club.clubId)), member => member.AccountId == FriendId); var creatorLeave = new ClubsAPI(db); SetUser(creatorLeave, CallerId); Assert.IsType(await creatorLeave.LeaveClub(club.clubId)); Assert.Contains(ClubStore.GetMembers(await ClubStore.GetAsync(db, club.clubId)), member => member.AccountId == CallerId && member.MembershipType == ClubMembershipTypes.Creator); } [Fact] public async Task ClubMainImageCanBeReadAndUpdatedByCoowners() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); var club = await ClubStore.CreateAsync(db, CallerId, "Image club", "Description"); var api = new ClubsAPI(db); SetUser(api, CallerId); var read = Assert.IsType(await api.GetClubMainImage(club.clubId)); using (var initial = JsonDocument.Parse(JsonSerializer.Serialize(read.Value))) { Assert.Equal("DefaultImgPurple", initial.RootElement.GetProperty("value") .GetProperty("Club").GetProperty("MainImageName").GetString()); } SetFormBody(api, "imageName=2026-07-12%2Fclub.jpg"); var updated = Assert.IsType(await api.SetClubMainImage(club.clubId)); using (var response = JsonDocument.Parse(JsonSerializer.Serialize(updated.Value))) { Assert.Equal("2026-07-12/club.jpg", response.RootElement.GetProperty("value") .GetProperty("Club").GetProperty("MainImageName").GetString()); } var reread = Assert.IsType(await api.GetClubMainImage(club.clubId)); using (var response = JsonDocument.Parse(JsonSerializer.Serialize(reread.Value))) { Assert.Equal("2026-07-12/club.jpg", response.RootElement.GetProperty("value") .GetProperty("Club").GetProperty("MainImageName").GetString()); } var missingImageApi = new ClubsAPI(db); SetUser(missingImageApi, CallerId); SetFormBody(missingImageApi, ""); Assert.IsType(await missingImageApi.SetClubMainImage(club.clubId)); var memberApi = new ClubsAPI(db); SetUser(memberApi, FriendId); SetFormBody(memberApi, "imageName=not-allowed.jpg"); var forbidden = Assert.IsType(await memberApi.SetClubMainImage(club.clubId)); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); var getRouteTemplates = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.GetClubMainImage))! .GetCustomAttributes().Select(attribute => attribute.Template).ToArray(); Assert.Contains("{clubId:long}/mainimage", getRouteTemplates); Assert.Contains("~/club/{clubId:long}/mainimage", getRouteTemplates); var putRouteTemplates = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.SetClubMainImage))! .GetCustomAttributes().Select(attribute => attribute.Template).ToArray(); Assert.Contains("{clubId:long}/mainimage", putRouteTemplates); Assert.Contains("~/club/{clubId:long}/mainimage", putRouteTemplates); } [Fact] public async Task ClubHomeAndSubscriptionShapesMatchTheReferenceContracts() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); var club = await ClubStore.CreateAsync(db, CallerId, "Home club", "Description"); await ClubStore.SetClubhouseAsync(db, club.clubId, RoomId); await ClubStore.SetHomeClubAsync(db, CallerId, club.clubId); var home = await ClubStore.GetHomeClubAsync(db, CallerId); Assert.Equal(club.clubId, home?.clubId); using var clubJson = JsonDocument.Parse(JsonSerializer.Serialize(ClubStore.BuildClub(home, CallerId))); Assert.Equal(JsonValueKind.Number, clubJson.RootElement.GetProperty("ClubId").ValueKind); Assert.False(clubJson.RootElement.TryGetProperty("isOwner", out _)); Assert.False(clubJson.RootElement.TryGetProperty("isMember", out _)); Assert.False(clubJson.RootElement.TryGetProperty("createdAt", out _)); var expectedClubKeys = new[] { "ClubId", "Name", "Description", "Category", "Visibility", "Joinability", "AllowJuniors", "MainImageName", "ClubType", "ClubhouseRoomId", "CreatorAccountId", "IsRRO", "MinLevel", "State", "MemberCount", }; Assert.Equal(expectedClubKeys.OrderBy(key => key, StringComparer.Ordinal), clubJson.RootElement.EnumerateObject().Select(property => property.Name).OrderBy(key => key, StringComparer.Ordinal)); club.members.Add(new ClubMemberRecord { accountId = StrangerId, membershipType = ClubMembershipTypes.PendingRequested, inviterAccountId = CallerId, }); var clubMembers = ClubStore.GetMembers(club); Assert.Contains(clubMembers, member => member.AccountId == StrangerId && member.MembershipType == ClubMembershipTypes.PendingRequested); var requestedMember = Assert.Single(clubMembers, member => member.AccountId == StrangerId); using var memberJson = JsonDocument.Parse(JsonSerializer.Serialize( ClubStore.BuildMemberListItem(requestedMember, club.clubId))); var expectedMemberKeys = new[] { "ClubMemberId", "ClubId", "AccountId", "MembershipType", "CreatedAt" }; Assert.Equal(expectedMemberKeys.OrderBy(key => key, StringComparer.Ordinal), memberJson.RootElement.EnumerateObject().Select(property => property.Name).OrderBy(key => key, StringComparer.Ordinal)); var membersAction = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.GetClubMembers))!; var memberRoutes = membersAction.GetCustomAttributes() .Select(attribute => attribute.Template); Assert.Contains("~/club/{clubId:long}/members", memberRoutes); using var subscription = JsonDocument.Parse(JsonSerializer.Serialize(ClubStore.BuildSubscriptionEnvelope(null, CallerId))); Assert.False(subscription.RootElement.GetProperty("success").GetBoolean()); Assert.Equal(JsonValueKind.Null, subscription.RootElement.GetProperty("value").ValueKind); var accountRoutes = typeof(ClubsAPI).GetMethod(nameof(ClubsAPI.GetClubsForAccount))! .GetCustomAttributes(typeof(HttpGetAttribute), false) .Cast() .Select(attribute => attribute.Template); Assert.Contains("~/clubs/account/{accountId:long}/clubs", accountRoutes); } }