feat(application): commands/queries + IProductPricingService (PRD-003)

- 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
This commit is contained in:
2026-04-19 18:08:16 -03:00
parent 54b0265994
commit 4b0567d252
15 changed files with 815 additions and 0 deletions

View File

@@ -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;
/// <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<CancellationToken>())
.Returns(new List<ProductPrice>
{
MakePrice(10, 1, 150m, Today)
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
_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<CancellationToken>())
.Returns(new List<ProductPrice>
{
MakePrice(20, 1, 200m, Tomorrow),
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
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");
}
}

View File

@@ -0,0 +1,115 @@
using FluentValidation.TestHelper;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Application.Products.Prices.AddPrice;
namespace SIGCM2.Application.Tests.Products.Prices;
/// <summary>
/// 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.
/// </summary>
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);
}

View File

@@ -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;
/// <summary>
/// PRD-003 — GetProductPricesQueryHandler tests.
/// Covers: §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404).
/// </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));
public GetProductPricesQueryHandlerTests()
{
_productsRepo.GetByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(ActiveProduct());
// default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo)
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ProductPrice>
{
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<ProductPrice>);
_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<CancellationToken>())
.Returns(new List<ProductPrice>().AsReadOnly() as IReadOnlyList<ProductPrice>);
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<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()
.GetByProductIdAsync(99, Arg.Any<CancellationToken>());
}
}

View File

@@ -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;
/// <summary>
/// PRD-003 — ProductPricingService tests.
/// Covers: GetPriceAtAsync happy path, fecha sin precio → null (OQ-B contrato).
/// </summary>
public class ProductPricingServiceTests
{
private readonly IProductPriceRepository _repo = Substitute.For<IProductPriceRepository>();
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<CancellationToken>())
.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<CancellationToken>())
.Returns((ProductPrice?)null);
var result = await _service.GetPriceAtAsync(1, QueryDate);
result.Should().BeNull();
}
[Fact]
public async Task GetPriceAtAsync_CallsGetActiveAsync_WithCorrectArgs()
{
_repo.GetActiveAsync(Arg.Any<int>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((ProductPrice?)null);
await _service.GetPriceAtAsync(productId: 5, date: QueryDate);
await _repo.Received(1).GetActiveAsync(5, QueryDate, Arg.Any<CancellationToken>());
}
[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<CancellationToken>())
.Returns((ProductPrice?)null);
var result = await _service.GetPriceAtAsync(1, futureDate);
result.Should().BeNull();
}
}