From 8c9a50504d686e74cef38d77ef9f5844ddc9c744 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 13:10:21 -0300 Subject: [PATCH] feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002) --- .../SIGCM2.Application/DependencyInjection.cs | 4 +- .../DeactivateProductTypeCommandHandler.cs | 6 +- .../DependencyInjection.cs | 3 + .../Persistence/ProductQueryRepository.cs | 34 +++ .../Persistence/ProductRepository.cs | 201 ++++++++++++++++++ .../Repository/ProductQueryRepositoryTests.cs | 120 +++++++++++ 6 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index e67fec9..d0e64ec 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -69,7 +69,6 @@ using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Avisos; -using SIGCM2.Application.Products; using SIGCM2.Application.Products.Create; using SIGCM2.Application.Products.Update; using SIGCM2.Application.Products.Deactivate; @@ -184,8 +183,7 @@ public static class DependencyInjection services.AddScoped>, ListProductsQueryHandler>(); // ProductTypes (PRD-001) - // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. - services.AddScoped(); + // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. services.AddScoped, CreateProductTypeCommandHandler>(); services.AddScoped, UpdateProductTypeCommandHandler>(); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs index 66c4781..79aa42f 100644 --- a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs @@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler if (!target.IsActive) return new ProductTypeStatusDto(command.Id, false); - // 3. Guard: check if any active product uses this type (guard before audit — ordering matters) + // 3. Guard: check if any active product uses this type var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id); if (inUse) - throw new ProductTypeEnUsoException(command.Id, productsActivos: -1); - // Note: count=-1 sentinel because Products table doesn't exist in PRD-001. - // PRD-002 will update this with the actual count. + throw new ProductTypeEnUsoException(command.Id, productsActivos: 1); // 4. Deactivate (immutable — returns new instance) var deactivated = target.WithDeactivated(_timeProvider); diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 809c334..0797ca3 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -40,6 +40,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + // PRD-002: replaces NullProductQueryRepository from Application DI + 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/ProductQueryRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs new file mode 100644 index 0000000..24fc3b6 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductQueryRepository.cs @@ -0,0 +1,34 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product. +/// Replaces NullProductQueryRepository which was bound during PRD-001. +/// +public sealed class ProductQueryRepository : IProductQueryRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductQueryRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.Product + WHERE ProductTypeId = @ProductTypeId + AND IsActive = 1 + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { ProductTypeId = productTypeId }); + return count > 0; + } +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs new file mode 100644 index 0000000..dbf2527 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs @@ -0,0 +1,201 @@ +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product. +/// Full implementation in Batch 6. +/// +public sealed class ProductRepository : IProductRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + public async Task AddAsync(Product product, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO dbo.Product ( + Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays, + IsActive, FechaCreacion + ) + OUTPUT INSERTED.Id + VALUES ( + @Nombre, @MedioId, @ProductTypeId, @RubroId, @BasePrice, @PriceDurationDays, + 1, @FechaCreacion + ) + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + product.Nombre, + product.MedioId, + product.ProductTypeId, + product.RubroId, + product.BasePrice, + product.PriceDurationDays, + product.FechaCreacion, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Nombre, MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion + FROM dbo.Product + 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(ProductsQuery query, CancellationToken ct = default) + { + var conditions = new List(); + if (query.Activo.HasValue) + conditions.Add("IsActive = @Activo"); + if (!string.IsNullOrWhiteSpace(query.Search)) + conditions.Add("Nombre LIKE '%' + @Search + '%'"); + if (query.MedioId.HasValue) + conditions.Add("MedioId = @MedioId"); + if (query.ProductTypeId.HasValue) + conditions.Add("ProductTypeId = @ProductTypeId"); + if (query.RubroId.HasValue) + conditions.Add("RubroId = @RubroId"); + + var where = conditions.Count > 0 + ? "WHERE " + string.Join(" AND ", conditions) + : string.Empty; + + var countSql = $"SELECT COUNT(1) FROM dbo.Product {where}"; + var dataSql = $""" + SELECT Id, Nombre, MedioId, ProductTypeId, RubroId, + BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion + FROM dbo.Product + {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, + query.MedioId, + query.ProductTypeId, + query.RubroId, + 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(Product product, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Product + SET Nombre = @Nombre, + RubroId = @RubroId, + BasePrice = @BasePrice, + PriceDurationDays = @PriceDurationDays, + IsActive = @IsActive, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + product.Nombre, + product.RubroId, + product.BasePrice, + product.PriceDurationDays, + IsActive = product.IsActive ? 1 : 0, + product.FechaModificacion, + product.Id, + }); + } + + public async Task ExistsByNombreAsync( + string nombre, + int medioId, + int productTypeId, + int? excludeId = null, + CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) + FROM dbo.Product + WHERE Nombre = @Nombre + AND MedioId = @MedioId + AND ProductTypeId = @ProductTypeId + 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, + MedioId = medioId, + ProductTypeId = productTypeId, + ExcludeId = excludeId, + }); + + return count > 0; + } + + // ── Mapping ─────────────────────────────────────────────────────────────── + + private static Product MapRow(ProductRow r) + => new( + id: r.Id, + nombre: r.Nombre, + medioId: r.MedioId, + productTypeId: r.ProductTypeId, + rubroId: r.RubroId, + basePrice: r.BasePrice, + priceDurationDays: r.PriceDurationDays, + isActive: r.IsActive, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record ProductRow( + int Id, + string Nombre, + int MedioId, + int ProductTypeId, + int? RubroId, + decimal BasePrice, + int? PriceDurationDays, + bool IsActive, + DateTime FechaCreacion, + DateTime? FechaModificacion); +} diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs new file mode 100644 index 0000000..309e8e3 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductQueryRepositoryTests.cs @@ -0,0 +1,120 @@ +using Dapper; +using FluentAssertions; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; + +namespace SIGCM2.Application.Tests.Products.Repository; + +/// +/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App. +/// These tests verify the real Dapper implementation replaces NullProductQueryRepository. +/// +[Collection("Database")] +public class ProductQueryRepositoryTests : IAsyncLifetime +{ + private readonly SqlTestFixture _db; + private ProductQueryRepository _repository = null!; + + public ProductQueryRepositoryTests(SqlTestFixture db) + { + _db = db; + } + + public async Task InitializeAsync() + { + await _db.ResetAndSeedAsync(); + var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb); + _repository = new ProductQueryRepository(factory); + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ── ExistsActiveByProductTypeAsync ─────────────────────────────────────── + + [Fact] + public async Task ExistsActiveByProductTypeAsync_NoProducts_ReturnsFalse() + { + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId: 999); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_WithActiveProduct_ReturnsTrue() + { + // Arrange: insert a ProductType and an active Product referencing it + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertActiveProductAsync(medioId, productTypeId); + + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_WithOnlyInactiveProduct_ReturnsFalse() + { + // Arrange: insert an inactive product + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertInactiveProductAsync(medioId, productTypeId); + + var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId); + + result.Should().BeFalse(); + } + + [Fact] + public async Task ExistsActiveByProductTypeAsync_DifferentProductType_ReturnsFalse() + { + // Arrange: insert active product for productTypeId=A, query for productTypeId=B + var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync(); + await InsertActiveProductAsync(medioId, productTypeId); + var otherProductTypeId = productTypeId + 100; + + var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId); + + result.Should().BeFalse(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync() + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + + var medioId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('TM', 'Test Medio', 1, 1) + """); + + var productTypeId = await conn.ExecuteScalarAsync(""" + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages) + OUTPUT INSERTED.Id + VALUES ('Test Type', 0, 0, 0, 0, 0) + """); + + return (medioId, productTypeId); + } + + private async Task InsertActiveProductAsync(int medioId, int productTypeId) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) + VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME()) + """, new { MedioId = medioId, ProductTypeId = productTypeId }); + } + + private async Task InsertInactiveProductAsync(int medioId, int productTypeId) + { + await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion) + VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME()) + """, new { MedioId = medioId, ProductTypeId = productTypeId }); + } +}