From 52e1d9aa83e91e280142448c541b96b66444a1ee Mon Sep 17 00:00:00 2001 From: Atralupus Date: Mon, 31 Aug 2026 16:59:01 +0900 Subject: [PATCH 1/2] Add API key authenticated REST API to back office Expose the back office operations as REST controllers so they can be driven without the Blazor UI. Authentication reads the X-API-Key header and compares it against the ARENA_API_KEY configuration value. The Blazor UI keeps using Google OAuth; only the controllers opt into the new scheme. Controllers mirror the existing pages one to one: seasons (ManageSeasons), ticket policies (Policy), transaction review (ConfirmTx), cache preparation (RankingCache, CacheInitialization), leaderboards and settlement CSVs (Leaderboard), and battle tx tracker progress (BlockTrackerStatus). Season and Round reference each other, so responses project into records instead of serializing entities. Every response uses a common ApiResponse envelope, and Swagger documents the endpoints with the API key security definition. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LsR64huwuS3v6GB8RejLCy --- .../ArenaService.BackOffice.csproj | 3 + .../ApiKeyAuthenticationHandler.cs | 79 ++++++ .../Controllers/BlockTrackerController.cs | 32 +++ .../Controllers/ConfirmTxController.cs | 103 +++++++ .../Controllers/LeaderboardController.cs | 180 ++++++++++++ .../Controllers/PoliciesController.cs | 163 +++++++++++ .../Controllers/RankingCacheController.cs | 185 +++++++++++++ .../Controllers/SeasonsController.cs | 221 +++++++++++++++ ArenaService.BackOffice/Models/ApiModels.cs | 262 ++++++++++++++++++ ArenaService.BackOffice/Models/ApiResponse.cs | 35 +++ ArenaService.BackOffice/Program.cs | 68 ++++- 11 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 ArenaService.BackOffice/Authentication/ApiKeyAuthenticationHandler.cs create mode 100644 ArenaService.BackOffice/Controllers/BlockTrackerController.cs create mode 100644 ArenaService.BackOffice/Controllers/ConfirmTxController.cs create mode 100644 ArenaService.BackOffice/Controllers/LeaderboardController.cs create mode 100644 ArenaService.BackOffice/Controllers/PoliciesController.cs create mode 100644 ArenaService.BackOffice/Controllers/RankingCacheController.cs create mode 100644 ArenaService.BackOffice/Controllers/SeasonsController.cs create mode 100644 ArenaService.BackOffice/Models/ApiModels.cs create mode 100644 ArenaService.BackOffice/Models/ApiResponse.cs diff --git a/ArenaService.BackOffice/ArenaService.BackOffice.csproj b/ArenaService.BackOffice/ArenaService.BackOffice.csproj index b348e8c..11a34ae 100644 --- a/ArenaService.BackOffice/ArenaService.BackOffice.csproj +++ b/ArenaService.BackOffice/ArenaService.BackOffice.csproj @@ -4,6 +4,8 @@ net8.0 enable enable + true + $(NoWarn);1591 @@ -12,6 +14,7 @@ + diff --git a/ArenaService.BackOffice/Authentication/ApiKeyAuthenticationHandler.cs b/ArenaService.BackOffice/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..59cb6db --- /dev/null +++ b/ArenaService.BackOffice/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace ArenaService.BackOffice.Authentication; + +public static class ApiKeyAuthenticationDefaults +{ + public const string AuthenticationScheme = "ApiKey"; + public const string HeaderName = "X-API-Key"; + public const string ConfigurationKey = "ARENA_API_KEY"; +} + +/// +/// Authenticates API requests with a shared secret supplied in the X-API-Key header +/// and compared against the ARENA_API_KEY configuration value. +/// The Blazor UI keeps using Google OAuth; only controllers opt into this scheme. +/// +public class ApiKeyAuthenticationHandler : AuthenticationHandler +{ + private readonly IConfiguration _configuration; + + public ApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration + ) + : base(options, logger, encoder) + { + _configuration = configuration; + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var provided)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var providedApiKey = provided.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(providedApiKey)) + { + return Task.FromResult(AuthenticateResult.Fail("API Key is empty.")); + } + + var configuredApiKey = _configuration[ApiKeyAuthenticationDefaults.ConfigurationKey]; + if (string.IsNullOrWhiteSpace(configuredApiKey)) + { + Logger.LogError( + "{Key} is not configured; API key authentication is disabled.", + ApiKeyAuthenticationDefaults.ConfigurationKey + ); + return Task.FromResult(AuthenticateResult.Fail("API Key authentication is not configured.")); + } + + if (!string.Equals(providedApiKey, configuredApiKey, StringComparison.Ordinal)) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid API Key.")); + } + + var claims = new[] + { + new Claim(ClaimTypes.Name, "ApiUser"), + new Claim( + ClaimTypes.AuthenticationMethod, + ApiKeyAuthenticationDefaults.AuthenticationScheme + ) + }; + var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme); + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + ApiKeyAuthenticationDefaults.AuthenticationScheme + ); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/ArenaService.BackOffice/Controllers/BlockTrackerController.cs b/ArenaService.BackOffice/Controllers/BlockTrackerController.cs new file mode 100644 index 0000000..ffd6e08 --- /dev/null +++ b/ArenaService.BackOffice/Controllers/BlockTrackerController.cs @@ -0,0 +1,32 @@ +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Battle transaction tracker progress. Mirrors the BlockTrackerStatus component in the layout. +/// +[ApiController] +[Route("api/block-tracker")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +[Produces("application/json")] +public class BlockTrackerController : ControllerBase +{ + private readonly IBlockTrackerRepository _blockTrackerRepository; + + public BlockTrackerController(IBlockTrackerRepository blockTrackerRepository) + { + _blockTrackerRepository = blockTrackerRepository; + } + + /// Gets the block index the battle transaction tracker has processed up to. + [HttpGet] + public async Task>> GetBattleTxTrackerBlockIndex() + { + var blockIndex = await _blockTrackerRepository.GetBattleTxTrackerBlockIndexAsync(); + return Ok(ApiResponse.Ok(blockIndex)); + } +} diff --git a/ArenaService.BackOffice/Controllers/ConfirmTxController.cs b/ArenaService.BackOffice/Controllers/ConfirmTxController.cs new file mode 100644 index 0000000..a3b8707 --- /dev/null +++ b/ArenaService.BackOffice/Controllers/ConfirmTxController.cs @@ -0,0 +1,103 @@ +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Models.Ticket; +using ArenaService.Shared.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Review queue for battles and ticket purchases whose transactions need a manual check. +/// Mirrors the ConfirmTx Blazor page. +/// +[ApiController] +[Route("api/confirm-tx")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +[Produces("application/json")] +public class ConfirmTxController : ControllerBase +{ + private readonly IBattleRepository _battleRepository; + private readonly ITicketRepository _ticketRepository; + private readonly ILogger _logger; + + public ConfirmTxController( + IBattleRepository battleRepository, + ITicketRepository ticketRepository, + ILogger logger + ) + { + _battleRepository = battleRepository; + _ticketRepository = ticketRepository; + _logger = logger; + } + + /// Lists battles awaiting review. + [HttpGet("battles")] + public async Task>>> GetUnreviewedBattles() + { + var battles = await _battleRepository.GetUnReviewedBattlesAsync(); + return Ok(ApiResponse>.Ok(battles.Select(BattleDto.From).ToList())); + } + + /// Lists battle and refresh ticket purchases awaiting review. + [HttpGet("ticket-purchases")] + public async Task>>> GetUnreviewedTicketPurchases() + { + var purchases = (await _ticketRepository.GetUnReviewedBattleTicketPurchasesAsync()) + .Cast() + .Concat(await _ticketRepository.GetUnReviewedRefreshTicketPurchasesAsync()) + .ToList(); + + return Ok( + ApiResponse>.Ok( + purchases.Select(TicketPurchaseLogDto.From).ToList() + ) + ); + } + + /// Marks a battle as reviewed. + [HttpPost("battles/{battleId:int}/review")] + public async Task> MarkBattleAsReviewed(int battleId) + { + try + { + await _battleRepository.UpdateBattle(battleId, battle => battle.Reviewed = true); + return Ok(ApiResponse.Ok($"Battle {battleId} marked as reviewed.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to mark battle {BattleId} as reviewed.", battleId); + return StatusCode(500, ApiResponse.Error($"Battle review failed: {ex.Message}")); + } + } + + /// + /// Marks a battle ticket purchase log as reviewed. + /// + /// + /// Same as the Blazor page: only battle ticket purchase logs are updated, so a refresh + /// ticket purchase id will not be found. + /// + [HttpPost("ticket-purchases/{purchaseLogId:int}/review")] + public async Task> MarkTicketPurchaseAsReviewed(int purchaseLogId) + { + try + { + await _ticketRepository.UpdateBattleTicketPurchaseLog( + purchaseLogId, + log => log.Reviewed = true + ); + return Ok(ApiResponse.Ok($"Ticket purchase {purchaseLogId} marked as reviewed.")); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to mark ticket purchase {PurchaseLogId} as reviewed.", + purchaseLogId + ); + return StatusCode(500, ApiResponse.Error($"Ticket purchase review failed: {ex.Message}")); + } + } +} diff --git a/ArenaService.BackOffice/Controllers/LeaderboardController.cs b/ArenaService.BackOffice/Controllers/LeaderboardController.cs new file mode 100644 index 0000000..93bc9b9 --- /dev/null +++ b/ArenaService.BackOffice/Controllers/LeaderboardController.cs @@ -0,0 +1,180 @@ +using System.Text; +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Client; +using ArenaService.Options; +using ArenaService.Shared.Constants; +using ArenaService.Shared.Models.Enums; +using ArenaService.Shared.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Completed season leaderboards and the settlement CSVs. Mirrors the Leaderboard Blazor page. +/// +[ApiController] +[Route("api/leaderboard")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +public class LeaderboardController : ControllerBase +{ + private const int StakeStateChunkSize = 100; + + private readonly ILeaderboardRepository _leaderboardRepository; + private readonly ISeasonRepository _seasonRepository; + private readonly IHeadlessClient _headlessClient; + private readonly IOptions _headlessOptions; + private readonly ILogger _logger; + + public LeaderboardController( + ILeaderboardRepository leaderboardRepository, + ISeasonRepository seasonRepository, + IHeadlessClient headlessClient, + IOptions headlessOptions, + ILogger logger + ) + { + _leaderboardRepository = leaderboardRepository; + _seasonRepository = seasonRepository; + _headlessClient = headlessClient; + _headlessOptions = headlessOptions; + _logger = logger; + } + + /// + /// Lists seasons whose leaderboard is final: non off-season, already ended at the current tip. + /// + [HttpGet("seasons")] + [Produces("application/json")] + public async Task>>> GetCompletedSeasons() + { + var tipIndex = await GetTipIndexAsync(); + if (tipIndex is null) + { + return StatusCode( + 502, + ApiResponse>.Error("Failed to read the tip index from headless.") + ); + } + + var seasons = (await _seasonRepository.GetAllSeasonsAsync()) + .Where(s => s.ArenaType != ArenaType.OFF_SEASON) + .Where(s => s.EndBlock < tipIndex) + .OrderByDescending(s => s.Id) + .Select(s => SeasonDto.From(s, includeRounds: false)) + .ToList(); + + return Ok(ApiResponse>.Ok(seasons)); + } + + /// Gets the final leaderboard of a season. + [HttpGet("{seasonId:int}")] + [Produces("application/json")] + public async Task>>> GetLeaderboard(int seasonId) + { + var leaderboard = await _leaderboardRepository.GetLeaderboardAsync(seasonId); + var entries = leaderboard + .Select(item => new LeaderboardEntryDto( + item.Rank, + item.Score, + item.Participant.AvatarAddress.ToString(), + item.Participant.User.AgentAddress.ToString(), + item.Participant.User.Level, + item.Participant.User.Cp, + item.Participant.User.PortraitId, + item.Participant.TotalWin, + item.Participant.TotalLose + )) + .ToList(); + + return Ok(ApiResponse>.Ok(entries)); + } + + /// Downloads the leaderboard CSV used for settlement. + [HttpGet("{seasonId:int}/csv")] + [Produces("text/csv")] + public async Task GetLeaderboardCsv(int seasonId) + { + var season = await _seasonRepository.GetSeasonAsync(seasonId); + var csv = await _leaderboardRepository.GenerateLeaderboardCsvAsync(seasonId); + var fileName = + $"{_headlessOptions.Value.Planet}_leaderboard_group_{season.SeasonGroupId}_{season.StartBlock}_{season.EndBlock}.csv"; + + return File(csv, "text/csv", fileName); + } + + /// + /// Downloads the staking CSV of everyone on the season's leaderboard, read from headless + /// in chunks of 100 agent addresses. + /// + [HttpGet("{seasonId:int}/staking-csv")] + [Produces("text/csv")] + public async Task GetStakingCsv(int seasonId) + { + var season = await _seasonRepository.GetSeasonAsync(seasonId); + var leaderboard = await _leaderboardRepository.GetLeaderboardAsync(seasonId); + + var csv = new StringBuilder(); + csv.AppendLine( + "BlockIndex,StakeVersion,AgentAddress,StakingAmount,StartedBlockIndex,ReceivedBlockIndex,CancellableBlockIndex,TimeStamp" + ); + + for (var offset = 0; offset < leaderboard.Count; offset += StakeStateChunkSize) + { + var agentAddresses = leaderboard + .Skip(offset) + .Take(StakeStateChunkSize) + .Select(item => item.Participant.User.AgentAddress.ToHex().ToLower()) + .ToList(); + + var result = await _headlessClient.GetStakeState.ExecuteAsync(agentAddresses); + if (result.Data is null) + { + _logger.LogWarning( + "GetStakeState returned no data for season {SeasonId} at offset {Offset}.", + seasonId, + offset + ); + continue; + } + + var blockIndex = result.Data.NodeStatus.Tip.Index; + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); + + foreach (var (stakeState, index) in result.Data.StateQuery.StakeStates.Select((s, i) => (s, i))) + { + if (stakeState is null) + { + continue; + } + + csv.AppendLine( + $"{blockIndex},V3,0x{agentAddresses[index]},{stakeState.Deposit}," + + $"{stakeState.StartedBlockIndex},{stakeState.ReceivedBlockIndex}," + + $"{stakeState.CancellableBlockIndex},{timestamp}" + ); + } + } + + var fileName = + $"{_headlessOptions.Value.Planet}_staking_data_group_{season.SeasonGroupId}_{season.StartBlock}_{season.EndBlock}.csv"; + + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", fileName); + } + + private async Task GetTipIndexAsync() + { + try + { + var result = await _headlessClient.GetTipIndex.ExecuteAsync(); + return result.Data?.NodeStatus.Tip.Index; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read the tip index from headless."); + return null; + } + } +} diff --git a/ArenaService.BackOffice/Controllers/PoliciesController.cs b/ArenaService.BackOffice/Controllers/PoliciesController.cs new file mode 100644 index 0000000..91eae97 --- /dev/null +++ b/ArenaService.BackOffice/Controllers/PoliciesController.cs @@ -0,0 +1,163 @@ +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Models.BattleTicket; +using ArenaService.Shared.Models.RefreshTicket; +using ArenaService.Shared.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Battle / refresh ticket policies. Mirrors the Policy Blazor page. +/// Policies are append-only: existing rows are never mutated. +/// +[ApiController] +[Route("api/policies")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +[Produces("application/json")] +public class PoliciesController : ControllerBase +{ + private readonly IBattleTicketPolicyRepository _battlePolicyRepository; + private readonly IRefreshTicketPolicyRepository _refreshPolicyRepository; + private readonly ILogger _logger; + + public PoliciesController( + IBattleTicketPolicyRepository battlePolicyRepository, + IRefreshTicketPolicyRepository refreshPolicyRepository, + ILogger logger + ) + { + _battlePolicyRepository = battlePolicyRepository; + _refreshPolicyRepository = refreshPolicyRepository; + _logger = logger; + } + + /// Lists every battle ticket policy. + [HttpGet("battle")] + public async Task>>> GetBattlePolicies() + { + var policies = await _battlePolicyRepository.GetAllBattlePoliciesAsync(); + return Ok( + ApiResponse>.Ok( + policies.Select(BattleTicketPolicyDto.From).ToList() + ) + ); + } + + /// Lists every refresh ticket policy. + [HttpGet("refresh")] + public async Task>>> GetRefreshPolicies() + { + var policies = await _refreshPolicyRepository.GetAllRefreshPoliciesAsync(); + return Ok( + ApiResponse>.Ok( + policies.Select(RefreshTicketPolicyDto.From).ToList() + ) + ); + } + + /// + /// Adds a battle ticket policy. purchasePrices must have exactly + /// maxPurchasableTicketsPerSeason entries, matching the UI validation. + /// + [HttpPost("battle")] + public async Task>> AddBattlePolicy( + [FromBody] AddBattleTicketPolicyRequest request + ) + { + if (request.PurchasePrices.Count != request.MaxPurchasableTicketsPerSeason) + { + return BadRequest( + ApiResponse.Error( + "Battle ticket prices must match the maximum season purchase count." + ) + ); + } + + try + { + var policy = await _battlePolicyRepository.AddBattlePolicyAsync( + new BattleTicketPolicy + { + Name = request.Name, + DefaultTicketsPerRound = request.DefaultTicketsPerRound, + MaxPurchasableTicketsPerRound = request.MaxPurchasableTicketsPerRound, + MaxPurchasableTicketsPerSeason = request.MaxPurchasableTicketsPerSeason, + PurchasePrices = request.PurchasePrices + } + ); + return Ok( + ApiResponse.Ok( + BattleTicketPolicyDto.From(policy), + "Battle ticket policy added." + ) + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add battle ticket policy."); + return StatusCode( + 500, + ApiResponse.Error($"Failed to add battle ticket policy: {ex.Message}") + ); + } + } + + /// + /// Adds a refresh ticket policy. purchasePrices must have exactly + /// maxPurchasableTicketsPerRound entries, matching the UI validation. + /// + [HttpPost("refresh")] + public async Task>> AddRefreshPolicy( + [FromBody] AddRefreshTicketPolicyRequest request + ) + { + if (request.DefaultTicketsPerRound < 1) + { + return BadRequest( + ApiResponse.Error( + "Refresh tickets must have at least one default ticket." + ) + ); + } + + if (request.PurchasePrices.Count != request.MaxPurchasableTicketsPerRound) + { + return BadRequest( + ApiResponse.Error( + "Refresh ticket prices must match the maximum round purchase count." + ) + ); + } + + try + { + var policy = await _refreshPolicyRepository.AddRefreshPolicyAsync( + new RefreshTicketPolicy + { + Name = request.Name, + DefaultTicketsPerRound = request.DefaultTicketsPerRound, + MaxPurchasableTicketsPerRound = request.MaxPurchasableTicketsPerRound, + PurchasePrices = request.PurchasePrices + } + ); + return Ok( + ApiResponse.Ok( + RefreshTicketPolicyDto.From(policy), + "Refresh ticket policy added." + ) + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add refresh ticket policy."); + return StatusCode( + 500, + ApiResponse.Error( + $"Failed to add refresh ticket policy: {ex.Message}" + ) + ); + } + } +} diff --git a/ArenaService.BackOffice/Controllers/RankingCacheController.cs b/ArenaService.BackOffice/Controllers/RankingCacheController.cs new file mode 100644 index 0000000..995804e --- /dev/null +++ b/ArenaService.BackOffice/Controllers/RankingCacheController.cs @@ -0,0 +1,185 @@ +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Repositories; +using ArenaService.Shared.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Season / round cache preparation. Mirrors the RankingCache and CacheInitialization Blazor pages. +/// +[ApiController] +[Route("api/ranking-cache")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +[Produces("application/json")] +public class RankingCacheController : ControllerBase +{ + private readonly ISeasonRepository _seasonRepository; + private readonly ISeasonCacheRepository _seasonCacheRepository; + private readonly IRankingRepository _rankingRepository; + private readonly IRoundRepository _roundRepository; + private readonly ISeasonPreparationService _seasonPreparationService; + private readonly IRoundPreparationService _roundPreparationService; + private readonly ICacheInitializationService _cacheInitializationService; + private readonly ILogger _logger; + + public RankingCacheController( + ISeasonRepository seasonRepository, + ISeasonCacheRepository seasonCacheRepository, + IRankingRepository rankingRepository, + IRoundRepository roundRepository, + ISeasonPreparationService seasonPreparationService, + IRoundPreparationService roundPreparationService, + ICacheInitializationService cacheInitializationService, + ILogger logger + ) + { + _seasonRepository = seasonRepository; + _seasonCacheRepository = seasonCacheRepository; + _rankingRepository = rankingRepository; + _roundRepository = roundRepository; + _seasonPreparationService = seasonPreparationService; + _roundPreparationService = roundPreparationService; + _cacheInitializationService = cacheInitializationService; + _logger = logger; + } + + /// + /// Current cached block index, season and round, plus the ranking entry count of the + /// previous, current and next round. + /// + [HttpGet] + public async Task>> GetStatus() + { + try + { + var blockIndex = await _seasonCacheRepository.GetBlockIndexAsync(); + var season = await _seasonCacheRepository.GetSeasonAsync(); + var round = await _seasonCacheRepository.GetRoundAsync(); + + var counts = new List(); + for (var offset = -1; offset <= 1; offset++) + { + var roundToCheck = round.RoundIndex + offset; + if (roundToCheck < 1) + { + continue; + } + + counts.Add( + new RankingCountDto( + roundToCheck, + await _rankingRepository.GetRankingCountAsync(season.Id, roundToCheck) + ) + ); + } + + return Ok( + ApiResponse.Ok( + new RankingCacheStatusDto( + blockIndex, + new CachedSeasonDto(season.Id, season.StartBlock, season.EndBlock), + new CachedRoundDto(round.Id, round.RoundIndex, round.StartBlock, round.EndBlock), + counts + ) + ) + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read the ranking cache status."); + return StatusCode( + 500, + ApiResponse.Error($"Failed to read cache status: {ex.Message}") + ); + } + } + + /// Prepares the cache for a season using its first round. + [HttpPost("seasons/{seasonId:int}/prepare")] + public async Task> PrepareSeason(int seasonId) + { + try + { + var season = await _seasonRepository.GetSeasonAsync(seasonId, q => q.Include(s => s.Rounds)); + if (season is null) + { + return NotFound(ApiResponse.Error($"Season {seasonId} not found.")); + } + + var firstRound = season.Rounds.OrderBy(r => r.RoundIndex).FirstOrDefault(); + if (firstRound is null) + { + return BadRequest(ApiResponse.Error($"Season {seasonId} has no rounds.")); + } + + await _seasonPreparationService.PrepareSeasonAsync((season, firstRound)); + return Ok(ApiResponse.Ok($"Season {seasonId} initialized.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to prepare season {SeasonId}.", seasonId); + return StatusCode(500, ApiResponse.Error($"Failed to prepare season: {ex.Message}")); + } + } + + /// Prepares the round following , taking a ranking snapshot. + [HttpPost("rounds/{roundId:int}/prepare-next")] + public async Task> PrepareNextRound(int roundId) + { + try + { + var round = await _roundRepository.GetRoundAsync(roundId, q => q.Include(r => r.Season)); + if (round is null) + { + return NotFound(ApiResponse.Error($"Round {roundId} not found.")); + } + + var season = await _seasonRepository.GetSeasonAsync( + round.SeasonId, + q => q.Include(s => s.Rounds) + ); + + await _roundPreparationService.PrepareNextRoundWithSnapshotAsync((season, round)); + return Ok(ApiResponse.Ok($"Next round after {roundId} prepared.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to prepare the round after {RoundId}.", roundId); + return StatusCode(500, ApiResponse.Error($"Failed to prepare next round: {ex.Message}")); + } + } + + /// + /// Rebuilds the ranking cache for the currently cached season and round. + /// + /// + /// Passes the cached round's Id as the service's round argument, exactly as the + /// CacheInitialization Blazor page does. + /// + [HttpPost("initialize")] + public async Task> InitializeRankingCache() + { + try + { + var season = await _seasonCacheRepository.GetSeasonAsync(); + var round = await _seasonCacheRepository.GetRoundAsync(); + + var result = await _cacheInitializationService.InitializeRankingCacheAsync(season.Id, round.Id); + return result + ? Ok(ApiResponse.Ok("Ranking cache initialized.")) + : StatusCode(500, ApiResponse.Error("Ranking cache initialization failed.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize the ranking cache."); + return StatusCode( + 500, + ApiResponse.Error($"Failed to initialize ranking cache: {ex.Message}") + ); + } + } +} diff --git a/ArenaService.BackOffice/Controllers/SeasonsController.cs b/ArenaService.BackOffice/Controllers/SeasonsController.cs new file mode 100644 index 0000000..62e214f --- /dev/null +++ b/ArenaService.BackOffice/Controllers/SeasonsController.cs @@ -0,0 +1,221 @@ +using ArenaService.BackOffice.Authentication; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Repositories; +using ArenaService.Shared.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ArenaService.BackOffice.Controllers; + +/// +/// Season / round management. Mirrors the ManageSeasons Blazor page. +/// +[ApiController] +[Route("api/seasons")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.AuthenticationScheme)] +[Produces("application/json")] +public class SeasonsController : ControllerBase +{ + private readonly ISeasonRepository _seasonRepository; + private readonly ISeasonCacheRepository _seasonCacheRepository; + private readonly ISeasonBlockAdjustmentService _seasonBlockAdjustmentService; + private readonly ISeasonService _seasonService; + private readonly ILogger _logger; + + public SeasonsController( + ISeasonRepository seasonRepository, + ISeasonCacheRepository seasonCacheRepository, + ISeasonBlockAdjustmentService seasonBlockAdjustmentService, + ISeasonService seasonService, + ILogger logger + ) + { + _seasonRepository = seasonRepository; + _seasonCacheRepository = seasonCacheRepository; + _seasonBlockAdjustmentService = seasonBlockAdjustmentService; + _seasonService = seasonService; + _logger = logger; + } + + /// Gets a page of seasons with their rounds and ticket policies. + /// deletable reflects whether the season starts after the cached block index. + [HttpGet] + public async Task>>> GetSeasons( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10 + ) + { + if (page < 1 || pageSize < 1) + { + return BadRequest( + ApiResponse>.Error("page and pageSize must be positive.") + ); + } + + var seasons = await _seasonRepository.GetSeasonsPagedAsync( + page, + pageSize, + q => q.Include(s => s.Rounds).Include(s => s.BattleTicketPolicy).Include(s => s.RefreshTicketPolicy) + ); + var totalCount = await _seasonRepository.GetTotalSeasonsCountAsync(); + + long? currentBlockIndex = null; + try + { + currentBlockIndex = await _seasonCacheRepository.GetBlockIndexAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read the cached block index; deletable will be null."); + } + + var items = seasons + .Select(s => SeasonDto.From(s, currentBlockIndex is null ? null : s.StartBlock >= currentBlockIndex)) + .ToList(); + + var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); + return Ok( + ApiResponse>.Ok( + new PagedResult(items, page, pageSize, totalCount, totalPages) + ) + ); + } + + /// Gets a single season with its rounds. + [HttpGet("{id:int}")] + public async Task>> GetSeason(int id) + { + try + { + var season = await _seasonRepository.GetSeasonAsync(id, q => q.Include(s => s.Rounds)); + return Ok(ApiResponse.Ok(SeasonDto.From(season))); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Season {SeasonId} lookup failed.", id); + return NotFound(ApiResponse.Error($"Season {id} not found.")); + } + } + + /// Gets the block the next season should start at (last season end block + 1). + [HttpGet("next-start-block")] + public async Task>> GetNextStartBlock() + { + var lastEndBlock = await _seasonRepository.GetLastSeasonEndBlockAsync(); + return Ok(ApiResponse.Ok(lastEndBlock + 1 ?? 1)); + } + + /// Creates a season together with its rounds. + [HttpPost] + public async Task>> AddSeason([FromBody] AddSeasonRequest request) + { + var endBlock = request.StartBlock + (long)request.RoundInterval * request.RoundCount - 1; + if (await _seasonRepository.IsBlockRangeOverlappingAsync(request.StartBlock, endBlock)) + { + return Conflict( + ApiResponse.Error( + $"Block range {request.StartBlock}-{endBlock} overlaps an existing season." + ) + ); + } + + try + { + var season = await _seasonRepository.AddSeasonWithRoundsAsync( + request.StartBlock, + request.RoundInterval, + request.RoundCount, + request.SeasonGroupId, + request.ArenaType, + request.RequiredMedalCount, + request.TotalPrize, + request.BattleTicketPolicyId, + request.RefreshTicketPolicyId + ); + return Ok(ApiResponse.Ok(SeasonDto.From(season), "Season created.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add season."); + return StatusCode(500, ApiResponse.Error($"Failed to add season: {ex.Message}")); + } + } + + /// Updates the editable metadata of a season. Block ranges are untouched. + [HttpPut("{id:int}")] + public async Task>> UpdateSeason( + int id, + [FromBody] UpdateSeasonRequest request + ) + { + try + { + var season = await _seasonRepository.UpdateSeasonAsync( + id, + request.SeasonGroupId, + request.ArenaType, + request.RoundInterval, + request.RequiredMedalCount, + request.TotalPrize, + request.BattleTicketPolicyId, + request.RefreshTicketPolicyId + ); + return Ok(ApiResponse.Ok(SeasonDto.From(season), "Season updated.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update season {SeasonId}.", id); + return StatusCode(500, ApiResponse.Error($"Failed to update season: {ex.Message}")); + } + } + + /// Moves the end block of a season, re-aligning its rounds. + [HttpPost("{id:int}/end-block")] + public async Task>> AdjustEndBlock( + int id, + [FromBody] AdjustEndBlockRequest request + ) + { + try + { + var season = await _seasonBlockAdjustmentService.AdjustSeasonEndBlockAsync( + id, + request.NewEndBlock + ); + return Ok(ApiResponse.Ok(SeasonDto.From(season), "Season end block adjusted.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to adjust end block of season {SeasonId}.", id); + return StatusCode( + 500, + ApiResponse.Error($"Failed to adjust season end block: {ex.Message}") + ); + } + } + + /// Deletes a season that has not started yet. + [HttpDelete("{id:int}")] + public async Task> DeleteSeason(int id) + { + try + { + var currentBlockIndex = await _seasonCacheRepository.GetBlockIndexAsync(); + if (!await _seasonService.CanDeleteSeasonAsync(id, currentBlockIndex)) + { + return Conflict( + ApiResponse.Error($"Season {id} has already started and cannot be deleted.") + ); + } + + await _seasonService.DeleteSeasonAsync(id); + return Ok(ApiResponse.Ok("Season deleted.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete season {SeasonId}.", id); + return StatusCode(500, ApiResponse.Error($"Failed to delete season: {ex.Message}")); + } + } +} diff --git a/ArenaService.BackOffice/Models/ApiModels.cs b/ArenaService.BackOffice/Models/ApiModels.cs new file mode 100644 index 0000000..f2d2ead --- /dev/null +++ b/ArenaService.BackOffice/Models/ApiModels.cs @@ -0,0 +1,262 @@ +using System.ComponentModel.DataAnnotations; +using ArenaService.Shared.Constants; +using ArenaService.Shared.Models; +using ArenaService.Shared.Models.BattleTicket; +using ArenaService.Shared.Models.Enums; +using ArenaService.Shared.Models.RefreshTicket; +using ArenaService.Shared.Models.Ticket; + +namespace ArenaService.BackOffice.Models; + +// ── Responses ──────────────────────────────────────────────────────────────── +// EF navigation properties are cyclic (Season <-> Round), so entities are never +// serialized directly. Every controller projects into the records below. + +public record RoundDto(int Id, int SeasonId, int RoundIndex, long StartBlock, long EndBlock) +{ + public static RoundDto From(Round r) => new(r.Id, r.SeasonId, r.RoundIndex, r.StartBlock, r.EndBlock); +} + +public record SeasonDto( + int Id, + int SeasonGroupId, + long StartBlock, + long EndBlock, + ArenaType ArenaType, + int RoundInterval, + int RequiredMedalCount, + int TotalPrize, + string PrizeDetailUrl, + int BattleTicketPolicyId, + int RefreshTicketPolicyId, + List? Rounds, + bool? Deletable +) +{ + public static SeasonDto From(Season s, bool? deletable = null, bool includeRounds = true) => + new( + s.Id, + s.SeasonGroupId, + s.StartBlock, + s.EndBlock, + s.ArenaType, + s.RoundInterval, + s.RequiredMedalCount, + s.TotalPrize, + s.PrizeDetailUrl, + s.BattleTicketPolicyId, + s.RefreshTicketPolicyId, + includeRounds && s.Rounds is not null + ? s.Rounds.OrderBy(r => r.StartBlock).Select(RoundDto.From).ToList() + : null, + deletable + ); +} + +public record PagedResult(List Items, int Page, int PageSize, int TotalCount, int TotalPages); + +public record BattleTicketPolicyDto( + int Id, + string Name, + int DefaultTicketsPerRound, + int MaxPurchasableTicketsPerRound, + int MaxPurchasableTicketsPerSeason, + List PurchasePrices +) +{ + public static BattleTicketPolicyDto From(BattleTicketPolicy p) => + new( + p.Id, + p.Name, + p.DefaultTicketsPerRound, + p.MaxPurchasableTicketsPerRound, + p.MaxPurchasableTicketsPerSeason, + p.PurchasePrices + ); +} + +public record RefreshTicketPolicyDto( + int Id, + string Name, + int DefaultTicketsPerRound, + int MaxPurchasableTicketsPerRound, + List PurchasePrices +) +{ + public static RefreshTicketPolicyDto From(RefreshTicketPolicy p) => + new(p.Id, p.Name, p.DefaultTicketsPerRound, p.MaxPurchasableTicketsPerRound, p.PurchasePrices); +} + +public record BattleDto( + int Id, + string AvatarAddress, + int SeasonId, + int RoundId, + int AvailableOpponentId, + BattleStatus BattleStatus, + string? TxId, + TxStatus? TxStatus, + string? ExceptionNames, + bool? Reviewed, + bool? IsVictory, + int? MyScoreChange, + int? OpponentScoreChange, + DateTime CreatedAt +) +{ + public static BattleDto From(Battle b) => + new( + b.Id, + b.AvatarAddress.ToString(), + b.SeasonId, + b.RoundId, + b.AvailableOpponentId, + b.BattleStatus, + b.TxId?.ToString(), + b.TxStatus, + b.ExceptionNames, + b.Reviewed, + b.IsVictory, + b.MyScoreChange, + b.OpponentScoreChange, + b.CreatedAt + ); +} + +public record TicketPurchaseLogDto( + int Id, + string TicketType, + string AvatarAddress, + int SeasonId, + int RoundId, + decimal? AmountPaid, + PurchaseStatus PurchaseStatus, + int PurchaseCount, + string TxId, + TxStatus? TxStatus, + string? ExceptionNames, + bool? Reviewed, + DateTime CreatedAt +) +{ + public static TicketPurchaseLogDto From(TicketPurchaseLog log) => + new( + log.Id, + log is BattleTicketPurchaseLog ? "battle" : "refresh", + log.AvatarAddress.ToString(), + log.SeasonId, + log.RoundId, + log.AmountPaid, + log.PurchaseStatus, + log.PurchaseCount, + log.TxId.ToString(), + log.TxStatus, + log.ExceptionNames, + log.Reviewed, + log.CreatedAt + ); +} + +public record LeaderboardEntryDto( + int Rank, + int Score, + string AvatarAddress, + string AgentAddress, + int Level, + long Cp, + int PortraitId, + int TotalWin, + int TotalLose +); + +public record CachedSeasonDto(int Id, long StartBlock, long EndBlock); + +public record CachedRoundDto(int Id, int RoundIndex, long StartBlock, long EndBlock); + +public record RankingCountDto(int RoundIndex, int RankingCount); + +public record RankingCacheStatusDto( + long BlockIndex, + CachedSeasonDto? CurrentSeason, + CachedRoundDto? CurrentRound, + List RankingCounts +); + +// ── Requests ───────────────────────────────────────────────────────────────── + +public class AddSeasonRequest +{ + [Required] + public long StartBlock { get; set; } + + [Range(1, int.MaxValue)] + public int RoundInterval { get; set; } + + [Range(1, int.MaxValue)] + public int RoundCount { get; set; } + + public int SeasonGroupId { get; set; } + + public ArenaType ArenaType { get; set; } = ArenaType.SEASON; + + public int RequiredMedalCount { get; set; } + + public int TotalPrize { get; set; } + + [Range(1, int.MaxValue)] + public int BattleTicketPolicyId { get; set; } + + [Range(1, int.MaxValue)] + public int RefreshTicketPolicyId { get; set; } +} + +public class UpdateSeasonRequest +{ + public int SeasonGroupId { get; set; } + public ArenaType ArenaType { get; set; } + + [Range(1, int.MaxValue)] + public int RoundInterval { get; set; } + + public int RequiredMedalCount { get; set; } + public int TotalPrize { get; set; } + + [Range(1, int.MaxValue)] + public int BattleTicketPolicyId { get; set; } + + [Range(1, int.MaxValue)] + public int RefreshTicketPolicyId { get; set; } +} + +public class AdjustEndBlockRequest +{ + [Required] + public long NewEndBlock { get; set; } +} + +public class AddBattleTicketPolicyRequest +{ + [Required] + public string Name { get; set; } = string.Empty; + + public int DefaultTicketsPerRound { get; set; } + public int MaxPurchasableTicketsPerRound { get; set; } + public int MaxPurchasableTicketsPerSeason { get; set; } + + /// Must contain exactly entries. + [Required] + public List PurchasePrices { get; set; } = new(); +} + +public class AddRefreshTicketPolicyRequest +{ + [Required] + public string Name { get; set; } = string.Empty; + + public int DefaultTicketsPerRound { get; set; } + public int MaxPurchasableTicketsPerRound { get; set; } + + /// Must contain exactly entries. + [Required] + public List PurchasePrices { get; set; } = new(); +} diff --git a/ArenaService.BackOffice/Models/ApiResponse.cs b/ArenaService.BackOffice/Models/ApiResponse.cs new file mode 100644 index 0000000..5f72054 --- /dev/null +++ b/ArenaService.BackOffice/Models/ApiResponse.cs @@ -0,0 +1,35 @@ +namespace ArenaService.BackOffice.Models; + +/// Common response envelope shared by every back office API controller. +public class ApiResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } + + public static ApiResponse Ok(T data, string? message = null) => + new() + { + Success = true, + Message = message, + Data = data + }; + + public static ApiResponse Error(string message) => + new() + { + Success = false, + Message = message, + Data = default + }; +} + +public class ApiResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + + public static ApiResponse Ok(string? message = null) => new() { Success = true, Message = message }; + + public static ApiResponse Error(string message) => new() { Success = false, Message = message }; +} diff --git a/ArenaService.BackOffice/Program.cs b/ArenaService.BackOffice/Program.cs index dcf4e98..26a8aff 100644 --- a/ArenaService.BackOffice/Program.cs +++ b/ArenaService.BackOffice/Program.cs @@ -1,6 +1,8 @@ using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Headers; +using System.Reflection; using System.Text; +using ArenaService.BackOffice.Authentication; using ArenaService.BackOffice.Components; using ArenaService.BackOffice.Options; using ArenaService.Options; @@ -15,6 +17,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); @@ -29,6 +32,59 @@ // Add services to the container. builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +// REST API surface (API key authenticated) alongside the Blazor UI. +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc( + "v1", + new OpenApiInfo + { + Title = "ArenaService BackOffice API", + Version = "v1", + Description = "Arena back office operations exposed as REST endpoints." + } + ); + + options.AddSecurityDefinition( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + new OpenApiSecurityScheme + { + Name = ApiKeyAuthenticationDefaults.HeaderName, + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Description = $"API key authentication using the {ApiKeyAuthenticationDefaults.HeaderName} header." + } + ); + + options.AddSecurityRequirement( + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = ApiKeyAuthenticationDefaults.AuthenticationScheme + } + }, + Array.Empty() + } + } + ); + + var xmlPath = Path.Combine( + AppContext.BaseDirectory, + $"{Assembly.GetExecutingAssembly().GetName().Name}.xml" + ); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } +}); + // Add Google Authentication builder .Services.AddAuthentication(options => @@ -50,7 +106,11 @@ googleOptions.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; googleOptions.CorrelationCookie.SameSite = SameSiteMode.Lax; - }); + }) + .AddScheme( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + null + ); builder.Services.AddDbContext(options => options @@ -185,6 +245,10 @@ headlessOptions.Value.JwtSecretKey is not null app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + +app.UseSwagger(); +app.UseSwaggerUI(); + app.UseAntiforgery(); app.MapGet( @@ -208,6 +272,8 @@ await context.ChallengeAsync( } ); +app.MapControllers(); + app.MapRazorComponents().AddInteractiveServerRenderMode().RequireAuthorization(); app.Run(); From fd82fb9cc23f65d2df36196a4d133d35c8e00696 Mon Sep 17 00:00:00 2001 From: Atralupus Date: Mon, 31 Aug 2026 17:10:57 +0900 Subject: [PATCH 2/2] Address review points found in #189 and add unit tests Apply the fixes #189 already landed on its own admin API, and cover the back office controllers with tests. - AddSeasonWithRoundsAsync computed endBlock with an int multiplication that could overflow before widening to long. Compute it checked, on long. - Ticket purchase review now takes a type parameter and updates refresh ticket purchase logs too. It previously only updated battle ticket logs, mirroring a limitation of the Blazor page even though UpdateRefreshTicketPurchaseLog exists. - Unreviewed battle and refresh purchases are now queried concurrently. - Addresses are lower cased, matching the existing API responses and the leaderboard CSV. Tests live in a new ArenaService.BackOffice.Tests project rather than ArenaService.Tests: both web projects generate ArenaService.Client types, so a single test project referencing both cannot resolve IHeadlessClient. The project is added to the solution. The CI matrix in .github/workflows/test.yaml still lists only ArenaService.Tests, so the new project is not run yet. Adding it needs a token with the workflow scope; see the pull request description. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LsR64huwuS3v6GB8RejLCy --- .../ApiKeyAuthenticationHandlerTests.cs | 119 +++++++++ .../ArenaService.BackOffice.Tests.csproj | 26 ++ .../ConfirmTxControllerTests.cs | 134 ++++++++++ .../LeaderboardControllerTests.cs | 109 +++++++++ .../PoliciesControllerTests.cs | 148 +++++++++++ .../RankingCacheControllerTests.cs | 137 +++++++++++ .../SeasonsControllerTests.cs | 231 ++++++++++++++++++ .../Controllers/ConfirmTxController.cs | 63 ++++- .../Controllers/LeaderboardController.cs | 4 +- ArenaService.BackOffice/Models/ApiModels.cs | 4 +- .../Repositories/SeasonRepository.cs | 6 +- ArenaService.sln | 6 + 12 files changed, 969 insertions(+), 18 deletions(-) create mode 100644 ArenaService.BackOffice.Tests/ApiKeyAuthenticationHandlerTests.cs create mode 100644 ArenaService.BackOffice.Tests/ArenaService.BackOffice.Tests.csproj create mode 100644 ArenaService.BackOffice.Tests/ConfirmTxControllerTests.cs create mode 100644 ArenaService.BackOffice.Tests/LeaderboardControllerTests.cs create mode 100644 ArenaService.BackOffice.Tests/PoliciesControllerTests.cs create mode 100644 ArenaService.BackOffice.Tests/RankingCacheControllerTests.cs create mode 100644 ArenaService.BackOffice.Tests/SeasonsControllerTests.cs diff --git a/ArenaService.BackOffice.Tests/ApiKeyAuthenticationHandlerTests.cs b/ArenaService.BackOffice.Tests/ApiKeyAuthenticationHandlerTests.cs new file mode 100644 index 0000000..6ff4f02 --- /dev/null +++ b/ArenaService.BackOffice.Tests/ApiKeyAuthenticationHandlerTests.cs @@ -0,0 +1,119 @@ +using System.Text.Encodings.Web; +using ArenaService.BackOffice.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class ApiKeyAuthenticationHandlerTests +{ + private const string ConfiguredKey = "configured-secret"; + + private static async Task AuthenticateAsync( + string? configuredKey, + string? providedKey, + bool sendHeader = true + ) + { + var settings = new Dictionary(); + if (configuredKey is not null) + { + settings[ApiKeyAuthenticationDefaults.ConfigurationKey] = configuredKey; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var optionsMonitor = new Mock>(); + optionsMonitor + .Setup(o => o.Get(It.IsAny())) + .Returns(new AuthenticationSchemeOptions()); + + var handler = new ApiKeyAuthenticationHandler( + optionsMonitor.Object, + NullLoggerFactory.Instance, + UrlEncoder.Default, + configuration + ); + + var context = new DefaultHttpContext(); + if (sendHeader) + { + context.Request.Headers[ApiKeyAuthenticationDefaults.HeaderName] = providedKey; + } + + await handler.InitializeAsync( + new AuthenticationScheme( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + null, + typeof(ApiKeyAuthenticationHandler) + ), + context + ); + + return await handler.AuthenticateAsync(); + } + + [Fact] + public async Task WhenApiKeyMatches_ShouldSucceed() + { + var result = await AuthenticateAsync(ConfiguredKey, ConfiguredKey); + + Assert.True(result.Succeeded); + Assert.Equal( + ApiKeyAuthenticationDefaults.AuthenticationScheme, + result.Ticket!.AuthenticationScheme + ); + Assert.Equal("ApiUser", result.Principal!.Identity!.Name); + } + + [Fact] + public async Task WhenHeaderIsMissing_ShouldReturnNoResult() + { + var result = await AuthenticateAsync(ConfiguredKey, null, sendHeader: false); + + Assert.False(result.Succeeded); + Assert.True(result.None); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task WhenHeaderIsBlank_ShouldFail(string providedKey) + { + var result = await AuthenticateAsync(ConfiguredKey, providedKey); + + Assert.False(result.Succeeded); + Assert.False(result.None); + } + + [Fact] + public async Task WhenApiKeyDoesNotMatch_ShouldFail() + { + var result = await AuthenticateAsync(ConfiguredKey, "wrong-secret"); + + Assert.False(result.Succeeded); + Assert.Equal("Invalid API Key.", result.Failure!.Message); + } + + [Fact] + public async Task WhenApiKeyDiffersOnlyByCase_ShouldFail() + { + var result = await AuthenticateAsync(ConfiguredKey, ConfiguredKey.ToUpperInvariant()); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task WhenApiKeyIsNotConfigured_ShouldFail() + { + var result = await AuthenticateAsync(null, ConfiguredKey); + + Assert.False(result.Succeeded); + Assert.Equal("API Key authentication is not configured.", result.Failure!.Message); + } +} diff --git a/ArenaService.BackOffice.Tests/ArenaService.BackOffice.Tests.csproj b/ArenaService.BackOffice.Tests/ArenaService.BackOffice.Tests.csproj new file mode 100644 index 0000000..8becc47 --- /dev/null +++ b/ArenaService.BackOffice.Tests/ArenaService.BackOffice.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/ArenaService.BackOffice.Tests/ConfirmTxControllerTests.cs b/ArenaService.BackOffice.Tests/ConfirmTxControllerTests.cs new file mode 100644 index 0000000..3076a4b --- /dev/null +++ b/ArenaService.BackOffice.Tests/ConfirmTxControllerTests.cs @@ -0,0 +1,134 @@ +using ArenaService.BackOffice.Controllers; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Models; +using ArenaService.Shared.Models.BattleTicket; +using ArenaService.Shared.Models.RefreshTicket; +using ArenaService.Shared.Repositories; +using Libplanet.Crypto; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class ConfirmTxControllerTests +{ + private readonly Mock _battleRepo = new(); + private readonly Mock _ticketRepo = new(); + private readonly ConfirmTxController _controller; + + public ConfirmTxControllerTests() + { + _controller = new ConfirmTxController( + _battleRepo.Object, + _ticketRepo.Object, + NullLogger.Instance + ); + } + + [Fact] + public async Task GetUnreviewedBattles_ShouldLowerCaseAvatarAddress() + { + var address = new PrivateKey().Address; + _battleRepo + .Setup(r => r.GetUnReviewedBattlesAsync()) + .ReturnsAsync(new List { new() { Id = 7, AvatarAddress = address, SeasonId = 1, RoundId = 2, Token = "token" } }); + + var ok = Assert.IsType((await _controller.GetUnreviewedBattles()).Result); + var payload = Assert.IsType>>(ok.Value); + + Assert.Equal(address.ToString().ToLower(), payload.Data![0].AvatarAddress); + } + + [Fact] + public async Task GetUnreviewedTicketPurchases_ShouldMergeBothTicketTypes() + { + _ticketRepo + .Setup(r => r.GetUnReviewedBattleTicketPurchasesAsync()) + .ReturnsAsync(new List + { + new() { Id = 1, AvatarAddress = new PrivateKey().Address } + }); + _ticketRepo + .Setup(r => r.GetUnReviewedRefreshTicketPurchasesAsync()) + .ReturnsAsync(new List + { + new() { Id = 2, AvatarAddress = new PrivateKey().Address } + }); + + var ok = Assert.IsType((await _controller.GetUnreviewedTicketPurchases()).Result); + var payload = Assert.IsType>>(ok.Value); + + Assert.Equal(2, payload.Data!.Count); + Assert.Contains(payload.Data!, d => d.TicketType == "battle"); + Assert.Contains(payload.Data!, d => d.TicketType == "refresh"); + } + + [Fact] + public async Task MarkBattleAsReviewed_ShouldUpdateBattle() + { + _battleRepo + .Setup(r => r.UpdateBattle(3, It.IsAny>())) + .ReturnsAsync(new Battle { Id = 3, Token = "token" }); + + Assert.IsType((await _controller.MarkBattleAsReviewed(3)).Result); + _battleRepo.Verify(r => r.UpdateBattle(3, It.IsAny>()), Times.Once); + } + + [Theory] + [InlineData("battle")] + [InlineData("BATTLE")] + public async Task MarkTicketPurchaseAsReviewed_WithBattleType_ShouldUpdateBattleLog(string type) + { + _ticketRepo + .Setup(r => r.UpdateBattleTicketPurchaseLog(5, It.IsAny>())) + .ReturnsAsync(new BattleTicketPurchaseLog { Id = 5 }); + + Assert.IsType((await _controller.MarkTicketPurchaseAsReviewed(5, type)).Result); + _ticketRepo.Verify( + r => r.UpdateBattleTicketPurchaseLog(5, It.IsAny>()), + Times.Once + ); + _ticketRepo.Verify( + r => r.UpdateRefreshTicketPurchaseLog(It.IsAny(), It.IsAny>()), + Times.Never + ); + } + + [Theory] + [InlineData("refresh")] + [InlineData("Refresh")] + public async Task MarkTicketPurchaseAsReviewed_WithRefreshType_ShouldUpdateRefreshLog(string type) + { + _ticketRepo + .Setup(r => r.UpdateRefreshTicketPurchaseLog(6, It.IsAny>())) + .ReturnsAsync(new RefreshTicketPurchaseLog { Id = 6 }); + + Assert.IsType((await _controller.MarkTicketPurchaseAsReviewed(6, type)).Result); + _ticketRepo.Verify( + r => r.UpdateRefreshTicketPurchaseLog(6, It.IsAny>()), + Times.Once + ); + _ticketRepo.Verify( + r => r.UpdateBattleTicketPurchaseLog(It.IsAny(), It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task MarkTicketPurchaseAsReviewed_WithUnknownType_ShouldReturnBadRequest() + { + var result = await _controller.MarkTicketPurchaseAsReviewed(7, "foo"); + + Assert.IsType(result.Result); + _ticketRepo.Verify( + r => r.UpdateBattleTicketPurchaseLog(It.IsAny(), It.IsAny>()), + Times.Never + ); + _ticketRepo.Verify( + r => r.UpdateRefreshTicketPurchaseLog(It.IsAny(), It.IsAny>()), + Times.Never + ); + } +} diff --git a/ArenaService.BackOffice.Tests/LeaderboardControllerTests.cs b/ArenaService.BackOffice.Tests/LeaderboardControllerTests.cs new file mode 100644 index 0000000..187827f --- /dev/null +++ b/ArenaService.BackOffice.Tests/LeaderboardControllerTests.cs @@ -0,0 +1,109 @@ +using ArenaService.BackOffice.Controllers; +using ArenaService.BackOffice.Models; +using ArenaService.Client; +using ArenaService.Options; +using ArenaService.Shared.Models; +using ArenaService.Shared.Repositories; +using Libplanet.Crypto; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class LeaderboardControllerTests +{ + private readonly Mock _leaderboardRepo = new(); + private readonly Mock _seasonRepo = new(); + private readonly Mock _headlessClient = new(); + private readonly LeaderboardController _controller; + + public LeaderboardControllerTests() + { + _controller = new LeaderboardController( + _leaderboardRepo.Object, + _seasonRepo.Object, + _headlessClient.Object, + Microsoft.Extensions.Options.Options.Create( + new HeadlessOptions { Planet = "odin", HeadlessEndpoint = new Uri("http://localhost") } + ), + NullLogger.Instance + ); + } + + private static (Participant, int, int) CreateEntry(Address avatarAddress, Address agentAddress, int score, int rank) => + ( + new Participant + { + AvatarAddress = avatarAddress, + SeasonId = 1, + Score = score, + TotalWin = 3, + TotalLose = 2, + User = new User + { + AvatarAddress = avatarAddress, + AgentAddress = agentAddress, + Level = 200, + Cp = 12345, + PortraitId = 10, + NameWithHash = "tester#1234" + } + }, + score, + rank + ); + + [Fact] + public async Task GetLeaderboard_ShouldLowerCaseAddresses() + { + var avatarAddress = new PrivateKey().Address; + var agentAddress = new PrivateKey().Address; + _leaderboardRepo + .Setup(r => r.GetLeaderboardAsync(1)) + .ReturnsAsync(new List<(Participant, int, int)> { CreateEntry(avatarAddress, agentAddress, 1500, 1) }); + + var ok = Assert.IsType((await _controller.GetLeaderboard(1)).Result); + var payload = Assert.IsType>>(ok.Value); + + Assert.Equal(avatarAddress.ToString().ToLower(), payload.Data![0].AvatarAddress); + Assert.Equal(agentAddress.ToString().ToLower(), payload.Data![0].AgentAddress); + } + + [Fact] + public async Task GetLeaderboard_ShouldProjectRankAndRecord() + { + _leaderboardRepo + .Setup(r => r.GetLeaderboardAsync(1)) + .ReturnsAsync(new List<(Participant, int, int)> + { + CreateEntry(new PrivateKey().Address, new PrivateKey().Address, 1500, 1), + CreateEntry(new PrivateKey().Address, new PrivateKey().Address, 1400, 2) + }); + + var ok = Assert.IsType((await _controller.GetLeaderboard(1)).Result); + var payload = Assert.IsType>>(ok.Value); + + Assert.Equal(new[] { 1, 2 }, payload.Data!.Select(e => e.Rank)); + Assert.Equal(new[] { 1500, 1400 }, payload.Data!.Select(e => e.Score)); + Assert.Equal(3, payload.Data![0].TotalWin); + Assert.Equal(2, payload.Data![0].TotalLose); + } + + [Fact] + public async Task GetLeaderboardCsv_ShouldNameFileWithPlanetAndBlockRange() + { + _seasonRepo + .Setup(r => r.GetSeasonAsync(1, It.IsAny, IQueryable>>())) + .ReturnsAsync(new Season { Id = 1, SeasonGroupId = 7, StartBlock = 100, EndBlock = 200 }); + _leaderboardRepo.Setup(r => r.GenerateLeaderboardCsvAsync(1)).ReturnsAsync(new byte[] { 1, 2, 3 }); + + var file = Assert.IsType(await _controller.GetLeaderboardCsv(1)); + + Assert.Equal("text/csv", file.ContentType); + Assert.Equal("odin_leaderboard_group_7_100_200.csv", file.FileDownloadName); + Assert.Equal(3, file.FileContents.Length); + } +} diff --git a/ArenaService.BackOffice.Tests/PoliciesControllerTests.cs b/ArenaService.BackOffice.Tests/PoliciesControllerTests.cs new file mode 100644 index 0000000..87c67c1 --- /dev/null +++ b/ArenaService.BackOffice.Tests/PoliciesControllerTests.cs @@ -0,0 +1,148 @@ +using ArenaService.BackOffice.Controllers; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Models.BattleTicket; +using ArenaService.Shared.Models.RefreshTicket; +using ArenaService.Shared.Repositories; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class PoliciesControllerTests +{ + private readonly Mock _battleRepo = new(); + private readonly Mock _refreshRepo = new(); + private readonly PoliciesController _controller; + + public PoliciesControllerTests() + { + _controller = new PoliciesController( + _battleRepo.Object, + _refreshRepo.Object, + NullLogger.Instance + ); + } + + private static List Prices(int count) => + Enumerable.Range(1, count).Select(i => i * 0.1m).ToList(); + + [Fact] + public async Task GetBattlePolicies_ShouldProjectEveryPolicy() + { + _battleRepo + .Setup(r => r.GetAllBattlePoliciesAsync()) + .ReturnsAsync(new List + { + new() + { + Id = 1, + Name = "policy-a", + DefaultTicketsPerRound = 5, + MaxPurchasableTicketsPerRound = 4, + MaxPurchasableTicketsPerSeason = 3, + PurchasePrices = Prices(3) + } + }); + + var ok = Assert.IsType((await _controller.GetBattlePolicies()).Result); + var payload = Assert.IsType>>(ok.Value); + + Assert.Single(payload.Data!); + Assert.Equal("policy-a", payload.Data![0].Name); + Assert.Equal(3, payload.Data![0].PurchasePrices.Count); + } + + [Fact] + public async Task AddBattlePolicy_WhenPriceCountDoesNotMatchSeasonMax_ShouldReturnBadRequest() + { + var result = await _controller.AddBattlePolicy( + new AddBattleTicketPolicyRequest + { + Name = "bad", + MaxPurchasableTicketsPerSeason = 5, + PurchasePrices = Prices(3) + } + ); + + Assert.IsType(result.Result); + _battleRepo.Verify(r => r.AddBattlePolicyAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddBattlePolicy_WithMatchingPriceCount_ShouldPersist() + { + _battleRepo + .Setup(r => r.AddBattlePolicyAsync(It.IsAny())) + .ReturnsAsync((BattleTicketPolicy p) => p); + + var result = await _controller.AddBattlePolicy( + new AddBattleTicketPolicyRequest + { + Name = "good", + DefaultTicketsPerRound = 5, + MaxPurchasableTicketsPerRound = 2, + MaxPurchasableTicketsPerSeason = 3, + PurchasePrices = Prices(3) + } + ); + + Assert.IsType(result.Result); + _battleRepo.Verify(r => r.AddBattlePolicyAsync(It.Is(p => p.Name == "good")), Times.Once); + } + + [Fact] + public async Task AddRefreshPolicy_WithoutDefaultTicket_ShouldReturnBadRequest() + { + var result = await _controller.AddRefreshPolicy( + new AddRefreshTicketPolicyRequest + { + Name = "bad", + DefaultTicketsPerRound = 0, + MaxPurchasableTicketsPerRound = 3, + PurchasePrices = Prices(3) + } + ); + + Assert.IsType(result.Result); + _refreshRepo.Verify(r => r.AddRefreshPolicyAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddRefreshPolicy_WhenPriceCountDoesNotMatchRoundMax_ShouldReturnBadRequest() + { + var result = await _controller.AddRefreshPolicy( + new AddRefreshTicketPolicyRequest + { + Name = "bad", + DefaultTicketsPerRound = 1, + MaxPurchasableTicketsPerRound = 5, + PurchasePrices = Prices(2) + } + ); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task AddRefreshPolicy_WithValidInput_ShouldPersist() + { + _refreshRepo + .Setup(r => r.AddRefreshPolicyAsync(It.IsAny())) + .ReturnsAsync((RefreshTicketPolicy p) => p); + + var result = await _controller.AddRefreshPolicy( + new AddRefreshTicketPolicyRequest + { + Name = "good", + DefaultTicketsPerRound = 1, + MaxPurchasableTicketsPerRound = 2, + PurchasePrices = Prices(2) + } + ); + + Assert.IsType(result.Result); + _refreshRepo.Verify(r => r.AddRefreshPolicyAsync(It.Is(p => p.Name == "good")), Times.Once); + } +} diff --git a/ArenaService.BackOffice.Tests/RankingCacheControllerTests.cs b/ArenaService.BackOffice.Tests/RankingCacheControllerTests.cs new file mode 100644 index 0000000..2fdbee6 --- /dev/null +++ b/ArenaService.BackOffice.Tests/RankingCacheControllerTests.cs @@ -0,0 +1,137 @@ +using ArenaService.BackOffice.Controllers; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Constants; +using ArenaService.Shared.Models; +using ArenaService.Shared.Repositories; +using ArenaService.Shared.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class RankingCacheControllerTests +{ + private readonly Mock _seasonRepo = new(); + private readonly Mock _seasonCacheRepo = new(); + private readonly Mock _rankingRepo = new(); + private readonly Mock _roundRepo = new(); + private readonly Mock _seasonPreparation = new(); + private readonly Mock _roundPreparation = new(); + private readonly Mock _cacheInitialization = new(); + private readonly RankingCacheController _controller; + + public RankingCacheControllerTests() + { + _controller = new RankingCacheController( + _seasonRepo.Object, + _seasonCacheRepo.Object, + _rankingRepo.Object, + _roundRepo.Object, + _seasonPreparation.Object, + _roundPreparation.Object, + _cacheInitialization.Object, + NullLogger.Instance + ); + } + + private static Season CreateSeason(int id = 1, params int[] roundIndexes) => + new() + { + Id = id, + ArenaType = ArenaType.SEASON, + Rounds = roundIndexes + .Select(i => new Round { Id = 100 + i, SeasonId = id, RoundIndex = i }) + .ToList() + }; + + [Fact] + public async Task GetStatus_ShouldReturnSurroundingRoundCounts() + { + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(1234); + _seasonCacheRepo.Setup(r => r.GetSeasonAsync()).ReturnsAsync((1, 100L, 200L)); + _seasonCacheRepo.Setup(r => r.GetRoundAsync()).ReturnsAsync((11, 3, 120L, 130L)); + _rankingRepo.Setup(r => r.GetRankingCountAsync(1, It.IsAny())).ReturnsAsync(50); + + var ok = Assert.IsType((await _controller.GetStatus()).Result); + var payload = Assert.IsType>(ok.Value); + + Assert.Equal(1234, payload.Data!.BlockIndex); + Assert.Equal(new[] { 2, 3, 4 }, payload.Data!.RankingCounts.Select(c => c.RoundIndex)); + } + + [Fact] + public async Task GetStatus_OnFirstRound_ShouldSkipRoundZero() + { + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(100); + _seasonCacheRepo.Setup(r => r.GetSeasonAsync()).ReturnsAsync((1, 100L, 200L)); + _seasonCacheRepo.Setup(r => r.GetRoundAsync()).ReturnsAsync((10, 1, 100L, 110L)); + _rankingRepo.Setup(r => r.GetRankingCountAsync(1, It.IsAny())).ReturnsAsync(0); + + var ok = Assert.IsType((await _controller.GetStatus()).Result); + var payload = Assert.IsType>(ok.Value); + + Assert.Equal(new[] { 1, 2 }, payload.Data!.RankingCounts.Select(c => c.RoundIndex)); + } + + [Fact] + public async Task PrepareSeason_ShouldUseFirstRound() + { + _seasonRepo + .Setup(r => r.GetSeasonAsync(1, It.IsAny, IQueryable>>())) + .ReturnsAsync(CreateSeason(1, 3, 1, 2)); + + Assert.IsType((await _controller.PrepareSeason(1)).Result); + _seasonPreparation.Verify( + s => s.PrepareSeasonAsync(It.Is<(Season Season, Round Round)>(t => t.Round.RoundIndex == 1)), + Times.Once + ); + } + + [Fact] + public async Task PrepareSeason_WhenSeasonHasNoRounds_ShouldReturnBadRequest() + { + _seasonRepo + .Setup(r => r.GetSeasonAsync(1, It.IsAny, IQueryable>>())) + .ReturnsAsync(CreateSeason(1)); + + Assert.IsType((await _controller.PrepareSeason(1)).Result); + _seasonPreparation.Verify( + s => s.PrepareSeasonAsync(It.IsAny<(Season, Round)>()), + Times.Never + ); + } + + [Fact] + public async Task PrepareNextRound_WhenRoundIsMissing_ShouldReturnNotFound() + { + _roundRepo + .Setup(r => r.GetRoundAsync(9, It.IsAny, IQueryable>>())) + .ReturnsAsync((Round?)null); + + Assert.IsType((await _controller.PrepareNextRound(9)).Result); + } + + [Fact] + public async Task InitializeRankingCache_WhenServiceFails_ShouldReturn500() + { + _seasonCacheRepo.Setup(r => r.GetSeasonAsync()).ReturnsAsync((1, 100L, 200L)); + _seasonCacheRepo.Setup(r => r.GetRoundAsync()).ReturnsAsync((11, 3, 120L, 130L)); + _cacheInitialization.Setup(s => s.InitializeRankingCacheAsync(1, 11)).ReturnsAsync(false); + + var result = Assert.IsType((await _controller.InitializeRankingCache()).Result); + + Assert.Equal(500, result.StatusCode); + } + + [Fact] + public async Task InitializeRankingCache_WhenServiceSucceeds_ShouldReturnOk() + { + _seasonCacheRepo.Setup(r => r.GetSeasonAsync()).ReturnsAsync((1, 100L, 200L)); + _seasonCacheRepo.Setup(r => r.GetRoundAsync()).ReturnsAsync((11, 3, 120L, 130L)); + _cacheInitialization.Setup(s => s.InitializeRankingCacheAsync(1, 11)).ReturnsAsync(true); + + Assert.IsType((await _controller.InitializeRankingCache()).Result); + } +} diff --git a/ArenaService.BackOffice.Tests/SeasonsControllerTests.cs b/ArenaService.BackOffice.Tests/SeasonsControllerTests.cs new file mode 100644 index 0000000..c3601d0 --- /dev/null +++ b/ArenaService.BackOffice.Tests/SeasonsControllerTests.cs @@ -0,0 +1,231 @@ +using ArenaService.BackOffice.Controllers; +using ArenaService.BackOffice.Models; +using ArenaService.Shared.Constants; +using ArenaService.Shared.Models; +using ArenaService.Shared.Repositories; +using ArenaService.Shared.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace ArenaService.BackOffice.Tests; + +public class SeasonsControllerTests +{ + private readonly Mock _seasonRepo = new(); + private readonly Mock _seasonCacheRepo = new(); + private readonly Mock _blockAdjustment = new(); + private readonly Mock _seasonService = new(); + private readonly SeasonsController _controller; + + public SeasonsControllerTests() + { + _controller = new SeasonsController( + _seasonRepo.Object, + _seasonCacheRepo.Object, + _blockAdjustment.Object, + _seasonService.Object, + NullLogger.Instance + ); + } + + private static Season CreateSeason(int id = 1, long startBlock = 100, long endBlock = 199) => + new() + { + Id = id, + SeasonGroupId = 1, + StartBlock = startBlock, + EndBlock = endBlock, + ArenaType = ArenaType.SEASON, + RoundInterval = 10, + RequiredMedalCount = 0, + TotalPrize = 100, + BattleTicketPolicyId = 1, + RefreshTicketPolicyId = 1, + Rounds = new List() + }; + + private static T Unwrap(ActionResult> result) + { + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType>(ok.Value); + Assert.True(payload.Success); + return payload.Data!; + } + + [Fact] + public async Task GetSeasons_ShouldReturnPagedResultWithTotals() + { + _seasonRepo + .Setup(r => r.GetSeasonsPagedAsync(1, 10, It.IsAny, IQueryable>>())) + .ReturnsAsync(new List { CreateSeason() }); + _seasonRepo.Setup(r => r.GetTotalSeasonsCountAsync()).ReturnsAsync(25); + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(50); + + var data = Unwrap(await _controller.GetSeasons()); + + Assert.Single(data.Items); + Assert.Equal(25, data.TotalCount); + Assert.Equal(3, data.TotalPages); + } + + [Fact] + public async Task GetSeasons_WhenSeasonHasNotStarted_ShouldMarkDeletable() + { + _seasonRepo + .Setup(r => r.GetSeasonsPagedAsync(1, 10, It.IsAny, IQueryable>>())) + .ReturnsAsync(new List { CreateSeason(startBlock: 100), CreateSeason(id: 2, startBlock: 10, endBlock: 40) }); + _seasonRepo.Setup(r => r.GetTotalSeasonsCountAsync()).ReturnsAsync(2); + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(50); + + var data = Unwrap(await _controller.GetSeasons()); + + Assert.True(data.Items[0].Deletable); + Assert.False(data.Items[1].Deletable); + } + + [Fact] + public async Task GetSeasons_WhenCacheIsUnavailable_ShouldLeaveDeletableNull() + { + _seasonRepo + .Setup(r => r.GetSeasonsPagedAsync(1, 10, It.IsAny, IQueryable>>())) + .ReturnsAsync(new List { CreateSeason() }); + _seasonRepo.Setup(r => r.GetTotalSeasonsCountAsync()).ReturnsAsync(1); + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ThrowsAsync(new InvalidOperationException()); + + var data = Unwrap(await _controller.GetSeasons()); + + Assert.Null(data.Items[0].Deletable); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(1, 0)] + public async Task GetSeasons_WithNonPositivePaging_ShouldReturnBadRequest(int page, int pageSize) + { + var result = await _controller.GetSeasons(page, pageSize); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetSeason_WhenMissing_ShouldReturnNotFound() + { + _seasonRepo + .Setup(r => r.GetSeasonAsync(99, It.IsAny, IQueryable>>())) + .ThrowsAsync(new InvalidOperationException("missing")); + + var result = await _controller.GetSeason(99); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task GetNextStartBlock_ShouldReturnLastEndBlockPlusOne() + { + _seasonRepo.Setup(r => r.GetLastSeasonEndBlockAsync()).ReturnsAsync(199); + + Assert.Equal(200, Unwrap(await _controller.GetNextStartBlock())); + } + + [Fact] + public async Task GetNextStartBlock_WhenNoSeasonExists_ShouldReturnOne() + { + _seasonRepo.Setup(r => r.GetLastSeasonEndBlockAsync()).ReturnsAsync((int?)null); + + Assert.Equal(1, Unwrap(await _controller.GetNextStartBlock())); + } + + [Fact] + public async Task AddSeason_WhenBlockRangeOverlaps_ShouldReturnConflict() + { + _seasonRepo.Setup(r => r.IsBlockRangeOverlappingAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var result = await _controller.AddSeason( + new AddSeasonRequest + { + StartBlock = 100, + RoundInterval = 10, + RoundCount = 10, + BattleTicketPolicyId = 1, + RefreshTicketPolicyId = 1 + } + ); + + Assert.IsType(result.Result); + _seasonRepo.Verify( + r => r.AddSeasonWithRoundsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task AddSeason_ShouldComputeEndBlockWithoutIntOverflow() + { + // roundInterval * roundCount overflows int; the check must run on the widened value. + long? capturedEndBlock = null; + _seasonRepo + .Setup(r => r.IsBlockRangeOverlappingAsync(It.IsAny(), It.IsAny())) + .Callback((_, endBlock) => capturedEndBlock = endBlock) + .ReturnsAsync(false); + _seasonRepo + .Setup(r => r.AddSeasonWithRoundsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSeason()); + + await _controller.AddSeason( + new AddSeasonRequest + { + StartBlock = 1, + RoundInterval = 100_000, + RoundCount = 100_000, + BattleTicketPolicyId = 1, + RefreshTicketPolicyId = 1 + } + ); + + Assert.Equal(10_000_000_000L, capturedEndBlock); + } + + [Fact] + public async Task DeleteSeason_WhenSeasonAlreadyStarted_ShouldReturnConflict() + { + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(150); + _seasonService.Setup(s => s.CanDeleteSeasonAsync(1, 150)).ReturnsAsync(false); + + var result = await _controller.DeleteSeason(1); + + Assert.IsType(result.Result); + _seasonService.Verify(s => s.DeleteSeasonAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteSeason_ShouldUseServerSideBlockIndex() + { + _seasonCacheRepo.Setup(r => r.GetBlockIndexAsync()).ReturnsAsync(42); + _seasonService.Setup(s => s.CanDeleteSeasonAsync(1, 42)).ReturnsAsync(true); + + var result = await _controller.DeleteSeason(1); + + Assert.IsType(result.Result); + _seasonCacheRepo.Verify(r => r.GetBlockIndexAsync(), Times.Once); + _seasonService.Verify(s => s.DeleteSeasonAsync(1), Times.Once); + } + + [Fact] + public async Task AdjustEndBlock_ShouldDelegateToService() + { + _blockAdjustment + .Setup(s => s.AdjustSeasonEndBlockAsync(1, 500)) + .ReturnsAsync(CreateSeason(endBlock: 500)); + + var data = Unwrap(await _controller.AdjustEndBlock(1, new AdjustEndBlockRequest { NewEndBlock = 500 })); + + Assert.Equal(500, data.EndBlock); + } +} diff --git a/ArenaService.BackOffice/Controllers/ConfirmTxController.cs b/ArenaService.BackOffice/Controllers/ConfirmTxController.cs index a3b8707..fb296bf 100644 --- a/ArenaService.BackOffice/Controllers/ConfirmTxController.cs +++ b/ArenaService.BackOffice/Controllers/ConfirmTxController.cs @@ -17,6 +17,12 @@ namespace ArenaService.BackOffice.Controllers; [Produces("application/json")] public class ConfirmTxController : ControllerBase { + private const string BattleTicketType = "battle"; + private const string RefreshTicketType = "refresh"; + + private static readonly HashSet ValidTicketTypes = + new(new[] { BattleTicketType, RefreshTicketType }, StringComparer.OrdinalIgnoreCase); + private readonly IBattleRepository _battleRepository; private readonly ITicketRepository _ticketRepository; private readonly ILogger _logger; @@ -44,9 +50,13 @@ public async Task>>> GetUnreviewedBattl [HttpGet("ticket-purchases")] public async Task>>> GetUnreviewedTicketPurchases() { - var purchases = (await _ticketRepository.GetUnReviewedBattleTicketPurchasesAsync()) + var battlePurchasesTask = _ticketRepository.GetUnReviewedBattleTicketPurchasesAsync(); + var refreshPurchasesTask = _ticketRepository.GetUnReviewedRefreshTicketPurchasesAsync(); + await Task.WhenAll(battlePurchasesTask, refreshPurchasesTask); + + var purchases = battlePurchasesTask.Result .Cast() - .Concat(await _ticketRepository.GetUnReviewedRefreshTicketPurchasesAsync()) + .Concat(refreshPurchasesTask.Result) .ToList(); return Ok( @@ -73,28 +83,55 @@ public async Task> MarkBattleAsReviewed(int battleId) } /// - /// Marks a battle ticket purchase log as reviewed. + /// Marks a ticket purchase log as reviewed. /// - /// - /// Same as the Blazor page: only battle ticket purchase logs are updated, so a refresh - /// ticket purchase id will not be found. - /// + /// Id of the purchase log. + /// + /// Which log the id belongs to: battle or refresh. Case insensitive. + /// The list endpoint returns this as ticketType on every entry. + /// [HttpPost("ticket-purchases/{purchaseLogId:int}/review")] - public async Task> MarkTicketPurchaseAsReviewed(int purchaseLogId) + public async Task> MarkTicketPurchaseAsReviewed( + int purchaseLogId, + [FromQuery] string type = BattleTicketType + ) { - try + if (!ValidTicketTypes.Contains(type)) { - await _ticketRepository.UpdateBattleTicketPurchaseLog( - purchaseLogId, - log => log.Reviewed = true + return BadRequest( + ApiResponse.Error( + $"Unsupported ticket type '{type}'. Expected '{BattleTicketType}' or '{RefreshTicketType}'." + ) ); + } + + var isRefresh = string.Equals(type, RefreshTicketType, StringComparison.OrdinalIgnoreCase); + + try + { + if (isRefresh) + { + await _ticketRepository.UpdateRefreshTicketPurchaseLog( + purchaseLogId, + log => log.Reviewed = true + ); + } + else + { + await _ticketRepository.UpdateBattleTicketPurchaseLog( + purchaseLogId, + log => log.Reviewed = true + ); + } + return Ok(ApiResponse.Ok($"Ticket purchase {purchaseLogId} marked as reviewed.")); } catch (Exception ex) { _logger.LogError( ex, - "Failed to mark ticket purchase {PurchaseLogId} as reviewed.", + "Failed to mark {Type} ticket purchase {PurchaseLogId} as reviewed.", + type, purchaseLogId ); return StatusCode(500, ApiResponse.Error($"Ticket purchase review failed: {ex.Message}")); diff --git a/ArenaService.BackOffice/Controllers/LeaderboardController.cs b/ArenaService.BackOffice/Controllers/LeaderboardController.cs index 93bc9b9..272cb9a 100644 --- a/ArenaService.BackOffice/Controllers/LeaderboardController.cs +++ b/ArenaService.BackOffice/Controllers/LeaderboardController.cs @@ -79,8 +79,8 @@ public async Task>>> GetLeade .Select(item => new LeaderboardEntryDto( item.Rank, item.Score, - item.Participant.AvatarAddress.ToString(), - item.Participant.User.AgentAddress.ToString(), + item.Participant.AvatarAddress.ToString().ToLower(), + item.Participant.User.AgentAddress.ToString().ToLower(), item.Participant.User.Level, item.Participant.User.Cp, item.Participant.User.PortraitId, diff --git a/ArenaService.BackOffice/Models/ApiModels.cs b/ArenaService.BackOffice/Models/ApiModels.cs index f2d2ead..de03ab9 100644 --- a/ArenaService.BackOffice/Models/ApiModels.cs +++ b/ArenaService.BackOffice/Models/ApiModels.cs @@ -107,7 +107,7 @@ DateTime CreatedAt public static BattleDto From(Battle b) => new( b.Id, - b.AvatarAddress.ToString(), + b.AvatarAddress.ToString().ToLower(), b.SeasonId, b.RoundId, b.AvailableOpponentId, @@ -143,7 +143,7 @@ public static TicketPurchaseLogDto From(TicketPurchaseLog log) => new( log.Id, log is BattleTicketPurchaseLog ? "battle" : "refresh", - log.AvatarAddress.ToString(), + log.AvatarAddress.ToString().ToLower(), log.SeasonId, log.RoundId, log.AmountPaid, diff --git a/ArenaService.Shared/Repositories/SeasonRepository.cs b/ArenaService.Shared/Repositories/SeasonRepository.cs index c478efc..ea0a681 100644 --- a/ArenaService.Shared/Repositories/SeasonRepository.cs +++ b/ArenaService.Shared/Repositories/SeasonRepository.cs @@ -158,7 +158,11 @@ public async Task AddSeasonWithRoundsAsync( int refreshTicketPolicyId ) { - long endBlock = startBlock + (roundInterval * roundCount) - 1; + long endBlock; + checked + { + endBlock = startBlock + ((long)roundInterval * roundCount) - 1; + } bool isOverlapping = await IsBlockRangeOverlappingAsync(startBlock, endBlock); if (isOverlapping) diff --git a/ArenaService.sln b/ArenaService.sln index 1074b10..b388578 100644 --- a/ArenaService.sln +++ b/ArenaService.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArenaService.Shared", "Aren EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArenaService.BackOffice", "ArenaService.BackOffice\ArenaService.BackOffice.csproj", "{E48C9390-18C7-4A16-92C6-724B7499BE32}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ArenaService.BackOffice.Tests", "ArenaService.BackOffice.Tests\ArenaService.BackOffice.Tests.csproj", "{11361BBB-43E6-41B3-8A2F-CEC4351840D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,5 +56,9 @@ Global {E48C9390-18C7-4A16-92C6-724B7499BE32}.Debug|Any CPU.Build.0 = Debug|Any CPU {E48C9390-18C7-4A16-92C6-724B7499BE32}.Release|Any CPU.ActiveCfg = Release|Any CPU {E48C9390-18C7-4A16-92C6-724B7499BE32}.Release|Any CPU.Build.0 = Release|Any CPU + {11361BBB-43E6-41B3-8A2F-CEC4351840D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11361BBB-43E6-41B3-8A2F-CEC4351840D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11361BBB-43E6-41B3-8A2F-CEC4351840D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11361BBB-43E6-41B3-8A2F-CEC4351840D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal