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);
+ }
}