feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003)
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — RED integration tests for dbo.ProductPrices + dbo.usp_AddProductPrice.
|
||||
/// These tests run directly against SIGCM2_Test_App via SqlTestFixture (Respawn).
|
||||
/// They are intentionally RED until V019 migration is applied and ProductPriceRepository is implemented.
|
||||
///
|
||||
/// Spec coverage:
|
||||
/// §REQ-1.1 — Happy path: first price, no active to close
|
||||
/// §REQ-1.1 — Happy path: closes previous active on new price
|
||||
/// §REQ-1.2 — Concurrency: only one winner, loser gets 409 or unique violation
|
||||
/// §REQ-2.2 — ForwardOnly: retroactive date → SQL 50409
|
||||
/// §REQ-2.3 — ForwardOnly: equal PriceValidFrom → SQL 50409
|
||||
/// §REQ-3.3 — Inactive product → SQL 50404
|
||||
/// SYSTEM_VERSIONING: UPDATE to close active produces history row
|
||||
/// Filtered unique index: duplicate active → SQL error 2601/2627
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqlTestFixture _db;
|
||||
private int _defaultProductId;
|
||||
private int _defaultMedioId;
|
||||
private int _defaultProductTypeId;
|
||||
|
||||
public ProductPriceRepositoryIntegrationTests(SqlTestFixture db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _db.ResetAndSeedAsync();
|
||||
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Ensure V019 schema is present (table + SP + indexes).
|
||||
await EnsureV019SchemaAsync(conn);
|
||||
|
||||
// Create a Medio, ProductType, and an active Product for use in all tests.
|
||||
_defaultMedioId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('PP', 'Medio ProductPrices', 1, 1)
|
||||
""");
|
||||
|
||||
_defaultProductTypeId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('Tipo PP', 0, 0, 0, 0, 0)
|
||||
""");
|
||||
|
||||
_defaultProductId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays, IsActive)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('Producto Precios Test', @MedioId, @ProductTypeId, NULL, 100.00, NULL, 1)
|
||||
""", new { MedioId = _defaultMedioId, ProductTypeId = _defaultProductTypeId });
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helper: invoke SP directly ────────────────────────────────────────────
|
||||
|
||||
private static async Task<(long NewId, long? ClosedId)> ExecAddPriceSpAsync(
|
||||
SqlConnection conn,
|
||||
int productId,
|
||||
decimal price,
|
||||
DateOnly priceValidFrom)
|
||||
{
|
||||
var p = new DynamicParameters();
|
||||
p.Add("@ProductId", productId);
|
||||
p.Add("@Price", price);
|
||||
p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), System.Data.DbType.Date);
|
||||
p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
|
||||
p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
|
||||
|
||||
await conn.ExecuteAsync("dbo.usp_AddProductPrice", p,
|
||||
commandType: System.Data.CommandType.StoredProcedure);
|
||||
|
||||
var newId = p.Get<long>("@NewId");
|
||||
var closedId = p.Get<long?>("@ClosedId");
|
||||
return (newId, closedId);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-1.1 — Primer precio: ClosedId es null
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FirstPrice_NoActiveToClose_ClosedIdIsNull()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var pvf = new DateOnly(2026, 4, 19);
|
||||
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 150.00m, pvf);
|
||||
|
||||
newId.Should().BeGreaterThan(0);
|
||||
closedId.Should().BeNull();
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||
"SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
|
||||
new { Id = newId });
|
||||
|
||||
((object?)row).Should().NotBeNull();
|
||||
((int)row!.ProductId).Should().Be(_defaultProductId);
|
||||
((decimal)row.Price).Should().Be(150.00m);
|
||||
((DateTime)row.PriceValidFrom).Should().Be(new DateTime(2026, 4, 19));
|
||||
((object?)row.PriceValidTo).Should().BeNull();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-1.1 — Happy path: cierra activo, inserta nuevo
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task HappyPath_ClosesActivePriceAndInsertsNew()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Primera inserción
|
||||
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||
|
||||
// Segunda inserción (forward)
|
||||
var pvf2 = new DateOnly(2026, 4, 20);
|
||||
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, pvf2);
|
||||
|
||||
// El nuevo es activo (PVT = NULL)
|
||||
var newRow = await conn.QuerySingleAsync<dynamic>(
|
||||
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = newId });
|
||||
((object?)newRow.PriceValidTo).Should().BeNull();
|
||||
|
||||
// El anterior está cerrado con PVT = pvf2 - 1 día
|
||||
closedId.Should().Be(firstId);
|
||||
var closedRow = await conn.QuerySingleAsync<dynamic>(
|
||||
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = firstId });
|
||||
((DateTime)closedRow.PriceValidTo).Should().Be(new DateTime(2026, 4, 19));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-2.2 — ForwardOnly: fecha retroactiva → THROW 50409
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnly_RetroactiveDate_ThrowsSql50409()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Precio activo desde 2026-04-01
|
||||
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||
|
||||
// Intento retroactivo
|
||||
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 90.00m, new DateOnly(2026, 3, 15));
|
||||
|
||||
await act.Should().ThrowAsync<SqlException>()
|
||||
.Where(ex => ex.Number == 50409);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-2.3 — ForwardOnly: misma fecha → THROW 50409
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ForwardOnly_EqualPriceValidFrom_ThrowsSql50409()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var pvf = new DateOnly(2026, 4, 19);
|
||||
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, pvf);
|
||||
|
||||
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 120.00m, pvf);
|
||||
|
||||
await act.Should().ThrowAsync<SqlException>()
|
||||
.Where(ex => ex.Number == 50409);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-3.3 — Producto inactivo → THROW 50404
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task InactiveProduct_ThrowsSql50404()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Desactivar el producto
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE dbo.Product SET IsActive = 0 WHERE Id = @Id",
|
||||
new { Id = _defaultProductId });
|
||||
|
||||
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 19));
|
||||
|
||||
await act.Should().ThrowAsync<SqlException>()
|
||||
.Where(ex => ex.Number == 50404);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-3.3 — Producto inexistente → THROW 50404
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NonExistentProduct_ThrowsSql50404()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var act = async () => await ExecAddPriceSpAsync(conn, 999999, 100.00m, new DateOnly(2026, 4, 19));
|
||||
|
||||
await act.Should().ThrowAsync<SqlException>()
|
||||
.Where(ex => ex.Number == 50404);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// §REQ-1.2 — Concurrencia: solo un ganador, el perdedor lanza excepción
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrency_OnlyOneSucceeds_OneThrows()
|
||||
{
|
||||
// Ambas conexiones intentan agregar precio al mismo producto simultáneamente.
|
||||
// Con SERIALIZABLE + UPDLOCK, exactamente una debe tener éxito.
|
||||
var pvf = new DateOnly(2026, 5, 1);
|
||||
|
||||
var task1 = Task.Run(async () =>
|
||||
{
|
||||
await using var conn1 = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn1.OpenAsync();
|
||||
return await ExecAddPriceSpAsync(conn1, _defaultProductId, 111.00m, pvf);
|
||||
});
|
||||
|
||||
var task2 = Task.Run(async () =>
|
||||
{
|
||||
await using var conn2 = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn2.OpenAsync();
|
||||
return await ExecAddPriceSpAsync(conn2, _defaultProductId, 222.00m, pvf);
|
||||
});
|
||||
|
||||
// Exactamente una debe lanzar (50409, 2601, 2627, o deadlock 1205)
|
||||
Exception? caughtEx = null;
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(task1, task2);
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
caughtEx = ex;
|
||||
}
|
||||
catch (AggregateException aex) when (aex.InnerExceptions.All(e => e is SqlException))
|
||||
{
|
||||
caughtEx = aex;
|
||||
}
|
||||
|
||||
caughtEx.Should().NotBeNull("one concurrent insert must fail");
|
||||
|
||||
// Exactamente 1 activo debe existir (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 must survive concurrency");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SYSTEM_VERSIONING: UPDATE del activo produce row en history
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow()
|
||||
{
|
||||
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
|
||||
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
|
||||
|
||||
// dbo.ProductPrices_History debe tener al menos 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().BeGreaterThanOrEqualTo(1,
|
||||
"SYSTEM_VERSIONING must produce a history row when the active price is closed");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Filtered unique index: two simultaneous direct INSERTs → 2601/2627
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FilteredUniqueIndex_DirectDuplicateActiveInsert_ThrowsUniqueViolation()
|
||||
{
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
// Insert an active price directly (bypassing SP to force a duplicate)
|
||||
const string insertSql = """
|
||||
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||
VALUES (@ProductId, @Price, @PriceValidFrom, NULL)
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(insertSql, new
|
||||
{
|
||||
ProductId = _defaultProductId,
|
||||
Price = 100m,
|
||||
PriceValidFrom = new DateTime(2026, 4, 1)
|
||||
});
|
||||
|
||||
var act = async () => await conn.ExecuteAsync(insertSql, new
|
||||
{
|
||||
ProductId = _defaultProductId,
|
||||
Price = 150m,
|
||||
PriceValidFrom = new DateTime(2026, 4, 10)
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<SqlException>()
|
||||
.Where(ex => ex.Number == 2601 || ex.Number == 2627,
|
||||
"filtered unique index UX_ProductPrices_Active must prevent two NULL PriceValidTo for same ProductId");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Schema setup: ensures V019 objects exist in SIGCM2_Test_App
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task EnsureV019SchemaAsync(SqlConnection conn)
|
||||
{
|
||||
// This will fail (RED) until V019__create_product_prices.sql is applied.
|
||||
// Once the migration file is created and applied, all tests above become GREEN.
|
||||
var tableExists = await conn.ExecuteScalarAsync<int>("""
|
||||
SELECT COUNT(1) FROM sys.tables
|
||||
WHERE object_id = OBJECT_ID(N'dbo.ProductPrices', N'U')
|
||||
""");
|
||||
|
||||
if (tableExists == 0)
|
||||
throw new InvalidOperationException(
|
||||
"dbo.ProductPrices does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
|
||||
|
||||
var spExists = await conn.ExecuteScalarAsync<int>("""
|
||||
SELECT COUNT(1) FROM sys.objects
|
||||
WHERE object_id = OBJECT_ID(N'dbo.usp_AddProductPrice', N'P')
|
||||
""");
|
||||
|
||||
if (spExists == 0)
|
||||
throw new InvalidOperationException(
|
||||
"dbo.usp_AddProductPrice does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user