feat(application): IProductTypeRepository + IProductQueryRepository stub + queries (PRD-001)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal 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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||
|
||||
public sealed record GetProductTypeByIdQuery(int Id);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user