2026-04-19 18:08:16 -03:00
|
|
|
using FluentAssertions;
|
|
|
|
|
using NSubstitute;
|
|
|
|
|
using SIGCM2.Application.Abstractions.Persistence;
|
2026-04-19 19:47:18 -03:00
|
|
|
using SIGCM2.Application.Common;
|
2026-04-19 18:08:16 -03:00
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-04-19 19:47:18 -03:00
|
|
|
/// 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).
|
2026-04-19 18:08:16 -03:00
|
|
|
/// </summary>
|
|
|
|
|
public class GetProductPricesQueryHandlerTests
|
|
|
|
|
{
|
|
|
|
|
private readonly IProductPriceRepository _pricesRepo = Substitute.For<IProductPriceRepository>();
|
|
|
|
|
private readonly IProductRepository _productsRepo = Substitute.For<IProductRepository>();
|
|
|
|
|
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));
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
private static PagedResult<ProductPrice> MakePagedResult(
|
|
|
|
|
IReadOnlyList<ProductPrice> items, int page = 1, int pageSize = 20, int? total = null) =>
|
|
|
|
|
new(items, page, pageSize, total ?? items.Count);
|
|
|
|
|
|
2026-04-19 18:08:16 -03:00
|
|
|
public GetProductPricesQueryHandlerTests()
|
|
|
|
|
{
|
|
|
|
|
_productsRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(ActiveProduct());
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
// default: 3 precios devueltos descending por el repo
|
|
|
|
|
var defaultItems = new List<ProductPrice>
|
|
|
|
|
{
|
|
|
|
|
MakePrice(3, Date3),
|
|
|
|
|
MakePrice(2, Date2, Date3.AddDays(-1)),
|
|
|
|
|
MakePrice(1, Date1, Date2.AddDays(-1))
|
|
|
|
|
};
|
|
|
|
|
_pricesRepo
|
|
|
|
|
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(MakePagedResult(defaultItems));
|
2026-04-19 18:08:16 -03:00
|
|
|
|
|
|
|
|
_handler = new GetProductPricesQueryHandler(_pricesRepo, _productsRepo);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
// ── Orden descending y mapping ──────────────────────────────────────────────
|
2026-04-19 18:08:16 -03:00
|
|
|
|
|
|
|
|
[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));
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
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);
|
2026-04-19 18:08:16 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Handle_MapsToDto_WithIsActive()
|
|
|
|
|
{
|
|
|
|
|
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
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);
|
2026-04-19 18:08:16 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Lista vacía (nuevo producto sin precios) ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[Fact]
|
2026-04-19 19:47:18 -03:00
|
|
|
public async Task Handle_EmptyHistory_ReturnsEmptyPagedResult()
|
2026-04-19 18:08:16 -03:00
|
|
|
{
|
2026-04-19 19:47:18 -03:00
|
|
|
// §REQ-4.3 / §P.6 — nuevo producto aún no tiene precios → items vacíos (no 404)
|
|
|
|
|
_pricesRepo
|
|
|
|
|
.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(new PagedResult<ProductPrice>(new List<ProductPrice>(), 1, 20, 0));
|
2026-04-19 18:08:16 -03:00
|
|
|
|
|
|
|
|
var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1));
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
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<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<CancellationToken>());
|
2026-04-19 18:08:16 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Producto inexistente ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Handle_ProductNotFound_ThrowsProductNotFoundException()
|
|
|
|
|
{
|
|
|
|
|
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns((Product?)null);
|
|
|
|
|
|
|
|
|
|
var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99));
|
|
|
|
|
|
|
|
|
|
await act.Should().ThrowAsync<ProductNotFoundException>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Handle_ProductNotFound_RepoGetByProductId_NotCalled()
|
|
|
|
|
{
|
|
|
|
|
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns((Product?)null);
|
|
|
|
|
|
|
|
|
|
var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99));
|
|
|
|
|
await act.Should().ThrowAsync<ProductNotFoundException>();
|
|
|
|
|
|
|
|
|
|
await _pricesRepo.DidNotReceive()
|
2026-04-19 19:47:18 -03:00
|
|
|
.GetByProductIdAsync(99, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
2026-04-19 18:08:16 -03:00
|
|
|
}
|
|
|
|
|
}
|