From 2fa92bb52ca82e4b7d611388ba9b62a1868a481e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 17 Jan 2026 21:20:58 +0700 Subject: [PATCH] feat: Add escrow command handlers and unit tests for wallet hold functionality, including updates to hold item status logic. --- .../WalletAggregate/HoldItem.cs | 4 +- .../Application/EscrowCommandHandlersTests.cs | 114 ++++++++++++++++++ .../Domain/HoldItemTests.cs | 2 +- .../Domain/WalletTests.cs | 33 +++++ .../WalletService.UnitTests.csproj | 1 + 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 services/wallet-service-net/tests/WalletService.UnitTests/Application/EscrowCommandHandlersTests.cs diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs index 6b55ce1f..d565a8a7 100644 --- a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs @@ -237,9 +237,9 @@ public class HoldItem : Entity // All amount has been used (executed or released) Status = ExecutedAmount > 0 ? HoldStatus.Executed : HoldStatus.Released; } - else if (ReleasedAmount > 0 || ExecutedAmount > 0) + else if (ReleasedAmount > 0) { - // Some amount was released or executed but not all + // Some amount was released but not all Status = HoldStatus.PartiallyReleased; } // else status remains Active diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Application/EscrowCommandHandlersTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Application/EscrowCommandHandlersTests.cs new file mode 100644 index 00000000..cb584cb0 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Application/EscrowCommandHandlersTests.cs @@ -0,0 +1,114 @@ +namespace WalletService.UnitTests.Application; + +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using WalletService.API.Application.Commands; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; +using Xunit; + +public class EscrowCommandHandlersTests +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _createLogger; + private readonly ILogger _executeLogger; + private readonly IUnitOfWork _unitOfWork; + + public EscrowCommandHandlersTests() + { + _walletRepository = Substitute.For(); + _unitOfWork = Substitute.For(); + _walletRepository.UnitOfWork.Returns(_unitOfWork); + _unitOfWork.SaveEntitiesAsync(Arg.Any()).Returns(true); + + _createLogger = Substitute.For>(); + _executeLogger = Substitute.For>(); + } + + [Fact] + public async Task CreateHold_ValidCommand_ShouldSucceed() + { + // Arrange + var walletId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var wallet = new Wallet(userId, CurrencyType.VND); + wallet.Deposit(1000m, CurrencyType.VND, "Init"); + + // Mock repository to return our wallet when searching by UserId (CreateHoldCommand uses UserId) + _walletRepository.GetByUserIdAsync(userId).Returns(wallet); + + var handler = new CreateHoldCommandHandler(_walletRepository, _createLogger); + var command = new CreateHoldCommand( + userId, + 100m, + "VND", + "CAMPAIGN", + Guid.NewGuid(), + "Test Hold"); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.RemainingAmount.Should().Be(100m); + wallet.GetBalance(CurrencyType.VND).Should().Be(900m); + + _walletRepository.Received(1).Update(wallet); + await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); + } + + [Fact] + public async Task ExecuteHold_ValidCommand_ShouldSucceed() + { + // Arrange + var userId = Guid.NewGuid(); + var wallet = new Wallet(userId, CurrencyType.VND); + wallet.Deposit(1000m, CurrencyType.VND, "Init"); + var hold = wallet.Hold(100m, CurrencyType.VND, "REF", Guid.NewGuid(), "Desc"); + + // Mock repository to return wallet by ID (ExecuteHoldCommand uses WalletId) + _walletRepository.GetByIdAsync(wallet.Id).Returns(wallet); + + var handler = new ExecuteHoldCommandHandler(_walletRepository, _executeLogger); + var command = new ExecuteHoldCommand( + wallet.Id, + hold.Id, + 40m, + "EXEC_REF"); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.ExecutedAmount.Should().Be(40m); + result.RemainingAmount.Should().Be(60m); + + await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any()); + } + + [Fact] + public async Task ExecuteHold_InsufficientHoldAmount_ShouldThrow() + { + // Arrange + var userId = Guid.NewGuid(); + var wallet = new Wallet(userId, CurrencyType.VND); + wallet.Deposit(1000m, CurrencyType.VND, "Init"); + var hold = wallet.Hold(100m, CurrencyType.VND, "REF", Guid.NewGuid(), "Desc"); + + _walletRepository.GetByIdAsync(wallet.Id).Returns(wallet); + + var handler = new ExecuteHoldCommandHandler(_walletRepository, _executeLogger); + var command = new ExecuteHoldCommand( + wallet.Id, + hold.Id, + 150m, // Try to execute more than held + "EXEC_REF"); + + // Act & Assert + await Assert.ThrowsAsync(() => + handler.Handle(command, CancellationToken.None)); + } +} diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs index 30c16ab3..6136c9c9 100644 --- a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs @@ -134,7 +134,7 @@ public class HoldItemTests // Act & Assert var act = () => hold.Execute(10m); act.Should().Throw() - .WithMessage("*not active*"); + .WithMessage("*Cannot modify hold in Cancelled status*"); } private static HoldItem CreateHold(decimal amount) diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs index 771a2465..4bd673ca 100644 --- a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs @@ -197,4 +197,37 @@ public class WalletTests } #endregion + + #region Escrow Tests / Tests Ký Quỹ + + [Fact] + public void Hold_ShouldReduceAvailableBalanceAndCreateHoldItem() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(1000m, CurrencyType.VND, "Init", "REF001"); + + // Act + var hold = wallet.Hold(100m, CurrencyType.VND, "REF", Guid.NewGuid(), "Desc"); + + // Assert + Assert.Equal(900m, wallet.GetBalance(CurrencyType.VND)); + Assert.Single(wallet.Holds); + Assert.Equal(100m, wallet.GetTotalHeldAmount(CurrencyType.VND)); + Assert.Equal(HoldStatus.Active, hold.Status); + } + + [Fact] + public void Hold_ShouldThrow_WhenInsufficientBalance() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(50m, CurrencyType.VND, "Init", "REF001"); + + // Act & Assert + Assert.Throws(() => + wallet.Hold(100m, CurrencyType.VND, "REF", Guid.NewGuid(), "Desc")); + } + + #endregion } diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj b/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj index e486846f..40144c8c 100644 --- a/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj +++ b/services/wallet-service-net/tests/WalletService.UnitTests/WalletService.UnitTests.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive