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,40 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Write + query access to dbo.ProductPrices.
|
||||||
|
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductPriceRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<(long NewId, long? ClosedId)> AddAsync(
|
||||||
|
int productId,
|
||||||
|
decimal price,
|
||||||
|
DateOnly priceValidFrom,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
|
||||||
|
/// Returns empty list when the product has no price history.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
||||||
|
int productId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<ProductPrice?> GetActiveAsync(
|
||||||
|
int productId,
|
||||||
|
DateOnly date,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update;
|
|||||||
using SIGCM2.Application.Products.Deactivate;
|
using SIGCM2.Application.Products.Deactivate;
|
||||||
using SIGCM2.Application.Products.GetById;
|
using SIGCM2.Application.Products.GetById;
|
||||||
using SIGCM2.Application.Products.List;
|
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.Create;
|
||||||
using SIGCM2.Application.ProductTypes.Update;
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
@@ -182,6 +186,11 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||||
|
|
||||||
|
// ProductPrices (PRD-003)
|
||||||
|
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||||
|
services.AddScoped<IProductPricingService, ProductPricingService>();
|
||||||
|
|
||||||
// ProductTypes (PRD-001)
|
// ProductTypes (PRD-001)
|
||||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
|
||||||
|
/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AddProductPriceCommand(
|
||||||
|
int ProductId,
|
||||||
|
decimal Price,
|
||||||
|
DateOnly PriceValidFrom);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddProductPriceCommandHandler
|
||||||
|
: ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>
|
||||||
|
{
|
||||||
|
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<AddProductPriceResponse> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
|
||||||
|
/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
|
||||||
|
/// FakeTimeProvider en tests garantiza determinismo.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddProductPriceCommandValidator : AbstractValidator<AddProductPriceCommand>
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Respuesta del comando AddProductPrice.
|
||||||
|
/// Closed es null si era el primer precio registrado para el producto.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AddProductPriceResponse(
|
||||||
|
ProductPriceDto Created,
|
||||||
|
ProductPriceDto? Closed);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetProductPricesQuery(int ProductId);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GetProductPricesQueryHandler
|
||||||
|
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
|
||||||
|
{
|
||||||
|
private readonly IProductPriceRepository _pricesRepo;
|
||||||
|
private readonly IProductRepository _productsRepo;
|
||||||
|
|
||||||
|
public GetProductPricesQueryHandler(
|
||||||
|
IProductPriceRepository pricesRepo,
|
||||||
|
IProductRepository productsRepo)
|
||||||
|
{
|
||||||
|
_pricesRepo = pricesRepo;
|
||||||
|
_productsRepo = productsRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProductPriceDto>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Prices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
|
||||||
|
/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductPriceDto(
|
||||||
|
long Id,
|
||||||
|
int ProductId,
|
||||||
|
decimal Price,
|
||||||
|
DateOnly PriceValidFrom,
|
||||||
|
DateOnly? PriceValidTo,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductPricingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
|
||||||
|
/// o null si ningún registro de precio cubre esa fecha.
|
||||||
|
/// </summary>
|
||||||
|
Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Products.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Implementación de IProductPricingService.
|
||||||
|
/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductPricingService : IProductPricingService
|
||||||
|
{
|
||||||
|
private readonly IProductPriceRepository _repo;
|
||||||
|
|
||||||
|
public ProductPricingService(IProductPriceRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var price = await _repo.GetActiveAsync(productId, date, ct);
|
||||||
|
return price?.Price;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user