diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs new file mode 100644 index 0000000..0b8a7f2 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs @@ -0,0 +1,288 @@ +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Exceptions; +using SIGCM2.Domain.Fiscal; +using SIGCM2.Infrastructure.Persistence; +using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos; + +namespace SIGCM2.Application.Tests.Infrastructure; + +/// +/// Integration tests for IngresosBrutosRepository against SIGCM2_Test. +/// NOTE: IngresosBrutos is in Respawn TablesToIgnore (seed data must survive resets). +/// Tests insert their own rows and identify them by the returned Id. +/// Provincia + VigenciaDesde combos chosen to be unique per test to avoid UQ violations. +/// +[Collection("Database")] +public class IngresosBrutosRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private IIngresosBrutosRepository _repo = null!; + + private static int _testCounter = 0; + + private static DateOnly NextUniqueDate() + { + var counter = Interlocked.Increment(ref _testCounter); + // Start at 2091-01-01 to avoid clashing with TipoDeIvaRepositoryTests (2090-01-01) + return new DateOnly(2091, 1, 1).AddDays(counter); + } + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repo = new IngresosBrutosRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── T400.10 / T400.12: InsertAsync + GetByIdAsync ───────────────────────── + + [Fact] + public async Task InsertAsync_ReturnsPositiveId_AndRowIsVisibleInGetById() + { + var vigencia = NextUniqueDate(); + // Use a province not in the seed or use a date far enough to avoid UQ clash + var entity = IibbEntity.ForCreation( + provincia: ProvinciaArgentina.Cordoba, + descripcion: "Test IIBB Cordoba", + alicuota: 3.5m, + vigenciaDesde: vigencia); + + var id = await _repo.InsertAsync(entity); + + id.Should().BeGreaterThan(0); + + var fetched = await _repo.GetByIdAsync(id); + fetched.Should().NotBeNull(); + fetched!.Id.Should().Be(id); + fetched.Provincia.Should().Be(ProvinciaArgentina.Cordoba); + fetched.Descripcion.Should().Be("Test IIBB Cordoba"); + fetched.Alicuota.Should().Be(3.5m); + fetched.Activo.Should().BeTrue(); + fetched.VigenciaDesde.Should().Be(vigencia); + fetched.VigenciaHasta.Should().BeNull(); + fetched.PredecesorId.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_NonExistentId_ReturnsNull() + { + var result = await _repo.GetByIdAsync(999_999_998); + + result.Should().BeNull(); + } + + // ── InsertAsync con Provincia duplicada en misma VigenciaDesde ──────────── + + [Fact] + public async Task InsertAsync_DuplicateProvincia_SameVigenciaDesde_ThrowsDuplicateProvinciaException() + { + var vigencia = NextUniqueDate(); + var first = IibbEntity.ForCreation(ProvinciaArgentina.Mendoza, "Primero", 2m, vigencia); + await _repo.InsertAsync(first); + + var second = IibbEntity.ForCreation(ProvinciaArgentina.Mendoza, "Segundo", 3m, vigencia); + var act = async () => await _repo.InsertAsync(second); + + await act.Should().ThrowAsync(); + } + + // ── UpdateCosmeticoAsync ────────────────────────────────────────────────── + + [Fact] + public async Task UpdateCosmeticoAsync_ChangesOnlyCosmeticFields_NotAlicuotaOrProvincia() + { + var vigencia = NextUniqueDate(); + var entity = IibbEntity.ForCreation(ProvinciaArgentina.Salta, "Original Salta", 1.5m, vigencia); + var id = await _repo.InsertAsync(entity); + + var result = await _repo.UpdateCosmeticoAsync(id, "Updated Salta", true); + + result.Should().BeTrue(); + + var fetched = await _repo.GetByIdAsync(id); + fetched!.Descripcion.Should().Be("Updated Salta"); + fetched.Activo.Should().BeTrue(); + // Immutable fields must NOT change + fetched.Alicuota.Should().Be(1.5m); + fetched.Provincia.Should().Be(ProvinciaArgentina.Salta); + fetched.VigenciaDesde.Should().Be(vigencia); + fetched.VigenciaHasta.Should().BeNull(); + } + + // ── UpdateCierreVigenciaAsync ───────────────────────────────────────────── + + [Fact] + public async Task UpdateCierreVigenciaAsync_SetsVigenciaHasta_WhenRowIsOpen() + { + var vigencia = NextUniqueDate(); + var entity = IibbEntity.ForCreation(ProvinciaArgentina.Tucuman, "Cierre test", 2m, vigencia); + var id = await _repo.InsertAsync(entity); + + var closeDate = vigencia.AddMonths(6); + var result = await _repo.UpdateCierreVigenciaAsync(id, closeDate); + + result.Should().BeTrue(); + + var fetched = await _repo.GetByIdAsync(id); + fetched!.VigenciaHasta.Should().Be(closeDate); + } + + [Fact] + public async Task UpdateCierreVigenciaAsync_DoubleClose_ReturnsFalseOnSecondCall() + { + var vigencia = NextUniqueDate(); + var entity = IibbEntity.ForCreation(ProvinciaArgentina.Jujuy, "Double close", 1m, vigencia); + var id = await _repo.InsertAsync(entity); + + var closeDate = vigencia.AddMonths(6); + var first = await _repo.UpdateCierreVigenciaAsync(id, closeDate); + var second = await _repo.UpdateCierreVigenciaAsync(id, closeDate.AddMonths(1)); + + first.Should().BeTrue(); + second.Should().BeFalse("the guard prevents double-close: WHERE VigenciaHasta IS NULL"); + } + + // ── SetActivoAsync ──────────────────────────────────────────────────────── + + [Fact] + public async Task SetActivoAsync_FalseAndBack_TogglesActivoCorrectly() + { + var vigencia = NextUniqueDate(); + var entity = IibbEntity.ForCreation(ProvinciaArgentina.Chaco, "Toggle test", 0m, vigencia); + var id = await _repo.InsertAsync(entity); + + await _repo.SetActivoAsync(id, false); + var inactive = await _repo.GetByIdAsync(id); + inactive!.Activo.Should().BeFalse(); + + await _repo.SetActivoAsync(id, true); + var active = await _repo.GetByIdAsync(id); + active!.Activo.Should().BeTrue(); + } + + // ── ListAsync con paginación + filtros ──────────────────────────────────── + + [Fact] + public async Task ListAsync_WithActivoFilter_ReturnsOnlyMatchingRows() + { + var d1 = NextUniqueDate(); + var d2 = NextUniqueDate(); + + var activeId = await _repo.InsertAsync( + IibbEntity.ForCreation(ProvinciaArgentina.Misiones, "Active row", 1m, d1)); + var inactiveId = await _repo.InsertAsync( + IibbEntity.ForCreation(ProvinciaArgentina.Misiones, "Inactive row", 1m, d2)); + await _repo.SetActivoAsync(inactiveId, false); + + var result = await _repo.ListAsync(new IngresosBrutosQuery( + Page: 1, + PageSize: 100, + Activo: true, + Provincia: null)); + + result.Items.Should().Contain(x => x.Id == activeId); + result.Items.Should().NotContain(x => x.Id == inactiveId); + } + + [Fact] + public async Task ListAsync_WithProvinciaFilter_ReturnsOnlyMatchingRows() + { + var vigencia = NextUniqueDate(); + var id = await _repo.InsertAsync( + IibbEntity.ForCreation(ProvinciaArgentina.Formosa, "Formosa row", 0m, vigencia)); + + var result = await _repo.ListAsync(new IngresosBrutosQuery( + Page: 1, + PageSize: 100, + Activo: null, + Provincia: ProvinciaArgentina.Formosa)); + + result.Total.Should().BeGreaterThan(0); + result.Items.Should().Contain(x => x.Id == id); + result.Items.Should().AllSatisfy(x => x.Provincia.Should().Be(ProvinciaArgentina.Formosa)); + } + + // ── Cadena de 3 versiones ───────────────────────────────────────────────── + + [Fact] + public async Task VersionChain_ThreeVersions_PredecesorIdChainIsCorrect() + { + var v1Date = NextUniqueDate(); + var v1 = IibbEntity.ForCreation(ProvinciaArgentina.LaPampa, "v1", 1m, v1Date); + var v1Id = await _repo.InsertAsync(v1); + + var v2Date = v1Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v1Id, v2Date.AddDays(-1)); + var v2 = IibbEntity.ForCreation(ProvinciaArgentina.LaPampa, "v2", 2m, v2Date, null, v1Id); + var v2Id = await _repo.InsertAsync(v2); + + var v3Date = v2Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v2Id, v3Date.AddDays(-1)); + var v3 = IibbEntity.ForCreation(ProvinciaArgentina.LaPampa, "v3", 3m, v3Date, null, v2Id); + var v3Id = await _repo.InsertAsync(v3); + + var fv2 = await _repo.GetByIdAsync(v2Id); + var fv3 = await _repo.GetByIdAsync(v3Id); + + fv2!.PredecesorId.Should().Be(v1Id); + fv3!.PredecesorId.Should().Be(v2Id); + } + + // ── GetHistorialAsync ───────────────────────────────────────────────────── + + [Fact] + public async Task GetHistorialAsync_ReturnsChainFromRootToId_OrderedByVigenciaDesdeAsc() + { + var v1Date = NextUniqueDate(); + var v1 = IibbEntity.ForCreation(ProvinciaArgentina.LaRioja, "Hist v1", 1m, v1Date); + var v1Id = await _repo.InsertAsync(v1); + + var v2Date = v1Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v1Id, v2Date.AddDays(-1)); + var v2 = IibbEntity.ForCreation(ProvinciaArgentina.LaRioja, "Hist v2", 2m, v2Date, null, v1Id); + var v2Id = await _repo.InsertAsync(v2); + + var v3Date = v2Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v2Id, v3Date.AddDays(-1)); + var v3 = IibbEntity.ForCreation(ProvinciaArgentina.LaRioja, "Hist v3", 3m, v3Date, null, v2Id); + var v3Id = await _repo.InsertAsync(v3); + + var historial = await _repo.GetHistorialAsync(v3Id); + + historial.Should().HaveCount(3); + historial[0].Id.Should().Be(v1Id, "root is first"); + historial[1].Id.Should().Be(v2Id); + historial[2].Id.Should().Be(v3Id, "requested Id is last"); + historial[0].VigenciaDesde.Should().BeBefore(historial[1].VigenciaDesde); + historial[1].VigenciaDesde.Should().BeBefore(historial[2].VigenciaDesde); + // Provincia preserved correctly across mapping + historial.Should().AllSatisfy(x => x.Provincia.Should().Be(ProvinciaArgentina.LaRioja)); + } + + [Fact] + public async Task GetHistorialAsync_SingleVersion_ReturnsListWithOneItem() + { + var vigencia = NextUniqueDate(); + var entity = IibbEntity.ForCreation(ProvinciaArgentina.Neuquen, "Solo", 1m, vigencia); + var id = await _repo.InsertAsync(entity); + + var historial = await _repo.GetHistorialAsync(id); + + historial.Should().HaveCount(1); + historial[0].Id.Should().Be(id); + } +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs new file mode 100644 index 0000000..7e8c684 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs @@ -0,0 +1,302 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Infrastructure; + +/// +/// Integration tests for TipoDeIvaRepository against SIGCM2_Test. +/// NOTE: TipoDeIva is in Respawn TablesToIgnore (seed data must survive resets). +/// Tests insert their own rows and identify them by the returned Id. +/// VigenciaDesde dates are chosen to be unique per test class to avoid UQ violations. +/// +[Collection("Database")] +public class TipoDeIvaRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private ITipoDeIvaRepository _repo = null!; + + // Use a unique date range for this test class to avoid UQ constraint clashes with seed + // seed uses VigenciaDesde = '2020-01-01'; we use a far-future range per test. + private static int _testCounter = 0; + + private static DateOnly NextUniqueDate() + { + var counter = Interlocked.Increment(ref _testCounter); + // Start at 2090-01-01 + counter days so each test gets its own unique date + return new DateOnly(2090, 1, 1).AddDays(counter); + } + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repo = new TipoDeIvaRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── T400.1 / T400.4: InsertAsync + GetByIdAsync ─────────────────────────── + + [Fact] + public async Task InsertAsync_ReturnsPositiveId_AndRowIsVisibleInGetById() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation( + codigo: "IVA_21", + descripcion: "Test IVA 21%", + porcentaje: 21m, + aplicaIVA: true, + vigenciaDesde: vigencia); + + var id = await _repo.InsertAsync(entity); + + id.Should().BeGreaterThan(0); + + var fetched = await _repo.GetByIdAsync(id); + fetched.Should().NotBeNull(); + fetched!.Id.Should().Be(id); + fetched.Codigo.Should().Be("IVA_21"); + fetched.Descripcion.Should().Be("Test IVA 21%"); + fetched.Porcentaje.Should().Be(21m); + fetched.AplicaIVA.Should().BeTrue(); + fetched.Activo.Should().BeTrue(); + fetched.VigenciaDesde.Should().Be(vigencia); + fetched.VigenciaHasta.Should().BeNull(); + fetched.PredecesorId.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_NonExistentId_ReturnsNull() + { + var result = await _repo.GetByIdAsync(999_999_999); + + result.Should().BeNull(); + } + + // ── T400.7: InsertAsync con Codigo duplicado en misma VigenciaDesde ─────── + + [Fact] + public async Task InsertAsync_DuplicateCodigo_SameVigenciaDesde_ThrowsDuplicateCodigoException() + { + var vigencia = NextUniqueDate(); + var first = TipoDeIva.ForCreation("IVA_21", "Primero", 21m, true, vigencia); + await _repo.InsertAsync(first); + + var second = TipoDeIva.ForCreation("IVA_21", "Segundo", 21m, true, vigencia); + var act = async () => await _repo.InsertAsync(second); + + await act.Should().ThrowAsync(); + } + + // ── UpdateCosmeticoAsync ────────────────────────────────────────────────── + + [Fact] + public async Task UpdateCosmeticoAsync_ChangesOnlyCosmeticFields_NotPorcentajeOrVigencias() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation("IVA_21", "Original Desc", 21m, true, vigencia); + var id = await _repo.InsertAsync(entity); + + var result = await _repo.UpdateCosmeticoAsync(id, "IVA_21", "Updated Desc", false, true); + + result.Should().BeTrue(); + + var fetched = await _repo.GetByIdAsync(id); + fetched!.Descripcion.Should().Be("Updated Desc"); + fetched.AplicaIVA.Should().BeFalse(); + // Immutable fields must NOT change + fetched.Porcentaje.Should().Be(21m); + fetched.VigenciaDesde.Should().Be(vigencia); + fetched.VigenciaHasta.Should().BeNull(); + fetched.PredecesorId.Should().BeNull(); + } + + // ── UpdateCierreVigenciaAsync ───────────────────────────────────────────── + + [Fact] + public async Task UpdateCierreVigenciaAsync_SetsVigenciaHasta_WhenRowIsOpen() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation("IVA_21", "Cierre test", 21m, true, vigencia); + var id = await _repo.InsertAsync(entity); + + var closeDate = vigencia.AddMonths(6); + var result = await _repo.UpdateCierreVigenciaAsync(id, closeDate); + + result.Should().BeTrue(); + + var fetched = await _repo.GetByIdAsync(id); + fetched!.VigenciaHasta.Should().Be(closeDate); + } + + // ── T400.6: Race double-close guard ────────────────────────────────────── + + [Fact] + public async Task UpdateCierreVigenciaAsync_DoubleClose_ReturnsFalseOnSecondCall() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation("IVA_21", "Double close", 21m, true, vigencia); + var id = await _repo.InsertAsync(entity); + + var closeDate = vigencia.AddMonths(6); + var first = await _repo.UpdateCierreVigenciaAsync(id, closeDate); + var second = await _repo.UpdateCierreVigenciaAsync(id, closeDate.AddMonths(1)); + + first.Should().BeTrue(); + second.Should().BeFalse("the guard prevents double-close: WHERE VigenciaHasta IS NULL"); + } + + // ── SetActivoAsync ──────────────────────────────────────────────────────── + + [Fact] + public async Task SetActivoAsync_FalseAndBack_TogglesActivoCorrectly() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation("IVA_21", "Toggle test", 21m, true, vigencia); + var id = await _repo.InsertAsync(entity); + + await _repo.SetActivoAsync(id, false); + var inactive = await _repo.GetByIdAsync(id); + inactive!.Activo.Should().BeFalse(); + + await _repo.SetActivoAsync(id, true); + var active = await _repo.GetByIdAsync(id); + active!.Activo.Should().BeTrue(); + } + + // ── ListAsync con paginación + filtros ──────────────────────────────────── + + [Fact] + public async Task ListAsync_WithActivoFilter_ReturnsOnlyMatchingRows() + { + // Insert one active and one inactive in their own date range + var d1 = NextUniqueDate(); + var d2 = NextUniqueDate(); + + var activeId = await _repo.InsertAsync( + TipoDeIva.ForCreation("IVA_21", "Active row", 21m, true, d1)); + var inactiveId = await _repo.InsertAsync( + TipoDeIva.ForCreation("IVA_21", "Inactive row", 21m, true, d2)); + await _repo.SetActivoAsync(inactiveId, false); + + var result = await _repo.ListAsync(new TiposDeIvaQuery( + Page: 1, + PageSize: 100, + Activo: true, + Codigo: null)); + + result.Items.Should().Contain(x => x.Id == activeId); + result.Items.Should().NotContain(x => x.Id == inactiveId); + } + + [Fact] + public async Task ListAsync_ReturnsCorrectTotalCount() + { + // Insert a row with a unique Codigo prefix we can filter on + var vigencia = NextUniqueDate(); + await _repo.InsertAsync( + TipoDeIva.ForCreation("EXENTO", "Total count test", 0m, false, vigencia)); + + var result = await _repo.ListAsync(new TiposDeIvaQuery( + Page: 1, + PageSize: 10, + Activo: null, + Codigo: "EXENTO")); + + result.Total.Should().BeGreaterThan(0); + result.Items.Should().AllSatisfy(x => x.Codigo.Should().StartWith("EXENTO")); + } + + // ── T400.5: Cadena de 3 versiones ──────────────────────────────────────── + + [Fact] + public async Task VersionChain_ThreeVersions_PredecesorIdChainIsCorrect() + { + // v1: IVA_21 at 21% + var v1Date = NextUniqueDate(); + var v1 = TipoDeIva.ForCreation("IVA_21", "Version 1", 21m, true, v1Date); + var v1Id = await _repo.InsertAsync(v1); + + // Close v1 vigencia + var v2Date = v1Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v1Id, v2Date.AddDays(-1)); + + // v2: 23% + var v2 = TipoDeIva.ForCreation("IVA_21", "Version 2", 23m, true, v2Date, null, v1Id); + var v2Id = await _repo.InsertAsync(v2); + + // Close v2 vigencia + var v3Date = v2Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v2Id, v3Date.AddDays(-1)); + + // v3: 25% + var v3 = TipoDeIva.ForCreation("IVA_21", "Version 3", 25m, true, v3Date, null, v2Id); + var v3Id = await _repo.InsertAsync(v3); + + // Verify chain + var fv2 = await _repo.GetByIdAsync(v2Id); + var fv3 = await _repo.GetByIdAsync(v3Id); + + fv2!.PredecesorId.Should().Be(v1Id); + fv3!.PredecesorId.Should().Be(v2Id); + } + + // ── T400.8: GetHistorialAsync ───────────────────────────────────────────── + + [Fact] + public async Task GetHistorialAsync_ReturnsChainFromRootToId_OrderedByVigenciaDesdeAsc() + { + var v1Date = NextUniqueDate(); + var v1 = TipoDeIva.ForCreation("IVA_21", "Hist v1", 21m, true, v1Date); + var v1Id = await _repo.InsertAsync(v1); + + var v2Date = v1Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v1Id, v2Date.AddDays(-1)); + var v2 = TipoDeIva.ForCreation("IVA_21", "Hist v2", 23m, true, v2Date, null, v1Id); + var v2Id = await _repo.InsertAsync(v2); + + var v3Date = v2Date.AddMonths(1); + await _repo.UpdateCierreVigenciaAsync(v2Id, v3Date.AddDays(-1)); + var v3 = TipoDeIva.ForCreation("IVA_21", "Hist v3", 25m, true, v3Date, null, v2Id); + var v3Id = await _repo.InsertAsync(v3); + + // Get historial from v3Id — should return chain v1, v2, v3 + var historial = await _repo.GetHistorialAsync(v3Id); + + historial.Should().HaveCount(3); + historial[0].Id.Should().Be(v1Id, "root is first"); + historial[1].Id.Should().Be(v2Id); + historial[2].Id.Should().Be(v3Id, "requested Id is last"); + historial[0].VigenciaDesde.Should().BeBefore(historial[1].VigenciaDesde); + historial[1].VigenciaDesde.Should().BeBefore(historial[2].VigenciaDesde); + } + + [Fact] + public async Task GetHistorialAsync_SingleVersion_ReturnsListWithOneItem() + { + var vigencia = NextUniqueDate(); + var entity = TipoDeIva.ForCreation("IVA_21", "Solo", 21m, true, vigencia); + var id = await _repo.InsertAsync(entity); + + var historial = await _repo.GetHistorialAsync(id); + + historial.Should().HaveCount(1); + historial[0].Id.Should().Be(id); + } +}