using System.Reflection; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.EntityFrameworkCore; using RadicadeServer.Clubs; using RadicadeServer.DB; using Xunit; namespace RadicadeServer.Tests; /// /// The Clubs screen asks GET clubs/club/categoryTags for its categories and then fetches /// one row per category, each on its own: /// /// GET clubs/club/search?sort=0&category=Social&count=32 /// /// Every one of those requests used to come back with the whole club catalogue, so a single club /// was drawn in every row - the "clubs are appearing in every single category" report. The /// category named in the request is the only thing that separates the rows. /// /// The route assertions are here for the same reason as in : /// a controller-level test calls the handler it wants by name, so it cannot see that the URL the /// client actually uses has no endpoint at all (the club invitation accept that answered 405). /// [Collection(DbFactoryCollection.Name)] public class ClubCategoryDiscoveryTests { private const long ViewerId = 700; private const long CreatorId = 701; private const long EducationalClubId = 702; private const long EntertainmentClubId = 703; private const long CustomTagClubId = 704; private static RecDBContext CreateDb() => new(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static void SetUser(Controller controller, long accountId, string queryString = "") { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString())); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; context.Request.QueryString = new QueryString(queryString); controller.ControllerContext = new ControllerContext { HttpContext = context }; } private static async Task SeedClubsAsync(RecDBContext db) => await ClubStore.SaveAsync(db, new List { new() { clubId = EducationalClubId, ownerPlayerId = CreatorId, name = "Science club", primaryTag = "Educational", }, new() { clubId = EntertainmentClubId, ownerPlayerId = CreatorId, name = "Movie club", primaryTag = "Entertainment", }, new() { clubId = CustomTagClubId, ownerPlayerId = CreatorId, name = "Chess club", primaryTag = "Social", customTags = new[] { "Educational" }, }, }); /// The club names one search answered with, in the order the client would read them. private static string[] ClubNamesOf(object response) { using var document = JsonDocument.Parse(JsonSerializer.Serialize(response)); return document.RootElement.GetProperty("Clubs").EnumerateArray() .Select(club => club.GetProperty("Name").GetString() ?? string.Empty) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); } private static int TotalClubsOf(object response) { using var document = JsonDocument.Parse(JsonSerializer.Serialize(response)); return document.RootElement.GetProperty("TotalClubs").GetInt32(); } [Fact] public async Task EveryCategoryRowGetsOnlyItsOwnClubs() { await using var db = CreateDb(); await SeedClubsAsync(db); foreach (var (category, expected) in new[] { // A custom tag is enough to put "Chess club" in the Educational row as well as Social. ("Social", new[] { "Chess club" }), ("Educational", new[] { "Chess club", "Science club" }), ("Entertainment", new[] { "Movie club" }), ("Creative", Array.Empty()), }) { var api = new ClubsAPI(db); SetUser(api, ViewerId, $"?sort=0&category={category}&count=32"); var ok = Assert.IsType(await api.SearchClubs()); var expectedInOrder = expected.OrderBy(name => name, StringComparer.Ordinal).ToArray(); Assert.Equal(expectedInOrder, ClubNamesOf(ok.Value!)); Assert.Equal(expectedInOrder.Length, TotalClubsOf(ok.Value!)); } } [Fact] public async Task ASearchWithoutACategoryStillReturnsTheWholeCatalogue() { await using var db = CreateDb(); await SeedClubsAsync(db); var api = new ClubsAPI(db); SetUser(api, ViewerId, "?sort=0&count=32"); var ok = Assert.IsType(await api.SearchClubs()); Assert.Equal(3, TotalClubsOf(ok.Value!)); } [Fact] public async Task ABoundCategoryParameterIsHonouredWhenTheQueryStringIsNotTheCarrier() { await using var db = CreateDb(); await SeedClubsAsync(db); var api = new ClubsAPI(db); SetUser(api, ViewerId); var ok = Assert.IsType(await api.SearchClubs(category: "Entertainment")); Assert.Equal(new[] { "Movie club" }, ClubNamesOf(ok.Value!)); } [Fact] public async Task TheMostActiveRowsFilterTheSameWayTheDiscoveryRowsDo() { await using var db = CreateDb(); await SeedClubsAsync(db); var today = new ClubsAPI(db); SetUser(today, ViewerId, "?category=Educational"); var todayOk = Assert.IsType(await today.GetMostActiveToday()); Assert.Equal(2, JsonSerializer.SerializeToElement(todayOk.Value).GetArrayLength()); var now = new ClubsAPI(db); SetUser(now, ViewerId, "?category=Showcase"); var nowOk = Assert.IsType(await now.GetMostActiveNow()); Assert.Empty(JsonSerializer.SerializeToElement(nowOk.Value).EnumerateArray()); } [Fact] public void CategoryMatchingIsCaseInsensitiveAndBlankMeansEverything() { var club = new ClubRecord { primaryTag = "Educational", customTags = new[] { "Science" } }; Assert.True(ClubStore.MatchesCategory(club, "educational")); Assert.True(ClubStore.MatchesCategory(club, " #Science ")); Assert.False(ClubStore.MatchesCategory(club, "#Social")); Assert.True(ClubStore.MatchesCategory(club, null)); Assert.True(ClubStore.MatchesCategory(club, " ")); Assert.False(ClubStore.MatchesCategory(null, "Educational")); Assert.Single(ClubStore.FilterByCategory(new List { club }, "Educational")); Assert.Empty(ClubStore.FilterByCategory(new List { club }, "Social")); } [Fact] public void CategoryTagsServesTheListTheClientBuildsItsRowsFrom() { using var db = CreateDb(); var ok = Assert.IsType(new ClubsAPI(db).GetCategoryTags()); Assert.Equal( new[] { "Social", "Casual", "Competitive", "Creative", "Entertainment", "Educational", "Lifestyle" }, Assert.IsType(ok.Value)); } /// /// The effective URLs of one action with its class routes expanded - the addresses the client /// asks for, rather than the action's own templates. /// private static string[] RoutesOf(string actionName) { var classRoutes = typeof(ClubsAPI).GetCustomAttributes(inherit: false) .Select(attribute => (attribute.Template ?? string.Empty).Trim('/')) .ToArray(); var routes = new List(); foreach (var attribute in typeof(ClubsAPI).GetMethod(actionName)! .GetCustomAttributes(inherit: false)) { var template = attribute.Template ?? string.Empty; if (template.StartsWith("~/", StringComparison.Ordinal)) { routes.AddRange(attribute.HttpMethods.Select(verb => $"{verb} /{template[2..]}")); continue; } routes.AddRange(from prefix in classRoutes from verb in attribute.HttpMethods select $"{verb} /{prefix}/{template.TrimStart('/')}"); } return routes.ToArray(); } [Fact] public void TheClientMemberActionsAreRegisteredAtTheUrlsItCalls() { Assert.Equal(new[] { "clubs/club", "api/clubs/v1/club", "api/clubs/v2/club" }, typeof(ClubsAPI).GetCustomAttributes(inherit: false) .Select(attribute => attribute.Template).ToArray()); var routes = new[] { "AcceptClubInvitation", "DeclineClubInvitation", "InviteMember", "RemoveClubMember", "SetMinimumLevel", "ChangeMemberType", }.SelectMany(RoutesOf).ToArray(); foreach (var expected in new[] { // The invitation a profile sends, and the accept the invitee clicks: the accept // answered 405 because only .../members/accept was registered, never .../acceptinvite. "PUT /clubs/club/{clubId:long}/members/invite", "POST /clubs/club/{clubId:long}/members/invitemembers", "POST /clubs/club/{clubId:long}/members/acceptinvite", "PUT /clubs/club/{clubId:long}/members/acceptinvite", "POST /clubs/club/{clubId:long}/members/declineinvite", "POST /clubs/club/{clubId:long}/members/remove", "DELETE /clubs/club/{clubId:long}/members/remove", "PUT /clubs/club/{clubId:long}/minlevel", "POST /clubs/club/{clubId:long}/members/changetype", }) { Assert.Contains(expected, routes); Assert.Single(routes, route => route == expected); } } [Fact] public void NoClubMemberActionRegistersAnAbsoluteCopyOfItsClassRoute() { // "~/clubs/club/{clubId}/members/remove" next to the class-relative // "{clubId}/members/remove" under [Route("clubs/club")] resolved to the same path twice, so // ASP.NET threw AmbiguousMatchException (500) for every call to it. The absolute // "~/club/..." aliases are a different path and stay. foreach (var action in new[] { "AcceptClubInvitation", "DeclineClubInvitation", "InviteMember", "RemoveClubMember", "SetMinimumLevel", "ChangeMemberType", }) { var templates = typeof(ClubsAPI).GetMethod(action)! .GetCustomAttributes(inherit: false) .Select(attribute => attribute.Template ?? string.Empty) .ToArray(); Assert.DoesNotContain(templates, template => template.StartsWith("~/clubs/", StringComparison.Ordinal) || template.StartsWith("~/api/clubs/", StringComparison.Ordinal)); } } }