Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs
dmolinari 4b0567d252 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
2026-04-19 18:08:16 -03:00

80 lines
2.8 KiB
C#

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();
}
}