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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user