using FluentAssertions; using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; using SIGCM2.Application.Common; using SIGCM2.Application.Products.Prices; using SIGCM2.Application.Products.Prices.GetHistory; using SIGCM2.Domain.Entities; using SIGCM2.Domain.Exceptions; namespace SIGCM2.Application.Tests.Products.Prices; /// /// PRD-003 (paginated) — GetProductPricesQueryHandler tests. /// Covers: §P.1 (defaults), §P.3 (empty), §P.4 (pageSize clamp), §P.5 (page clamp), /// §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404). /// public class GetProductPricesQueryHandlerTests { private readonly IProductPriceRepository _pricesRepo = Substitute.For(); private readonly IProductRepository _productsRepo = Substitute.For(); private readonly GetProductPricesQueryHandler _handler; private static readonly DateOnly Date1 = new(2026, 1, 1); private static readonly DateOnly Date2 = new(2026, 2, 1); private static readonly DateOnly Date3 = new(2026, 3, 1); private static Product ActiveProduct() => Product.ForCreation( "Test", medioId: 1, productTypeId: 2, rubroId: null, basePrice: 100m, priceDurationDays: null, TimeProvider.System); private static ProductPrice MakePrice(long id, DateOnly pvf, DateOnly? pvt = null) => new(id, ProductId: 1, Price: 100m * id, pvf, pvt, FechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); private static PagedResult MakePagedResult( IReadOnlyList items, int page = 1, int pageSize = 20, int? total = null) => new(items, page, pageSize, total ?? items.Count); public GetProductPricesQueryHandlerTests() { _productsRepo.GetByIdAsync(1, Arg.Any()) .Returns(ActiveProduct()); // default: 3 precios devueltos descending por el repo var defaultItems = new List { MakePrice(3, Date3), MakePrice(2, Date2, Date3.AddDays(-1)), MakePrice(1, Date1, Date2.AddDays(-1)) }; _pricesRepo .GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(MakePagedResult(defaultItems)); _handler = new GetProductPricesQueryHandler(_pricesRepo, _productsRepo); } // ── Orden descending y mapping ────────────────────────────────────────────── [Fact] public async Task Handle_ReturnsAllPrices_InDescendingOrder() { // §REQ-4.1 — historial completo ordenado descending por PriceValidFrom var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result.Items.Should().HaveCount(3); result.Items[0].PriceValidFrom.Should().Be(Date3); result.Items[1].PriceValidFrom.Should().Be(Date2); result.Items[2].PriceValidFrom.Should().Be(Date1); } [Fact] public async Task Handle_MapsToDto_WithIsActive() { var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result.Items[0].IsActive.Should().BeTrue(); // pvt=null → activo result.Items[1].IsActive.Should().BeFalse(); // pvt IS NOT NULL → cerrado } [Fact] public async Task Handle_ReturnsPagedResultShape_WithCorrectMeta() { // §P.1 — defaults page=1, pageSize=20 forwarded to repo and reflected in result var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result.Page.Should().Be(1); result.PageSize.Should().Be(20); } // ── Lista vacía (nuevo producto sin precios) ───────────────────────────────── [Fact] public async Task Handle_EmptyHistory_ReturnsEmptyPagedResult() { // §REQ-4.3 / §P.6 — nuevo producto aún no tiene precios → items vacíos (no 404) _pricesRepo .GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PagedResult(new List(), 1, 20, 0)); var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result.Items.Should().BeEmpty(); result.Total.Should().Be(0); } // ── Clamping defensivo ────────────────────────────────────────────────────── [Fact] public async Task Handle_PageZero_ClampsToOne() { // §P.5 — page=0 → Math.Max(1,0) = 1 await _handler.Handle(new GetProductPricesQuery(ProductId: 1, Page: 0)); await _pricesRepo.Received(1) .GetByProductIdAsync(1, page: 1, pageSize: 20, Arg.Any()); } [Fact] public async Task Handle_PageNegative_ClampsToOne() { // §P.5 — page=-5 → Math.Max(1,-5) = 1 await _handler.Handle(new GetProductPricesQuery(ProductId: 1, Page: -5)); await _pricesRepo.Received(1) .GetByProductIdAsync(1, page: 1, pageSize: 20, Arg.Any()); } [Fact] public async Task Handle_PageSizeOver100_ClampsTo100() { // §P.4 — pageSize=500 → Math.Clamp(500,1,100) = 100 await _handler.Handle(new GetProductPricesQuery(ProductId: 1, PageSize: 500)); await _pricesRepo.Received(1) .GetByProductIdAsync(1, page: 1, pageSize: 100, Arg.Any()); } [Fact] public async Task Handle_PageSizeZero_ClampsToOne() { // pageSize=0 → Math.Clamp(0,1,100) = 1 await _handler.Handle(new GetProductPricesQuery(ProductId: 1, PageSize: 0)); await _pricesRepo.Received(1) .GetByProductIdAsync(1, page: 1, pageSize: 1, Arg.Any()); } // ── Producto inexistente ──────────────────────────────────────────────────── [Fact] public async Task Handle_ProductNotFound_ThrowsProductNotFoundException() { _productsRepo.GetByIdAsync(99, Arg.Any()) .Returns((Product?)null); var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99)); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_ProductNotFound_RepoGetByProductId_NotCalled() { _productsRepo.GetByIdAsync(99, Arg.Any()) .Returns((Product?)null); var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99)); await act.Should().ThrowAsync(); await _pricesRepo.DidNotReceive() .GetByProductIdAsync(99, Arg.Any(), Arg.Any(), Arg.Any()); } }