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

277 lines
11 KiB
C#

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;
/// <summary>
/// The Clubs screen asks <c>GET clubs/club/categoryTags</c> for its categories and then fetches
/// one row per category, each on its own:
///
/// GET clubs/club/search?sort=0&amp;category=Social&amp;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 <see cref="MatchmakeInstanceRouteTests"/>:
/// 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).
/// </summary>
[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<ClubRecord>
{
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" },
},
});
/// <summary>The club names one search answered with, in the order the client would read them.</summary>
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<string>()),
})
{
var api = new ClubsAPI(db);
SetUser(api, ViewerId, $"?sort=0&category={category}&count=32");
var ok = Assert.IsType<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<ClubRecord> { club }, "Educational"));
Assert.Empty(ClubStore.FilterByCategory(new List<ClubRecord> { club }, "Social"));
}
[Fact]
public void CategoryTagsServesTheListTheClientBuildsItsRowsFrom()
{
using var db = CreateDb();
var ok = Assert.IsType<OkObjectResult>(new ClubsAPI(db).GetCategoryTags());
Assert.Equal(
new[] { "Social", "Casual", "Competitive", "Creative", "Entertainment", "Educational", "Lifestyle" },
Assert.IsType<string[]>(ok.Value));
}
/// <summary>
/// The effective URLs of one action with its class routes expanded - the addresses the client
/// asks for, rather than the action's own templates.
/// </summary>
private static string[] RoutesOf(string actionName)
{
var classRoutes = typeof(ClubsAPI).GetCustomAttributes<RouteAttribute>(inherit: false)
.Select(attribute => (attribute.Template ?? string.Empty).Trim('/'))
.ToArray();
var routes = new List<string>();
foreach (var attribute in typeof(ClubsAPI).GetMethod(actionName)!
.GetCustomAttributes<HttpMethodAttribute>(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<RouteAttribute>(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<HttpMethodAttribute>(inherit: false)
.Select(attribute => attribute.Template ?? string.Empty)
.ToArray();
Assert.DoesNotContain(templates,
template => template.StartsWith("~/clubs/", StringComparison.Ordinal) ||
template.StartsWith("~/api/clubs/", StringComparison.Ordinal));
}
}
}