diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs index 7d8ae73ac..24d197b2a 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiAuthenticationError.cs @@ -8,7 +8,10 @@ public class ApiAuthenticationError : ApiError public const string NoPermissionsForCreationWhen = "You lack the permissions to create this type of resource."; public static readonly ApiAuthenticationError NoPermissionsForCreation = new(NoPermissionsForCreationWhen); - + + public const string NotAuthenticatedWhen = "You are not authenticated."; + public static readonly ApiAuthenticationError NotAuthenticated = new(NotAuthenticatedWhen); + public bool Warning { get; init; } public ApiAuthenticationError(string message, bool warning = false) : base(message, Forbidden) diff --git a/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs new file mode 100644 index 000000000..95fd7ada9 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/CategoryApiEndpoints.cs @@ -0,0 +1,134 @@ +using AttribDoc.Attributes; +using Bunkum.Core; +using Bunkum.Core.Endpoints; +using Bunkum.Core.Responses; +using Bunkum.Listener.Protocol; +using Refresh.Core.Configuration; +using Refresh.Core.Types.Categories; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; +using Refresh.Database.Query; +using Refresh.Interfaces.APIv3.Documentation.Attributes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; +using Refresh.Interfaces.APIv3.Extensions; + +namespace Refresh.Interfaces.APIv3.Endpoints; + +public class CategoryApiEndpoints : EndpointGroup +{ + [ApiV3Endpoint("levels"), Authentication(false)] + [ClientCacheResponse(1800)] // cache for half an hour + [DocSummary("Retrieves a list of categories you can use to search levels")] + [DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")] + [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] + public ApiListResponse GetLevelCategories(RequestContext context, CategoryService categories, + DataContext dataContext) + { + bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); + if (!result) return ApiValidationError.BooleanParseError; + + IEnumerable resp; + + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext); + else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext); + + return new ApiListResponse(resp); + } + + [ApiV3Endpoint("levels/{route}"), Authentication(false)] + [DocSummary("Retrieves a list of levels from a category")] + [DocError(typeof(ApiNotFoundError), "The level category cannot be found")] + [DocUsesPageData] + [DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")] + [DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")] + [DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")] + [DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " + + "the user with this username instead of your own. Optional.")] + public ApiListResponse GetLevels(RequestContext context, CategoryService categories, GameUser? user, + [DocSummary("The name of the category you'd like to retrieve levels from. " + + "Make a request to /levels to see a list of available categories")] + string route, DataContext dataContext) + { + if (string.IsNullOrWhiteSpace(route)) + { + return new ApiError("You didn't specify a route. " + + "You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound); + } + + (int skip, int count) = context.GetPageData(); + + DatabaseList? list = categories.LevelCategories + .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? + .Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user); + + if (list == null) return ApiNotFoundError.Instance; + + DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); + return levels; + } + + [ApiV3Endpoint("users"), Authentication(false)] + [ClientCacheResponse(1800)] // cache for half an hour + [DocSummary("Retrieves a list of categories you can use to search users. Returns an empty list if the instance doesn't allow showing online users.")] + [DocQueryParam("includePreviews", "If true, a single user will be added to each category representing a user from that category. False by default.")] + [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] + public ApiListResponse GetUserCategories(RequestContext context, CategoryService categories, + DataContext dataContext, GameServerConfig config) + { + bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); + if (!result) return ApiValidationError.BooleanParseError; + + if (!config.PermitShowingOnlineUsers) return new ApiListResponse([]); + IEnumerable resp; + + if (includePreviews) resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, context, dataContext); + else resp = ApiUserCategoryResponse.FromOldList(categories.UserCategories, dataContext); + + return new ApiListResponse(resp); + } + + [ApiV3Endpoint("users/{route}"), Authentication(false)] + [DocSummary("Retrieves a list of users from a category.")] + [DocError(typeof(ApiNotFoundError), "The user category cannot be found, or the instance does not allow showing online users.")] + [DocUsesPageData] + [DocQueryParam("username", "If set, certain categories like 'hearted' will return the related users of " + + "the user with this username instead of your own. Optional.")] + public Response GetUsers(RequestContext context, CategoryService categories, GameUser? user, + [DocSummary("The name of the category you'd like to retrieve users from. " + + "Make a request to /users to see a list of available categories")] + string route, DataContext dataContext, GameServerConfig config) + { + // Bunkum usually routes users/me requests to here aswell, so use this hack to serve those requests properly. + if (route == "me") + { + if (user == null) return ApiAuthenticationError.NotAuthenticated; // Error documented in UserApiEndpoints.GetMyUser() + return new Response(new ApiResponse(ApiExtendedGameUserResponse.FromOld(user, dataContext)!), ContentType.Json); + } + + if (string.IsNullOrWhiteSpace(route)) + { + return new ApiError("You didn't specify a route. " + + "You probably meant to use the `/users` endpoint and left a trailing slash in the URL.", NotFound); + } + + if (!config.PermitShowingOnlineUsers) return ApiNotFoundError.Instance; + (int skip, int count) = context.GetPageData(); + + DatabaseList? list = categories.UserCategories + .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? + .Fetch(context, skip, count, dataContext, user); + + if (list == null) return ApiNotFoundError.Instance; + + ApiListResponse users = DatabaseListExtensions.FromOldList(list, dataContext); + return new Response(users, ContentType.Json); + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs new file mode 100644 index 000000000..6fffe81f1 --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiCategoryResponse.cs @@ -0,0 +1,35 @@ +using Refresh.Core.Types.Categories; +using Refresh.Core.Types.Data; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiCategoryResponse : IApiResponse, IDataConvertableFrom +{ + public required string Name { get; set; } + public required string Description { get; set; } + public required string IconHash { get; set; } + public required string FontAwesomeIcon { get; set; } + public required string ApiRoute { get; set; } + public required bool RequiresUser { get; set; } + public required bool Hidden { get; set; } = false; + + public static ApiCategoryResponse? FromOld(GameCategory? old, DataContext dataContext) + { + if (old == null) return null; + + return new ApiCategoryResponse + { + Name = old.Name, + Description = old.Description, + IconHash = old.IconHash, + FontAwesomeIcon = old.FontAwesomeIcon, + ApiRoute = old.ApiRoute, + RequiresUser = old.RequiresUser, + Hidden = old.Hidden, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs similarity index 79% rename from Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs rename to Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs index b5a184abc..e78fd2633 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiLevelCategoryResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiLevelCategoryResponse.cs @@ -5,20 +5,14 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Query; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; -namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] -public class ApiLevelCategoryResponse : IApiResponse, IDataConvertableFrom +public class ApiLevelCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom { - public required string Name { get; set; } - public required string Description { get; set; } - public required string IconHash { get; set; } - public required string FontAwesomeIcon { get; set; } - public required string ApiRoute { get; set; } - public required bool RequiresUser { get; set; } public required ApiGameLevelResponse? PreviewLevel { get; set; } - public required bool Hidden { get; set; } = false; public static ApiLevelCategoryResponse? FromOld(GameLevelCategory? old, GameLevel? previewLevel, DataContext dataContext) diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs new file mode 100644 index 000000000..c3f21552f --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Categories/ApiUserCategoryResponse.cs @@ -0,0 +1,51 @@ +using Bunkum.Core; +using Refresh.Core.Types.Categories.Users; +using Refresh.Core.Types.Data; +using Refresh.Database; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiUserCategoryResponse : ApiCategoryResponse, IApiResponse, IDataConvertableFrom +{ + public required ApiGameUserResponse? PreviewItem { get; set; } + + public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, GameUser? PreviewItem, + DataContext dataContext) + { + if (old == null) return null; + + return new ApiUserCategoryResponse + { + Name = old.Name, + Description = old.Description, + IconHash = old.IconHash, + FontAwesomeIcon = old.FontAwesomeIcon, + ApiRoute = old.ApiRoute, + RequiresUser = old.RequiresUser, + PreviewItem = ApiGameUserResponse.FromOld(PreviewItem, dataContext), + Hidden = old.Hidden, + }; + } + + public static ApiUserCategoryResponse? FromOld(GameUserCategory? old, DataContext dataContext) + => FromOld(old, null, dataContext); + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + + public static IEnumerable FromOldList(IEnumerable oldList, + RequestContext context, + DataContext dataContext) + { + return oldList.Select(category => + { + DatabaseList? list = category.Fetch(context, 0, 1, dataContext, dataContext.User); + GameUser? item = list?.Items.FirstOrDefault(); + + return FromOld(category, item, dataContext); + }).ToList()!; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs index 83f6aa1bf..f0cfcf71c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs @@ -11,19 +11,8 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; /// A user with full information, like current role, ban status, etc. /// [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] -public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom +public class ApiExtendedGameUserResponse : ApiGameUserResponse, IApiResponse, IDataConvertableFrom { - public required string UserId { get; set; } - public required string Username { get; set; } - public required string IconHash { get; set; } - public required string VitaIconHash { get; set; } - public required string BetaIconHash { get; set; } - public required string Description { get; set; } - public required ApiGameLocationResponse Location { get; set; } - public required DateTimeOffset JoinDate { get; set; } - public required DateTimeOffset LastLoginDate { get; set; } - public required GameUserRole Role { get; set; } - public required string? BanReason { get; set; } public required DateTimeOffset? BanExpiryDate { get; set; } @@ -45,13 +34,10 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom null; user:notnull => notnull")] - public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext) + public static new ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext) { if (user == null) return null; @@ -62,6 +48,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom FromOldList(IEnumerable oldList, + public static new IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs index 72dc85d9e..f8edc67f5 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs @@ -9,9 +9,6 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom { - // HEY! When adding fields here, remember to propagate them in ApiExtendedGameUser too! - // Otherwise, they won't show up in the admin panel endpoints or /users/me. Thank you! - public required string UserId { get; set; } public required string Username { get; set; } public required string IconHash { get; set; } diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index d95b26221..7221ca41c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -6,79 +6,20 @@ using Refresh.Common.Verification; using Refresh.Core.Authentication.Permission; using Refresh.Core.Services; -using Refresh.Core.Types.Categories; using Refresh.Core.Types.Data; using Refresh.Database; -using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; -using Refresh.Database.Query; -using Refresh.Interfaces.APIv3.Documentation.Attributes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; -using Refresh.Interfaces.APIv3.Extensions; namespace Refresh.Interfaces.APIv3.Endpoints; public class LevelApiEndpoints : EndpointGroup { - [ApiV3Endpoint("levels"), Authentication(false)] - [ClientCacheResponse(86400 / 2)] // cache for half a day - [DocSummary("Retrieves a list of categories you can use to search levels")] - [DocQueryParam("includePreviews", "If true, a single level will be added to each category representing a level from that category. False by default.")] - [DocError(typeof(ApiValidationError), "The boolean 'includePreviews' could not be parsed by the server.")] - public ApiListResponse GetCategories(RequestContext context, CategoryService categories, - MatchService matchService, GameDatabaseContext database, GameUser? user, IDataStore dataStore, - DataContext dataContext) - { - bool result = bool.TryParse(context.QueryString.Get("includePreviews") ?? "false", out bool includePreviews); - if (!result) return ApiValidationError.BooleanParseError; - - IEnumerable resp; - - // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression - if (includePreviews) resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, context, dataContext); - else resp = ApiLevelCategoryResponse.FromOldList(categories.LevelCategories, dataContext); - - return new ApiListResponse(resp); - } - - [ApiV3Endpoint("levels/{route}"), Authentication(false)] - [DocSummary("Retrieves a list of levels from a category")] - [DocError(typeof(ApiNotFoundError), "The level category cannot be found")] - [DocUsesPageData] - [DocQueryParam("game", "Filters levels to a specific game version. Allowed values: lbp1-3, vita, psp, beta")] - [DocQueryParam("seed", "The random seed to use for randomization. Uses 0 if not specified.")] - [DocQueryParam("players", "Filters levels to those accommodating the specified number of players.")] - [DocQueryParam("username", "If set, certain categories like 'hearted' or 'byUser' will return the levels of " + - "the user with this username instead of your own. Optional.")] - public ApiListResponse GetLevels(RequestContext context, GameDatabaseContext database, - MatchService matchService, CategoryService categories, GameUser? user, IDataStore dataStore, - [DocSummary("The name of the category you'd like to retrieve levels from. " + - "Make a request to /levels to see a list of available categories")] - string route, DataContext dataContext) - { - if (string.IsNullOrWhiteSpace(route)) - { - return new ApiError("You didn't specify a route. " + - "You probably meant to use the `/levels` endpoint and left a trailing slash in the URL.", NotFound); - } - - (int skip, int count) = context.GetPageData(); - - DatabaseList? list = categories.LevelCategories - .FirstOrDefault(c => c.ApiRoute.StartsWith(route))? - .Fetch(context, skip, count, dataContext, new LevelFilterSettings(context, TokenGame.Website), user); - - if (list == null) return ApiNotFoundError.Instance; - - DatabaseList levels = DatabaseListExtensions.FromOldList(list, dataContext); - return levels; - } - [ApiV3Endpoint("levels/id/{id}"), Authentication(false)] [DocSummary("Gets an individual level by a numerical ID")] [DocError(typeof(ApiNotFoundError), "The level cannot be found")] diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index 10608e94a..1cc34c86b 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -47,9 +47,13 @@ public ApiResponse GetUserByUuid(RequestContext context, Ga [ApiV3Endpoint("users/me"), MinimumRole(GameUserRole.Restricted)] [DocSummary("Returns your own user, provided you are authenticated")] - public ApiResponse GetMyUser(RequestContext context, GameUser user, + [DocError(typeof(ApiAuthenticationError), "You are not authenticated")] + public ApiResponse GetMyUser(RequestContext context, GameUser? user, GameDatabaseContext database, IDataStore dataStore, DataContext dataContext) - => ApiExtendedGameUserResponse.FromOld(user, dataContext); + { + if (user == null) return ApiAuthenticationError.NotAuthenticated; + return ApiExtendedGameUserResponse.FromOld(user, dataContext); + } [ApiV3Endpoint("users/me", HttpMethods.Patch)] [DocSummary("Updates your profile with the given data")] diff --git a/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs index bbc481b9a..03808f125 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/LevelApiTests.cs @@ -5,6 +5,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; namespace RefreshTests.GameServer.Tests.ApiV3; diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index f67be31eb..11a401e5f 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -3,6 +3,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request.Authentication; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Categories; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; using RefreshTests.GameServer.Extensions; @@ -159,4 +160,108 @@ public void CanPatchOwnUser() user = context.Database.GetUserByObjectId(user.UserId)!; Assert.That(user.Description, Is.EqualTo(description)); } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsUserCategories(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + ApiListResponse? categories = context.Http.GetList("/api/v3/users"); + Assert.That(categories, Is.Not.Null); + + if (!showOnlineUsers) + { + Assert.That(categories!.ListInfo!.TotalItems, Is.Zero); + return; + } + + Assert.That(categories!.ListInfo!.TotalItems, Is.EqualTo(categories.Data!.Count)); + Assert.That(categories.ListInfo.TotalItems, Is.Not.Zero); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsUserCategoriesWithPreviews(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + ApiListResponse? categories = context.Http.GetList("/api/v3/users?includePreviews=true"); + Assert.That(categories, Is.Not.Null); + + if (!showOnlineUsers) + { + Assert.That(categories!.ListInfo!.TotalItems, Is.Zero); + return; + } + + ApiUserCategoryResponse? category = categories?.Data?.FirstOrDefault(c => c.ApiRoute == "newest"); + Assert.That(category, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(category!.PreviewItem, Is.Not.Null); + Assert.That(category.PreviewItem!.UserId, Is.EqualTo(user.UserId.ToString())); + }); + } + + [Test] + public void DoesntGetUserCategoriesWithGarbledPreviews() + { + using TestContext context = this.GetServer(); + + ApiListResponse? categories = context.Http.GetList("/api/v3/users?includePreviews=IIIIIIIIHEHAHAHAHAHAHAHA", false, true); // https://youtu.be/mpAnsf12JkA?t=2 + Assert.That(categories, Is.Not.Null); + categories!.AssertErrorIsEqual(ApiValidationError.BooleanParseError); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void GetsNewestUsers(bool showOnlineUsers) + { + using TestContext context = this.GetServer(); + List users = []; + const int usersCount = 10; + + // Prepare users + for (int i = 0; i < usersCount; i++) + { + GameUser user = context.CreateUser(); + users.Add(user); + } + + // Prepare config + context.Server.Value.GameServerConfig.PermitShowingOnlineUsers = showOnlineUsers; + + if (!showOnlineUsers) + { + HttpResponseMessage message = context.Http.GetAsync("/api/v3/users/newest").Result; + Assert.That(message.StatusCode, Is.EqualTo(NotFound)); + return; + } + + ApiListResponse? response = context.Http.GetList("/api/v3/users/newest?count=20", false); + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response?.ListInfo, Is.Not.Null); + Assert.That(response!.ListInfo!.TotalItems, Is.EqualTo(usersCount)); + Assert.That(response!.Data!, Has.Count.EqualTo(usersCount)); + + int index = 0; + users = users.OrderByDescending(u => u.JoinDate).ToList(); + foreach(ApiGameUserResponse user in response.Data!) + { + Assert.That(user.UserId, Is.EqualTo(users[index].UserId.ToString())); + index++; + } + } } \ No newline at end of file