feat: PRD-001 ProductType (flags + multimedia) #38

Merged
dmolinari merged 10 commits from feature/PRD-001 into main 2026-04-19 15:18:53 +00:00
3 changed files with 448 additions and 0 deletions
Showing only changes of commit 936d1dc353 - Show all commits

View File

@@ -39,6 +39,7 @@ public static class DependencyInjection
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
services.AddScoped<IRubroRepository, RubroRepository>();
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -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<int> 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<int>(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<ProductType?> 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<ProductTypeRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<PagedResult<ProductType>> GetPagedAsync(
ProductTypesQuery query,
CancellationToken ct = default)
{
// Build the WHERE clause dynamically.
var conditions = new List<string>();
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<int>(countSql, parameters);
var rows = await connection.QueryAsync<ProductTypeRow>(dataSql, parameters);
var items = rows.Select(MapRow).ToList();
return new PagedResult<ProductType>(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<bool> 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<int>(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);
}

View File

@@ -0,0 +1,233 @@
using Dapper;
using FluentAssertions;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.ProductTypes;
/// <summary>
/// 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.
/// </summary>
[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<int>(
"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);
}
}