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
13 changed files with 375 additions and 0 deletions
Showing only changes of commit 3c9e852379 - Show all commits

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
/// PRD-001 binds to NullProductQueryRepository (always returns false).
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
/// </summary>
public interface IProductQueryRepository
{
/// <summary>
/// Returns true if at least one active Product with the given ProductTypeId exists.
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for ProductType.
/// All reads needed by write handlers are included here.
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
/// </summary>
public interface IProductTypeRepository
{
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing ProductType row.</summary>
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
/// <summary>
/// Returns true if an active ProductType with the given nombre exists.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Common;
/// <summary>
/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
/// </summary>
public sealed record ProductTypesQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed record GetProductTypeByIdQuery(int Id);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed class GetProductTypeByIdQueryHandler
: ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>
{
private readonly IProductTypeRepository _repo;
public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
{
_repo = repo;
}
public async Task<ProductTypeDetailDto> Handle(GetProductTypeByIdQuery query)
{
var pt = await _repo.GetByIdAsync(query.Id)
?? throw new ProductTypeNotFoundException(query.Id);
return new ProductTypeDetailDto(
pt.Id, pt.Nombre,
pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
pt.AllowImages,
pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
pt.IsActive,
pt.FechaCreacion, pt.FechaModificacion);
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed record ProductTypeDetailDto(
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,7 @@
namespace SIGCM2.Application.ProductTypes.List;
public sealed record ListProductTypesQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null);

View File

@@ -0,0 +1,32 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.ProductTypes.List;
public sealed class ListProductTypesQueryHandler
: ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>
{
private readonly IProductTypeRepository _repo;
public ListProductTypesQueryHandler(IProductTypeRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ProductTypeListItemDto>> Handle(ListProductTypesQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items.Select(p => new ProductTypeListItemDto(
p.Id, p.Nombre,
p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
p.AllowImages, p.IsActive)).ToList();
return new PagedResult<ProductTypeListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.ProductTypes.List;
public sealed record ProductTypeListItemDto(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
bool IsActive);

View File

@@ -0,0 +1,14 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Products;
/// <summary>
/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product.
/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes.
/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002.
/// </summary>
public sealed class NullProductQueryRepository : IProductQueryRepository
{
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
=> Task.FromResult(false);
}

View File

@@ -0,0 +1,70 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.ProductTypes.GetById;
public class GetProductTypeByIdQueryHandlerTests
{
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
private readonly GetProductTypeByIdQueryHandler _handler;
public GetProductTypeByIdQueryHandlerTests()
{
_handler = new GetProductTypeByIdQueryHandler(_repo);
}
private static ProductType FullProductType(int id = 5) =>
new(id, "Clasificados",
hasDuration: true, requiresText: true, requiresCategory: false, isBundle: false,
allowImages: true, maxImages: 10, maxImageSizeMB: 2.5m, maxImageWidth: 1920, maxImageHeight: 1080,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
[Fact]
public async Task Handle_Found_ReturnsDetailDto()
{
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(FullProductType(5));
var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
result.Id.Should().Be(5);
result.Nombre.Should().Be("Clasificados");
}
[Fact]
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
{
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
var act = async () => await _handler.Handle(new GetProductTypeByIdQuery(999));
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
.Where(e => e.ProductTypeId == 999);
}
[Fact]
public async Task Handle_DetailContainsAllFields()
{
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(FullProductType(5));
var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
result.HasDuration.Should().BeTrue();
result.RequiresText.Should().BeTrue();
result.RequiresCategory.Should().BeFalse();
result.IsBundle.Should().BeFalse();
result.AllowImages.Should().BeTrue();
result.MaxImages.Should().Be(10);
result.MaxImageSizeMB.Should().Be(2.5m);
result.MaxImageWidth.Should().Be(1920);
result.MaxImageHeight.Should().Be(1080);
result.IsActive.Should().BeTrue();
result.FechaCreacion.Should().Be(new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc));
result.FechaModificacion.Should().Be(new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
}
}

View File

@@ -0,0 +1,109 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.ProductTypes.List;
public class ListProductTypesQueryHandlerTests
{
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
private readonly ListProductTypesQueryHandler _handler;
public ListProductTypesQueryHandlerTests()
{
_handler = new ListProductTypesQueryHandler(_repo);
}
private static ProductType MakeProductType(int id, string nombre, bool isActive = true) =>
new(id, nombre, false, false, false, false, false, null, null, null, null,
isActive, DateTime.UtcNow, null);
private static PagedResult<ProductType> PagedOf(List<ProductType> items, int page = 1, int pageSize = 20, int? total = null) =>
new(items, page, pageSize, total ?? items.Count);
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_DefaultQuery_Returns20Active()
{
var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(PagedOf(items));
var result = await _handler.Handle(new ListProductTypesQuery());
result.Items.Should().HaveCount(5);
}
[Fact]
public async Task Handle_PageLessThan1_ClampsToOne()
{
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(PagedOf([]));
await _handler.Handle(new ListProductTypesQuery(Page: 0));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductTypesQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PageSizeOver100_ClampsTo100()
{
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(PagedOf([]));
await _handler.Handle(new ListProductTypesQuery(PageSize: 200));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductTypesQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ActivoFalse_ReturnsInactives()
{
var inactive = new List<ProductType> { MakeProductType(99, "Inactivo", false) };
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(PagedOf(inactive));
var result = await _handler.Handle(new ListProductTypesQuery(Activo: false));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductTypesQuery>(q => q.Activo == false),
Arg.Any<CancellationToken>());
result.Items.Should().HaveCount(1);
}
[Fact]
public async Task Handle_SearchFilter_PassesThroughToRepo()
{
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(PagedOf([]));
await _handler.Handle(new ListProductTypesQuery(Search: "clasif"));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductTypesQuery>(q => q.Search == "clasif"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PagedResult_ReturnsItemsAndTotal()
{
var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<ProductType>(items, 2, 5, 15));
var result = await _handler.Handle(new ListProductTypesQuery(Page: 2, PageSize: 5));
result.Total.Should().Be(15);
result.Page.Should().Be(2);
result.PageSize.Should().Be(5);
result.Items.Should().HaveCount(5);
}
}

View File

@@ -0,0 +1,26 @@
using FluentAssertions;
using SIGCM2.Application.Products;
namespace SIGCM2.Application.Tests.ProductTypes;
public class NullProductQueryRepositoryTests
{
private readonly NullProductQueryRepository _sut = new();
[Fact]
public async Task ExistsActiveByProductTypeAsync_AlwaysReturnsFalse()
{
var result = await _sut.ExistsActiveByProductTypeAsync(productTypeId: 1);
result.Should().BeFalse();
}
[Fact]
public async Task ExistsActiveByProductTypeAsync_WithCancellationToken_DoesNotThrow()
{
using var cts = new CancellationTokenSource();
var act = async () => await _sut.ExistsActiveByProductTypeAsync(productTypeId: 999, ct: cts.Token);
await act.Should().NotThrowAsync();
}
}