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.
This commit is contained in:
2026-04-19 09:49:08 -03:00
parent 5c8f19bf39
commit 936d1dc353
3 changed files with 448 additions and 0 deletions

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