using FluentAssertions; using NSubstitute; using SIGCM2.Application.Abstractions.Persistence; 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 — GetProductPricesQueryHandler tests. /// Covers: §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)); public GetProductPricesQueryHandlerTests() { _productsRepo.GetByIdAsync(1, Arg.Any()) .Returns(ActiveProduct()); // default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo) _pricesRepo.GetByProductIdAsync(1, Arg.Any()) .Returns(new List { MakePrice(3, Date3), // activo (pvt=null) MakePrice(2, Date2, Date3.AddDays(-1)), // cerrado MakePrice(1, Date1, Date2.AddDays(-1)) // cerrado más antiguo }.AsReadOnly() as IReadOnlyList); _handler = new GetProductPricesQueryHandler(_pricesRepo, _productsRepo); } // ── Orden descending ──────────────────────────────────────────────────────── [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.Should().HaveCount(3); result[0].PriceValidFrom.Should().Be(Date3); // más reciente primero result[1].PriceValidFrom.Should().Be(Date2); result[2].PriceValidFrom.Should().Be(Date1); } [Fact] public async Task Handle_MapsToDto_WithIsActive() { var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result[0].IsActive.Should().BeTrue(); // pvt=null → activo result[1].IsActive.Should().BeFalse(); // pvt IS NOT NULL → cerrado } // ── Lista vacía (nuevo producto sin precios) ───────────────────────────────── [Fact] public async Task Handle_EmptyHistory_ReturnsEmptyList() { // §REQ-4.3 — nuevo producto aún no tiene precios → lista vacía (no 404) _pricesRepo.GetByProductIdAsync(1, Arg.Any()) .Returns(new List().AsReadOnly() as IReadOnlyList); var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); result.Should().BeEmpty(); } // ── 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()); } }