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/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..fb296bf --- /dev/null +++ b/ArenaService.BackOffice/Controllers/ConfirmTxController.cs @@ -0,0 +1,140 @@ +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 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; + + 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 battlePurchasesTask = _ticketRepository.GetUnReviewedBattleTicketPurchasesAsync(); + var refreshPurchasesTask = _ticketRepository.GetUnReviewedRefreshTicketPurchasesAsync(); + await Task.WhenAll(battlePurchasesTask, refreshPurchasesTask); + + var purchases = battlePurchasesTask.Result + .Cast() + .Concat(refreshPurchasesTask.Result) + .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 ticket purchase log as reviewed. + /// + /// 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, + [FromQuery] string type = BattleTicketType + ) + { + if (!ValidTicketTypes.Contains(type)) + { + 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 {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 new file mode 100644 index 0000000..272cb9a --- /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().ToLower(), + item.Participant.User.AgentAddress.ToString().ToLower(), + 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..de03ab9 --- /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().ToLower(), + 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().ToLower(), + 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(); 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