From 59f30cddfb392821dfa6206ba17fec1f84bcdfc4 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 17:53:58 -0300 Subject: [PATCH] =?UTF-8?q?feat(bd):=20V019=20crea=20dbo.ProductPrices=20+?= =?UTF-8?q?=20SP=20+=20=C3=ADndices=20(PRD-003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/migrations/V019_ROLLBACK.sql | 71 ++++ .../V019__create_product_prices.sql | 196 ++++++++++ .../ProductPriceRepositoryIntegrationTests.cs | 360 ++++++++++++++++++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 145 +++++++ 4 files changed, 772 insertions(+) create mode 100644 database/migrations/V019_ROLLBACK.sql create mode 100644 database/migrations/V019__create_product_prices.sql create mode 100644 tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs diff --git a/database/migrations/V019_ROLLBACK.sql b/database/migrations/V019_ROLLBACK.sql new file mode 100644 index 0000000..4827316 --- /dev/null +++ b/database/migrations/V019_ROLLBACK.sql @@ -0,0 +1,71 @@ +-- V019_ROLLBACK.sql +-- PRD-003: Reversa de V019__create_product_prices.sql. +-- +-- Pasos: +-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE). +-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime. +-- 3. Drop de dbo.ProductPrices_History. +-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada). +-- 5. Drop de dbo.usp_AddProductPrice. +-- +-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal). +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF); + PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.'; +END +GO + +-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning). +IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL +BEGIN + ALTER TABLE dbo.ProductPrices + DROP PERIOD FOR SYSTEM_TIME; + + -- Drop default constraints antes de drop de columnas. + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime') + ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime; + IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime') + ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime; + + ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime; + ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime; + PRINT 'ProductPrices: PERIOD + hidden cols dropped.'; +END +GO + +-- 3. Drop de la history table. +IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ProductPrices_History; + PRINT 'Table dbo.ProductPrices_History dropped.'; +END +GO + +-- 4. Drop de la tabla principal (constraints + índices en cascada). +IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.ProductPrices; + PRINT 'Table dbo.ProductPrices dropped.'; +END +GO + +-- 5. Drop del SP. +IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL +BEGIN + DROP PROCEDURE dbo.usp_AddProductPrice; + PRINT 'Procedure dbo.usp_AddProductPrice dropped.'; +END +GO + +PRINT ''; +PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.'; +GO diff --git a/database/migrations/V019__create_product_prices.sql b/database/migrations/V019__create_product_prices.sql new file mode 100644 index 0000000..9dbb740 --- /dev/null +++ b/database/migrations/V019__create_product_prices.sql @@ -0,0 +1,196 @@ +-- V019__create_product_prices.sql +-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2). +-- +-- Cambios: +-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años). +-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt. +-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only). +-- +-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V019_ROLLBACK.sql. +-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api. +-- +-- Notas: +-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo): +-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1). +-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail +-- en pesos con 2 decimales; la diferencia es intencional (D6). +-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8). +-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <). +-- +-- SDD Design: engram sdd/prd-003-product-prices-historicos/design + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.ProductPrices +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ProductPrices ( + Id BIGINT IDENTITY(1,1) NOT NULL + CONSTRAINT PK_ProductPrices PRIMARY KEY, + ProductId INT NOT NULL, + Price DECIMAL(12,2) NOT NULL, + PriceValidFrom DATE NOT NULL, + PriceValidTo DATE NULL, + FechaCreacion DATETIME2(3) NOT NULL + CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()), + CONSTRAINT FK_ProductPrices_Product + FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION, + CONSTRAINT CK_ProductPrices_Price_Positive + CHECK (Price > 0), + CONSTRAINT CK_ProductPrices_ValidRange + CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom) + ); + PRINT 'Table dbo.ProductPrices created.'; +END +ELSE + PRINT 'Table dbo.ProductPrices already exists — skip.'; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. SYSTEM_VERSIONING — ProductPrices +-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar +-- colisión con las business cols PriceValidFrom/PriceValidTo (D1). +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL +BEGIN + ALTER TABLE dbo.ProductPrices + ADD + SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()), + SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); + PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.ProductPrices + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ProductPrices_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).'; +END +ELSE + PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'ProductPrices_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'ProductPrices_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Índices +-- ═══════════════════════════════════════════════════════════════════════ + +-- Un único activo por producto (imposibilita violar a nivel BD). +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices')) +BEGIN + CREATE UNIQUE INDEX UX_ProductPrices_Active + ON dbo.ProductPrices (ProductId) + WHERE PriceValidTo IS NULL; + PRINT 'Index UX_ProductPrices_Active created.'; +END +GO + +-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs). +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices')) +BEGIN + CREATE INDEX IX_ProductPrices_Lookup + ON dbo.ProductPrices (ProductId, PriceValidFrom DESC) + INCLUDE (Price, PriceValidTo); + PRINT 'Index IX_ProductPrices_Lookup created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SP — dbo.usp_AddProductPrice +-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK. +-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio). +-- ═══════════════════════════════════════════════════════════════════════ +GO +CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice + @ProductId INT, + @Price DECIMAL(12,2), + @PriceValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + -- Validación: producto debe existir y estar activo. + IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1) + BEGIN + ROLLBACK; + THROW 50404, 'Product not found or inactive', 1; + END + + -- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index. + DECLARE @ActiveId BIGINT, @ActivePVF DATE; + SELECT TOP 1 + @ActiveId = Id, + @ActivePVF = PriceValidFrom + FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ProductId = @ProductId AND PriceValidTo IS NULL; + + -- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo. + IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF + BEGIN + ROLLBACK; + THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1; + END + + -- Cierra el activo previo: PVT = PVF(nuevo) - 1 día. + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ProductPrices + SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + -- Inserta el nuevo activo. + INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo) + VALUES (@ProductId, @Price, @PriceValidFrom, NULL); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH +END +GO + +PRINT ''; +PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.'; +PRINT 'Next migration: V020 (TBD).'; +GO diff --git a/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs new file mode 100644 index 0000000..77bdd46 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs @@ -0,0 +1,360 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Application.Tests.Products.Repository; + +/// +/// 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 +/// +[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(""" + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo) + OUTPUT INSERTED.Id + VALUES ('PP', 'Medio ProductPrices', 1, 1) + """); + + _defaultProductTypeId = await conn.ExecuteScalarAsync(""" + 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(""" + 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("@NewId"); + var closedId = p.Get("@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( + "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( + "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( + "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() + .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() + .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() + .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() + .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( + "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( + "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() + .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(""" + 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(""" + 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."); + } +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index 821813a..f2bf05a 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -66,6 +66,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'. await EnsureV018SchemaAsync(); + // V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice. + await EnsureV019SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -96,6 +99,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "ProductType_History"), // PRD-002 (V018): Product es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "Product_History"), + // PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING. + new Respawn.Graph.Table("dbo", "ProductPrices_History"), ] }); @@ -1122,4 +1127,144 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(createMedioIdx); await _connection.ExecuteAsync(createRubroIdx); } + + /// + /// PRD-003 (V019): applies dbo.ProductPrices + SYSTEM_VERSIONING + indexes + SP usp_AddProductPrice + /// idempotently to the test database. Mirrors V019__create_product_prices.sql. + /// + public async Task EnsureV019SchemaAsync() + { + const string createTable = """ + IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL + BEGIN + CREATE TABLE dbo.ProductPrices ( + Id BIGINT IDENTITY(1,1) NOT NULL + CONSTRAINT PK_ProductPrices PRIMARY KEY, + ProductId INT NOT NULL, + Price DECIMAL(12,2) NOT NULL, + PriceValidFrom DATE NOT NULL, + PriceValidTo DATE NULL, + FechaCreacion DATETIME2(3) NOT NULL + CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()), + CONSTRAINT FK_ProductPrices_Product + FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION, + CONSTRAINT CK_ProductPrices_Price_Positive + CHECK (Price > 0), + CONSTRAINT CK_ProductPrices_ValidRange + CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom) + ); + END + """; + + const string addPeriod = """ + IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL + BEGIN + ALTER TABLE dbo.ProductPrices + ADD + SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()), + SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime); + END + """; + + const string setVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.ProductPrices + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.ProductPrices_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string createActiveIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices')) + BEGIN + CREATE UNIQUE INDEX UX_ProductPrices_Active + ON dbo.ProductPrices (ProductId) + WHERE PriceValidTo IS NULL; + END + """; + + const string createLookupIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices')) + BEGIN + CREATE INDEX IX_ProductPrices_Lookup + ON dbo.ProductPrices (ProductId, PriceValidFrom DESC) + INCLUDE (Price, PriceValidTo); + END + """; + + const string createSp = """ + IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NULL + EXEC('CREATE PROCEDURE dbo.usp_AddProductPrice AS RETURN 0'); + """; + + const string alterSp = """ + ALTER PROCEDURE dbo.usp_AddProductPrice + @ProductId INT, + @Price DECIMAL(12,2), + @PriceValidFrom DATE, + @NewId BIGINT OUTPUT, + @ClosedId BIGINT OUTPUT + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + + BEGIN TRY + BEGIN TRANSACTION; + + IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id=@ProductId AND IsActive=1) + BEGIN + ROLLBACK; + THROW 50404, 'Product not found or inactive', 1; + END + + DECLARE @ActiveId BIGINT, @ActivePVF DATE; + SELECT TOP 1 @ActiveId = Id, @ActivePVF = PriceValidFrom + FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK) + WHERE ProductId = @ProductId AND PriceValidTo IS NULL; + + IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF + BEGIN + ROLLBACK; + THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1; + END + + IF @ActiveId IS NOT NULL + BEGIN + UPDATE dbo.ProductPrices + SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom) + WHERE Id = @ActiveId; + SET @ClosedId = @ActiveId; + END + ELSE + SET @ClosedId = NULL; + + INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo) + VALUES (@ProductId, @Price, @PriceValidFrom, NULL); + SET @NewId = SCOPE_IDENTITY(); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH + END + """; + + await _connection.ExecuteAsync(createTable); + await _connection.ExecuteAsync(addPeriod); + await _connection.ExecuteAsync(setVersioning); + await _connection.ExecuteAsync(createActiveIndex); + await _connection.ExecuteAsync(createLookupIndex); + await _connection.ExecuteAsync(createSp); + await _connection.ExecuteAsync(alterSp); + } }