From 936d1dc3530b7596b2ac175ca94d7d1447894353 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 09:49:08 -0300 Subject: [PATCH] feat(infrastructure): ProductTypeRepository Dapper + DI wiring (PRD-001) CRUD + paginado con filtros sobre dbo.ProductType; history temporal verificada en tests. 11 integration tests nuevos, suite total 935 GREEN. --- .../DependencyInjection.cs | 1 + .../Persistence/ProductTypeRepository.cs | 214 ++++++++++++++++ .../ProductTypeRepositoryTests.cs | 233 ++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 40ee1ab..809c334 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -39,6 +39,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs new file mode 100644 index 0000000..3ebab93 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs @@ -0,0 +1,214 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class ProductTypeRepository : IProductTypeRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductTypeRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task AddAsync(ProductType productType, CancellationToken ct = default) + { + // DF handles: IsActive (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.ProductType ( + Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight + ) + OUTPUT INSERTED.Id + VALUES ( + @Nombre, @HasDuration, @RequiresText, @RequiresCategory, @IsBundle, + @AllowImages, @MaxImages, @MaxImageSizeMB, @MaxImageWidth, @MaxImageHeight + ) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + productType.Nombre, + HasDuration = productType.HasDuration ? 1 : 0, + RequiresText = productType.RequiresText ? 1 : 0, + RequiresCategory = productType.RequiresCategory ? 1 : 0, + IsBundle = productType.IsBundle ? 1 : 0, + AllowImages = productType.AllowImages ? 1 : 0, + productType.MaxImages, + productType.MaxImageSizeMB, + productType.MaxImageWidth, + productType.MaxImageHeight, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight, + IsActive, FechaCreacion, FechaModificacion + FROM dbo.ProductType + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task> GetPagedAsync( + ProductTypesQuery query, + CancellationToken ct = default) + { + // Build the WHERE clause dynamically. + var conditions = new List(); + if (query.Activo.HasValue) + conditions.Add("IsActive = @Activo"); + if (!string.IsNullOrWhiteSpace(query.Search)) + conditions.Add("Nombre LIKE '%' + @Search + '%'"); + + var where = conditions.Count > 0 + ? "WHERE " + string.Join(" AND ", conditions) + : string.Empty; + + var countSql = $"SELECT COUNT(1) FROM dbo.ProductType {where}"; + var dataSql = $""" + SELECT Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, + AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight, + IsActive, FechaCreacion, FechaModificacion + FROM dbo.ProductType + {where} + ORDER BY Nombre + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + var offset = (query.Page - 1) * query.PageSize; + var parameters = new + { + Activo = query.Activo.HasValue ? (object)(query.Activo.Value ? 1 : 0) : null, + Search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search, + Offset = offset, + PageSize = query.PageSize, + }; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var total = await connection.ExecuteScalarAsync(countSql, parameters); + var rows = await connection.QueryAsync(dataSql, parameters); + var items = rows.Select(MapRow).ToList(); + + return new PagedResult(items, query.Page, query.PageSize, total); + } + + public async Task UpdateAsync(ProductType productType, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.ProductType + SET Nombre = @Nombre, + HasDuration = @HasDuration, + RequiresText = @RequiresText, + RequiresCategory = @RequiresCategory, + IsBundle = @IsBundle, + AllowImages = @AllowImages, + MaxImages = @MaxImages, + MaxImageSizeMB = @MaxImageSizeMB, + MaxImageWidth = @MaxImageWidth, + MaxImageHeight = @MaxImageHeight, + IsActive = @IsActive, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + productType.Nombre, + HasDuration = productType.HasDuration ? 1 : 0, + RequiresText = productType.RequiresText ? 1 : 0, + RequiresCategory = productType.RequiresCategory ? 1 : 0, + IsBundle = productType.IsBundle ? 1 : 0, + AllowImages = productType.AllowImages ? 1 : 0, + productType.MaxImages, + productType.MaxImageSizeMB, + productType.MaxImageWidth, + productType.MaxImageHeight, + IsActive = productType.IsActive ? 1 : 0, + productType.FechaModificacion, + productType.Id, + }); + } + + public async Task ExistsByNombreAsync( + string nombre, + int? excludeId = null, + CancellationToken ct = default) + { + // DB collation is SQL_Latin1_General_CP1_CI_AI on Nombre (CI) — comparison is + // already case-insensitive; no need for UPPER(). The filtered unique index + // (UQ_ProductType_Nombre_Activo WHERE IsActive=1) aligns with this query. + const string sql = """ + SELECT COUNT(1) + FROM dbo.ProductType + WHERE Nombre = @Nombre + AND IsActive = 1 + AND (@ExcludeId IS NULL OR Id <> @ExcludeId) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new + { + Nombre = nombre, + ExcludeId = excludeId, + }); + + return count > 0; + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static ProductType MapRow(ProductTypeRow r) + => new( + id: r.Id, + nombre: r.Nombre, + hasDuration: r.HasDuration, + requiresText: r.RequiresText, + requiresCategory: r.RequiresCategory, + isBundle: r.IsBundle, + allowImages: r.AllowImages, + maxImages: r.MaxImages, + maxImageSizeMB: r.MaxImageSizeMB, + maxImageWidth: r.MaxImageWidth, + maxImageHeight: r.MaxImageHeight, + isActive: r.IsActive, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record ProductTypeRow( + int Id, + string Nombre, + bool HasDuration, + bool RequiresText, + bool RequiresCategory, + bool IsBundle, + bool AllowImages, + int? MaxImages, + decimal? MaxImageSizeMB, + int? MaxImageWidth, + int? MaxImageHeight, + bool IsActive, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs new file mode 100644 index 0000000..fd294d4 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs @@ -0,0 +1,233 @@ +using Dapper; +using FluentAssertions; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; + +namespace SIGCM2.Application.Tests.ProductTypes; + +/// +/// Integration tests for ProductTypeRepository against SIGCM2_Test_App. +/// Uses shared SqlTestFixture via [Collection("Database")] — fixture maneja Respawn + seeds. +/// Temporal: after UpdateAsync, dbo.ProductType_History MUST have ≥1 row for that Id. +/// +[Collection("Database")] +public class ProductTypeRepositoryTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private ProductTypeRepository _repository = null!; + private TimeProvider _timeProvider = null!; + + public ProductTypeRepositoryTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + + var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb); + _repository = new ProductTypeRepository(factory); + _timeProvider = TimeProvider.System; + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_AndGetById_ReturnsAllFields() + { + var pt = ProductType.ForCreation( + nombre: "Avisos Clasificados", + hasDuration: true, + requiresText: true, + requiresCategory: true, + isBundle: false, + allowImages: true, + maxImages: 5, + maxImageSizeMB: 2.5m, + maxImageWidth: 800, + maxImageHeight: 600, + timeProvider: _timeProvider); + + var id = await _repository.AddAsync(pt); + var result = await _repository.GetByIdAsync(id); + + result.Should().NotBeNull(); + result!.Id.Should().Be(id); + result.Nombre.Should().Be("Avisos Clasificados"); + result.HasDuration.Should().BeTrue(); + result.RequiresText.Should().BeTrue(); + result.RequiresCategory.Should().BeTrue(); + result.IsBundle.Should().BeFalse(); + result.AllowImages.Should().BeTrue(); + result.MaxImages.Should().Be(5); + result.MaxImageSizeMB.Should().Be(2.5m); + result.MaxImageWidth.Should().Be(800); + result.MaxImageHeight.Should().Be(600); + result.IsActive.Should().BeTrue(); + result.FechaCreacion.Should().BeAfter(DateTime.MinValue); + result.FechaModificacion.Should().BeNull(); + } + + [Fact] + public async Task AddAsync_NoImages_PersistsNullMultimedia() + { + var pt = ProductType.ForCreation( + nombre: "Aviso Simple", + hasDuration: false, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + maxImages: null, + maxImageSizeMB: null, + maxImageWidth: null, + maxImageHeight: null, + timeProvider: _timeProvider); + + var id = await _repository.AddAsync(pt); + var result = await _repository.GetByIdAsync(id); + + result.Should().NotBeNull(); + result!.AllowImages.Should().BeFalse(); + result.MaxImages.Should().BeNull(); + result.MaxImageSizeMB.Should().BeNull(); + result.MaxImageWidth.Should().BeNull(); + result.MaxImageHeight.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + result.Should().BeNull(); + } + + // ── ExistsByNombreAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task ExistsByNombreAsync_ExistingActiveNombre_ReturnsTrue() + { + var pt = ProductType.ForCreation("Tipo Unico", false, false, false, false, false, null, null, null, null, _timeProvider); + await _repository.AddAsync(pt); + + var exists = await _repository.ExistsByNombreAsync("Tipo Unico"); + + exists.Should().BeTrue(); + } + + [Fact] + public async Task ExistsByNombreAsync_WithExcludeId_ExcludesSelf() + { + var pt = ProductType.ForCreation("Auto-Excluido", false, false, false, false, false, null, null, null, null, _timeProvider); + var id = await _repository.AddAsync(pt); + + var exists = await _repository.ExistsByNombreAsync("Auto-Excluido", excludeId: id); + + exists.Should().BeFalse(); + } + + [Fact] + public async Task ExistsByNombreAsync_InactiveNombre_ReturnsFalse() + { + var pt = ProductType.ForCreation("Tipo Inactivo", false, false, false, false, false, null, null, null, null, _timeProvider); + var id = await _repository.AddAsync(pt); + var entity = await _repository.GetByIdAsync(id); + await _repository.UpdateAsync(entity!.WithDeactivated(_timeProvider)); + + var exists = await _repository.ExistsByNombreAsync("Tipo Inactivo"); + + exists.Should().BeFalse(); + } + + // ── UpdateAsync ──────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_PersistsChanges() + { + var pt = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, _timeProvider); + var id = await _repository.AddAsync(pt); + var loaded = await _repository.GetByIdAsync(id); + + var updated = loaded! + .WithRenamed("Renombrado", _timeProvider) + .WithUpdatedFlags(true, true, false, false, _timeProvider); + await _repository.UpdateAsync(updated); + + var result = await _repository.GetByIdAsync(id); + result!.Nombre.Should().Be("Renombrado"); + result.HasDuration.Should().BeTrue(); + result.RequiresText.Should().BeTrue(); + result.FechaModificacion.Should().NotBeNull(); + } + + [Fact] + public async Task UpdateAsync_DeactivatesAndRecordsHistory() + { + var pt = ProductType.ForCreation("Para Baja", false, false, false, false, false, null, null, null, null, _timeProvider); + var id = await _repository.AddAsync(pt); + var loaded = await _repository.GetByIdAsync(id); + + await _repository.UpdateAsync(loaded!.WithDeactivated(_timeProvider)); + + var result = await _repository.GetByIdAsync(id); + result!.IsActive.Should().BeFalse(); + + // Verify temporal history has at least 1 row + var historyCount = await _db.Connection.ExecuteScalarAsync( + "SELECT COUNT(1) FROM dbo.ProductType_History WHERE Id = @Id", new { Id = id }); + historyCount.Should().BeGreaterThan(0); + } + + // ── GetPagedAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FiltersActiveByDefault() + { + var active = ProductType.ForCreation("Activo Paginado", false, false, false, false, false, null, null, null, null, _timeProvider); + var inactive = ProductType.ForCreation("Inactivo Paginado", false, false, false, false, false, null, null, null, null, _timeProvider); + + await _repository.AddAsync(active); + var inactiveId = await _repository.AddAsync(inactive); + var inactiveLoaded = await _repository.GetByIdAsync(inactiveId); + await _repository.UpdateAsync(inactiveLoaded!.WithDeactivated(_timeProvider)); + + var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 50, Activo: true); + var result = await _repository.GetPagedAsync(query); + + result.Items.Should().Contain(x => x.Nombre == "Activo Paginado"); + result.Items.Should().NotContain(x => x.Nombre == "Inactivo Paginado"); + } + + [Fact] + public async Task GetPagedAsync_SearchFilter_FiltersCorrectly() + { + await _repository.AddAsync(ProductType.ForCreation("Buscar ABC", false, false, false, false, false, null, null, null, null, _timeProvider)); + await _repository.AddAsync(ProductType.ForCreation("Otro Tipo", false, false, false, false, false, null, null, null, null, _timeProvider)); + + var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 50, Activo: null, Search: "ABC"); + var result = await _repository.GetPagedAsync(query); + + result.Items.Should().Contain(x => x.Nombre == "Buscar ABC"); + result.Items.Should().NotContain(x => x.Nombre == "Otro Tipo"); + } + + [Fact] + public async Task GetPagedAsync_Pagination_RespectsPageSize() + { + for (var i = 1; i <= 5; i++) + await _repository.AddAsync(ProductType.ForCreation($"Paginado {i:D2}", false, false, false, false, false, null, null, null, null, _timeProvider)); + + var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 3, Activo: null); + var result = await _repository.GetPagedAsync(query); + + result.Items.Should().HaveCount(3); + result.Total.Should().BeGreaterThanOrEqualTo(5); + result.Page.Should().Be(1); + result.PageSize.Should().Be(3); + } +}