From 4b0567d25284f12691b75356c2f2093b4bb9e7e5 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 18:08:16 -0300 Subject: [PATCH] feat(application): commands/queries + IProductPricingService (PRD-003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IProductPriceRepository (AddAsync/GetByProductIdAsync/GetActiveAsync) - ProductPriceDto, AddProductPriceCommand/Response, GetProductPricesQuery - AddProductPriceCommandValidator (FluentValidation + TimeProvider, fecha >= hoy_AR) - AddProductPriceCommandHandler (TransactionScope AsyncFlow, audit fail-closed) - GetProductPricesQueryHandler (verifica producto existe, lista vacía válida) - IProductPricingService + ProductPricingService (GetPriceAtAsync → decimal?) - DI wiring en DependencyInjection.cs - 29 tests NSubstitute + FakeTimeProvider, 1081 Application.Tests GREEN --- .../Persistence/IProductPriceRepository.cs | 40 ++++ .../SIGCM2.Application/DependencyInjection.cs | 9 + .../Prices/AddPrice/AddProductPriceCommand.cs | 10 + .../AddPrice/AddProductPriceCommandHandler.cs | 84 +++++++ .../AddProductPriceCommandValidator.cs | 29 +++ .../AddPrice/AddProductPriceResponse.cs | 9 + .../GetHistory/GetProductPricesQuery.cs | 8 + .../GetProductPricesQueryHandler.cs | 42 ++++ .../Products/Prices/ProductPriceDto.cs | 13 + .../Pricing/IProductPricingService.cs | 18 ++ .../Products/Pricing/ProductPricingService.cs | 24 ++ .../AddProductPriceCommandHandlerTests.cs | 222 ++++++++++++++++++ .../AddProductPriceCommandValidatorTests.cs | 115 +++++++++ .../GetProductPricesQueryHandlerTests.cs | 113 +++++++++ .../Pricing/ProductPricingServiceTests.cs | 79 +++++++ 15 files changed, 815 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs create mode 100644 src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs create mode 100644 src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs new file mode 100644 index 0000000..0831183 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs @@ -0,0 +1,40 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// PRD-003 — Write + query access to dbo.ProductPrices. +/// Implemented by ProductPriceRepository (Dapper) in Infrastructure. +/// +public interface IProductPriceRepository +{ + /// + /// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope. + /// Returns (newId, closedId?). Throws: + /// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627). + /// - ProductNotFoundException on SQL THROW 50404. + /// + Task<(long NewId, long? ClosedId)> AddAsync( + int productId, + decimal price, + DateOnly priceValidFrom, + CancellationToken ct = default); + + /// + /// Returns all price rows for the product, ordered descending by PriceValidFrom (active first). + /// Returns empty list when the product has no price history. + /// + Task> GetByProductIdAsync( + int productId, + CancellationToken ct = default); + + /// + /// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given + /// civil date, or null if no row matches (no history, or date is before any recorded price). + /// Used by ProductPricingService.GetPriceAtAsync. + /// + Task GetActiveAsync( + int productId, + DateOnly date, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index d0e64ec..98f5384 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update; using SIGCM2.Application.Products.Deactivate; using SIGCM2.Application.Products.GetById; using SIGCM2.Application.Products.List; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Application.Products.Prices.AddPrice; +using SIGCM2.Application.Products.Prices.GetHistory; +using SIGCM2.Application.Products.Pricing; using SIGCM2.Application.ProductTypes.Create; using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Deactivate; @@ -182,6 +186,11 @@ public static class DependencyInjection services.AddScoped, GetProductByIdQueryHandler>(); services.AddScoped>, ListProductsQueryHandler>(); + // ProductPrices (PRD-003) + services.AddScoped, AddProductPriceCommandHandler>(); + services.AddScoped>, GetProductPricesQueryHandler>(); + services.AddScoped(); + // ProductTypes (PRD-001) // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs new file mode 100644 index 0000000..e2faef6 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product. +/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider). +/// +public sealed record AddProductPriceCommand( + int ProductId, + decimal Price, + DateOnly PriceValidFrom); diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs new file mode 100644 index 0000000..99861ff --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs @@ -0,0 +1,84 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Handler del comando AddProductPrice. +/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) → +/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) → +/// tx.Complete() → construye response con GetByProductIdAsync. +/// +public sealed class AddProductPriceCommandHandler + : ICommandHandler +{ + private readonly IProductPriceRepository _pricesRepo; + private readonly IProductRepository _productsRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public AddProductPriceCommandHandler( + IProductPriceRepository pricesRepo, + IProductRepository productsRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _pricesRepo = pricesRepo; + _productsRepo = productsRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(AddProductPriceCommand command) + { + // 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD). + var product = await _productsRepo.GetByIdAsync(command.ProductId) + ?? throw new ProductNotFoundException(command.ProductId); + + if (!product.IsActive) + throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes + + // 2. TX + SP + audit (fail-closed). + // El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total. + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var (newId, closedId) = await _pricesRepo.AddAsync( + command.ProductId, command.Price, command.PriceValidFrom); + + await _audit.LogAsync( + action: "product_price.created", + targetType: "ProductPrice", + targetId: newId.ToString(), + metadata: new + { + after = new + { + command.ProductId, + command.Price, + priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"), + }, + closedPriceId = closedId + }); + + tx.Complete(); + + // 3. Compongo la respuesta post-commit con lectura de historial actualizado. + var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId); + var created = prices.Single(p => p.Id == newId); + var closed = closedId.HasValue + ? prices.SingleOrDefault(p => p.Id == closedId.Value) + : null; + + return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed)); + } + + private static ProductPriceDto ToDto(ProductPrice p) + => new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive); +} diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs new file mode 100644 index 0000000..25ac827 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — FluentValidation validator para AddProductPriceCommand. +/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now). +/// FakeTimeProvider en tests garantiza determinismo. +/// +public sealed class AddProductPriceCommandValidator : AbstractValidator +{ + public AddProductPriceCommandValidator(TimeProvider timeProvider) + { + var today = timeProvider.GetArgentinaToday(); + + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("ProductId debe ser un entero positivo."); + + RuleFor(x => x.Price) + .GreaterThan(0m) + .WithMessage("El precio debe ser mayor a cero."); + + RuleFor(x => x.PriceValidFrom) + .GreaterThanOrEqualTo(today) + .WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva."); + } +} diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs new file mode 100644 index 0000000..669e04b --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Respuesta del comando AddProductPrice. +/// Closed es null si era el primer precio registrado para el producto. +/// +public sealed record AddProductPriceResponse( + ProductPriceDto Created, + ProductPriceDto? Closed); diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs new file mode 100644 index 0000000..8dce568 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Products.Prices.GetHistory; + +/// +/// PRD-003 — Query para obtener el historial de precios de un Product. +/// Devuelve lista ordenada descending por PriceValidFrom (activo primero). +/// Lanza ProductNotFoundException si el producto no existe. +/// +public sealed record GetProductPricesQuery(int ProductId); diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs new file mode 100644 index 0000000..c81e87c --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs @@ -0,0 +1,42 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Prices.GetHistory; + +/// +/// PRD-003 — Handler de GetProductPricesQuery. +/// Verifica que el producto exista (404 si no), luego retorna historial de precios +/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY). +/// Lista vacía es válida (nuevo producto sin precios registrados aún). +/// +public sealed class GetProductPricesQueryHandler + : ICommandHandler> +{ + private readonly IProductPriceRepository _pricesRepo; + private readonly IProductRepository _productsRepo; + + public GetProductPricesQueryHandler( + IProductPriceRepository pricesRepo, + IProductRepository productsRepo) + { + _pricesRepo = pricesRepo; + _productsRepo = productsRepo; + } + + public async Task> Handle(GetProductPricesQuery query) + { + // Verifica existencia del producto (lanza 404 si no existe). + _ = await _productsRepo.GetByIdAsync(query.ProductId) + ?? throw new ProductNotFoundException(query.ProductId); + + var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId); + + return prices + .Select(p => new ProductPriceDto( + p.Id, p.ProductId, p.Price, + p.PriceValidFrom, p.PriceValidTo, p.IsActive)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs new file mode 100644 index 0000000..36d6e63 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Products.Prices; + +/// +/// PRD-003 — DTO de lectura para un registro de precio histórico de Product. +/// IsActive = true cuando PriceValidTo is null (precio vigente en curso). +/// +public sealed record ProductPriceDto( + long Id, + int ProductId, + decimal Price, + DateOnly PriceValidFrom, + DateOnly? PriceValidTo, + bool IsActive); diff --git a/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs new file mode 100644 index 0000000..94b4306 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Application.Products.Pricing; + +/// +/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2). +/// Contrato forward para PRC-001 (tasación). +/// +/// Retorna null si no existe historial de precios para el producto en la fecha indicada. +/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException) +/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices). +/// +public interface IProductPricingService +{ + /// + /// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada, + /// o null si ningún registro de precio cubre esa fecha. + /// + Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs new file mode 100644 index 0000000..c7819a5 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs @@ -0,0 +1,24 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Products.Pricing; + +/// +/// PRD-003 — Implementación de IProductPricingService. +/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil. +/// +public sealed class ProductPricingService : IProductPricingService +{ + private readonly IProductPriceRepository _repo; + + public ProductPricingService(IProductPriceRepository repo) + { + _repo = repo; + } + + /// + public async Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default) + { + var price = await _repo.GetActiveAsync(productId, date, ct); + return price?.Price; + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs new file mode 100644 index 0000000..78a4815 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +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()) + .Returns(new List + { + MakePrice(10, 1, 150m, Today) + }.AsReadOnly() as IReadOnlyList); + + _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()) + .Returns(new List + { + MakePrice(20, 1, 200m, Tomorrow), + MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1)) + }.AsReadOnly() as IReadOnlyList); + + 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"); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs new file mode 100644 index 0000000..4af2f71 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs @@ -0,0 +1,115 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Application.Products.Prices.AddPrice; + +namespace SIGCM2.Application.Tests.Products.Prices; + +/// +/// PRD-003 — Validator tests for AddProductPriceCommand. +/// FakeTimeProvider fija hoy = 2026-04-19 (UTC-3, ART). +/// Covers: §REQ-3.1 (price > 0), §REQ-3.2 (priceValidFrom >= hoy_AR), productId > 0. +/// +public class AddProductPriceCommandValidatorTests +{ + // Hoy en ART (UTC-3): 2026-04-19T12:00:00 UTC → 2026-04-19T09:00:00 ART + // FakeTimeProvider se crea con un DateTimeOffset UTC; GetArgentinaToday() convierte a ART. + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero)); + private readonly AddProductPriceCommandValidator _validator; + + public AddProductPriceCommandValidatorTests() + { + _validator = new AddProductPriceCommandValidator(_time); + } + + private static readonly DateOnly Today = new(2026, 4, 19); + private static readonly DateOnly Yesterday = new(2026, 4, 18); + private static readonly DateOnly Tomorrow = new(2026, 4, 20); + + // ── ProductId ──────────────────────────────────────────────────────────────── + + [Fact] + public void ProductId_Zero_FailsValidation() + { + var cmd = ValidCmd() with { ProductId = 0 }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductId); + } + + [Fact] + public void ProductId_Negative_FailsValidation() + { + var cmd = ValidCmd() with { ProductId = -1 }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductId); + } + + [Fact] + public void ProductId_Positive_Passes() + { + var cmd = ValidCmd() with { ProductId = 1 }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ProductId); + } + + // ── Price ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Price_Zero_FailsValidation() + { + // §REQ-3.1 — price debe ser > 0 + var cmd = ValidCmd() with { Price = 0m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Price_Negative_FailsValidation() + { + var cmd = ValidCmd() with { Price = -0.01m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Price_Positive_Passes() + { + var cmd = ValidCmd() with { Price = 100m }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Price); + } + + // ── PriceValidFrom ──────────────────────────────────────────────────────────── + + [Fact] + public void PriceValidFrom_InPast_FailsValidation() + { + // §REQ-3.2 — fecha pasada → invalid + var cmd = ValidCmd() with { PriceValidFrom = Yesterday }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceValidFrom); + } + + [Fact] + public void PriceValidFrom_Today_Passes() + { + // Hoy mismo debe ser válido (inclusive lower bound) + var cmd = ValidCmd() with { PriceValidFrom = Today }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceValidFrom); + } + + [Fact] + public void PriceValidFrom_Future_Passes() + { + // §OQ-C — fechas futuras permitidas (programar próximo precio) + var cmd = ValidCmd() with { PriceValidFrom = Tomorrow }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceValidFrom); + } + + // ── Happy path ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidCommand_PassesAllRules() + { + _validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + private static AddProductPriceCommand ValidCmd() => new( + ProductId: 1, + Price: 150.00m, + PriceValidFrom: Today); +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs new file mode 100644 index 0000000..311bcca --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs @@ -0,0 +1,113 @@ +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()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs b/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs new file mode 100644 index 0000000..e58f47e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.Pricing; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Products.Pricing; + +/// +/// PRD-003 — ProductPricingService tests. +/// Covers: GetPriceAtAsync happy path, fecha sin precio → null (OQ-B contrato). +/// +public class ProductPricingServiceTests +{ + private readonly IProductPriceRepository _repo = Substitute.For(); + private readonly ProductPricingService _service; + + private static readonly DateOnly QueryDate = new(2026, 4, 19); + + private static ProductPrice ActivePrice(decimal price = 150m) => + new(Id: 1, ProductId: 1, Price: price, + PriceValidFrom: new DateOnly(2026, 1, 1), PriceValidTo: null, + FechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + public ProductPricingServiceTests() + { + _service = new ProductPricingService(_repo); + } + + // ── Happy path ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPriceAtAsync_ActivePriceCoverDate_ReturnsPrice() + { + _repo.GetActiveAsync(1, QueryDate, Arg.Any()) + .Returns(ActivePrice(200m)); + + var result = await _service.GetPriceAtAsync(1, QueryDate); + + result.Should().Be(200m); + } + + [Fact] + public async Task GetPriceAtAsync_NoPriceForDate_ReturnsNull() + { + // OQ-B — si no hay historial para la fecha, retorna null. + // El consumidor (PRC-001) decide si usar BasePrice o lanzar excepción. + _repo.GetActiveAsync(1, QueryDate, Arg.Any()) + .Returns((ProductPrice?)null); + + var result = await _service.GetPriceAtAsync(1, QueryDate); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetPriceAtAsync_CallsGetActiveAsync_WithCorrectArgs() + { + _repo.GetActiveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ProductPrice?)null); + + await _service.GetPriceAtAsync(productId: 5, date: QueryDate); + + await _repo.Received(1).GetActiveAsync(5, QueryDate, Arg.Any()); + } + + [Fact] + public async Task GetPriceAtAsync_FutureDateOutsideWindow_ReturnsNull() + { + // Precio cerrado cuya ventana no cubre la fecha consultada → repo devuelve null + var futureDate = new DateOnly(2030, 1, 1); + _repo.GetActiveAsync(1, futureDate, Arg.Any()) + .Returns((ProductPrice?)null); + + var result = await _service.GetPriceAtAsync(1, futureDate); + + result.Should().BeNull(); + } +}