From 8db2b333c04ed738763152c0e8897d7af45793f0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:09:40 -0300 Subject: [PATCH] test(adm-009): TipoDeIva + IngresosBrutos handler tests (Red) --- .../Domain/Fiscal/IngresosBrutosTests.cs | 22 +- .../CreateTipoDeIvaCommandHandlerTests.cs | 117 +++++++++++ .../DeactivateTipoDeIvaCommandHandlerTests.cs | 80 ++++++++ .../GetTipoDeIvaByIdQueryHandlerTests.cs | 51 +++++ .../GetHistorialTipoDeIvaQueryHandlerTests.cs | 52 +++++ .../List/ListTiposDeIvaQueryHandlerTests.cs | 61 ++++++ ...uevaVersionTipoDeIvaCommandHandlerTests.cs | 189 ++++++++++++++++++ .../ReactivateTipoDeIvaCommandHandlerTests.cs | 69 +++++++ .../UpdateTipoDeIvaCommandHandlerTests.cs | 118 +++++++++++ 9 files changed, 748 insertions(+), 11 deletions(-) create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/List/ListTiposDeIvaQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs diff --git a/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs b/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs index 4a98d78..e3602c8 100644 --- a/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs +++ b/tests/SIGCM2.Application.Tests/Domain/Fiscal/IngresosBrutosTests.cs @@ -1,7 +1,7 @@ using System.Reflection; using FluentAssertions; -using SIGCM2.Domain.Entities; using SIGCM2.Domain.Fiscal; +using IibbDomain = SIGCM2.Domain.Entities.IngresosBrutos; namespace SIGCM2.Application.Tests.Domain.Fiscal; @@ -10,7 +10,7 @@ public class IngresosBrutosTests private static readonly DateOnly Desde2020 = new(2020, 1, 1); private static readonly DateOnly Desde2026 = new(2026, 6, 1); - private static IngresosBrutos MakeIIBB( + private static IibbDomain MakeIIBB( int id = 1, ProvinciaArgentina provincia = ProvinciaArgentina.BuenosAires, string descripcion = "IIBB Buenos Aires", @@ -19,7 +19,7 @@ public class IngresosBrutosTests DateOnly? vigenciaDesde = null, DateOnly? vigenciaHasta = null, int? predecesorId = null) - => IngresosBrutos.FromDb( + => IibbDomain.FromDb( id: id, provincia: provincia, descripcion: descripcion, @@ -36,7 +36,7 @@ public class IngresosBrutosTests [Fact] public void ForCreation_ValidArgs_ReturnsEntity() { - var iibb = IngresosBrutos.ForCreation(ProvinciaArgentina.Cordoba, "IIBB Córdoba", 2.5m, Desde2020); + var iibb = IibbDomain.ForCreation(ProvinciaArgentina.Cordoba, "IIBB Córdoba", 2.5m, Desde2020); iibb.Provincia.Should().Be(ProvinciaArgentina.Cordoba); iibb.Descripcion.Should().Be("IIBB Córdoba"); @@ -50,7 +50,7 @@ public class IngresosBrutosTests [Fact] public void ForCreation_AlicuotaNegativa_ThrowsArgumentException() { - var act = () => IngresosBrutos.ForCreation(ProvinciaArgentina.BuenosAires, "desc", -1m, Desde2020); + var act = () => IibbDomain.ForCreation(ProvinciaArgentina.BuenosAires, "desc", -1m, Desde2020); act.Should().Throw() .WithParameterName("alicuota"); @@ -59,7 +59,7 @@ public class IngresosBrutosTests [Fact] public void ForCreation_AlicuotaMayorA100_ThrowsArgumentException() { - var act = () => IngresosBrutos.ForCreation(ProvinciaArgentina.BuenosAires, "desc", 101m, Desde2020); + var act = () => IibbDomain.ForCreation(ProvinciaArgentina.BuenosAires, "desc", 101m, Desde2020); act.Should().Throw() .WithParameterName("alicuota"); @@ -71,7 +71,7 @@ public class IngresosBrutosTests [InlineData(100)] public void ForCreation_AlicuotaEnRango_NoLanza(double alicuota) { - var act = () => IngresosBrutos.ForCreation(ProvinciaArgentina.Salta, "desc", (decimal)alicuota, Desde2020); + var act = () => IibbDomain.ForCreation(ProvinciaArgentina.Salta, "desc", (decimal)alicuota, Desde2020); act.Should().NotThrow(); } @@ -82,7 +82,7 @@ public class IngresosBrutosTests var desde = new DateOnly(2026, 6, 1); var hasta = new DateOnly(2026, 1, 1); - var act = () => IngresosBrutos.ForCreation(ProvinciaArgentina.SantaFe, "desc", 3m, desde, hasta); + var act = () => IibbDomain.ForCreation(ProvinciaArgentina.SantaFe, "desc", 3m, desde, hasta); act.Should().Throw() .WithParameterName("vigenciaHasta"); @@ -209,7 +209,7 @@ public class IngresosBrutosTests [Fact] public void IngresosBrutos_No_Debe_Exponer_WithAlicuota() { - var method = typeof(IngresosBrutos).GetMethod("WithAlicuota", BindingFlags.Public | BindingFlags.Instance); + var method = typeof(IibbDomain).GetMethod("WithAlicuota", BindingFlags.Public | BindingFlags.Instance); method.Should().BeNull("Alicuota es inmutable — usar NuevaVersion"); } @@ -217,7 +217,7 @@ public class IngresosBrutosTests [Fact] public void IngresosBrutos_No_Debe_Exponer_WithProvincia() { - var method = typeof(IngresosBrutos).GetMethod("WithProvincia", BindingFlags.Public | BindingFlags.Instance); + var method = typeof(IibbDomain).GetMethod("WithProvincia", BindingFlags.Public | BindingFlags.Instance); method.Should().BeNull("Provincia es inmutable en IngresosBrutos"); } @@ -228,7 +228,7 @@ public class IngresosBrutosTests public void FromDb_SetsAllProperties() { var fechaCreacion = DateTime.UtcNow; - var iibb = IngresosBrutos.FromDb( + var iibb = IibbDomain.FromDb( id: 10, provincia: ProvinciaArgentina.Tucuman, descripcion: "IIBB Tucuman", alicuota: 1.5m, activo: true, vigenciaDesde: Desde2020, vigenciaHasta: null, diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs new file mode 100644 index 0000000..4eb8259 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Create/CreateTipoDeIvaCommandHandlerTests.cs @@ -0,0 +1,117 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.TiposDeIva.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.Create; + +public class CreateTipoDeIvaCommandHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateTipoDeIvaCommandHandler _handler; + + private static CreateTipoDeIvaCommand ValidCommand() => new( + Codigo: "IVA_21", + Descripcion: "IVA 21%", + Porcentaje: 21m, + AplicaIVA: true, + VigenciaDesde: new DateOnly(2024, 1, 1)); + + public CreateTipoDeIvaCommandHandlerTests() + { + _handler = new CreateTipoDeIvaCommandHandler(_repo, _audit); + _repo.InsertAsync(Arg.Any(), Arg.Any()).Returns(42); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(42, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal("IVA_21", result.Codigo); + Assert.Equal("IVA 21%", result.Descripcion); + Assert.Equal(21m, result.Porcentaje); + Assert.True(result.AplicaIVA); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithCreateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "tipo_iva.create", + targetType: "TipoDeIva", + targetId: "42", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsInsertOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).InsertAsync(Arg.Any(), Arg.Any()); + } + + // ── audit fail-closed ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUp() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_AuditLoggerThrows_InsertWasCalledButScopeNotCompleted() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + try { await _handler.Handle(ValidCommand()); } catch (InvalidOperationException) { } + + // Insert was called (it's before audit in the scope), but audit threw = scope not completed + await _repo.Received(1).InsertAsync(Arg.Any(), Arg.Any()); + await _audit.Received(1).LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── triangulation: different porcentaje value ──────────────────────────── + + [Fact] + public async Task Handle_WithZeroPorcentaje_ReturnsDtoWithCorrectPorcentaje() + { + var cmd = new CreateTipoDeIvaCommand( + Codigo: "EXENTO", + Descripcion: "Exento de IVA", + Porcentaje: 0m, + AplicaIVA: false, + VigenciaDesde: new DateOnly(2024, 1, 1)); + + var result = await _handler.Handle(cmd); + + Assert.Equal(0m, result.Porcentaje); + Assert.False(result.AplicaIVA); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs new file mode 100644 index 0000000..5c1e887 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Deactivate/DeactivateTipoDeIvaCommandHandlerTests.cs @@ -0,0 +1,80 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.TiposDeIva.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.Deactivate; + +public class DeactivateTipoDeIvaCommandHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateTipoDeIvaCommandHandler _handler; + + private static TipoDeIva MakeEntity(bool activo = true) => TipoDeIva.FromDb( + id: 1, codigo: "IVA_21", descripcion: "IVA 21%", porcentaje: 21m, aplicaIVA: true, + activo: activo, vigenciaDesde: new DateOnly(2024, 1, 1), vigenciaHasta: null, + predecesorId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public DeactivateTipoDeIvaCommandHandlerTests() + { + _handler = new DeactivateTipoDeIvaCommandHandler(_repo, _audit); + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity()); + _repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + } + + [Fact] + public async Task Handle_NotFound_ThrowsTipoDeIvaNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((TipoDeIva?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateTipoDeIvaCommand(99))); + } + + [Fact] + public async Task Handle_HappyPath_CallsSetActivoFalse() + { + await _handler.Handle(new DeactivateTipoDeIvaCommand(1)); + + await _repo.Received(1).SetActivoAsync(1, false, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditDeactivate() + { + await _handler.Handle(new DeactivateTipoDeIvaCommand(1)); + + await _audit.Received(1).LogAsync( + action: "tipo_iva.deactivate", + targetType: "TipoDeIva", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotent_NoAudit() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity(activo: false)); + + await _handler.Handle(new DeactivateTipoDeIvaCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUp() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateTipoDeIvaCommand(1))); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandlerTests.cs new file mode 100644 index 0000000..b6b1633 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/GetById/GetTipoDeIvaByIdQueryHandlerTests.cs @@ -0,0 +1,51 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.TiposDeIva.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.GetById; + +public class GetTipoDeIvaByIdQueryHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly GetTipoDeIvaByIdQueryHandler _handler; + + private static TipoDeIva MakeEntity(int id = 1) => TipoDeIva.FromDb( + id: id, codigo: "IVA_21", descripcion: "IVA 21%", porcentaje: 21m, aplicaIVA: true, + activo: true, vigenciaDesde: new DateOnly(2024, 1, 1), vigenciaHasta: null, + predecesorId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public GetTipoDeIvaByIdQueryHandlerTests() + { + _handler = new GetTipoDeIvaByIdQueryHandler(_repo); + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity()); + } + + [Fact] + public async Task Handle_Found_ReturnsDtoWithCorrectId() + { + var result = await _handler.Handle(new GetTipoDeIvaByIdQuery(1)); + + Assert.Equal(1, result.Id); + } + + [Fact] + public async Task Handle_Found_ReturnsDtoWithAllFields() + { + var result = await _handler.Handle(new GetTipoDeIvaByIdQuery(1)); + + Assert.Equal("IVA_21", result.Codigo); + Assert.Equal(21m, result.Porcentaje); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_NotFound_ThrowsTipoDeIvaNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((TipoDeIva?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetTipoDeIvaByIdQuery(99))); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandlerTests.cs new file mode 100644 index 0000000..acbfdfd --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/GetHistorial/GetHistorialTipoDeIvaQueryHandlerTests.cs @@ -0,0 +1,52 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.TiposDeIva.GetHistorial; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.TiposDeIva.GetHistorial; + +public class GetHistorialTipoDeIvaQueryHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly GetHistorialTipoDeIvaQueryHandler _handler; + + public GetHistorialTipoDeIvaQueryHandlerTests() + { + _handler = new GetHistorialTipoDeIvaQueryHandler(_repo); + } + + private static TipoDeIva MakeEntity(int id, int? predecesorId, DateOnly desde) => + TipoDeIva.FromDb(id, "IVA_21", "IVA 21%", 21m, true, true, desde, + predecesorId.HasValue ? desde.AddYears(1).AddDays(-1) : null, + predecesorId, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ChainOf3_ReturnsListWith3ItemsInOrder() + { + var chain = new List + { + MakeEntity(1, null, new DateOnly(2022, 1, 1)), + MakeEntity(2, 1, new DateOnly(2023, 1, 1)), + MakeEntity(3, 2, new DateOnly(2024, 1, 1)), + }; + _repo.GetHistorialAsync(3, Arg.Any()).Returns(chain); + + var result = await _handler.Handle(new GetHistorialTipoDeIvaQuery(3)); + + Assert.Equal(3, result.Count); + Assert.Equal(1, result[0].Version); // root + Assert.Equal(3, result[2].Version); // current + } + + [Fact] + public async Task Handle_SingleVersion_Returns1Item() + { + var chain = new List { MakeEntity(1, null, new DateOnly(2024, 1, 1)) }; + _repo.GetHistorialAsync(1, Arg.Any()).Returns(chain); + + var result = await _handler.Handle(new GetHistorialTipoDeIvaQuery(1)); + + Assert.Single(result); + Assert.Equal(1, result[0].Version); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/List/ListTiposDeIvaQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/List/ListTiposDeIvaQueryHandlerTests.cs new file mode 100644 index 0000000..1d79f15 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/List/ListTiposDeIvaQueryHandlerTests.cs @@ -0,0 +1,61 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.TiposDeIva.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.TiposDeIva.List; + +public class ListTiposDeIvaQueryHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly ListTiposDeIvaQueryHandler _handler; + + private static TipoDeIva MakeEntity(int id) => TipoDeIva.FromDb( + id: id, codigo: "IVA_21", descripcion: "IVA 21%", porcentaje: 21m, aplicaIVA: true, + activo: true, vigenciaDesde: new DateOnly(2024, 1, 1), vigenciaHasta: null, + predecesorId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public ListTiposDeIvaQueryHandlerTests() + { + _handler = new ListTiposDeIvaQueryHandler(_repo); + } + + [Fact] + public async Task Handle_WithItems_ReturnsPagedResultWithMappedDtos() + { + var items = new List { MakeEntity(1), MakeEntity(2) }; + _repo.ListAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(items, 1, 10, 2)); + + var result = await _handler.Handle(new ListTiposDeIvaQuery(1, 10, null, null)); + + Assert.Equal(2, result.Items.Count); + Assert.Equal(2, result.Total); + } + + [Fact] + public async Task Handle_EmptyResult_ReturnsPagedResultWithZeroItems() + { + _repo.ListAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(new List(), 1, 10, 0)); + + var result = await _handler.Handle(new ListTiposDeIvaQuery(1, 10, null, null)); + + Assert.Equal(0, result.Items.Count); + Assert.Equal(0, result.Total); + } + + [Fact] + public async Task Handle_PassesFiltersToRepository() + { + _repo.ListAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(new List(), 1, 10, 0)); + + await _handler.Handle(new ListTiposDeIvaQuery(2, 5, true, "IVA_21")); + + await _repo.Received(1).ListAsync( + Arg.Is(q => q.Page == 2 && q.PageSize == 5 && q.Activo == true && q.Codigo == "IVA_21"), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs new file mode 100644 index 0000000..f569d7f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/NuevaVersion/NuevaVersionTipoDeIvaCommandHandlerTests.cs @@ -0,0 +1,189 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.TiposDeIva.NuevaVersion; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.NuevaVersion; + +public class NuevaVersionTipoDeIvaCommandHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly NuevaVersionTipoDeIvaCommandHandler _handler; + + private static TipoDeIva MakePredecesora(int id = 1, DateOnly? vigenciaHasta = null) => + TipoDeIva.FromDb( + id: id, + codigo: "IVA_21", + descripcion: "IVA 21%", + porcentaje: 21m, + aplicaIVA: true, + activo: true, + vigenciaDesde: new DateOnly(2024, 1, 1), + vigenciaHasta: vigenciaHasta, + predecesorId: null, + fechaCreacion: DateTime.UtcNow, + fechaModificacion: null); + + private static NuevaVersionTipoDeIvaCommand ValidCommand() => new( + PredecesoraId: 1, + NuevoPorcentaje: 27m, + VigenciaDesde: new DateOnly(2025, 1, 1)); + + public NuevaVersionTipoDeIvaCommandHandlerTests() + { + _handler = new NuevaVersionTipoDeIvaCommandHandler(_repo, _audit); + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakePredecesora()); + _repo.UpdateCierreVigenciaAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _repo.InsertAsync(Arg.Any(), Arg.Any()).Returns(99); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectIds() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(1, result.PredecesoraId); + Assert.Equal(99, result.NuevaVersionId); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateCierreVigenciaOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateCierreVigenciaAsync( + 1, + new DateOnly(2024, 12, 31), // vigenciaDesde(2025-01-01) - 1 day + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsInsertOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).InsertAsync( + Arg.Is(e => e.Porcentaje == 27m && e.PredecesorId == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditOnceWithCorrectAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "tipo_iva.nueva_version", + targetType: "TipoDeIva", + targetId: Arg.Any(), + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── predecesora not found ──────────────────────────────────────────────── + + [Fact] + public async Task Handle_PredecesoraNotFound_ThrowsTipoDeIvaNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((TipoDeIva?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new NuevaVersionTipoDeIvaCommand(999, 27m, new DateOnly(2025, 1, 1)))); + } + + // ── predecesora ya cerrada ──────────────────────────────────────────────── + + [Fact] + public async Task Handle_PredecesoraYaCerrada_ThrowsPredecesorYaCerradoException() + { + _repo.GetByIdAsync(1, Arg.Any()) + .Returns(MakePredecesora(vigenciaHasta: new DateOnly(2024, 12, 31))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── predecesora inactiva ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_PredecesoraInactiva_ThrowsPredecesorYaCerradoException() + { + var inactiva = TipoDeIva.FromDb(1, "IVA_21", "IVA 21%", 21m, true, false, + new DateOnly(2024, 1, 1), null, null, DateTime.UtcNow, null); + _repo.GetByIdAsync(1, Arg.Any()).Returns(inactiva); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── vigenciaDesde inválida ──────────────────────────────────────────────── + + [Fact] + public async Task Handle_VigenciaDesdeNotAfterPredecesora_ThrowsArgumentException() + { + var cmd = new NuevaVersionTipoDeIvaCommand( + PredecesoraId: 1, + NuevoPorcentaje: 27m, + VigenciaDesde: new DateOnly(2024, 1, 1)); // same as predecesora + + await Assert.ThrowsAsync( + () => _handler.Handle(cmd)); + } + + // ── race condition: UpdateCierreVigencia returns false ──────────────────── + + [Fact] + public async Task Handle_UpdateCierreVigenciaReturnsFalse_ThrowsPredecesorYaCerradoException() + { + _repo.UpdateCierreVigenciaAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_UpdateCierreVigenciaReturnsFalse_InsertIsNeverCalled() + { + _repo.UpdateCierreVigenciaAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + + try { await _handler.Handle(ValidCommand()); } catch (PredecesorYaCerradoException) { } + + await _repo.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any()); + } + + // ── audit fail-closed ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUp() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_AuditLoggerThrows_InsertWasCalledButAuditFailed() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + try { await _handler.Handle(ValidCommand()); } catch (InvalidOperationException) { } + + // Insert was called before audit — audit throwing means scope.Complete never runs + await _repo.Received(1).InsertAsync(Arg.Any(), Arg.Any()); + await _audit.Received(1).LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs new file mode 100644 index 0000000..a930e54 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Reactivate/ReactivateTipoDeIvaCommandHandlerTests.cs @@ -0,0 +1,69 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.TiposDeIva.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.Reactivate; + +public class ReactivateTipoDeIvaCommandHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateTipoDeIvaCommandHandler _handler; + + private static TipoDeIva MakeEntity(bool activo = false) => TipoDeIva.FromDb( + id: 1, codigo: "IVA_21", descripcion: "IVA 21%", porcentaje: 21m, aplicaIVA: true, + activo: activo, vigenciaDesde: new DateOnly(2024, 1, 1), vigenciaHasta: null, + predecesorId: null, fechaCreacion: DateTime.UtcNow, fechaModificacion: null); + + public ReactivateTipoDeIvaCommandHandlerTests() + { + _handler = new ReactivateTipoDeIvaCommandHandler(_repo, _audit); + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity()); + _repo.SetActivoAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + } + + [Fact] + public async Task Handle_NotFound_ThrowsTipoDeIvaNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((TipoDeIva?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateTipoDeIvaCommand(99))); + } + + [Fact] + public async Task Handle_HappyPath_CallsSetActivoTrue() + { + await _handler.Handle(new ReactivateTipoDeIvaCommand(1)); + + await _repo.Received(1).SetActivoAsync(1, true, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditReactivate() + { + await _handler.Handle(new ReactivateTipoDeIvaCommand(1)); + + await _audit.Received(1).LogAsync( + action: "tipo_iva.reactivate", + targetType: "TipoDeIva", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotent_NoAudit() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity(activo: true)); + + await _handler.Handle(new ReactivateTipoDeIvaCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs new file mode 100644 index 0000000..240a8ae --- /dev/null +++ b/tests/SIGCM2.Application.Tests/TiposDeIva/Update/UpdateTipoDeIvaCommandHandlerTests.cs @@ -0,0 +1,118 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.TiposDeIva.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.TiposDeIva.Update; + +public class UpdateTipoDeIvaCommandHandlerTests +{ + private readonly ITipoDeIvaRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdateTipoDeIvaCommandHandler _handler; + + private static TipoDeIva MakeEntity(int id = 1) => TipoDeIva.FromDb( + id: id, + codigo: "IVA_21", + descripcion: "IVA 21%", + porcentaje: 21m, + aplicaIVA: true, + activo: true, + vigenciaDesde: new DateOnly(2024, 1, 1), + vigenciaHasta: null, + predecesorId: null, + fechaCreacion: DateTime.UtcNow, + fechaModificacion: null); + + private static UpdateTipoDeIvaCommand ValidCommand(int id = 1) => new( + Id: id, + Codigo: "IVA_21", + Descripcion: "IVA 21% actualizado", + AplicaIVA: true, + Activo: true); + + public UpdateTipoDeIvaCommandHandlerTests() + { + _handler = new UpdateTipoDeIvaCommandHandler(_repo, _audit); + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeEntity()); + _repo.UpdateCosmeticoAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + } + + // ── not found ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsTipoDeIvaNotFoundException() + { + _repo.GetByIdAsync(99, Arg.Any()).Returns((TipoDeIva?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand(99))); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedDescription() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal("IVA 21% actualizado", result.Descripcion); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateCosmeticoOnce() + { + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateCosmeticoAsync( + 1, "IVA_21", "IVA 21% actualizado", true, true, + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "tipo_iva.update", + targetType: "TipoDeIva", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + // ── audit fail-closed ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditLoggerThrows_ExceptionBubblesUp() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("audit fail"))); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── triangulation: activo toggle ───────────────────────────────────────── + + [Fact] + public async Task Handle_DeactivateViaUpdate_ReturnsDtoWithActivoFalse() + { + var cmd = new UpdateTipoDeIvaCommand(Id: 1, Codigo: "IVA_21", + Descripcion: "IVA 21%", AplicaIVA: true, Activo: false); + + var result = await _handler.Handle(cmd); + + // The handler passes the Activo value to UpdateCosmeticoAsync + await _repo.Received(1).UpdateCosmeticoAsync( + 1, "IVA_21", "IVA 21%", true, false, + Arg.Any()); + Assert.False(result.Activo); + } +}