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; /// /// 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. /// public class AddProductPriceCommandHandlerTests { private readonly IProductPriceRepository _pricesRepo = Substitute.For(); private readonly IProductRepository _productsRepo = Substitute.For(); private readonly IAuditLogger _audit = Substitute.For(); 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()) .Returns(ActiveProduct(1)); _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns((10L, (long?)null)); _pricesRepo.GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PagedResult( new List { 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()); } [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(), ct: Arg.Any()); } [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(), Arg.Any(), Arg.Any()) .Returns((20L, (long?)5L)); // newId=20, closedId=5 _pricesRepo.GetByProductIdAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PagedResult( new List { 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()) .Returns((Product?)null); var act = async () => await _handler.Handle(ValidCmd(productId: 99)); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_ProductNotFound_RepoAddAsync_NotCalled() { _productsRepo.GetByIdAsync(99, Arg.Any()) .Returns((Product?)null); var act = async () => await _handler.Handle(ValidCmd(productId: 99)); await act.Should().ThrowAsync(); await _pricesRepo.DidNotReceive().AddAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } // ── 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()) .Returns(inactiveProduct); var act = async () => await _handler.Handle(ValidCmd(productId: 2)); await act.Should().ThrowAsync(); } // ── 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(), Arg.Any(), Arg.Any()) .ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today)); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); } [Fact] public async Task Handle_RepoThrowsForwardOnlyException_AuditNotCalled() { _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today)); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync(); await _audit.DidNotReceive().LogAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } // ── 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(), targetType: Arg.Any(), targetId: Arg.Any(), metadata: Arg.Any(), ct: Arg.Any()) .ThrowsAsync(new InvalidOperationException("Audit DB error")); var act = async () => await _handler.Handle(ValidCmd()); await act.Should().ThrowAsync() .WithMessage("Audit DB error"); } }