From 30f2f7bf09b56f2d90381d17cc1e8d4d42642e2c Mon Sep 17 00:00:00 2001 From: RaidMax Date: Sun, 24 May 2020 21:22:26 -0500 Subject: [PATCH] [issue #139] client lookup and stats api --- Application/ApplicationManager.cs | 4 +- .../AutomessageFeed/AutomessageFeed.csproj | 2 +- .../IW4ScriptCommands.csproj | 2 +- Plugins/LiveRadar/LiveRadar.csproj | 2 +- Plugins/Login/Login.csproj | 2 +- .../ProfanityDeterment.csproj | 2 +- Plugins/Stats/Dtos/StatsInfoRequest.cs | 10 + Plugins/Stats/Dtos/StatsInfoResult.cs | 56 +++++ .../Stats/Helpers/StatsResourceQueryHelper.cs | 75 +++++++ Plugins/Stats/Stats.csproj | 4 +- Plugins/Web/StatsWeb/API/StatsController.cs | 69 ++++++ Plugins/Web/StatsWeb/StatsWeb.csproj | 2 +- Plugins/Welcome/Welcome.csproj | 2 +- SharedLibraryCore/Database/Models/EFAlias.cs | 3 + SharedLibraryCore/Dtos/ErrorResponse.cs | 15 ++ SharedLibraryCore/Dtos/FindClientRequest.cs | 17 ++ SharedLibraryCore/Dtos/FindClientResult.cs | 20 ++ SharedLibraryCore/Dtos/PaginationInfo.cs | 2 +- SharedLibraryCore/Services/ClientService.cs | 64 +++++- SharedLibraryCore/SharedLibraryCore.csproj | 22 +- Tests/ApplicationTests/ApiTests.cs | 203 ++++++++++++++++++ .../ApplicationTests/ApplicationTests.csproj | 1 + Tests/ApplicationTests/CommandTests.cs | 1 + .../DependencyInjectionExtensions.cs | 1 - Tests/ApplicationTests/PluginTests.cs | 1 + Tests/ApplicationTests/ServiceTests.cs | 173 +++++++++++++++ Tests/ApplicationTests/StatsTests.cs | 56 +++++ .../Controllers/API/ClientController.cs | 66 ++++++ .../API/Dtos/FindClientResponse.cs | 18 ++ .../Validation/FindClientRequestValidator.cs | 33 +++ WebfrontCore/Startup.cs | 14 +- WebfrontCore/WebfrontCore.csproj | 1 + 32 files changed, 907 insertions(+), 36 deletions(-) create mode 100644 Plugins/Stats/Dtos/StatsInfoRequest.cs create mode 100644 Plugins/Stats/Dtos/StatsInfoResult.cs create mode 100644 Plugins/Stats/Helpers/StatsResourceQueryHelper.cs create mode 100644 Plugins/Web/StatsWeb/API/StatsController.cs create mode 100644 SharedLibraryCore/Dtos/ErrorResponse.cs create mode 100644 SharedLibraryCore/Dtos/FindClientRequest.cs create mode 100644 SharedLibraryCore/Dtos/FindClientResult.cs create mode 100644 Tests/ApplicationTests/ApiTests.cs create mode 100644 Tests/ApplicationTests/ServiceTests.cs create mode 100644 WebfrontCore/Controllers/API/ClientController.cs create mode 100644 WebfrontCore/Controllers/API/Dtos/FindClientResponse.cs create mode 100644 WebfrontCore/Controllers/API/Validation/FindClientRequestValidator.cs diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index 482c62012..a1b8cd665 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -69,12 +69,12 @@ namespace IW4MAdmin.Application ITranslationLookup translationLookup, IConfigurationHandler commandConfiguration, IConfigurationHandler appConfigHandler, IGameServerInstanceFactory serverInstanceFactory, IEnumerable plugins, IParserRegexFactory parserRegexFactory, IEnumerable customParserEvents, - IEventHandler eventHandler, IScriptCommandFactory scriptCommandFactory) + IEventHandler eventHandler, IScriptCommandFactory scriptCommandFactory, IDatabaseContextFactory contextFactory) { MiddlewareActionHandler = actionHandler; _servers = new ConcurrentBag(); MessageTokens = new List(); - ClientSvc = new ClientService(); + ClientSvc = new ClientService(contextFactory); AliasSvc = new AliasService(); PenaltySvc = new PenaltyService(); ConfigHandler = appConfigHandler; diff --git a/Plugins/AutomessageFeed/AutomessageFeed.csproj b/Plugins/AutomessageFeed/AutomessageFeed.csproj index 89666191f..84758a266 100644 --- a/Plugins/AutomessageFeed/AutomessageFeed.csproj +++ b/Plugins/AutomessageFeed/AutomessageFeed.csproj @@ -10,7 +10,7 @@ - + diff --git a/Plugins/IW4ScriptCommands/IW4ScriptCommands.csproj b/Plugins/IW4ScriptCommands/IW4ScriptCommands.csproj index 205b908aa..363c2e25a 100644 --- a/Plugins/IW4ScriptCommands/IW4ScriptCommands.csproj +++ b/Plugins/IW4ScriptCommands/IW4ScriptCommands.csproj @@ -10,7 +10,7 @@ - + diff --git a/Plugins/LiveRadar/LiveRadar.csproj b/Plugins/LiveRadar/LiveRadar.csproj index 9a3945ff9..2f1eae5e7 100644 --- a/Plugins/LiveRadar/LiveRadar.csproj +++ b/Plugins/LiveRadar/LiveRadar.csproj @@ -16,7 +16,7 @@ - + diff --git a/Plugins/Login/Login.csproj b/Plugins/Login/Login.csproj index 5bee8dbe4..ff703f783 100644 --- a/Plugins/Login/Login.csproj +++ b/Plugins/Login/Login.csproj @@ -23,7 +23,7 @@ - + diff --git a/Plugins/ProfanityDeterment/ProfanityDeterment.csproj b/Plugins/ProfanityDeterment/ProfanityDeterment.csproj index 505343996..c53888ac6 100644 --- a/Plugins/ProfanityDeterment/ProfanityDeterment.csproj +++ b/Plugins/ProfanityDeterment/ProfanityDeterment.csproj @@ -16,7 +16,7 @@ - + diff --git a/Plugins/Stats/Dtos/StatsInfoRequest.cs b/Plugins/Stats/Dtos/StatsInfoRequest.cs new file mode 100644 index 000000000..5cca4e865 --- /dev/null +++ b/Plugins/Stats/Dtos/StatsInfoRequest.cs @@ -0,0 +1,10 @@ +namespace Stats.Dtos +{ + public class StatsInfoRequest + { + /// + /// client identifier + /// + public int? ClientId { get; set; } + } +} diff --git a/Plugins/Stats/Dtos/StatsInfoResult.cs b/Plugins/Stats/Dtos/StatsInfoResult.cs new file mode 100644 index 000000000..183bbb4fa --- /dev/null +++ b/Plugins/Stats/Dtos/StatsInfoResult.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.Json.Serialization; + +namespace Stats.Dtos +{ + public class StatsInfoResult + { + /// + /// ranking on the server + /// + public int Ranking { get; set; } + + /// + /// number of kills + /// + public int Kills { get; set; } + + /// + /// number of deaths + /// + public int Deaths { get; set; } + + /// + /// performance level (elo rating + skill) / 2 + /// + public double Performance { get; set; } + + /// + /// SPM + /// + public double ScorePerMinute { get; set; } + + /// + /// last connection + /// + public DateTime LastPlayed { get; set; } + + /// + /// how many seconds played on the server + /// + public double TotalSecondsPlayed { get; set; } + + /// + /// name of the server + /// + public string ServerName { get; set; } + + /// + /// server game + /// + public string ServerGame { get; set; } + + [JsonIgnore] + public long ServerId { get; set; } + } +} diff --git a/Plugins/Stats/Helpers/StatsResourceQueryHelper.cs b/Plugins/Stats/Helpers/StatsResourceQueryHelper.cs new file mode 100644 index 000000000..61ec3a0e9 --- /dev/null +++ b/Plugins/Stats/Helpers/StatsResourceQueryHelper.cs @@ -0,0 +1,75 @@ +using IW4MAdmin.Plugins.Stats.Models; +using Microsoft.EntityFrameworkCore; +using SharedLibraryCore.Helpers; +using SharedLibraryCore.Interfaces; +using Stats.Dtos; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Stats.Helpers +{ + /// + /// implementation for IResourceQueryHelper + /// used to obtain client statistics information + /// + public class StatsResourceQueryHelper : IResourceQueryHelper + { + private readonly IDatabaseContextFactory _contextFactory; + + public StatsResourceQueryHelper(IDatabaseContextFactory databaseContextFactory) + { + _contextFactory = databaseContextFactory; + } + + /// + public async Task> QueryResource(StatsInfoRequest query) + { + var result = new ResourceQueryHelperResult(); + using var context = _contextFactory.CreateContext(enableTracking: false); + + // we need to get the ratings separately because there's not explicit FK + var ratings = await context.Set() + .Where(_ratingHistory => _ratingHistory.ClientId == query.ClientId) + .SelectMany(_ratingHistory => _ratingHistory.Ratings.Where(_rating => _rating.ServerId != null && _rating.Newest) + .Select(_rating => new + { + _rating.ServerId, + _rating.Ranking, + _rating.When + })) + .ToListAsync(); + + var iqStats = context.Set() + .Where(_stats => _stats.ClientId == query.ClientId) + .Select(_stats => new StatsInfoResult + { + ServerId = _stats.ServerId, + Kills = _stats.Kills, + Deaths = _stats.Deaths, + Performance = Math.Round((_stats.EloRating + _stats.Skill) / 2.0, 2), + ScorePerMinute = _stats.SPM, + LastPlayed = _stats.Client.LastConnection, + TotalSecondsPlayed = _stats.TimePlayed, + ServerGame = _stats.Server.GameName.ToString(), + ServerName = _stats.Server.HostName, + }); + + var queryResults = await iqStats.ToListAsync(); + + // add the rating query's results to the full query + foreach(var eachResult in queryResults) + { + var rating = ratings.FirstOrDefault(_rating => _rating.ServerId == eachResult.ServerId); + eachResult.Ranking = rating?.Ranking ?? 0; + eachResult.LastPlayed = rating?.When ?? eachResult.LastPlayed; + } + + result.Results = queryResults; + result.RetrievedResultCount = queryResults.Count; + result.TotalResultCount = result.RetrievedResultCount; + + return result; + } + } +} diff --git a/Plugins/Stats/Stats.csproj b/Plugins/Stats/Stats.csproj index 52ff5583f..a504c6ca0 100644 --- a/Plugins/Stats/Stats.csproj +++ b/Plugins/Stats/Stats.csproj @@ -12,11 +12,11 @@ Client Statistics Plugin for IW4MAdmin 2018 Debug;Release;Prerelease - 7.1 + 8.0 - + diff --git a/Plugins/Web/StatsWeb/API/StatsController.cs b/Plugins/Web/StatsWeb/API/StatsController.cs new file mode 100644 index 000000000..483918615 --- /dev/null +++ b/Plugins/Web/StatsWeb/API/StatsController.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SharedLibraryCore; +using SharedLibraryCore.Dtos; +using SharedLibraryCore.Interfaces; +using Stats.Dtos; +using System; +using System.Threading.Tasks; + +namespace StatsWeb.API +{ + [ApiController] + [Route("api/stats")] + public class StatsController : ControllerBase + { + private readonly ILogger _logger; + private readonly IResourceQueryHelper _statsQueryHelper; + + public StatsController(ILogger logger, IResourceQueryHelper statsQueryHelper) + { + _statsQueryHelper = statsQueryHelper; + _logger = logger; + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [HttpGet("{clientId}")] + public async Task ClientStats(int clientId) + { + if (clientId < 1 || !ModelState.IsValid) + { + return BadRequest(new ErrorResponse + { + Messages = new[] { $"Client Id must be between 1 and {int.MaxValue}" } + }); + + } + + var request = new StatsInfoRequest() + { + ClientId = clientId + }; + + try + { + var result = await _statsQueryHelper.QueryResource(request); + + if (result.RetrievedResultCount == 0) + { + return NotFound(); + } + + return Ok(result.Results); + } + + catch (Exception e) + { + _logger.WriteWarning($"Could not get client stats for client id {clientId}"); + _logger.WriteDebug(e.GetExceptionInfo()); + + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse + { + Messages = new[] { e.Message } + }); + } + } + } +} diff --git a/Plugins/Web/StatsWeb/StatsWeb.csproj b/Plugins/Web/StatsWeb/StatsWeb.csproj index c578cc61c..5fe5518d4 100644 --- a/Plugins/Web/StatsWeb/StatsWeb.csproj +++ b/Plugins/Web/StatsWeb/StatsWeb.csproj @@ -14,7 +14,7 @@ Always - + diff --git a/Plugins/Welcome/Welcome.csproj b/Plugins/Welcome/Welcome.csproj index 0da24b214..7d86c8c6b 100644 --- a/Plugins/Welcome/Welcome.csproj +++ b/Plugins/Welcome/Welcome.csproj @@ -16,7 +16,7 @@ - + diff --git a/SharedLibraryCore/Database/Models/EFAlias.cs b/SharedLibraryCore/Database/Models/EFAlias.cs index 0ccc94bd9..b8eee0eb6 100644 --- a/SharedLibraryCore/Database/Models/EFAlias.cs +++ b/SharedLibraryCore/Database/Models/EFAlias.cs @@ -24,5 +24,8 @@ namespace SharedLibraryCore.Database.Models [NotMapped] public const int MAX_NAME_LENGTH = 24; + + [NotMapped] + public const int MIN_NAME_LENGTH = 3; } } diff --git a/SharedLibraryCore/Dtos/ErrorResponse.cs b/SharedLibraryCore/Dtos/ErrorResponse.cs new file mode 100644 index 000000000..ea9744b5e --- /dev/null +++ b/SharedLibraryCore/Dtos/ErrorResponse.cs @@ -0,0 +1,15 @@ +namespace SharedLibraryCore.Dtos +{ + public class ErrorResponse + { + /// + /// todo: type of error + /// + public string Type { get; set; } + + /// + /// relevant error messages + /// + public string[] Messages { get; set; } + } +} diff --git a/SharedLibraryCore/Dtos/FindClientRequest.cs b/SharedLibraryCore/Dtos/FindClientRequest.cs new file mode 100644 index 000000000..7586c749c --- /dev/null +++ b/SharedLibraryCore/Dtos/FindClientRequest.cs @@ -0,0 +1,17 @@ +namespace SharedLibraryCore.Dtos +{ + public class FindClientRequest : PaginationInfo + { + /// + /// name of client + /// + public string Name { get; set; } + + /// + /// network id of client + /// + public string Xuid { get; set; } + + public string ToDebugString() => $"[Name={Name}, Xuid={Xuid}]"; + } +} diff --git a/SharedLibraryCore/Dtos/FindClientResult.cs b/SharedLibraryCore/Dtos/FindClientResult.cs new file mode 100644 index 000000000..a1995df3e --- /dev/null +++ b/SharedLibraryCore/Dtos/FindClientResult.cs @@ -0,0 +1,20 @@ +namespace SharedLibraryCore.Dtos +{ + public class FindClientResult + { + /// + /// client identifier + /// + public int ClientId { get; set; } + + /// + /// networkid of client + /// + public string Xuid { get; set; } + + /// + /// name of client + /// + public string Name { get; set; } + } +} diff --git a/SharedLibraryCore/Dtos/PaginationInfo.cs b/SharedLibraryCore/Dtos/PaginationInfo.cs index bd7794337..e062ea4f9 100644 --- a/SharedLibraryCore/Dtos/PaginationInfo.cs +++ b/SharedLibraryCore/Dtos/PaginationInfo.cs @@ -13,7 +13,7 @@ /// /// how many itesm to take /// - public int Count { get; set; } + public int Count { get; set; } = 100; /// /// filter query diff --git a/SharedLibraryCore/Services/ClientService.cs b/SharedLibraryCore/Services/ClientService.cs index cd24acee8..808c474f1 100644 --- a/SharedLibraryCore/Services/ClientService.cs +++ b/SharedLibraryCore/Services/ClientService.cs @@ -2,6 +2,8 @@ using SharedLibraryCore.Database; using SharedLibraryCore.Database.Models; using SharedLibraryCore.Dtos; +using SharedLibraryCore.Helpers; +using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; using System.Linq; @@ -10,8 +12,15 @@ using static SharedLibraryCore.Database.Models.EFClient; namespace SharedLibraryCore.Services { - public class ClientService : Interfaces.IEntityService + public class ClientService : IEntityService, IResourceQueryHelper { + private readonly IDatabaseContextFactory _contextFactory; + + public ClientService(IDatabaseContextFactory databaseContextFactory) + { + _contextFactory = databaseContextFactory; + } + public async Task Create(EFClient entity) { using (var context = new DatabaseContext()) @@ -105,7 +114,7 @@ namespace SharedLibraryCore.Services private async Task UpdateAlias(string originalName, int? ip, EFClient entity, DatabaseContext context) { - string name = originalName.CapClientName(EFAlias.MAX_NAME_LENGTH); + string name = originalName.CapClientName(EFAlias.MAX_NAME_LENGTH); // entity is the tracked db context item // get all aliases by IP address and LinkId @@ -724,5 +733,56 @@ namespace SharedLibraryCore.Services await ctx.SaveChangesAsync(); } } + + /// + /// find clients matching the given query + /// + /// query filters + /// + public async Task> QueryResource(FindClientRequest query) + { + var result = new ResourceQueryHelperResult(); + using var context = _contextFactory.CreateContext(enableTracking: false); + + IQueryable iqClients = null; + + if (!string.IsNullOrEmpty(query.Xuid)) + { + long networkId = query.Xuid.ConvertGuidToLong(System.Globalization.NumberStyles.HexNumber); + iqClients = context.Clients.Where(_client => _client.NetworkId == networkId); + } + + else if (!string.IsNullOrEmpty(query.Name)) + { + iqClients = context.Clients.Where(_client => EF.Functions.Like(_client.CurrentAlias.Name.ToLower(), $"%{query.Name.ToLower()}%")); + } + + if (query.Direction == SortDirection.Ascending) + { + iqClients = iqClients.OrderBy(_client => _client.LastConnection); + } + + else + { + iqClients = iqClients.OrderByDescending(_client => _client.LastConnection); + } + + var queryResults = await iqClients + .Select(_client => new FindClientResult + { + ClientId = _client.ClientId, + Xuid = _client.NetworkId.ToString("X"), + Name = _client.CurrentAlias.Name + }) + .Skip(query.Offset) + .Take(query.Count) + .ToListAsync(); + + result.TotalResultCount = await iqClients.CountAsync(); + result.Results = queryResults; + result.RetrievedResultCount = queryResults.Count; + + return result; + } } } diff --git a/SharedLibraryCore/SharedLibraryCore.csproj b/SharedLibraryCore/SharedLibraryCore.csproj index 28f2fcd17..07742dcd2 100644 --- a/SharedLibraryCore/SharedLibraryCore.csproj +++ b/SharedLibraryCore/SharedLibraryCore.csproj @@ -6,12 +6,12 @@ RaidMax.IW4MAdmin.SharedLibraryCore - 2.4.0 + 2.4.2 RaidMax Forever None Debug;Release;Prerelease false - 7.1 + 8.0 IW4MAdmin https://github.com/RaidMax/IW4M-Admin/ https://www.raidmax.org/IW4MAdmin/ @@ -20,8 +20,6 @@ true MIT Shared Library for IW4MAdmin - 2.4.0.0 - 2.4.0.0 @@ -29,22 +27,6 @@ true - - - - - - - - - - - - - - - - diff --git a/Tests/ApplicationTests/ApiTests.cs b/Tests/ApplicationTests/ApiTests.cs new file mode 100644 index 000000000..7278b2ec3 --- /dev/null +++ b/Tests/ApplicationTests/ApiTests.cs @@ -0,0 +1,203 @@ +using ApplicationTests.Fixtures; +using FakeItEasy; +using FluentValidation; +using FluentValidation.AspNetCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SharedLibraryCore.Dtos; +using SharedLibraryCore.Helpers; +using SharedLibraryCore.Interfaces; +using Stats.Dtos; +using StatsWeb.Dtos; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using WebfrontCore.Controllers.API; +using WebfrontCore.Controllers.API.Dtos; +using WebfrontCore.Controllers.API.Validation; + +namespace ApplicationTests +{ + [TestFixture] + public class ApiTests + { + private IServiceProvider serviceProvider; + private IDatabaseContextFactory contextFactory; + private ClientController clientController; + private StatsWeb.API.StatsController statsController; + private IResourceQueryHelper fakeClientQueryHelper; + private IResourceQueryHelper fakeStatsQueryHelper; + + + [SetUp] + public void Setup() + { + var collection = new ServiceCollection(); + + collection.AddMvc() + .AddFluentValidation(); + + serviceProvider = collection.AddSingleton() + .AddSingleton() + .AddSingleton(A.Fake>()) + .AddSingleton(A.Fake>()) + .AddTransient, FindClientRequestValidator>() + .BuildBase() + .BuildServiceProvider(); + + clientController = serviceProvider.GetRequiredService(); + statsController = serviceProvider.GetRequiredService(); + contextFactory = serviceProvider.GetRequiredService(); + fakeClientQueryHelper = serviceProvider.GetRequiredService>(); + fakeStatsQueryHelper = serviceProvider.GetRequiredService>(); + } + + #region CLIENT_CONTROLLER + [Test] + public async Task Test_ClientController_FindAsync_Happy() + { + var query = new FindClientRequest() + { + Name = "test" + }; + + int expectedClientId = 123; + + A.CallTo(() => fakeClientQueryHelper.QueryResource(A.Ignored)) + .Returns(Task.FromResult(new ResourceQueryHelperResult() + { + Results = new[] + { + new FindClientResult() + { + ClientId = expectedClientId + } + } + })); + + var result = await clientController.FindAsync(query); + Assert.IsInstanceOf(result); + + var viewResult = (result as OkObjectResult).Value as FindClientResponse; + Assert.NotNull(viewResult); + Assert.AreEqual(expectedClientId, viewResult.Clients.First().ClientId); + } + + [Test] + public async Task Test_ClientController_FindAsync_InvalidModelState() + { + var query = new FindClientRequest(); + + clientController.ModelState.AddModelError("test", "test"); + var result = await clientController.FindAsync(query); + Assert.IsInstanceOf(result); + clientController.ModelState.Clear(); + } + + [Test] + public async Task Test_ClientController_FindAsync_Exception() + { + string expectedExceptionMessage = "failure"; + int expectedStatusCode = 500; + var query = new FindClientRequest(); + A.CallTo(() => fakeClientQueryHelper.QueryResource(A.Ignored)) + .Throws(new Exception(expectedExceptionMessage)); + + var result = await clientController.FindAsync(query); + Assert.IsInstanceOf(result); + + var statusResult = (result as ObjectResult); + Assert.AreEqual(expectedStatusCode, statusResult.StatusCode); + //Assert.IsTrue((statusResult.Value as ErrorResponse).Messages.Contains(expectedExceptionMessage)); + } + #endregion + + #region STATS_CONTROLLER + [Test] + public async Task Test_StatsController_ClientStats_Happy() + { + var client = ClientGenerators.CreateBasicClient(null); + + var query = new StatsInfoRequest + { + ClientId = client.ClientId + }; + + var queryResult = new ResourceQueryHelperResult() + { + Results = new[] + { + new StatsInfoResult + { + Deaths = 1, + Kills = 1, + LastPlayed = DateTime.Now, + Performance = 100, + Ranking = 10, + ScorePerMinute = 500, + ServerGame = "IW4", + ServerId = 123, + ServerName = "IW4Host", + TotalSecondsPlayed = 100 + } + }, + TotalResultCount = 1, + RetrievedResultCount = 1 + }; + + A.CallTo(() => fakeStatsQueryHelper.QueryResource(A.Ignored)) + .Returns(Task.FromResult(queryResult)); + + var result = await statsController.ClientStats(query.ClientId.Value); + Assert.IsInstanceOf(result); + + var viewResult = (result as OkObjectResult).Value as IEnumerable; + Assert.NotNull(viewResult); + Assert.AreEqual(queryResult.Results, viewResult); + } + + [Test] + public async Task Test_StatsController_ClientStats_InvalidModelState() + { + statsController.ModelState.AddModelError("test", "test"); + var result = await statsController.ClientStats(1); + Assert.IsInstanceOf(result); + statsController.ModelState.Clear(); + } + + [Test] + public async Task Test_StatsController_ClientStats_Exception() + { + string expectedExceptionMessage = "failure"; + int expectedStatusCode = 500; + + A.CallTo(() => fakeStatsQueryHelper.QueryResource(A.Ignored)) + .Throws(new Exception(expectedExceptionMessage)); + + var result = await statsController.ClientStats(1); + Assert.IsInstanceOf(result); + + var statusResult = (result as ObjectResult); + Assert.AreEqual(expectedStatusCode, statusResult.StatusCode); + Assert.IsTrue((statusResult.Value as ErrorResponse).Messages.Contains(expectedExceptionMessage)); + } + + [Test] + public async Task Test_StatsController_ClientStats_NotFound() + { + var queryResult = new ResourceQueryHelperResult() + { + Results = new List() + }; + + A.CallTo(() => fakeStatsQueryHelper.QueryResource(A.Ignored)) + .Returns(Task.FromResult(queryResult)); + + var result = await statsController.ClientStats(1); + Assert.IsInstanceOf(result); + } + #endregion + } +} diff --git a/Tests/ApplicationTests/ApplicationTests.csproj b/Tests/ApplicationTests/ApplicationTests.csproj index e638f886c..343f58ecd 100644 --- a/Tests/ApplicationTests/ApplicationTests.csproj +++ b/Tests/ApplicationTests/ApplicationTests.csproj @@ -2,6 +2,7 @@ netcoreapp3.1 + 8.0 diff --git a/Tests/ApplicationTests/CommandTests.cs b/Tests/ApplicationTests/CommandTests.cs index 3d5d50488..f766eb292 100644 --- a/Tests/ApplicationTests/CommandTests.cs +++ b/Tests/ApplicationTests/CommandTests.cs @@ -37,6 +37,7 @@ namespace ApplicationTests serviceProvider = new ServiceCollection() .BuildBase(new EventHandlerMock(true)) + .AddSingleton(A.Fake()) .BuildServiceProvider() .SetupTestHooks(); diff --git a/Tests/ApplicationTests/DependencyInjectionExtensions.cs b/Tests/ApplicationTests/DependencyInjectionExtensions.cs index fb34a285d..a3bb79d1a 100644 --- a/Tests/ApplicationTests/DependencyInjectionExtensions.cs +++ b/Tests/ApplicationTests/DependencyInjectionExtensions.cs @@ -42,7 +42,6 @@ namespace ApplicationTests .AddSingleton(A.Fake()) .AddSingleton(A.Fake()) .AddSingleton() - .AddSingleton(A.Fake()) .AddSingleton(A.Fake()) .AddSingleton(eventHandler) .AddSingleton(ConfigurationGenerators.CreateApplicationConfiguration()) diff --git a/Tests/ApplicationTests/PluginTests.cs b/Tests/ApplicationTests/PluginTests.cs index 5278eaed1..00ecc8128 100644 --- a/Tests/ApplicationTests/PluginTests.cs +++ b/Tests/ApplicationTests/PluginTests.cs @@ -31,6 +31,7 @@ namespace ApplicationTests public void Setup() { serviceProvider = new ServiceCollection().BuildBase() + .AddSingleton(A.Fake()) .AddSingleton() .BuildServiceProvider(); fakeManager = serviceProvider.GetRequiredService(); diff --git a/Tests/ApplicationTests/ServiceTests.cs b/Tests/ApplicationTests/ServiceTests.cs new file mode 100644 index 000000000..7e69dd674 --- /dev/null +++ b/Tests/ApplicationTests/ServiceTests.cs @@ -0,0 +1,173 @@ +using ApplicationTests.Fixtures; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SharedLibraryCore.Dtos; +using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ApplicationTests +{ + [TestFixture] + public class ServiceTests + { + private IServiceProvider serviceProvider; + private IDatabaseContextFactory contextFactory; + private ClientService clientService; + + [SetUp] + public void Setup() + { + serviceProvider = new ServiceCollection() + .AddSingleton() + .BuildBase() + + .BuildServiceProvider(); + + contextFactory = serviceProvider.GetRequiredService(); + clientService = serviceProvider.GetRequiredService(); + } + + #region QUERY_RESOURCE + [Test] + public async Task Test_QueryClientResource_Xuid() + { + var client = ClientGenerators.CreateBasicClient(null); + client.NetworkId = -1; + + var query = new FindClientRequest() + { + Xuid = client.NetworkId.ToString("X") + }; + + using var context = contextFactory.CreateContext(); + + context.Clients.Add(client); + await context.SaveChangesAsync(); + + var result = await clientService.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.AreEqual(query.Xuid, result.Results.First().Xuid); + + context.Clients.Remove(client); + await context.SaveChangesAsync(); + } + + [Test] + public async Task Test_QueryClientResource_NameExactMatch() + { + var query = new FindClientRequest() + { + Name = "test" + }; + + using var context = contextFactory.CreateContext(); + var client = ClientGenerators.CreateBasicClient(null); + client.Name = query.Name; + context.Clients.Add(client); + await context.SaveChangesAsync(); + + var result = await clientService.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.AreEqual(query.Name, result.Results.First().Name); + + context.Clients.Remove(client); + await context.SaveChangesAsync(); + } + + [Test] + public async Task Test_QueryClientResource_NameCaseInsensitivePartial() + { + var query = new FindClientRequest() + { + Name = "TEST" + }; + + using var context = contextFactory.CreateContext(); + var client = ClientGenerators.CreateBasicClient(null); + client.Name = "atesticle"; + context.Clients.Add(client); + await context.SaveChangesAsync(); + + var result = await clientService.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.IsTrue(result.Results.First().Name.ToUpper().Contains(query.Name)); + + context.Clients.Remove(client); + await context.SaveChangesAsync(); + } + + [Test] + public async Task Test_QueryClientResource_SortDirection() + { + var firstClient = ClientGenerators.CreateBasicClient(null); + firstClient.ClientId = 0; + firstClient.NetworkId = -1; + firstClient.LastConnection = DateTime.Now.AddHours(-1); + firstClient.Name = "test"; + var secondClient = ClientGenerators.CreateBasicClient(null); + secondClient.ClientId = 0; + secondClient.NetworkId = -2; + secondClient.LastConnection = DateTime.Now; + secondClient.Name = firstClient.Name; + + var query = new FindClientRequest() + { + Name = firstClient.Name + }; + + using var context = contextFactory.CreateContext(); + + context.Clients.Add(firstClient); + context.Clients.Add(secondClient); + await context.SaveChangesAsync(); + + var result = await clientService.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.AreEqual(secondClient.NetworkId.ToString("X"), result.Results.First().Xuid); + Assert.AreEqual(firstClient.NetworkId.ToString("X"), result.Results.Last().Xuid); + + query.Direction = SortDirection.Ascending; + result = await clientService.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.AreEqual(firstClient.NetworkId.ToString("X"), result.Results.First().Xuid); + Assert.AreEqual(secondClient.NetworkId.ToString("X"), result.Results.Last().Xuid); + + context.Clients.Remove(firstClient); + context.Clients.Remove(secondClient); + await context.SaveChangesAsync(); + } + + [Test] + public async Task Test_QueryClientResource_NoMatch() + { + var query = new FindClientRequest() + { + Name = "test" + }; + + using var context = contextFactory.CreateContext(); + var client = ClientGenerators.CreateBasicClient(null); + client.Name = "client"; + context.Clients.Add(client); + await context.SaveChangesAsync(); + + var result = await clientService.QueryResource(query); + + Assert.IsEmpty(result.Results); + + context.Clients.Remove(client); + await context.SaveChangesAsync(); + } + #endregion + } +} diff --git a/Tests/ApplicationTests/StatsTests.cs b/Tests/ApplicationTests/StatsTests.cs index cff545ceb..f37584e8b 100644 --- a/Tests/ApplicationTests/StatsTests.cs +++ b/Tests/ApplicationTests/StatsTests.cs @@ -14,6 +14,8 @@ using Microsoft.Extensions.DependencyInjection; using IW4MAdmin.Plugins.Stats.Helpers; using ApplicationTests.Fixtures; using System.Threading.Tasks; +using Stats.Helpers; +using Stats.Dtos; namespace ApplicationTests { @@ -23,6 +25,7 @@ namespace ApplicationTests ILogger logger; private IServiceProvider serviceProvider; private IConfigurationHandlerFactory handlerFactory; + private IDatabaseContextFactory contextFactory; [SetUp] public void Setup() @@ -31,10 +34,13 @@ namespace ApplicationTests handlerFactory = A.Fake(); serviceProvider = new ServiceCollection() + .AddSingleton() .BuildBase() .AddSingleton() .BuildServiceProvider(); + contextFactory = serviceProvider.GetRequiredService(); + void testLog(string msg) => Console.WriteLine(msg); A.CallTo(() => logger.WriteError(A.Ignored)).Invokes((string msg) => testLog(msg)); @@ -155,5 +161,55 @@ namespace ApplicationTests await mgr.UpdateStatHistory(target, stats); } + + #region QUERY_HELPER + [Test] + public async Task Test_StatsQueryHelper_Get() + { + var queryHelper = serviceProvider.GetRequiredService(); + using var context = contextFactory.CreateContext(); + + var server = new EFServer() { ServerId = 1 }; + var stats = new EFClientStatistics() + { + Client = ClientGenerators.CreateBasicClient(null), + SPM = 100, + Server = server + }; + + var ratingHistory = new EFClientRatingHistory() + { + Client = stats.Client, + Ratings = new[] + { + new EFRating() + { + Ranking = 100, + Server = server, + Newest = true + } + } + }; + + context.Set().Add(stats); + context.Set().Add(ratingHistory); + await context.SaveChangesAsync(); + + var query = new StatsInfoRequest() + { + ClientId = stats.Client.ClientId + }; + var result = await queryHelper.QueryResource(query); + + Assert.IsNotEmpty(result.Results); + Assert.AreEqual(stats.SPM, result.Results.First().ScorePerMinute); + Assert.AreEqual(ratingHistory.Ratings.First().Ranking, result.Results.First().Ranking); + + context.Set().Remove(stats); + context.Set().Remove(ratingHistory); + context.Set().Remove(server); + await context.SaveChangesAsync(); + } + #endregion } } diff --git a/WebfrontCore/Controllers/API/ClientController.cs b/WebfrontCore/Controllers/API/ClientController.cs new file mode 100644 index 000000000..51fe34854 --- /dev/null +++ b/WebfrontCore/Controllers/API/ClientController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SharedLibraryCore; +using SharedLibraryCore.Dtos; +using SharedLibraryCore.Interfaces; +using System; +using System.Linq; +using System.Threading.Tasks; +using WebfrontCore.Controllers.API.Dtos; + +namespace WebfrontCore.Controllers.API +{ + /// + /// api controller for client operations + /// + [ApiController] + [Route("api/client")] + public class ClientController : ControllerBase + { + private readonly IResourceQueryHelper _clientQueryHelper; + private readonly ILogger _logger; + + public ClientController(ILogger logger, IResourceQueryHelper clientQueryHelper) + { + _logger = logger; + _clientQueryHelper = clientQueryHelper; + } + + [HttpGet("find")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task FindAsync([FromQuery]FindClientRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(new ErrorResponse() + { + Messages = ModelState.Values.SelectMany(_value => _value.Errors.Select(_error => _error.ErrorMessage)).ToArray() + }); + } + + try + { + var results = await _clientQueryHelper.QueryResource(request); + + return Ok(new FindClientResponse + { + TotalFoundClients = results.TotalResultCount, + Clients = results.Results + }); + } + + catch (Exception e) + { + _logger.WriteWarning($"Failed to retrieve clients with query - {request.ToDebugString()}"); + _logger.WriteDebug(e.GetExceptionInfo()); + + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse() + { + Messages = new[] { e.Message } + }); + } + } + } +} diff --git a/WebfrontCore/Controllers/API/Dtos/FindClientResponse.cs b/WebfrontCore/Controllers/API/Dtos/FindClientResponse.cs new file mode 100644 index 000000000..8a8c44461 --- /dev/null +++ b/WebfrontCore/Controllers/API/Dtos/FindClientResponse.cs @@ -0,0 +1,18 @@ +using SharedLibraryCore.Dtos; +using System.Collections.Generic; + +namespace WebfrontCore.Controllers.API.Dtos +{ + public class FindClientResponse + { + /// + /// total number of client found matching the query + /// + public long TotalFoundClients { get; set; } + + /// + /// collection of doun clients + /// + public IEnumerable Clients { get; set; } + } +} diff --git a/WebfrontCore/Controllers/API/Validation/FindClientRequestValidator.cs b/WebfrontCore/Controllers/API/Validation/FindClientRequestValidator.cs new file mode 100644 index 000000000..c9d3fc86f --- /dev/null +++ b/WebfrontCore/Controllers/API/Validation/FindClientRequestValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Dtos; + +namespace WebfrontCore.Controllers.API.Validation +{ + /// + /// validator for FindClientRequest + /// + public class FindClientRequestValidator : AbstractValidator + { + public FindClientRequestValidator() + { + RuleFor(_request => _request.Name) + .NotEmpty() + .When(_request => string.IsNullOrEmpty(_request.Xuid)); + + RuleFor(_request => _request.Name) + .MinimumLength(EFAlias.MIN_NAME_LENGTH) + .MaximumLength(EFAlias.MAX_NAME_LENGTH); + + RuleFor(_request => _request.Xuid) + .NotEmpty() + .When(_request => string.IsNullOrEmpty(_request.Name)); + + RuleFor(_request => _request.Count) + .InclusiveBetween(1, 100); + + RuleFor(_request => _request.Offset) + .GreaterThanOrEqualTo(0); + } + } +} diff --git a/WebfrontCore/Startup.cs b/WebfrontCore/Startup.cs index 9344ed8cb..d39321e21 100644 --- a/WebfrontCore/Startup.cs +++ b/WebfrontCore/Startup.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Authentication.Cookies; +using FluentValidation; +using FluentValidation.AspNetCore; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -9,8 +11,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharedLibraryCore; using SharedLibraryCore.Database; +using SharedLibraryCore.Dtos; using SharedLibraryCore.Helpers; using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Services; +using Stats.Dtos; +using Stats.Helpers; using StatsWeb; using StatsWeb.Dtos; using System.Collections.Generic; @@ -19,6 +25,8 @@ using System.Linq; using System.Net; using System.Reflection; using System.Threading.Tasks; +using WebfrontCore.Controllers.API.Dtos; +using WebfrontCore.Controllers.API.Validation; using WebfrontCore.Middleware; namespace WebfrontCore @@ -55,6 +63,7 @@ namespace WebfrontCore // Add framework services. var mvcBuilder = services.AddMvc(_options => _options.SuppressAsyncSuffixInActionNames = false) + .AddFluentValidation() .ConfigureApplicationPartManager(_partManager => { foreach (var assembly in pluginAssemblies()) @@ -105,6 +114,9 @@ namespace WebfrontCore services.AddSingleton(Program.Manager); services.AddSingleton, ChatResourceQueryHelper>(); + services.AddTransient, FindClientRequestValidator>(); + services.AddSingleton, ClientService>(); + services.AddSingleton, StatsResourceQueryHelper>(); // todo: this needs to be handled more gracefully services.AddSingleton(Program.ApplicationServiceProvider.GetService()); diff --git a/WebfrontCore/WebfrontCore.csproj b/WebfrontCore/WebfrontCore.csproj index 0d8fba8a5..f26fc89c4 100644 --- a/WebfrontCore/WebfrontCore.csproj +++ b/WebfrontCore/WebfrontCore.csproj @@ -66,6 +66,7 @@ +