2026-04-19 18:08:16 -03:00
|
|
|
using FluentAssertions;
|
|
|
|
|
using Microsoft.Extensions.Time.Testing;
|
|
|
|
|
using NSubstitute;
|
|
|
|
|
using NSubstitute.ExceptionExtensions;
|
|
|
|
|
using SIGCM2.Application.Abstractions.Persistence;
|
|
|
|
|
using SIGCM2.Application.Audit;
|
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.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));
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
_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));
|
2026-04-19 18:08:16 -03:00
|
|
|
|
|
|
|
|
_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
|
|
|
|
|
|
2026-04-19 19:47:18 -03:00
|
|
|
_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));
|
2026-04-19 18:08:16 -03:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|