using NSubstitute; using NSubstitute.ExceptionExtensions; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.PuntosDeVenta.Reservar; using SIGCM2.Domain.Enums; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar; public class ReservarNumeroCommandHandlerTests { private readonly IPuntoDeVentaRepository _repo = Substitute.For(); private readonly ReservarNumeroCommandHandler _handler; private static readonly ReservarNumeroCommand ValidCommand = new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA); public ReservarNumeroCommandHandlerTests() { // Use delay = 0 for fast tests _handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]); } // ── happy path ─────────────────────────────────────────────────────────── [Fact] public async Task Handle_HappyPath_ReturnsNumeroReservado() { _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Returns(7); var result = await _handler.Handle(ValidCommand); Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante); Assert.Equal(7, result.NumeroReservado); } // ── retry deadlock ──────────────────────────────────────────────────────── [Fact] public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult() { var deadlock = new DeadlockTransientException(); _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Returns( _ => Task.FromException(deadlock), _ => Task.FromException(deadlock), _ => Task.FromResult(3)); var result = await _handler.Handle(ValidCommand); Assert.Equal(3, result.NumeroReservado); } [Fact] public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException() { var deadlock = new DeadlockTransientException(); _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Returns( _ => Task.FromException(deadlock), _ => Task.FromException(deadlock), _ => Task.FromException(deadlock)); await Assert.ThrowsAsync( () => _handler.Handle(ValidCommand)); } [Fact] public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal() { // backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries) var deadlock = new DeadlockTransientException(); _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Returns(_ => Task.FromException(deadlock)); try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { } await _repo.Received(4).ReservarNumeroAsync( Arg.Any(), Arg.Any(), Arg.Any()); } // ── domain exceptions bubble up without retry ───────────────────────────── [Fact] public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately() { _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Throws(new PuntoDeVentaInactivoException(10)); await Assert.ThrowsAsync( () => _handler.Handle(ValidCommand)); await _repo.Received(1).ReservarNumeroAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_MedioInactivo_BubblesUpImmediately() { _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Throws(new MedioInactivoException(5)); await Assert.ThrowsAsync( () => _handler.Handle(ValidCommand)); await _repo.Received(1).ReservarNumeroAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task Handle_PdvNotFound_BubblesUpImmediately() { _repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any()) .Throws(new PuntoDeVentaNotFoundException(10)); await Assert.ThrowsAsync( () => _handler.Handle(ValidCommand)); await _repo.Received(1).ReservarNumeroAsync( Arg.Any(), Arg.Any(), Arg.Any()); } }