feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002)

This commit is contained in:
2026-04-19 13:10:21 -03:00
parent bb455be745
commit 8c9a50504d
6 changed files with 361 additions and 7 deletions

View File

@@ -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<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
// ProductTypes (PRD-001)
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();

View File

@@ -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);

View File

@@ -40,6 +40,9 @@ public static class DependencyInjection
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
services.AddScoped<IRubroRepository, RubroRepository>();
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
// PRD-002: replaces NullProductQueryRepository from Application DI
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,34 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product.
/// Replaces NullProductQueryRepository which was bound during PRD-001.
/// </summary>
public sealed class ProductQueryRepository : IProductQueryRepository
{
private readonly SqlConnectionFactory _factory;
public ProductQueryRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<bool> 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<int>(sql, new { ProductTypeId = productTypeId });
return count > 0;
}
}

View File

@@ -0,0 +1,201 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product.
/// Full implementation in Batch 6.
/// </summary>
public sealed class ProductRepository : IProductRepository
{
private readonly SqlConnectionFactory _factory;
public ProductRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<int> 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<int>(sql, new
{
product.Nombre,
product.MedioId,
product.ProductTypeId,
product.RubroId,
product.BasePrice,
product.PriceDurationDays,
product.FechaCreacion,
});
}
public async Task<Product?> 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<ProductRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default)
{
var conditions = new List<string>();
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<int>(countSql, parameters);
var rows = await connection.QueryAsync<ProductRow>(dataSql, parameters);
var items = rows.Select(MapRow).ToList();
return new PagedResult<Product>(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<bool> 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<int>(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);
}

View File

@@ -0,0 +1,120 @@
using Dapper;
using FluentAssertions;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Products.Repository;
/// <summary>
/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App.
/// These tests verify the real Dapper implementation replaces NullProductQueryRepository.
/// </summary>
[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<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
OUTPUT INSERTED.Id
VALUES ('TM', 'Test Medio', 1, 1)
""");
var productTypeId = await conn.ExecuteScalarAsync<int>("""
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 });
}
}