mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
277 lines
11 KiB
C#
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&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 <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));
|
|
}
|
|
}
|
|
}
|