test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user