feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45

Merged
dmolinari merged 7 commits from feature/PRD-003 into main 2026-04-19 22:07:22 +00:00
2 changed files with 434 additions and 0 deletions
Showing only changes of commit 7cabb677f3 - Show all commits

View File

@@ -0,0 +1,157 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.TestSupport;
using Xunit;
namespace SIGCM2.Api.Tests.Products;
/// <summary>
/// PRD-003 — Batch 7 / T7.4: Audit failure e2e test.
///
/// Verifies that when IAuditLogger.LogAsync throws, the TransactionScope rolls back:
/// - dbo.ProductPrices row is NOT inserted (fail-closed).
/// - dbo.AuditEvent row is NOT created.
/// - HTTP response is 500 (unhandled exception propagates through ExceptionFilter).
///
/// Uses CreateClientWithOverrides (pattern from issue #36) to inject a throwing IAuditLogger
/// mock without touching the shared factory.
///
/// DB: SIGCM2_Test_Api (ApiIntegration collection).
/// </summary>
[Collection("ApiIntegration")]
public sealed class ProductPricesAuditFailureTests : IAsyncLifetime
{
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
private readonly TestWebAppFactory _factory;
private int _medioId;
private int _productTypeId;
public ProductPricesAuditFailureTests(TestWebAppFactory factory)
{
_factory = factory;
}
public async Task InitializeAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
_medioId = await conn.QuerySingleAsync<int>(
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
var ptId = await conn.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
if (ptId is null)
{
ptId = await conn.QuerySingleAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion)
VALUES ('PT_AuditFail_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""");
}
_productTypeId = ptId.Value;
}
public Task DisposeAsync() => Task.CompletedTask;
// ── T7.4 — Audit failure rolls back the ProductPrice insert ──────────────
[Fact]
public async Task PostPrice_WhenAuditLoggerThrows_Returns500AndRollsBackInsert()
{
// Arrange: seed a unique product
var productId = await SeedProductAsync();
// Count rows before the failing POST
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var priceCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = productId });
var auditCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
// Build a mock IAuditLogger that throws on LogAsync
var throwingAuditLogger = Substitute.For<IAuditLogger>();
throwingAuditLogger
.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("audit down — simulated failure"));
// Use CreateClientWithOverrides to inject the mock without touching the shared factory
using var client = _factory.CreateClientWithOverrides(services =>
{
services.RemoveAll<IAuditLogger>();
services.AddScoped<IAuditLogger>(_ => throwingAuditLogger);
});
// Generate admin token (use factory services, not child)
var jwt = _factory.Services.GetRequiredService<IJwtService>();
var token = jwt.GenerateAccessToken(new Usuario(
id: 1, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd");
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = JsonContent.Create(new { price = 999m, priceValidFrom = pvf });
// Act
var resp = await client.SendAsync(req);
// Assert HTTP: audit failure → unhandled exception → 500
resp.StatusCode.Should().Be(HttpStatusCode.InternalServerError,
because: "IAuditLogger throwing must propagate as 500 (fail-closed)");
// Assert DB: NO new ProductPrices row was persisted (TransactionScope rolled back)
await using var verifyConn = new SqlConnection(ConnectionString);
await verifyConn.OpenAsync();
var priceCountAfter = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = productId });
priceCountAfter.Should().Be(priceCountBefore,
because: "TransactionScope must roll back the ProductPrices INSERT when audit fails");
// Assert DB: NO new AuditEvent row for this product
var auditCountAfter = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.AuditEvent WHERE Action = 'product_price.created' AND TargetType = 'ProductPrice'");
auditCountAfter.Should().Be(auditCountBefore,
because: "no AuditEvent row must be created when the audit logger throws");
}
// ── Helper: seed an active product ───────────────────────────────────────
private async Task<int> SeedProductAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35];
return await conn.QuerySingleAsync<int>("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion)
VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, 1, SYSUTCDATETIME());
SELECT CAST(SCOPE_IDENTITY() AS INT);
""",
new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId });
}
}

View File

@@ -466,6 +466,283 @@ public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
"retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.1: Concurrency con 3 tasks + SemaphoreSlim barrier
// §REQ-1.2 — Exactamente 1 ganador, 2 perdedores lanzan excepción manejable
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Concurrency_ThreeConcurrentInserts_ExactlyOneSucceeds()
{
// Barrera: todos esperan en el semáforo; cuando se liberan juntos, la race es auténtica.
var barrier = new SemaphoreSlim(0, 3);
var pvf = new DateOnly(2027, 6, 1);
async Task<Exception?> TryInsert(decimal price)
{
// Cada task espera en la barrera antes de ejecutar
await barrier.WaitAsync();
try
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
await ExecAddPriceSpAsync(conn, _defaultProductId, price, pvf);
return null; // éxito
}
catch (SqlException ex)
{
return ex; // perdedor: 50409, 2601, 2627 o deadlock 1205
}
}
var t1 = Task.Run(() => TryInsert(111.00m));
var t2 = Task.Run(() => TryInsert(222.00m));
var t3 = Task.Run(() => TryInsert(333.00m));
// Liberar las 3 tasks simultáneamente
barrier.Release(3);
var results = await Task.WhenAll(t1, t2, t3);
// Exactamente 1 éxito (null), exactamente 2 fallos
var successes = results.Count(r => r is null);
var failures = results.Count(r => r is not null);
successes.Should().Be(1, "exactly one concurrent insert must succeed");
failures.Should().Be(2, "the other two must fail with a SqlException");
// Verificar que el estado final es exactamente 1 activo (PriceValidTo IS NULL)
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await verifyConn.OpenAsync();
var activeCount = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL",
new { ProductId = _defaultProductId });
activeCount.Should().Be(1, "only one active price (PriceValidTo IS NULL) must survive the race");
// Sin duplicados: COUNT(*) para este producto debe ser 1 (solo la ganadora)
var totalCount = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = _defaultProductId });
totalCount.Should().Be(1, "no duplicate rows must exist for the same ProductId");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.2: SYSTEM_VERSIONING — no history before close, 1 row after
// Verifica que el SP produce exactamente 1 row en dbo.ProductPrices_History
// al cerrar el activo, y que antes del cierre la tabla está vacía para ese Id.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task SystemVersioning_BeforeClose_HistoryTableIsEmpty()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Antes del UPDATE (cierre), history debe estar vacía para este Id
var histCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
new { Id = firstId });
histCountBefore.Should().Be(0,
"SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE, not on INSERT");
}
[Fact]
public async Task SystemVersioning_AfterClose_ExactlyOneHistoryRow()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Cierra el activo con una segunda inserción más futura
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
// dbo.ProductPrices_History debe tener exactamente 1 row para el Id cerrado
var histCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
new { Id = firstId });
histCount.Should().Be(1,
"SYSTEM_VERSIONING must produce exactly one history row when the active price is closed via UPDATE");
// El row activo en dbo.ProductPrices debe tener PriceValidTo <> NULL
var pvt = await conn.ExecuteScalarAsync<DateTime?>(
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
new { Id = firstId });
pvt.Should().NotBeNull("the closed row in dbo.ProductPrices must have PriceValidTo set");
pvt!.Value.Date.Should().Be(new DateTime(2026, 4, 19),
"PriceValidTo = new PVF - 1 day = 2026-04-19");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.3: FOR SYSTEM_TIME AS OF — snapshot temporal
// Verifica que la history table preserva el estado del activo en el instante
// pre-cierre y que la query temporal devuelve el precio correcto.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// T0: insertar precio1 y capturar el instante UTC antes de cerrarlo
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Capturar T0 inmediatamente después del INSERT (SYSTEM_VERSIONING usa DATETIME2 UTC)
var t0 = await conn.ExecuteScalarAsync<DateTime>(
"SELECT SYSUTCDATETIME()");
// Esperar 200ms para que DATETIME2(3) avance y el registro de history tenga un rango claro
await Task.Delay(200);
// Insertar precio2 que cierra precio1 — esto produce el row en history
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 5, 1));
// T7.3.a — Query FOR SYSTEM_TIME AS OF T0: debe devolver precio1 con PriceValidTo = NULL
// (estado del registro tal como estaba en T0, antes del cierre)
var snapshotRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
"""
SELECT Id, Price, PriceValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME AS OF @T0
WHERE ProductId = @ProductId
AND Id = @Id
""",
new { T0 = t0, ProductId = _defaultProductId, Id = firstId });
((object?)snapshotRow).Should().NotBeNull(
"FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before close)");
((decimal)snapshotRow!.Price).Should().Be(100.00m);
((object?)snapshotRow.PriceValidTo).Should().BeNull(
"at T0 the row was still active (PriceValidTo IS NULL)");
// T7.3.b — Query actual (sin FOR SYSTEM_TIME): precio1 debe tener PriceValidTo != NULL
var currentRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
"SELECT Id, Price, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
new { Id = firstId });
((object?)currentRow).Should().NotBeNull();
((object?)currentRow!.PriceValidTo).Should().NotBeNull(
"in current state, the first price is closed (PriceValidTo IS NOT NULL)");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.5: GetActiveAsync boundary cases (ventanas civiles inclusivas)
// §REQ-4.4 — Inclusive en ambos extremos (PVF y PVT)
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetActiveAsync_BeforeFirstPrice_ReturnsNull()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31] (cerrado)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31));
result.Should().BeNull("date is before the first PriceValidFrom");
}
[Fact]
public async Task GetActiveAsync_ExactMatchPvf_ReturnsPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// Exact match en PVF
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 1, 1));
result.Should().NotBeNull("date equals PriceValidFrom → inclusive lower bound");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_MiddleOfRange_ReturnsPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 2, 15));
result.Should().NotBeNull("date is in the middle of precio1 window");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_ExactMatchPvt_ReturnsClosedPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31] — PVT=2026-03-31 (día anterior a 2026-04-01)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// Exact match en PVT del precio1 → inclusive upper bound
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 3, 31));
result.Should().NotBeNull("date equals PriceValidTo → inclusive upper bound");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_ExactMatchNextPvf_ReturnsNextPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// PVF del precio2 → debe devolver precio2
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 4, 1));
result.Should().NotBeNull("date equals PriceValidFrom of precio2 → inclusive lower bound");
result!.Price.Should().Be(150.00m);
}
[Fact]
public async Task GetActiveAsync_FarFuture_ReturnsActivePrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Solo precio activo desde 2026-04-01 (PriceValidTo = NULL)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2099, 12, 31));
result.Should().NotBeNull("far-future date should return the open-ended active price");
result!.Price.Should().Be(200.00m);
result.IsActive.Should().BeTrue();
result.PriceValidTo.Should().BeNull();
}
// ── Helper: build a real ProductPriceRepository using the test DB ─────────
private static ProductPriceRepository BuildRepository()