Files
Radicade/ServerTests/PlayerReportedRouteTests.cs
T
2026-10-16 23:33:13 -07:00

299 lines
13 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<T>(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<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
.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<ThreadAPI>(CreateDb());
var result = Assert.IsType<OkObjectResult>(controller.GetPartyThread());
Assert.Empty(Assert.IsAssignableFrom<System.Collections.IEnumerable>(result.Value).Cast<object>());
}
// ---- 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<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
.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<ThreadAPI>(db);
SetJsonBody(controller, """{ "isFavorited": true }""");
Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<ThreadAPI>(db);
controller.HttpContext.Request.Method = "DELETE";
Assert.IsType<OkObjectResult>(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<RelationshipsV1>(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<OkObjectResult>(await controller.Ignore());
Assert.IsType<OkObjectResult>(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<RelationshipsV1>(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<OkObjectResult>(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<RelationshipsV1>(db);
Assert.IsType<BadRequestObjectResult>(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<EventsAPI>(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<OkObjectResult>(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<EventsAPI>(db);
SetJsonBody(controller, """{ "PlayerEventId": 999 }""");
var result = Assert.IsType<OkObjectResult>(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<EventsAPI>(db, CallerId);
Assert.IsType<ForbidResult>(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<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>()
.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<AccountsAPI>(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<AccountsAPI>(db);
var account = await controller.GetProfile();
Assert.Equal("🔥", account!.displayEmoji);
}
}