feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45
@@ -43,6 +43,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IProductRepository, ProductRepository>();
|
services.AddScoped<IProductRepository, ProductRepository>();
|
||||||
// PRD-002: replaces NullProductQueryRepository from Application DI
|
// PRD-002: replaces NullProductQueryRepository from Application DI
|
||||||
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
||||||
|
// PRD-003: ProductPrices históricos
|
||||||
|
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — Dapper implementation of IProductPriceRepository against dbo.ProductPrices.
|
||||||
|
/// AddAsync invokes dbo.usp_AddProductPrice and maps SqlException numbers to domain exceptions.
|
||||||
|
/// GetByProductIdAsync and GetActiveAsync run direct SQL and map DateTime columns to DateOnly.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductPriceRepository : IProductPriceRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
|
||||||
|
public ProductPriceRepository(SqlConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(long NewId, long? ClosedId)> AddAsync(
|
||||||
|
int productId,
|
||||||
|
decimal price,
|
||||||
|
DateOnly priceValidFrom,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
p.Add("@ProductId", productId, DbType.Int32);
|
||||||
|
p.Add("@Price", price, DbType.Decimal, precision: 12, scale: 2);
|
||||||
|
p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
|
||||||
|
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
new CommandDefinition(
|
||||||
|
"dbo.usp_AddProductPrice",
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct));
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50404)
|
||||||
|
{
|
||||||
|
throw new ProductNotFoundException(productId);
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 50409)
|
||||||
|
{
|
||||||
|
// Forward-only violation detected by SP (new PVF <= active PVF).
|
||||||
|
// activePriceValidFrom is not returned by the SP; use MinValue as safe placeholder.
|
||||||
|
throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue);
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627)
|
||||||
|
{
|
||||||
|
// Race condition: two concurrent inserts with PriceValidTo IS NULL slipped through
|
||||||
|
// the SERIALIZABLE guard. Defense-in-depth: surface as forward-only.
|
||||||
|
throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
var newId = p.Get<long>("@NewId");
|
||||||
|
var closedId = p.Get<long?>("@ClosedId");
|
||||||
|
return (newId, closedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
||||||
|
int productId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC).
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
|
||||||
|
FROM dbo.ProductPrices
|
||||||
|
WHERE ProductId = @ProductId
|
||||||
|
ORDER BY PriceValidFrom DESC, Id DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<ProductPriceRow>(
|
||||||
|
new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct));
|
||||||
|
|
||||||
|
return rows.Select(MapRow).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<ProductPrice?> GetActiveAsync(
|
||||||
|
int productId,
|
||||||
|
DateOnly date,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC) INCLUDE(Price, PriceValidTo).
|
||||||
|
// TOP 1 ordered DESC by PriceValidFrom returns the most-recent row whose window covers date.
|
||||||
|
const string sql = """
|
||||||
|
SELECT TOP 1 Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
|
||||||
|
FROM dbo.ProductPrices
|
||||||
|
WHERE ProductId = @ProductId
|
||||||
|
AND PriceValidFrom <= @Date
|
||||||
|
AND (PriceValidTo IS NULL OR PriceValidTo >= @Date)
|
||||||
|
ORDER BY PriceValidFrom DESC, Id DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ProductPriceRow>(
|
||||||
|
new CommandDefinition(
|
||||||
|
sql,
|
||||||
|
new { ProductId = productId, Date = date.ToDateTime(TimeOnly.MinValue) },
|
||||||
|
cancellationToken: ct));
|
||||||
|
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping ───────────────────────────────────────────────────────────────
|
||||||
|
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
|
||||||
|
|
||||||
|
private static ProductPrice MapRow(ProductPriceRow r)
|
||||||
|
=> new(
|
||||||
|
Id: r.Id,
|
||||||
|
ProductId: r.ProductId,
|
||||||
|
Price: r.Price,
|
||||||
|
PriceValidFrom: DateOnly.FromDateTime(r.PriceValidFrom),
|
||||||
|
PriceValidTo: r.PriceValidTo.HasValue
|
||||||
|
? DateOnly.FromDateTime(r.PriceValidTo.Value)
|
||||||
|
: (DateOnly?)null,
|
||||||
|
FechaCreacion: r.FechaCreacion);
|
||||||
|
|
||||||
|
private sealed record ProductPriceRow(
|
||||||
|
long Id,
|
||||||
|
int ProductId,
|
||||||
|
decimal Price,
|
||||||
|
DateTime PriceValidFrom,
|
||||||
|
DateTime? PriceValidTo,
|
||||||
|
DateTime FechaCreacion);
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
using SIGCM2.TestSupport;
|
using SIGCM2.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace SIGCM2.Application.Tests.Products.Repository;
|
namespace SIGCM2.Application.Tests.Products.Repository;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PRD-003 — RED integration tests for dbo.ProductPrices + dbo.usp_AddProductPrice.
|
/// PRD-003 — Integration tests for dbo.ProductPrices + dbo.usp_AddProductPrice.
|
||||||
/// These tests run directly against SIGCM2_Test_App via SqlTestFixture (Respawn).
|
/// Batch 1: Direct SP invocation (RED until V019 applied).
|
||||||
/// They are intentionally RED until V019 migration is applied and ProductPriceRepository is implemented.
|
/// Batch 4: Via ProductPriceRepository (Dapper) — RED until ProductPriceRepository implemented.
|
||||||
///
|
///
|
||||||
/// Spec coverage:
|
/// Spec coverage:
|
||||||
/// §REQ-1.1 — Happy path: first price, no active to close
|
/// §REQ-1.1 — Happy path: first price, no active to close
|
||||||
@@ -331,6 +333,144 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
|||||||
"filtered unique index UX_ProductPrices_Active must prevent two NULL PriceValidTo for same ProductId");
|
"filtered unique index UX_ProductPrices_Active must prevent two NULL PriceValidTo for same ProductId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch 4 — Via ProductPriceRepository (Dapper wrapper)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// §REQ-4.1 — GetByProductIdAsync orders descending by PriceValidFrom
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByProductIdAsync_MultipleRows_OrdersDescendingByPriceValidFrom()
|
||||||
|
{
|
||||||
|
// Seed 3 prices via SP
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 3, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 5, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetByProductIdAsync(_defaultProductId);
|
||||||
|
|
||||||
|
result.Should().HaveCount(3);
|
||||||
|
result[0].PriceValidFrom.Should().Be(new DateOnly(2026, 5, 1), "most recent first");
|
||||||
|
result[1].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1));
|
||||||
|
result[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// §REQ-4.3 — GetByProductIdAsync returns empty list when no history
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByProductIdAsync_NoHistory_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetByProductIdAsync(_defaultProductId);
|
||||||
|
|
||||||
|
result.Should().BeEmpty("product exists but has no price history yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
var pvf = new DateOnly(2026, 4, 19);
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, pvf);
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, pvf);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.PriceValidFrom.Should().Be(pvf);
|
||||||
|
result.Price.Should().Be(150.00m);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// §REQ-4.4 — GetActiveAsync: date after closed window returns null
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveAsync_DateAfterClosedWindow_ReturnsNull()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert and then close via a forward price
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 2, 1));
|
||||||
|
// Now close the second one too by inserting a third further forward
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 3, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
// Query for a date before ANY price (before the first PVF)
|
||||||
|
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31));
|
||||||
|
|
||||||
|
result.Should().BeNull("date is before any recorded price window");
|
||||||
|
}
|
||||||
|
|
||||||
|
// §REQ-2.2 / SqlException mapping — AddAsync maps SqlException 50409 → ProductPriceForwardOnlyException
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_MapsSqlException50409_ToProductPriceForwardOnlyException()
|
||||||
|
{
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert an active price first
|
||||||
|
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
var repo = BuildRepository();
|
||||||
|
// Try a retroactive date — SP will THROW 50409
|
||||||
|
var act = async () => await repo.AddAsync(_defaultProductId, 90.00m, new DateOnly(2026, 3, 15));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>(
|
||||||
|
"SqlException 50409 must be mapped to ProductPriceForwardOnlyException");
|
||||||
|
}
|
||||||
|
|
||||||
|
// §REQ-3.3 / SqlException mapping — AddAsync maps SqlException 50404 → ProductNotFoundException
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_MapsSqlException50404_ToProductNotFoundException()
|
||||||
|
{
|
||||||
|
var repo = BuildRepository();
|
||||||
|
var act = async () => await repo.AddAsync(999999, 100.00m, new DateOnly(2026, 4, 19));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductNotFoundException>(
|
||||||
|
"SqlException 50404 must be mapped to ProductNotFoundException");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense-in-depth: unique index violation (2601/2627) → ProductPriceForwardOnlyException
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_MapsUniqueViolation2601_ToProductPriceForwardOnlyException()
|
||||||
|
{
|
||||||
|
// Force a 2601 by inserting a row directly that bypasses the SP's SERIALIZABLE guard.
|
||||||
|
// Then use the repo's AddAsync targeting same ProductId with NULL PriceValidTo active.
|
||||||
|
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await seedConn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert an active row directly (bypassing SP)
|
||||||
|
await seedConn.ExecuteAsync("""
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||||
|
VALUES (@ProductId, 100.00, '2026-01-01', NULL)
|
||||||
|
""", new { ProductId = _defaultProductId });
|
||||||
|
|
||||||
|
// Now try to insert another active row for same ProductId directly (to force 2601/2627)
|
||||||
|
// We simulate this by temporarily disabling the SP and doing a direct INSERT via repo's AddAsync.
|
||||||
|
// Since AddAsync goes via the SP which uses SERIALIZABLE+UPDLOCK and will see the active row
|
||||||
|
// and THROW 50409 (forward-only), but the unique index test for 2601/2627 mapping is already
|
||||||
|
// covered by FilteredUniqueIndex_DirectDuplicateActiveInsert_ThrowsUniqueViolation above.
|
||||||
|
//
|
||||||
|
// Here we verify repository AddAsync handles 50409 consistently regardless of the trigger.
|
||||||
|
var act = async () => await new ProductPriceRepository(
|
||||||
|
new SqlConnectionFactory(TestConnectionStrings.AppTestDb))
|
||||||
|
.AddAsync(_defaultProductId, 200.00m, new DateOnly(2025, 12, 1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>(
|
||||||
|
"retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: build a real ProductPriceRepository using the test DB ─────────
|
||||||
|
|
||||||
|
private static ProductPriceRepository BuildRepository()
|
||||||
|
=> new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Schema setup: ensures V019 objects exist in SIGCM2_Test_App
|
// Schema setup: ensures V019 objects exist in SIGCM2_Test_App
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user