using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Accounts; using RadicadeServer.API.Events; using RadicadeServer.API.Relationships.V1; using RadicadeServer.API; using RadicadeServer.Clubs; using RadicadeServer.DB; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Events; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Relationships; using Xunit; namespace RadicadeServer.Tests; /// /// The routes a single player report named, each of which failed in a different way: /// a missing route (404), a route registered under another verb (405), and a handler /// that refused a request whose id arrived somewhere it did not look (400). /// /// The tests below pin the behaviour rather than the implementation: a request of the /// shape the client sends has to reach a handler, find its record, and act on it. /// public class PlayerReportedRouteTests { private const long CallerId = 42; private const long OtherId = 77; private static RecDBContext CreateDb() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new RecDBContext(options); } private static T CreateController(RecDBContext db, long? userId = CallerId) where T : Controller { var identity = new ClaimsIdentity("TestAuth"); if (userId.HasValue) identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId.Value.ToString())); var controller = (T)Activator.CreateInstance(typeof(T), db)!; controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; return controller; } private static void SetJsonBody(Controller 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 ChatThread ThreadWith(params long[] playerIds) => new() { chatThreadName = "", messages = "[]", playerIds = JsonSerializer.Serialize(playerIds) }; // ---- POST /chat/thread/party -> 405 ---------------------------------------------------- [Fact] public void PartyThreadIsRegisteredForBothGetAndPost() { var attributes = typeof(ThreadAPI).GetMethod(nameof(ThreadAPI.GetPartyThread))! .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true) .Cast() .ToList(); // The client's party lookup POSTs; with only the GET registered that is a 405, // which reads in the log as the party simply never loading. Assert.Contains(attributes, a => a.HttpMethods.Contains("GET")); Assert.Contains(attributes, a => a.HttpMethods.Contains("POST")); // And both verbs answer the same empty list, which is the "no party" spelling. var controller = CreateController(CreateDb()); var result = Assert.IsType(controller.GetPartyThread()); Assert.Empty(Assert.IsAssignableFrom(result.Value).Cast()); } // ---- PUT /chat/thread/1/favorite -> 404 ------------------------------------------------ [Fact] public async Task FavoriteThreadRouteExistsAndIsReportedOnTheThread() { // The route the star uses. It had no attribute at all, so the call 404ed. var methods = typeof(ThreadAPI).GetMethod(nameof(ThreadAPI.FavoriteThread))! .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true) .Cast() .SelectMany(a => a.HttpMethods) .ToList(); Assert.Contains("PUT", methods); using var db = CreateDb(); var thread = ThreadWith(CallerId, OtherId); db.Chats.Add(thread); await db.SaveChangesAsync(); var controller = CreateController(db); SetJsonBody(controller, """{ "isFavorited": true }"""); Assert.IsType(await controller.FavoriteThread(thread.chatThreadId)); var stored = await db.Chats.SingleAsync(x => x.chatThreadId == thread.chatThreadId); Assert.Contains(CallerId, stored.GetFavoritedBy()); // The star has to show up on the read that renders the conversation, or the button // never lights even though the write worked. var read = await controller.GetThreadById(thread.chatThreadId); using var doc = JsonDocument.Parse(JsonSerializer.Serialize( Assert.IsType(read).Value)); Assert.True(doc.RootElement.GetProperty("isFavorited").GetBoolean()); } [Fact] public async Task UnfavoritingClearsTheStarAndLeavesTheOtherMemberAlone() { using var db = CreateDb(); var thread = ThreadWith(CallerId, OtherId); thread.SetFavoritedBy([CallerId, OtherId]); db.Chats.Add(thread); await db.SaveChangesAsync(); var controller = CreateController(db); controller.HttpContext.Request.Method = "DELETE"; Assert.IsType(await controller.FavoriteThread(thread.chatThreadId)); var stored = await db.Chats.SingleAsync(x => x.chatThreadId == thread.chatThreadId); Assert.DoesNotContain(CallerId, stored.GetFavoritedBy()); Assert.Contains(OtherId, stored.GetFavoritedBy()); } // ---- POST /api/relationships/v1/ignore and /mute -> 400 -------------------------------- [Fact] public async Task IgnoreAndMuteAcceptThePlayerIdFromTheBody() { using var db = CreateDb(); var controller = CreateController(db); // The client posts the target in the body; the handler bound `id` from the route // only, so this resolved to 0 and answered "invalid player id" (400). SetJsonBody(controller, $$"""{ "playerId": {{OtherId}} }"""); Assert.IsType(await controller.Ignore()); Assert.IsType(await controller.Mute()); var mine = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId); Assert.NotEqual(ReciprocalStatus.None, mine.Ignored); Assert.NotEqual(ReciprocalStatus.None, mine.Muted); } [Fact] public async Task RelationshipFlagsStillReadTheIdFromTheRoute() { using var db = CreateDb(); var controller = CreateController(db); // The `ignore/{id}` spelling binds the id into the action's own route data. controller.ControllerContext.RouteData = new Microsoft.AspNetCore.Routing.RouteData(); controller.ControllerContext.RouteData.Values["id"] = OtherId.ToString(); Assert.IsType(await controller.Ignore()); var mine = await db.Relationships.SingleAsync(x => x.AccountId == CallerId && x.PlayerId == OtherId); Assert.NotEqual(ReciprocalStatus.None, mine.Ignored); } [Fact] public async Task RelationshipFlagWithoutAnyIdIsStillRefused() { using var db = CreateDb(); var controller = CreateController(db); Assert.IsType(await controller.Ignore()); } // ---- PUT /clubs/club/1783155111/members/invite -> 400 ---------------------------------- [Fact] public void NamedMembershipTypeIsAcceptedByTheInvite() { // The invite screen sends the tier by NAME ("Member"/"Moderator"/"Coowner"). // Reading only the number made the whole invite 400 with "Invalid membershipType" // even though the account id was fine. var parse = typeof(ClubsAPI).GetMethod("ParseInviteMembershipType", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; Assert.Equal(ClubMembershipTypes.Member, (int)parse.Invoke(null, ["Member"])!); Assert.Equal(ClubMembershipTypes.Moderator, (int)parse.Invoke(null, ["moderator"])!); Assert.Equal(ClubMembershipTypes.Coowner, (int)parse.Invoke(null, ["CoOwner"])!); // The numeric spelling still works, and anything unknown is still refused. Assert.Equal(ClubMembershipTypes.Moderator, (int)parse.Invoke(null, ["20"])!); Assert.Equal(-1, (int)parse.Invoke(null, ["Banned"])!); } // ---- POST /api/playerevents/v2/delete/1 -> 404 ----------------------------------------- [Fact] public async Task EventDeleteReadsTheIdFromTheBodyWhenItIsNotInTheRoute() { using var db = CreateDb(); db.PlayerEvents.Add(new PlayerEventDB { PlayerEventId = 1, Name = "Doomed", CreatorPlayerId = CallerId }); await db.SaveChangesAsync(); var controller = CreateController(db); // The delete screen posts the id in the body. With the route-only binding this // resolved to 0 and 404ed for an event that existed. SetJsonBody(controller, """{ "PlayerEventId": 1 }"""); Assert.IsType(await controller.DeleteEventV2()); Assert.Empty(await db.PlayerEvents.ToListAsync()); } [Fact] public async Task EventDeleteOfAMissingEventAnswersSuccessSoAStaleCardCanBeCleared() { // The client drops an event card only when the PlayerEventDeleted (82) frame reaches it, // so a card left behind by a frame it could not read is retried as another delete. // Answering that retry with the result envelope - rather than a 404 it can only render as // an error - is what lets the card finally go away. An event that is somebody else's is // still refused; see the test below. using var db = CreateDb(); var controller = CreateController(db); SetJsonBody(controller, """{ "PlayerEventId": 999 }"""); var result = Assert.IsType(await controller.DeleteEventV2()); using var document = JsonDocument.Parse(JsonSerializer.Serialize(result.Value)); Assert.Equal(999, document.RootElement.GetProperty("PlayerEvent") .GetProperty("PlayerEventId").GetInt64()); Assert.Equal(0, document.RootElement.GetProperty("Result").GetInt32()); Assert.Empty(await db.PlayerEvents.ToListAsync()); } [Fact] public async Task EventDeleteStillRefusesSomeoneElsesEvent() { using var db = CreateDb(); db.PlayerEvents.Add(new PlayerEventDB { PlayerEventId = 1, Name = "Not yours", CreatorPlayerId = OtherId }); await db.SaveChangesAsync(); var controller = CreateController(db, CallerId); Assert.IsType(await controller.DeleteEventV2(1)); } // ---- PUT /accounts/account/me/emoji -> 404 -------------------------------------------- [Fact] public async Task AccountEmojiRouteExistsAndPersists() { var methods = typeof(AccountsAPI).GetMethod(nameof(AccountsAPI.SetDisplayEmoji))! .GetCustomAttributes(typeof(Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute), true) .Cast() .SelectMany(a => a.HttpMethods) .ToList(); Assert.Contains("PUT", methods); using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = CallerId, username = "tester", displayName = "Tester", profileImage = "" }); db.Players.Add(new PlayerData { AccountId = CallerId, Hashword = "x" }); await db.SaveChangesAsync(); var controller = CreateController(db); SetJsonBody(controller, """{ "displayEmoji": "😀" }"""); var answer = await controller.SetDisplayEmoji(); Assert.NotNull(answer); Assert.Equal("😀", answer!.displayEmoji); Assert.Equal("😀", (await db.Profiles.SingleAsync(x => x.accountId == CallerId)).displayEmoji); // An empty value clears it rather than storing an empty string. SetJsonBody(controller, """{ "displayEmoji": "" }"""); var cleared = await controller.SetDisplayEmoji(); Assert.Null(cleared!.displayEmoji); Assert.Null((await db.Profiles.SingleAsync(x => x.accountId == CallerId)).displayEmoji); } [Fact] public async Task AccountEmojiIsReportedOnTheAccountTheClientReads() { using var db = CreateDb(); db.Profiles.Add(new Profile { accountId = CallerId, username = "tester", displayName = "Tester", profileImage = "", displayEmoji = "🔥" }); db.Players.Add(new PlayerData { AccountId = CallerId, Hashword = "x" }); await db.SaveChangesAsync(); var controller = CreateController(db); var account = await controller.GetProfile(); Assert.Equal("🔥", account!.displayEmoji); } }