feat: PRD-002 Product CRUD #40
@@ -69,7 +69,6 @@ using SIGCM2.Application.Rubros.GetById;
|
|||||||
using SIGCM2.Application.Rubros.Dtos;
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Avisos;
|
using SIGCM2.Application.Avisos;
|
||||||
using SIGCM2.Application.Products;
|
|
||||||
using SIGCM2.Application.Products.Create;
|
using SIGCM2.Application.Products.Create;
|
||||||
using SIGCM2.Application.Products.Update;
|
using SIGCM2.Application.Products.Update;
|
||||||
using SIGCM2.Application.Products.Deactivate;
|
using SIGCM2.Application.Products.Deactivate;
|
||||||
@@ -184,8 +183,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||||
|
|
||||||
// ProductTypes (PRD-001)
|
// ProductTypes (PRD-001)
|
||||||
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||||
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
|
||||||
|
|
||||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
|
|||||||
if (!target.IsActive)
|
if (!target.IsActive)
|
||||||
return new ProductTypeStatusDto(command.Id, false);
|
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);
|
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
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.
|
|
||||||
|
|
||||||
// 4. Deactivate (immutable — returns new instance)
|
// 4. Deactivate (immutable — returns new instance)
|
||||||
var deactivated = target.WithDeactivated(_timeProvider);
|
var deactivated = target.WithDeactivated(_timeProvider);
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||||
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
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
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal file
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user