From 3c9e8523796f2579037b5f1ac0a41759585d1f53 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 09:38:51 -0300 Subject: [PATCH] feat(application): IProductTypeRepository + IProductQueryRepository stub + queries (PRD-001) --- .../Persistence/IProductQueryRepository.cs | 15 +++ .../Persistence/IProductTypeRepository.cs | 31 +++++ .../Common/ProductTypesQuery.cs | 10 ++ .../GetById/GetProductTypeByIdQuery.cs | 3 + .../GetById/GetProductTypeByIdQueryHandler.cs | 30 +++++ .../GetById/ProductTypeDetailDto.cs | 17 +++ .../List/ListProductTypesQuery.cs | 7 ++ .../List/ListProductTypesQueryHandler.cs | 32 +++++ .../List/ProductTypeListItemDto.cs | 11 ++ .../Products/NullProductQueryRepository.cs | 14 +++ .../GetProductTypeByIdQueryHandlerTests.cs | 70 +++++++++++ .../List/ListProductTypesQueryHandlerTests.cs | 109 ++++++++++++++++++ .../NullProductQueryRepositoryTests.cs | 26 +++++ 13 files changed, 375 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs create mode 100644 src/api/SIGCM2.Application/Common/ProductTypesQuery.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs new file mode 100644 index 0000000..e4418ba --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// 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). +/// +public interface IProductQueryRepository +{ + /// + /// Returns true if at least one active Product with the given ProductTypeId exists. + /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products. + /// + Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs new file mode 100644 index 0000000..77b4e33 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// Write-side repository for ProductType. +/// All reads needed by write handlers are included here. +/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery. +/// +public interface IProductTypeRepository +{ + /// Inserts a new ProductType and returns the DB-assigned Id. + Task AddAsync(ProductType productType, CancellationToken ct = default); + + /// Returns the ProductType with the given Id, or null if not found. + Task GetByIdAsync(int id, CancellationToken ct = default); + + /// Returns a paged result of ProductTypes matching the query. + Task> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default); + + /// Persists all changes to an existing ProductType row. + Task UpdateAsync(ProductType productType, CancellationToken ct = default); + + /// + /// 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). + /// + Task ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs new file mode 100644 index 0000000..1659f13 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Common; + +/// +/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync). +/// +public sealed record ProductTypesQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + string? Search = null); diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs new file mode 100644 index 0000000..f24f204 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.GetById; + +public sealed record GetProductTypeByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs new file mode 100644 index 0000000..27b52e9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs @@ -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 +{ + private readonly IProductTypeRepository _repo; + + public GetProductTypeByIdQueryHandler(IProductTypeRepository repo) + { + _repo = repo; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs new file mode 100644 index 0000000..52cafc2 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs new file mode 100644 index 0000000..bc4db25 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs new file mode 100644 index 0000000..585b8eb --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs @@ -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> +{ + private readonly IProductTypeRepository _repo; + + public ListProductTypesQueryHandler(IProductTypeRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs new file mode 100644 index 0000000..083df94 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs new file mode 100644 index 0000000..0f9b509 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs @@ -0,0 +1,14 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Products; + +/// +/// 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. +/// +public sealed class NullProductQueryRepository : IProductQueryRepository +{ + public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + => Task.FromResult(false); +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs new file mode 100644 index 0000000..640d278 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs @@ -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(); + 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()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(new GetProductTypeByIdQuery(999)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 999); + } + + [Fact] + public async Task Handle_DetailContainsAllFields() + { + _repo.GetByIdAsync(5, Arg.Any()).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)); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs new file mode 100644 index 0000000..1b525ab --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs @@ -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(); + 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 PagedOf(List 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(), Arg.Any()) + .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(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(Page: 0)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_PageSizeOver100_ClampsTo100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(PageSize: 200)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActivoFalse_ReturnsInactives() + { + var inactive = new List { MakeProductType(99, "Inactivo", false) }; + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf(inactive)); + + var result = await _handler.Handle(new ListProductTypesQuery(Activo: false)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Activo == false), + Arg.Any()); + result.Items.Should().HaveCount(1); + } + + [Fact] + public async Task Handle_SearchFilter_PassesThroughToRepo() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(Search: "clasif")); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Search == "clasif"), + Arg.Any()); + } + + [Fact] + public async Task Handle_PagedResult_ReturnsItemsAndTotal() + { + var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList(); + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(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); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs new file mode 100644 index 0000000..175b4cc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs @@ -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(); + } +}