Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs
dmolinari 0dce3ee4ac feat(api): pagination on GET product prices (closes #47)
- GET /api/v1/products/{id}/prices now returns PagedResult<ProductPriceDto>
  with OFFSET/FETCH + COUNT via Dapper (two queries on same connection)
- Query params: ?page (default 1) and ?pageSize (default 20, max 100)
- Clamping: Math.Max(1, page) + Math.Clamp(pageSize, 1, 100) in handler
- Auth upgraded from [Authorize] to [RequirePermission("catalogo:productos:gestionar")]
- IProductPriceRepository.GetByProductIdAsync signature updated to paginated form
- AddProductPriceCommandHandler adapted to read back via page=1, pageSize=2
- TDD cycle: RED (tests updated to PagedResult shape) -> GREEN (implementation) -> REFACTOR
- Tests: 1418 total (1106 Application + 312 Api), 0 failures

closes #47
2026-04-19 19:47:18 -03:00

225 lines
9.3 KiB
C#

using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Prices;
/// <summary>
/// PRD-003 — AddProductPriceCommandHandler tests.
/// Covers: §REQ-6.1 (happy path + audit), §REQ-6.2 (audit fail → rollback),
/// §REQ-3.3 (producto inexistente → ProductNotFoundException),
/// §REQ-2.2 (ForwardOnly propagation), §REQ-2.1 (response con Closed).
/// NSubstitute, FakeTimeProvider.
/// </summary>
public class AddProductPriceCommandHandlerTests
{
private readonly IProductPriceRepository _pricesRepo = Substitute.For<IProductPriceRepository>();
private readonly IProductRepository _productsRepo = Substitute.For<IProductRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero));
private readonly AddProductPriceCommandHandler _handler;
private static readonly DateOnly Today = new(2026, 4, 19);
private static readonly DateOnly Tomorrow = new(2026, 4, 20);
// Producto activo de ejemplo
private static Product ActiveProduct(int id = 1) => Product.ForCreation(
"Test Product", medioId: 1, productTypeId: 2,
rubroId: null, basePrice: 100m, priceDurationDays: null,
TimeProvider.System);
// ProductPrice stub de retorno
private static ProductPrice MakePrice(long id, int productId, decimal price,
DateOnly pvf, DateOnly? pvt = null) =>
new(id, productId, price, pvf, pvt,
FechaCreacion: new DateTime(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc));
public AddProductPriceCommandHandlerTests()
{
// defaults: producto activo existe; AddAsync devuelve newId=10, closedId=null
_productsRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(ActiveProduct(1));
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((10L, (long?)null));
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<ProductPrice>(
new List<ProductPrice> { MakePrice(10, 1, 150m, Today) },
1, 2, 1));
_handler = new AddProductPriceCommandHandler(_pricesRepo, _productsRepo, _audit, _time);
}
private static AddProductPriceCommand ValidCmd(int productId = 1) => new(
ProductId: productId,
Price: 150m,
PriceValidFrom: Today);
// ── Happy path ─────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_ReturnsCreatedDto()
{
// §REQ-1.1 happy path — primer precio (sin activo previo)
var result = await _handler.Handle(ValidCmd());
result.Created.Should().NotBeNull();
result.Created.Id.Should().Be(10);
result.Created.Price.Should().Be(150m);
result.Created.PriceValidFrom.Should().Be(Today);
result.Created.IsActive.Should().BeTrue();
result.Closed.Should().BeNull();
}
[Fact]
public async Task Handle_HappyPath_CallsRepoAddAsync_Once()
{
await _handler.Handle(ValidCmd());
await _pricesRepo.Received(1).AddAsync(
1, 150m, Today, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_HappyPath_LogsAuditEvent_ProductPriceCreated()
{
// §REQ-6.1 — audit "product_price.created" llamado exactamente una vez
await _handler.Handle(ValidCmd());
await _audit.Received(1).LogAsync(
action: "product_price.created",
targetType: "ProductPrice",
targetId: "10",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ClosesActivePrice_ResponseContainsClosed()
{
// §REQ-2.1 — POST crea nuevo y cierra el activo → response.Closed != null
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((20L, (long?)5L)); // newId=20, closedId=5
_pricesRepo.GetByProductIdAsync(1, Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<ProductPrice>(
new List<ProductPrice>
{
MakePrice(20, 1, 200m, Tomorrow),
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
},
1, 2, 2));
var result = await _handler.Handle(ValidCmd() with { Price = 200m, PriceValidFrom = Tomorrow });
result.Created.Id.Should().Be(20);
result.Closed.Should().NotBeNull();
result.Closed!.Id.Should().Be(5);
result.Closed.IsActive.Should().BeFalse();
}
// ── Producto inexistente ────────────────────────────────────────────────────
[Fact]
public async Task Handle_ProductNotFound_ThrowsProductNotFoundException()
{
// §REQ-3.3
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(ValidCmd(productId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
}
[Fact]
public async Task Handle_ProductNotFound_RepoAddAsync_NotCalled()
{
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(ValidCmd(productId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
await _pricesRepo.DidNotReceive().AddAsync(
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
}
// ── Producto inactivo ───────────────────────────────────────────────────────
[Fact]
public async Task Handle_ProductInactive_ThrowsProductNotFoundException()
{
// Producto inactivo → tratamos como 404 (invisible para clientes)
var activeFirst = Product.ForCreation(
"Inactive", medioId: 1, productTypeId: 2,
rubroId: null, basePrice: 100m, priceDurationDays: null,
TimeProvider.System);
var inactiveProduct = activeFirst.WithDeactivated(TimeProvider.System);
_productsRepo.GetByIdAsync(2, Arg.Any<CancellationToken>())
.Returns(inactiveProduct);
var act = async () => await _handler.Handle(ValidCmd(productId: 2));
await act.Should().ThrowAsync<ProductNotFoundException>();
}
// ── ForwardOnly violation ───────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrowsForwardOnlyException_PropagatesIt()
{
// §REQ-2.3 — SP lanza 50409 → repo lo mapea a ProductPriceForwardOnlyException → handler lo propaga
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>();
}
[Fact]
public async Task Handle_RepoThrowsForwardOnlyException_AuditNotCalled()
{
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── Audit fail → rollback ───────────────────────────────────────────────────
[Fact]
public async Task Handle_AuditThrows_ExceptionPropagates_TransactionNotCompleted()
{
// §REQ-6.2 — audit falla → rollback total (no commit)
_audit.LogAsync(
action: Arg.Any<string>(),
targetType: Arg.Any<string>(),
targetId: Arg.Any<string>(),
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Audit DB error");
}
}