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/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs new file mode 100644 index 0000000..b3c8fbc --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductPricesController.cs @@ -0,0 +1,93 @@ +using FluentValidation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Application.Products.Prices.AddPrice; +using SIGCM2.Application.Products.Prices.GetHistory; + +namespace SIGCM2.Api.Controllers; + +/// +/// PRD-003: ProductPrices historic pricing management. +/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role). +/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'. +/// +[ApiController] +public sealed class ProductPricesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _addValidator; + + public ProductPricesController( + IDispatcher dispatcher, + IValidator addValidator) + { + _dispatcher = dispatcher; + _addValidator = addValidator; + } + + // ── READ endpoint ────────────────────────────────────────────────────────── + + /// + /// Returns the full price history for a Product, ordered descending by PriceValidFrom. + /// Returns 200 with empty array if the product has no prices yet. + /// Returns 404 if the product does not exist. + /// + [HttpGet("api/v1/products/{id:int}/prices")] + [Authorize] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductPrices([FromRoute] int id) + { + var query = new GetProductPricesQuery(id); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + // ── WRITE endpoint ───────────────────────────────────────────────────────── + + /// + /// Adds a new price to a Product. Closes the current active price if one exists. + /// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom. + /// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices. + /// + [HttpPost("api/v1/admin/products/{id:int}/prices")] + [RequirePermission("catalogo:productos:gestionar")] + [ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task AddProductPrice( + [FromRoute] int id, + [FromBody] AddProductPriceRequest request) + { + var command = new AddProductPriceCommand( + ProductId: id, + Price: request.Price, + PriceValidFrom: request.PriceValidFrom); + + var validation = await _addValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetProductPrices), new { id }, result); + } +} + +// ── Request body record ─────────────────────────────────────────────────────── + +/// PRD-003: Add ProductPrice request body. +public sealed record AddProductPriceRequest( + decimal Price, + DateOnly PriceValidFrom); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index 8fc1884..9b3602e 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -475,6 +475,45 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // PRD-003: ProductPrices exceptions + case ProductPriceForwardOnlyException forwardOnlyEx: + context.Result = new ObjectResult(new + { + error = "product_price_forward_only", + message = forwardOnlyEx.Message, + productId = forwardOnlyEx.ProductId + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case ProductPriceInvalidException priceInvalidEx: + context.Result = new ObjectResult(new + { + error = "product_price_invalid", + message = priceInvalidEx.Message, + field = priceInvalidEx.Field + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + context.ExceptionHandled = true; + break; + + case ProductSinPrecioActivoException sinPrecioEx: + context.Result = new ObjectResult(new + { + error = "product_sin_precio_activo", + message = sinPrecioEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + // PRD-002: Product exceptions case ProductNotFoundException productNotFoundEx: context.Result = new ObjectResult(new diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs new file mode 100644 index 0000000..0831183 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductPriceRepository.cs @@ -0,0 +1,40 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// PRD-003 — Write + query access to dbo.ProductPrices. +/// Implemented by ProductPriceRepository (Dapper) in Infrastructure. +/// +public interface IProductPriceRepository +{ + /// + /// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope. + /// Returns (newId, closedId?). Throws: + /// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627). + /// - ProductNotFoundException on SQL THROW 50404. + /// + Task<(long NewId, long? ClosedId)> AddAsync( + int productId, + decimal price, + DateOnly priceValidFrom, + CancellationToken ct = default); + + /// + /// Returns all price rows for the product, ordered descending by PriceValidFrom (active first). + /// Returns empty list when the product has no price history. + /// + Task> GetByProductIdAsync( + int productId, + CancellationToken ct = default); + + /// + /// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given + /// civil date, or null if no row matches (no history, or date is before any recorded price). + /// Used by ProductPricingService.GetPriceAtAsync. + /// + Task GetActiveAsync( + int productId, + DateOnly date, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index d0e64ec..98f5384 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update; using SIGCM2.Application.Products.Deactivate; using SIGCM2.Application.Products.GetById; using SIGCM2.Application.Products.List; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Application.Products.Prices.AddPrice; +using SIGCM2.Application.Products.Prices.GetHistory; +using SIGCM2.Application.Products.Pricing; using SIGCM2.Application.ProductTypes.Create; using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Deactivate; @@ -182,6 +186,11 @@ public static class DependencyInjection services.AddScoped, GetProductByIdQueryHandler>(); services.AddScoped>, ListProductsQueryHandler>(); + // ProductPrices (PRD-003) + services.AddScoped, AddProductPriceCommandHandler>(); + services.AddScoped>, GetProductPricesQueryHandler>(); + services.AddScoped(); + // ProductTypes (PRD-001) // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs new file mode 100644 index 0000000..e2faef6 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommand.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product. +/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider). +/// +public sealed record AddProductPriceCommand( + int ProductId, + decimal Price, + DateOnly PriceValidFrom); diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs new file mode 100644 index 0000000..d54ee9f --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandHandler.cs @@ -0,0 +1,89 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Handler del comando AddProductPrice. +/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) → +/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) → +/// tx.Complete() → construye response con GetByProductIdAsync. +/// +public sealed class AddProductPriceCommandHandler + : ICommandHandler +{ + private readonly IProductPriceRepository _pricesRepo; + private readonly IProductRepository _productsRepo; + private readonly IAuditLogger _audit; + private readonly TimeProvider _timeProvider; + + public AddProductPriceCommandHandler( + IProductPriceRepository pricesRepo, + IProductRepository productsRepo, + IAuditLogger audit, + TimeProvider timeProvider) + { + _pricesRepo = pricesRepo; + _productsRepo = productsRepo; + _audit = audit; + _timeProvider = timeProvider; + } + + public async Task Handle(AddProductPriceCommand command) + { + // 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD). + var product = await _productsRepo.GetByIdAsync(command.ProductId) + ?? throw new ProductNotFoundException(command.ProductId); + + if (!product.IsActive) + throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes + + // 2. TX + SP + audit (fail-closed). + // El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total. + // GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar + // "TransactionScope is already complete" al abrir una nueva conexión dentro del using. + long newId; + long? closedId; + using (var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled)) + { + (newId, closedId) = await _pricesRepo.AddAsync( + command.ProductId, command.Price, command.PriceValidFrom); + + await _audit.LogAsync( + action: "product_price.created", + targetType: "ProductPrice", + targetId: newId.ToString(), + metadata: new + { + after = new + { + command.ProductId, + command.Price, + priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"), + }, + closedPriceId = closedId + }); + + tx.Complete(); + } // TX disposed (committed) here — BEFORE the post-commit read below. + + // 3. Compongo la respuesta post-commit con lectura de historial actualizado. + var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId); + var created = prices.Single(p => p.Id == newId); + var closed = closedId.HasValue + ? prices.SingleOrDefault(p => p.Id == closedId.Value) + : null; + + return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed)); + } + + private static ProductPriceDto ToDto(ProductPrice p) + => new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive); +} diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs new file mode 100644 index 0000000..25ac827 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — FluentValidation validator para AddProductPriceCommand. +/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now). +/// FakeTimeProvider en tests garantiza determinismo. +/// +public sealed class AddProductPriceCommandValidator : AbstractValidator +{ + public AddProductPriceCommandValidator(TimeProvider timeProvider) + { + var today = timeProvider.GetArgentinaToday(); + + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("ProductId debe ser un entero positivo."); + + RuleFor(x => x.Price) + .GreaterThan(0m) + .WithMessage("El precio debe ser mayor a cero."); + + RuleFor(x => x.PriceValidFrom) + .GreaterThanOrEqualTo(today) + .WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva."); + } +} diff --git a/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs new file mode 100644 index 0000000..669e04b --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/AddPrice/AddProductPriceResponse.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Products.Prices.AddPrice; + +/// +/// PRD-003 — Respuesta del comando AddProductPrice. +/// Closed es null si era el primer precio registrado para el producto. +/// +public sealed record AddProductPriceResponse( + ProductPriceDto Created, + ProductPriceDto? Closed); diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs new file mode 100644 index 0000000..8dce568 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQuery.cs @@ -0,0 +1,8 @@ +namespace SIGCM2.Application.Products.Prices.GetHistory; + +/// +/// PRD-003 — Query para obtener el historial de precios de un Product. +/// Devuelve lista ordenada descending por PriceValidFrom (activo primero). +/// Lanza ProductNotFoundException si el producto no existe. +/// +public sealed record GetProductPricesQuery(int ProductId); diff --git a/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs new file mode 100644 index 0000000..c81e87c --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/GetHistory/GetProductPricesQueryHandler.cs @@ -0,0 +1,42 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Products.Prices.GetHistory; + +/// +/// PRD-003 — Handler de GetProductPricesQuery. +/// Verifica que el producto exista (404 si no), luego retorna historial de precios +/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY). +/// Lista vacía es válida (nuevo producto sin precios registrados aún). +/// +public sealed class GetProductPricesQueryHandler + : ICommandHandler> +{ + private readonly IProductPriceRepository _pricesRepo; + private readonly IProductRepository _productsRepo; + + public GetProductPricesQueryHandler( + IProductPriceRepository pricesRepo, + IProductRepository productsRepo) + { + _pricesRepo = pricesRepo; + _productsRepo = productsRepo; + } + + public async Task> Handle(GetProductPricesQuery query) + { + // Verifica existencia del producto (lanza 404 si no existe). + _ = await _productsRepo.GetByIdAsync(query.ProductId) + ?? throw new ProductNotFoundException(query.ProductId); + + var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId); + + return prices + .Select(p => new ProductPriceDto( + p.Id, p.ProductId, p.Price, + p.PriceValidFrom, p.PriceValidTo, p.IsActive)) + .ToList(); + } +} diff --git a/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs new file mode 100644 index 0000000..36d6e63 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Prices/ProductPriceDto.cs @@ -0,0 +1,13 @@ +namespace SIGCM2.Application.Products.Prices; + +/// +/// PRD-003 — DTO de lectura para un registro de precio histórico de Product. +/// IsActive = true cuando PriceValidTo is null (precio vigente en curso). +/// +public sealed record ProductPriceDto( + long Id, + int ProductId, + decimal Price, + DateOnly PriceValidFrom, + DateOnly? PriceValidTo, + bool IsActive); diff --git a/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs new file mode 100644 index 0000000..94b4306 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Pricing/IProductPricingService.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Application.Products.Pricing; + +/// +/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2). +/// Contrato forward para PRC-001 (tasación). +/// +/// Retorna null si no existe historial de precios para el producto en la fecha indicada. +/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException) +/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices). +/// +public interface IProductPricingService +{ + /// + /// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada, + /// o null si ningún registro de precio cubre esa fecha. + /// + Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs new file mode 100644 index 0000000..c7819a5 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/Pricing/ProductPricingService.cs @@ -0,0 +1,24 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Products.Pricing; + +/// +/// PRD-003 — Implementación de IProductPricingService. +/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil. +/// +public sealed class ProductPricingService : IProductPricingService +{ + private readonly IProductPriceRepository _repo; + + public ProductPricingService(IProductPriceRepository repo) + { + _repo = repo; + } + + /// + public async Task GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default) + { + var price = await _repo.GetActiveAsync(productId, date, ct); + return price?.Price; + } +} diff --git a/src/api/SIGCM2.Domain/Entities/ProductPrice.cs b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs new file mode 100644 index 0000000..47fdacf --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/ProductPrice.cs @@ -0,0 +1,25 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// PRD-003 — Immutable record representing a price snapshot for a Product. +/// Business dates are Cat2 (civil Argentina dates, DateOnly). Forward-only — no mutations. +/// PriceValidTo = null means the row is the currently active price. +/// +public sealed record ProductPrice( + long Id, + int ProductId, + decimal Price, + DateOnly PriceValidFrom, + DateOnly? PriceValidTo, + DateTime FechaCreacion) +{ + /// True if this row is the currently active price (PriceValidTo is null). + public bool IsActive => PriceValidTo is null; + + /// + /// True if this price's window covers the given civil date (inclusive on both ends). + /// An active row (PriceValidTo = null) covers any date on or after PriceValidFrom. + /// + public bool CoversDate(DateOnly date) + => PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date); +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs new file mode 100644 index 0000000..5a5819b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceForwardOnlyException.cs @@ -0,0 +1,23 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly +/// greater than the currently active price's PriceValidFrom. → HTTP 409 +/// +public sealed class ProductPriceForwardOnlyException : DomainException +{ + public int ProductId { get; } + public DateOnly NewPriceValidFrom { get; } + public DateOnly ActivePriceValidFrom { get; } + + public ProductPriceForwardOnlyException( + int productId, + DateOnly newPriceValidFrom, + DateOnly activePriceValidFrom) + : base($"El nuevo PriceValidFrom ({newPriceValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al PriceValidFrom del precio activo ({activePriceValidFrom:yyyy-MM-dd}).") + { + ProductId = productId; + NewPriceValidFrom = newPriceValidFrom; + ActivePriceValidFrom = activePriceValidFrom; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs new file mode 100644 index 0000000..6a2447e --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductPriceInvalidException.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a ProductPrice value fails domain business validation +/// (e.g., Price <= 0, PriceValidFrom in the past). → HTTP 400 +/// Used as defense-in-depth alongside FluentValidation in the Application layer. +/// +public sealed class ProductPriceInvalidException : DomainException +{ + public string Field { get; } + public string Reason { get; } + + public ProductPriceInvalidException(string field, string reason) + : base($"Valor inválido para {field}: {reason}") + { + Field = field; + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs new file mode 100644 index 0000000..490f040 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductSinPrecioActivoException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when no ProductPrice row covers the requested date for the given product. → HTTP 404 +/// Consumers of IProductPricingService may throw this when GetPriceAtAsync returns null. +/// +public sealed class ProductSinPrecioActivoException : DomainException +{ + public int ProductId { get; } + public DateOnly Date { get; } + + public ProductSinPrecioActivoException(int productId, DateOnly date) + : base($"No existe precio registrado para el producto {productId} aplicable a la fecha {date:yyyy-MM-dd}.") + { + ProductId = productId; + Date = date; + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 0797ca3..20e5090 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -43,6 +43,8 @@ public static class DependencyInjection services.AddScoped(); // PRD-002: replaces NullProductQueryRepository from Application DI services.AddScoped(); + // PRD-003: ProductPrices históricos + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs new file mode 100644 index 0000000..03621f7 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductPriceRepository.cs @@ -0,0 +1,144 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Infrastructure.Persistence; + +/// +/// PRD-003 — Dapper implementation of IProductPriceRepository against dbo.ProductPrices. +/// AddAsync invokes dbo.usp_AddProductPrice and maps SqlException numbers to domain exceptions. +/// GetByProductIdAsync and GetActiveAsync run direct SQL and map DateTime columns to DateOnly. +/// +public sealed class ProductPriceRepository : IProductPriceRepository +{ + private readonly SqlConnectionFactory _factory; + + public ProductPriceRepository(SqlConnectionFactory factory) + { + _factory = factory; + } + + /// + public async Task<(long NewId, long? ClosedId)> AddAsync( + int productId, + decimal price, + DateOnly priceValidFrom, + CancellationToken ct = default) + { + var p = new DynamicParameters(); + p.Add("@ProductId", productId, DbType.Int32); + p.Add("@Price", price, DbType.Decimal, precision: 12, scale: 2); + p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), DbType.Date); + p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output); + p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output); + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + "dbo.usp_AddProductPrice", + p, + commandType: CommandType.StoredProcedure, + cancellationToken: ct)); + } + catch (SqlException ex) when (ex.Number == 50404) + { + throw new ProductNotFoundException(productId); + } + catch (SqlException ex) when (ex.Number == 50409) + { + // Forward-only violation detected by SP (new PVF <= active PVF). + // activePriceValidFrom is not returned by the SP; use MinValue as safe placeholder. + throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue); + } + catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627) + { + // Race condition: two concurrent inserts with PriceValidTo IS NULL slipped through + // the SERIALIZABLE guard. Defense-in-depth: surface as forward-only. + throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue); + } + + var newId = p.Get("@NewId"); + var closedId = p.Get("@ClosedId"); + return (newId, closedId); + } + + /// + public async Task> GetByProductIdAsync( + int productId, + CancellationToken ct = default) + { + // Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC). + const string sql = """ + SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion + FROM dbo.ProductPrices + WHERE ProductId = @ProductId + ORDER BY PriceValidFrom DESC, Id DESC + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync( + new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct)); + + return rows.Select(MapRow).ToList(); + } + + /// + public async Task GetActiveAsync( + int productId, + DateOnly date, + CancellationToken ct = default) + { + // Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC) INCLUDE(Price, PriceValidTo). + // TOP 1 ordered DESC by PriceValidFrom returns the most-recent row whose window covers date. + const string sql = """ + SELECT TOP 1 Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion + FROM dbo.ProductPrices + WHERE ProductId = @ProductId + AND PriceValidFrom <= @Date + AND (PriceValidTo IS NULL OR PriceValidTo >= @Date) + ORDER BY PriceValidFrom DESC, Id DESC + """; + + await using var connection = _factory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition( + sql, + new { ProductId = productId, Date = date.ToDateTime(TimeOnly.MinValue) }, + cancellationToken: ct)); + + return row is null ? null : MapRow(row); + } + + // ── Mapping ─────────────────────────────────────────────────────────────── + // Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here. + + private static ProductPrice MapRow(ProductPriceRow r) + => new( + Id: r.Id, + ProductId: r.ProductId, + Price: r.Price, + PriceValidFrom: DateOnly.FromDateTime(r.PriceValidFrom), + PriceValidTo: r.PriceValidTo.HasValue + ? DateOnly.FromDateTime(r.PriceValidTo.Value) + : (DateOnly?)null, + FechaCreacion: r.FechaCreacion); + + private sealed record ProductPriceRow( + long Id, + int ProductId, + decimal Price, + DateTime PriceValidFrom, + DateTime? PriceValidTo, + DateTime FechaCreacion); +} diff --git a/src/web/src/features/products/api/addProductPrice.ts b/src/web/src/features/products/api/addProductPrice.ts new file mode 100644 index 0000000..a5da71a --- /dev/null +++ b/src/web/src/features/products/api/addProductPrice.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { AddProductPriceRequest, AddProductPriceResponse } from '../types' + +export async function addProductPrice( + productId: number, + payload: AddProductPriceRequest, +): Promise { + const res = await axiosClient.post( + `/api/v1/admin/products/${productId}/prices`, + payload, + ) + return res.data +} diff --git a/src/web/src/features/products/api/getProductPrices.ts b/src/web/src/features/products/api/getProductPrices.ts new file mode 100644 index 0000000..1f9c533 --- /dev/null +++ b/src/web/src/features/products/api/getProductPrices.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductPrice } from '../types' + +export async function getProductPrices(productId: number): Promise { + const res = await axiosClient.get(`/api/v1/products/${productId}/prices`) + return res.data +} diff --git a/src/web/src/features/products/components/AddProductPriceDialog.tsx b/src/web/src/features/products/components/AddProductPriceDialog.tsx new file mode 100644 index 0000000..36ced8c --- /dev/null +++ b/src/web/src/features/products/components/AddProductPriceDialog.tsx @@ -0,0 +1,206 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { todayArgentina } from '@/lib/dateFormat' +import { useAddProductPrice } from '../hooks/useAddProductPrice' + +// ─── Schema (Zod, espejo del backend) ──────────────────────────────────────── + +const addPriceSchema = z.object({ + price: z.coerce + .number('Debe ser un número') + .positive('El precio debe ser mayor a cero.'), + priceValidFrom: z + .string() + .min(1, 'La fecha es requerida.') + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.') + .refine( + (v) => v >= todayArgentina(), + 'La fecha no puede ser anterior a hoy.', + ), +}) + +type AddPriceFormRaw = { + price: string + priceValidFrom: string +} + +type AddPriceFormOutput = z.infer + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveBackendError(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string; message?: string } + if (err.response.status === 409) { + return data.message ?? 'La fecha debe ser posterior al precio vigente.' + } + if (err.response.status === 404) { + return data.message ?? 'Producto no encontrado.' + } + return data.message ?? data.error ?? 'Error al guardar el precio.' + } + return 'Error al guardar el precio. Intentá de nuevo.' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface AddProductPriceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + productId: number +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function AddProductPriceDialog({ + open, + onOpenChange, + productId, +}: AddProductPriceDialogProps) { + const mutation = useAddProductPrice(productId) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(addPriceSchema) as any, + defaultValues: { + price: '', + priceValidFrom: '', + }, + mode: 'onSubmit', + }) + + // Reset form and mutation state when dialog opens + useEffect(() => { + if (open) { + form.reset({ price: '', priceValidFrom: '' }) + mutation.reset() + } + }, [open]) // eslint-disable-line react-hooks/exhaustive-deps + + const backendError = resolveBackendError(mutation.error) + const today = todayArgentina() + + function handleSubmit(values: AddPriceFormOutput) { + mutation.mutate( + { price: values.price, priceValidFrom: values.priceValidFrom }, + { + onSuccess: () => { + onOpenChange(false) + }, + }, + ) + } + + return ( + + + + Programar nuevo precio + + Ingresá el nuevo precio y la fecha desde la que estará vigente. El + precio actual quedará cerrado. + + + + + [0], + )} + className="space-y-4" + noValidate + > + {backendError && ( + + + {backendError} + + )} + + {/* Precio */} + ( + + Precio + + + + + + )} + /> + + {/* Vigente desde */} + ( + + Vigente desde + + + + + + )} + /> + + + onOpenChange(false)} + disabled={mutation.isPending} + > + Cancelar + + + {mutation.isPending ? 'Guardando...' : 'Guardar'} + + + + + + + ) +} diff --git a/src/web/src/features/products/components/ProductPriceHistory.tsx b/src/web/src/features/products/components/ProductPriceHistory.tsx new file mode 100644 index 0000000..54eaaac --- /dev/null +++ b/src/web/src/features/products/components/ProductPriceHistory.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { AlertCircle, Plus } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { CanPerform } from '@/components/auth/CanPerform' +import { formatCivilDate } from '@/lib/dateFormat' +import { formatCurrency } from '@/lib/numberFormat' +import { useProductPrices } from '../hooks/useProductPrices' +import { AddProductPriceDialog } from './AddProductPriceDialog' + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProductPriceHistoryProps { + productId: number +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { + const [addOpen, setAddOpen] = useState(false) + const { data: prices, isLoading, isError } = useProductPrices(productId) + + if (isLoading) { + return ( + + + + + + ) + } + + if (isError) { + return ( + + + Error al cargar precios del producto. + + ) + } + + const isEmpty = !prices?.length + + return ( + + + Historial de precios + + setAddOpen(true)}> + + Programar nuevo precio + + + + + {isEmpty ? ( + + Sin historial de precios. Este producto no tiene precios registrados. + + setAddOpen(true)}> + + Programar nuevo precio + + + + ) : ( + + + + + Desde + Hasta + Precio + Estado + + + + {prices.map((p) => ( + + {formatCivilDate(p.priceValidFrom)} + + {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} + + {formatCurrency(p.price)} + + {p.isActive ? ( + Vigente + ) : null} + + + ))} + + + + )} + + + + ) +} diff --git a/src/web/src/features/products/hooks/useAddProductPrice.ts b/src/web/src/features/products/hooks/useAddProductPrice.ts new file mode 100644 index 0000000..6dab471 --- /dev/null +++ b/src/web/src/features/products/hooks/useAddProductPrice.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { addProductPrice } from '../api/addProductPrice' +import type { AddProductPriceRequest } from '../types' + +export function useAddProductPrice(productId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] }) + }, + }) +} diff --git a/src/web/src/features/products/hooks/useProductPrices.ts b/src/web/src/features/products/hooks/useProductPrices.ts new file mode 100644 index 0000000..0453bf4 --- /dev/null +++ b/src/web/src/features/products/hooks/useProductPrices.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getProductPrices } from '../api/getProductPrices' + +export function useProductPrices(productId: number) { + return useQuery({ + queryKey: ['products', productId, 'prices'], + queryFn: () => getProductPrices(productId), + enabled: productId > 0, + staleTime: 30_000, + }) +} diff --git a/src/web/src/features/products/index.ts b/src/web/src/features/products/index.ts index a9f4708..98ba373 100644 --- a/src/web/src/features/products/index.ts +++ b/src/web/src/features/products/index.ts @@ -6,4 +6,7 @@ export type { UpdateProductRequest, PagedResult, ListProductsParams, + ProductPrice, + AddProductPriceRequest, + AddProductPriceResponse, } from './types' diff --git a/src/web/src/features/products/pages/ProductsPage.tsx b/src/web/src/features/products/pages/ProductsPage.tsx index 9512d85..acf961c 100644 --- a/src/web/src/features/products/pages/ProductsPage.tsx +++ b/src/web/src/features/products/pages/ProductsPage.tsx @@ -5,11 +5,18 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { CanPerform } from '@/components/auth/CanPerform' import { useProducts } from '../hooks/useProducts' import { useDeactivateProduct } from '../hooks/useDeactivateProduct' import { ProductFormDialog } from '../components/ProductFormDialog' import { DeactivateProductDialog } from '../components/DeactivateProductDialog' +import { ProductPriceHistory } from '../components/ProductPriceHistory' import type { ProductListItem, ProductDetail } from '../types' const PAGE_SIZE = 20 @@ -26,6 +33,11 @@ export function ProductsPage() { const [deactivateOpen, setDeactivateOpen] = useState(false) const [deactivatingProduct, setDeactivatingProduct] = useState(null) + // ── Prices dialog state (PRD-003) ──────────────────────────────────────── + const [pricesOpen, setPricesOpen] = useState(false) + const [pricesProductId, setPricesProductId] = useState(null) + const [pricesProductName, setPricesProductName] = useState('') + // ── Pagination & filter state ──────────────────────────────────────────── const [page, setPage] = useState(1) const [medioIdFilter, setMedioIdFilter] = useState(undefined) @@ -59,6 +71,12 @@ export function ProductsPage() { setDeactivateOpen(true) } + function openPrices(p: ProductListItem) { + setPricesProductId(p.id) + setPricesProductName(p.nombre) + setPricesOpen(true) + } + async function handleDeactivate(id: number) { await deactivateProduct(id) toast.success('Producto desactivado') @@ -153,8 +171,16 @@ export function ProductsPage() { - - + + openPrices(p)} + aria-label={`Ver precios de ${p.nombre}`} + > + Ver precios + + Desactivar - - + + ))} @@ -231,6 +257,18 @@ export function ProductsPage() { onConfirm={handleDeactivate} /> )} + + {/* Prices history dialog (PRD-003) */} + {pricesProductId !== null && ( + + + + Precios — {pricesProductName} + + + + + )} ) } diff --git a/src/web/src/features/products/types.ts b/src/web/src/features/products/types.ts index 5ff1369..036ca2e 100644 --- a/src/web/src/features/products/types.ts +++ b/src/web/src/features/products/types.ts @@ -56,3 +56,24 @@ export interface ListProductsParams { productTypeId?: number rubroId?: number } + +// PRD-003 — ProductPrices históricos + +export interface ProductPrice { + id: number + productId: number + price: number + priceValidFrom: string // "yyyy-MM-dd" (Cat2) + priceValidTo: string | null + isActive: boolean +} + +export interface AddProductPriceRequest { + price: number + priceValidFrom: string // "yyyy-MM-dd" +} + +export interface AddProductPriceResponse { + created: ProductPrice + closed: ProductPrice | null +} diff --git a/src/web/src/lib/numberFormat.ts b/src/web/src/lib/numberFormat.ts new file mode 100644 index 0000000..3e30a6b --- /dev/null +++ b/src/web/src/lib/numberFormat.ts @@ -0,0 +1,17 @@ +/** + * Formateo de números — utility centralizada. + * Usar SIEMPRE estas funciones en lugar de Intl.NumberFormat inline. + */ + +/** + * Formatea un número como moneda ARS (pesos argentinos). + * Output: "$ 1.500,50" o similar según locale. + */ +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} diff --git a/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx b/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx new file mode 100644 index 0000000..6e0d1e5 --- /dev/null +++ b/src/web/src/tests/features/products/AddProductPriceDialog.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { AddProductPriceDialog } from '../../../features/products/components/AddProductPriceDialog' +import type { AddProductPriceResponse } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +// ─── Server ─────────────────────────────────────────────────────────────────── + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() + vi.useRealTimers() +}) +afterAll(() => server.close()) + +// ─── Fake timers helper ─────────────────────────────────────────────────────── +// Fix "today" to 2026-04-19 ART (UTC-3). +// 2026-04-19T12:00:00-03:00 = 2026-04-19T15:00:00Z +// todayArgentina() uses Intl.DateTimeFormat with timeZone so this is stable. + +function setupFakeTimers() { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(new Date('2026-04-19T15:00:00.000Z')) +} + +// ─── Render helper ──────────────────────────────────────────────────────────── + +function renderDialog(onOpenChange = vi.fn()) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return { + qc, + result: render( + + + , + ), + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('AddProductPriceDialog — renders', () => { + it('renders dialog with price and date fields', () => { + renderDialog() + expect(screen.getByRole('dialog')).toBeInTheDocument() + // Use role spinbutton (number input) for price field + expect(screen.getByRole('spinbutton', { name: /precio$/i })).toBeInTheDocument() + expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument() + }) +}) + +describe('AddProductPriceDialog — client-side validation', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('shows error when priceValidFrom is yesterday (2026-04-18 < 2026-04-19)', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + // "Yesterday" in ART = 2026-04-18 + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-18') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/no puede ser anterior a hoy/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('accepts priceValidFrom = today (2026-04-19) as valid — no date error shown', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json( + { + created: { id: 1, productId: 1, price: 100, priceValidFrom: '2026-04-19', priceValidTo: null, isActive: true }, + closed: null, + } satisfies AddProductPriceResponse, + { status: 201 }, + ), + ), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-19') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + // Should NOT show the date validation error + await waitFor( + () => expect(screen.queryByText(/no puede ser anterior a hoy/i)).not.toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('shows error when price is 0', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '0') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) + + it('shows error when price is negative', async () => { + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '-50') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + const submitBtn = screen.getByRole('button', { name: /^guardar$/i }) + await userEvent.click(submitBtn) + + await waitFor( + () => expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) +}) + +describe('AddProductPriceDialog — happy path submit', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('calls mutation with correct payload and closes on success', async () => { + const mockResponse: AddProductPriceResponse = { + created: { + id: 2, + productId: 1, + price: 500.25, + priceValidFrom: '2026-04-25', + priceValidTo: null, + isActive: true, + }, + closed: null, + } + + let capturedBody: unknown = null + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, async ({ request }) => { + capturedBody = await request.json() + return HttpResponse.json(mockResponse, { status: 201 }) + }), + ) + + const onOpenChange = vi.fn() + renderDialog(onOpenChange) + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '500.25') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor( + () => expect(capturedBody).toEqual({ price: 500.25, priceValidFrom: '2026-04-25' }), + { timeout: 3000 }, + ) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 3000 }) + }) +}) + +describe('AddProductPriceDialog — server error 409', () => { + beforeEach(() => { + setupFakeTimers() + }) + + it('shows inline message when server returns 409 (ForwardOnly)', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json( + { + error: 'product_price_forward_only', + message: 'La fecha debe ser posterior al precio vigente', + }, + { status: 409 }, + ), + ), + ) + renderDialog() + + const priceInput = screen.getByRole('spinbutton', { name: /precio$/i }) + await userEvent.clear(priceInput) + await userEvent.type(priceInput, '100') + + const dateInput = screen.getByLabelText(/vigente desde/i) + await userEvent.clear(dateInput) + await userEvent.type(dateInput, '2026-04-25') + + await userEvent.click(screen.getByRole('button', { name: /^guardar$/i })) + + await waitFor( + () => expect(screen.getByText(/fecha debe ser posterior/i)).toBeInTheDocument(), + { timeout: 3000 }, + ) + }) +}) diff --git a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx new file mode 100644 index 0000000..5ff48ee --- /dev/null +++ b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory' +import { useAuthStore } from '../../../stores/authStore' +import type { ProductPrice } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const mockActivePrice: ProductPrice = { + id: 2, + productId: 1, + price: 1500.50, + priceValidFrom: '2026-04-01', + priceValidTo: null, + isActive: true, +} + +const mockClosedPrice: ProductPrice = { + id: 1, + productId: 1, + price: 1000.00, + priceValidFrom: '2026-01-01', + priceValidTo: '2026-03-31', + isActive: false, +} + +const adminUser = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['catalogo:productos:gestionar'], + mustChangePassword: false, +} + +const regularUser = { + id: 2, + username: 'viewer', + nombre: 'Viewer', + rol: 'viewer', + permisos: [], + mustChangePassword: false, +} + +// ─── Server ─────────────────────────────────────────────────────────────────── + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +// ─── Render helper ──────────────────────────────────────────────────────────── + +function renderHistory(productId = 1, user = adminUser) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProductPriceHistory — loading state', () => { + it('renders skeleton while loading', () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, async () => { + await new Promise(() => {}) + return HttpResponse.json([]) + }), + ) + renderHistory() + // Should show loading indicator + const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse') + expect(skeletons.length).toBeGreaterThan(0) + }) +}) + +describe('ProductPriceHistory — error state', () => { + it('renders error message on fetch failure', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json({ error: 'server_error' }, { status: 500 }), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/error al cargar precios/i)).toBeInTheDocument(), + ) + }) +}) + +describe('ProductPriceHistory — empty state', () => { + it('shows CTA when no prices exist', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/sin historial de precios/i)).toBeInTheDocument(), + ) + // Should show at least one button to add first price (for users with permission) + // Both header button and empty-state CTA render in empty state + const addButtons = screen.getAllByRole('button', { name: /programar nuevo precio/i }) + expect(addButtons.length).toBeGreaterThan(0) + }) +}) + +describe('ProductPriceHistory — data rendering', () => { + it('renders price list with formatted dates and prices', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice, mockClosedPrice]), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText('01/04/2026')).toBeInTheDocument(), + ) + // Active price row visible + expect(screen.getByText('01/01/2026')).toBeInTheDocument() + // Closed price "hasta" date visible + expect(screen.getByText('31/03/2026')).toBeInTheDocument() + }) + + it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice, mockClosedPrice]), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + }) + + it('shows formatted currency for prices', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice]), + ), + ) + renderHistory() + // ARS currency format — 1500.50 + await waitFor(() => { + const cells = screen.getAllByRole('cell') + const hasCurrency = cells.some((c) => c.textContent?.includes('1.500') || c.textContent?.includes('1500')) + expect(hasCurrency).toBe(true) + }) + }) +}) + +describe('ProductPriceHistory — dialog integration', () => { + it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice]), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + const btn = screen.getByRole('button', { name: /programar nuevo precio/i }) + await userEvent.click(btn) + // Dialog should open — check for dialog heading + await waitFor(() => + expect(screen.getByRole('dialog')).toBeInTheDocument(), + ) + }) + + it('hides "Programar nuevo precio" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json([mockActivePrice]), + ), + ) + renderHistory(1, regularUser) + await waitFor(() => expect(screen.getByText('Vigente')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/products/productPrices.hooks.test.ts b/src/web/src/tests/features/products/productPrices.hooks.test.ts new file mode 100644 index 0000000..5eea52e --- /dev/null +++ b/src/web/src/tests/features/products/productPrices.hooks.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useProductPrices } from '../../../features/products/hooks/useProductPrices' +import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice' +import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockPrice: ProductPrice = { + id: 1, + productId: 1, + price: 500, + priceValidFrom: '2026-04-01', + priceValidTo: null, + isActive: true, +} + +const mockResponse: AddProductPriceResponse = { + created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true }, + closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false }, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function makeWrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return { qc, wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) } +} + +describe('useProductPrices', () => { + it('fetches prices for productId and returns data', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + ) + const { qc, wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(1), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual([mockPrice]) + // Verify caching: queryKey should be ['products', 1, 'prices'] + expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined() + }) + + it('is disabled when productId is 0', async () => { + // No server handler — if the query fired it would fail with unhandled request + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(0), { wrapper }) + // Should never enter loading/success + expect(result.current.isFetching).toBe(false) + expect(result.current.data).toBeUndefined() + }) +}) + +describe('useAddProductPrice', () => { + it('calls POST and invalidates product prices queries on success', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json(mockResponse, { status: 201 }), + ), + ) + const { qc, wrapper } = makeWrapper() + const invalidateSpy = vi.spyOn(qc, 'invalidateQueries') + + const { result } = renderHook(() => useAddProductPrice(1), { wrapper }) + + await act(async () => { + result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' }) + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] }) + }) + + it('returns error state on 409', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => + HttpResponse.json({ error: 'product_price_forward_only' }, { status: 409 }), + ), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useAddProductPrice(1), { wrapper }) + await act(async () => { + result.current.mutate({ price: 100, priceValidFrom: '2026-04-19' }) + }) + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs new file mode 100644 index 0000000..ccc63e7 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesAuditFailureTests.cs @@ -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; + +/// +/// 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). +/// +[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( + "SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id"); + + var ptId = await conn.QuerySingleOrDefaultAsync( + "SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id"); + if (ptId is null) + { + ptId = await conn.QuerySingleAsync(""" + 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( + "SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId", + new { ProductId = productId }); + + var auditCountBefore = await conn.ExecuteScalarAsync( + "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(); + throwingAuditLogger + .LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .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(); + services.AddScoped(_ => throwingAuditLogger); + }); + + // Generate admin token (use factory services, not child) + var jwt = _factory.Services.GetRequiredService(); + 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( + "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( + "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 SeedProductAsync() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35]; + return await conn.QuerySingleAsync(""" + 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 }); + } +} diff --git a/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs new file mode 100644 index 0000000..132293f --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs @@ -0,0 +1,450 @@ +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 SIGCM2.Application.Abstractions.Security; +using SIGCM2.Domain.Entities; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Api.Tests.Products; + +/// +/// PRD-003 — Integration tests for: +/// GET /api/v1/products/{id}/prices (requires authentication) +/// POST /api/v1/admin/products/{id}/prices (requires catalogo:productos:gestionar) +/// DB: SIGCM2_Test_Api (ApiIntegration collection — shared TestWebAppFactory). +/// +/// Each test creates its own product (via SQL) to avoid inter-test dependencies. +/// +[Collection("ApiIntegration")] +public sealed class ProductPricesControllerTests : IAsyncLifetime +{ + private const string ConnectionString = TestConnectionStrings.ApiTestDb; + + private readonly TestWebAppFactory _factory; + private readonly HttpClient _client; + + // Seeded once per class — valid MedioId and ProductTypeId from canonical data + private int _medioId; + private int _productTypeId; + + public ProductPricesControllerTests(TestWebAppFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + public async Task InitializeAsync() + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + + // Resolve a valid MedioId (dbo.Medio uses 'Activo' column) + _medioId = await conn.QuerySingleAsync( + "SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id"); + + // Resolve or seed a valid ProductTypeId (dbo.ProductType uses 'IsActive' column) + var ptId = await conn.QuerySingleOrDefaultAsync( + "SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id"); + if (ptId is null) + { + ptId = await conn.QuerySingleAsync(""" + INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages, IsActive, FechaCreacion) + VALUES ('PT_Prices_Test', 0, 0, 0, 0, 0, 1, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS INT); + """); + } + _productTypeId = ptId.Value; + } + + public Task DisposeAsync() => Task.CompletedTask; + + // ── Auth helpers ────────────────────────────────────────────────────────── + + /// Generates a bearer token for admin (has catalogo:productos:gestionar via 'admin' role). + private string GetAdminToken() + { + var jwt = _factory.Services.GetRequiredService(); + return jwt.GenerateAccessToken(new Usuario( + id: 1, username: "admin", passwordHash: "x", + nombre: "Admin", apellido: "Sys", email: null, + rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + } + + /// Generates a bearer token for cajero (does NOT have catalogo:productos:gestionar). + private string GetCajeroToken() + { + var jwt = _factory.Services.GetRequiredService(); + return jwt.GenerateAccessToken(new Usuario( + id: 9999, username: "cajero_test", passwordHash: "x", + nombre: "Cajero", apellido: "Test", email: null, + rol: "cajero", permisosJson: """{"grant":[],"deny":[]}""", activo: true)); + } + + private HttpRequestMessage BuildRequest( + HttpMethod method, string url, object? body = null, string? token = null) + { + var req = new HttpRequestMessage(method, url); + if (token is not null) + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + if (body is not null) + req.Content = JsonContent.Create(body); + return req; + } + + // ── DB seed helpers ─────────────────────────────────────────────────────── + + /// Seeds a unique product and returns its Id. + private async Task SeedProductAsync(bool activo = true) + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var nombre = $"PP_{Guid.NewGuid():N}"[..35]; + // dbo.Product uses 'IsActive' column (PRD-002 convention) + return await conn.QuerySingleAsync(""" + INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, PriceDurationDays, IsActive, FechaCreacion) + VALUES (@Nombre, @MedioId, @PtId, 100.00, NULL, @IsActive, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS INT); + """, + new { Nombre = nombre, MedioId = _medioId, PtId = _productTypeId, IsActive = activo ? 1 : 0 }); + } + + /// + /// Inserts a ProductPrice row directly (bypasses SP forward-only guard) to set up test scenarios. + /// + private async Task SeedPriceDirectAsync( + int productId, decimal price, DateOnly pvf, DateOnly? pvt) + { + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync(""" + INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion) + VALUES (@ProductId, @Price, @PriceValidFrom, @PriceValidTo, SYSUTCDATETIME()); + SELECT CAST(SCOPE_IDENTITY() AS BIGINT); + """, + new + { + ProductId = productId, + Price = price, + PriceValidFrom = pvf.ToDateTime(TimeOnly.MinValue), + PriceValidTo = pvt.HasValue ? (DateTime?)pvt.Value.ToDateTime(TimeOnly.MinValue) : null + }); + } + + // ── GET /api/v1/products/{id}/prices ───────────────────────────────────── + + [Fact] + public async Task GetPrices_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/1/prices"); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetPrices_EmptyHistory_Returns200WithEmptyArray() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + + using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await resp.Content.ReadFromJsonAsync(); + json.ValueKind.Should().Be(JsonValueKind.Array); + json.GetArrayLength().Should().Be(0); + } + + [Fact] + public async Task GetPrices_WithHistory_Returns200OrderedDescending() + { + var productId = await SeedProductAsync(); + + // Seed 3 prices: 2 closed + 1 active (in ascending order to verify API returns DESC) + await SeedPriceDirectAsync(productId, 50m, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)); + await SeedPriceDirectAsync(productId, 75m, new DateOnly(2026, 2, 1), new DateOnly(2026, 2, 28)); + await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 3, 1), null); + + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Get, $"/api/v1/products/{productId}/prices", token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await resp.Content.ReadFromJsonAsync(); + items.GetArrayLength().Should().Be(3); + + // First item = most recent (active, March) + var first = items[0]; + first.GetProperty("priceValidFrom").GetString().Should().Be("2026-03-01"); + first.GetProperty("isActive").GetBoolean().Should().BeTrue(); + first.GetProperty("priceValidTo").ValueKind.Should().Be(JsonValueKind.Null); + + // Last item = oldest (January) + var last = items[2]; + last.GetProperty("priceValidFrom").GetString().Should().Be("2026-01-01"); + last.GetProperty("isActive").GetBoolean().Should().BeFalse(); + last.GetProperty("priceValidTo").GetString().Should().Be("2026-01-31"); + } + + [Fact] + public async Task GetPrices_ProductNotFound_Returns404() + { + var token = GetAdminToken(); + using var req = BuildRequest(HttpMethod.Get, "/api/v1/products/999999999/prices", token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetString().Should().Be("product_not_found"); + } + + // ── POST /api/v1/admin/products/{id}/prices ─────────────────────────────── + + [Fact] + public async Task PostPrice_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/1/prices", + body: new { price = 200m, priceValidFrom = "2026-05-01" }); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PostPrice_WithoutPermission_Returns403() + { + var productId = await SeedProductAsync(); + var token = GetCajeroToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 200m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task PostPrice_FirstPrice_Returns201WithClosedNull() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + // Use tomorrow to ensure >= hoy_AR passes FluentValidation + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 250m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await resp.Content.ReadFromJsonAsync(); + body.TryGetProperty("created", out var created).Should().BeTrue(); + body.TryGetProperty("closed", out var closed).Should().BeTrue(); + + created.GetProperty("price").GetDecimal().Should().Be(250m); + created.GetProperty("priceValidFrom").GetString().Should().Be(pvf); + created.GetProperty("isActive").GetBoolean().Should().BeTrue(); + closed.ValueKind.Should().Be(JsonValueKind.Null); + + // Location header must be present + resp.Headers.Location.Should().NotBeNull(); + } + + [Fact] + public async Task PostPrice_SecondPrice_Returns201WithClosedNotNull() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + + // Seed an existing active price at a past date (direct insert bypasses SP) + await SeedPriceDirectAsync(productId, 100m, new DateOnly(2026, 4, 1), null); + + // New price must be strictly greater than active PVF + var newPvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(2).ToString("yyyy-MM-dd"); + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 300m, priceValidFrom = newPvf }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await resp.Content.ReadFromJsonAsync(); + var closed = body.GetProperty("closed"); + closed.ValueKind.Should().NotBe(JsonValueKind.Null); + closed.GetProperty("price").GetDecimal().Should().Be(100m); + + // PriceValidTo of closed = newPvf - 1 day + var expectedPvt = DateOnly.ParseExact(newPvf, "yyyy-MM-dd").AddDays(-1).ToString("yyyy-MM-dd"); + closed.GetProperty("priceValidTo").GetString().Should().Be(expectedPvt); + + var created = body.GetProperty("created"); + created.GetProperty("price").GetDecimal().Should().Be(300m); + created.GetProperty("priceValidFrom").GetString().Should().Be(newPvf); + created.GetProperty("isActive").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task PostPrice_PriceZero_Returns400() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 0m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostPrice_NegativePrice_Returns400() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = -5m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostPrice_PastDate_Returns400() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + // Clearly in the past + const string pastDate = "2020-01-01"; + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 100m, priceValidFrom = pastDate }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task PostPrice_ProductNotFound_Returns404() + { + var token = GetAdminToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, "/api/v1/admin/products/999999999/prices", + body: new { price = 100m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.NotFound); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetString().Should().Be("product_not_found"); + } + + [Fact] + public async Task PostPrice_ForwardOnlyViolation_Returns409() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + + // Seed active price at a far-future date + await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null); + + // Try to add price with a date BEFORE the active one + const string retroPvf = "2099-11-01"; + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 150m, priceValidFrom = retroPvf }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetString().Should().Be("product_price_forward_only"); + } + + [Fact] + public async Task PostPrice_SamePvfAsActive_Returns409() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + + // Seed active price at a far-future date + await SeedPriceDirectAsync(productId, 200m, new DateOnly(2099, 12, 1), null); + + // Try to add price with the SAME date — also rejected (forward-only: must be strictly greater) + const string samePvf = "2099-12-01"; + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 250m, priceValidFrom = samePvf }, + token: token); + var resp = await _client.SendAsync(req); + + resp.StatusCode.Should().Be(HttpStatusCode.Conflict); + var body = await resp.Content.ReadFromJsonAsync(); + body.GetProperty("error").GetString().Should().Be("product_price_forward_only"); + } + + // ── Audit Event ─────────────────────────────────────────────────────────── + + [Fact] + public async Task PostPrice_Success_CreatesAuditEvent() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 500m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await resp.Content.ReadFromJsonAsync(); + var newId = body.GetProperty("created").GetProperty("id").GetInt64(); + + // Verify audit event row in SIGCM2_Test_Api + await using var conn = new SqlConnection(ConnectionString); + await conn.OpenAsync(); + var auditCount = await conn.QuerySingleAsync(""" + SELECT COUNT(1) FROM dbo.AuditEvent + WHERE Action = 'product_price.created' + AND TargetType = 'ProductPrice' + AND TargetId = @TargetId + """, new { TargetId = newId.ToString() }); + + auditCount.Should().Be(1, because: "IAuditLogger must record product_price.created after a successful POST"); + } + + // ── DateOnly JSON format ────────────────────────────────────────────────── + + [Fact] + public async Task PostPrice_DateOnly_SerializesAsYyyyMmDd() + { + var productId = await SeedProductAsync(); + var token = GetAdminToken(); + var pvf = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1).ToString("yyyy-MM-dd"); + + using var req = BuildRequest(HttpMethod.Post, $"/api/v1/admin/products/{productId}/prices", + body: new { price = 999m, priceValidFrom = pvf }, + token: token); + var resp = await _client.SendAsync(req); + resp.StatusCode.Should().Be(HttpStatusCode.Created); + + var body = await resp.Content.ReadFromJsonAsync(); + var returnedPvf = body.GetProperty("created").GetProperty("priceValidFrom").GetString(); + + // DateOnlyJsonConverter must produce "yyyy-MM-dd" — no time, no TZ suffix + returnedPvf.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}$", + because: "DateOnlyJsonConverter (UDT-011) must serialize DateOnly as yyyy-MM-dd"); + returnedPvf.Should().Be(pvf); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs new file mode 100644 index 0000000..4f54e1d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceExceptionTests.cs @@ -0,0 +1,128 @@ +using FluentAssertions; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Domain; + +/// +/// PRD-003 — Domain unit tests for ProductPrice-related exceptions. +/// Verifies constructor props, message content, and DomainException inheritance. +/// +public class ProductPriceExceptionTests +{ + // ── ProductPriceForwardOnlyException ───────────────────────────────────── + + [Fact] + public void ProductPriceForwardOnlyException_SetsProperties() + { + var newPvf = new DateOnly(2026, 3, 1); + var activePvf = new DateOnly(2026, 4, 1); + + var ex = new ProductPriceForwardOnlyException(productId: 42, newPvf, activePvf); + + ex.ProductId.Should().Be(42); + ex.NewPriceValidFrom.Should().Be(newPvf); + ex.ActivePriceValidFrom.Should().Be(activePvf); + } + + [Fact] + public void ProductPriceForwardOnlyException_MessageContainsKeyDates() + { + var newPvf = new DateOnly(2026, 3, 1); + var activePvf = new DateOnly(2026, 4, 1); + + var ex = new ProductPriceForwardOnlyException(productId: 5, newPvf, activePvf); + + ex.Message.Should().Contain("2026-03-01"); + ex.Message.Should().Contain("2026-04-01"); + } + + [Fact] + public void ProductPriceForwardOnlyException_InheritsFromDomainException() + { + var ex = new ProductPriceForwardOnlyException( + productId: 1, + newPriceValidFrom: new DateOnly(2026, 1, 1), + activePriceValidFrom: new DateOnly(2026, 2, 1)); + + ex.Should().BeAssignableTo(); + } + + // ── ProductPriceInvalidException ───────────────────────────────────────── + + [Fact] + public void ProductPriceInvalidException_SetsProperties() + { + var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0"); + + ex.Field.Should().Be("Price"); + ex.Reason.Should().Be("must be > 0"); + } + + [Fact] + public void ProductPriceInvalidException_MessageContainsFieldAndReason() + { + var ex = new ProductPriceInvalidException(field: "Price", reason: "must be > 0"); + + ex.Message.Should().Contain("Price"); + ex.Message.Should().Contain("must be > 0"); + } + + [Fact] + public void ProductPriceInvalidException_InheritsFromDomainException() + { + var ex = new ProductPriceInvalidException("Price", "invalid"); + + ex.Should().BeAssignableTo(); + } + + // ── ProductSinPrecioActivoException ────────────────────────────────────── + + [Fact] + public void ProductSinPrecioActivoException_SetsProperties() + { + var date = new DateOnly(2026, 4, 19); + + var ex = new ProductSinPrecioActivoException(productId: 99, date); + + ex.ProductId.Should().Be(99); + ex.Date.Should().Be(date); + } + + [Fact] + public void ProductSinPrecioActivoException_MessageContainsProductIdAndDate() + { + var date = new DateOnly(2026, 4, 19); + + var ex = new ProductSinPrecioActivoException(productId: 99, date); + + ex.Message.Should().Contain("99"); + ex.Message.Should().Contain("2026-04-19"); + } + + [Fact] + public void ProductSinPrecioActivoException_InheritsFromDomainException() + { + var ex = new ProductSinPrecioActivoException(1, new DateOnly(2026, 4, 1)); + + ex.Should().BeAssignableTo(); + } + + // ── ProductNotFoundException — already exists, verify it works ──────────── + + [Fact] + public void ProductNotFoundException_SetsProductIdAndMessage() + { + var ex = new ProductNotFoundException(77); + + ex.ProductId.Should().Be(77); + ex.Message.Should().Contain("77"); + } + + [Fact] + public void ProductNotFoundException_InheritsFromDomainException() + { + var ex = new ProductNotFoundException(1); + + ex.Should().BeAssignableTo(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs new file mode 100644 index 0000000..aa03dbc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs @@ -0,0 +1,158 @@ +using FluentAssertions; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Domain; + +/// +/// PRD-003 — Domain unit tests for ProductPrice record. +/// Covers: §REQ-4.1 (IsActive), §REQ-4.2 (CoversDate inclusive bounds), +/// §REQ-4.4 (active row covers any date on or after PriceValidFrom). +/// +public class ProductPriceTests +{ + private static ProductPrice MakePrice( + long id = 1, + int productId = 10, + decimal price = 1500.00m, + DateOnly? from = null, + DateOnly? to = null, + DateTime? fechaCreacion = null) + => new( + Id: id, + ProductId: productId, + Price: price, + PriceValidFrom: from ?? new DateOnly(2026, 4, 1), + PriceValidTo: to, + FechaCreacion: fechaCreacion ?? new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + // ── §REQ-4.1: IsActive ──────────────────────────────────────────────────── + + [Fact] + public void IsActive_TrueWhenPriceValidToIsNull() + { + var price = MakePrice(to: null); + + price.IsActive.Should().BeTrue(); + } + + [Fact] + public void IsActive_FalseWhenPriceValidToHasValue() + { + var price = MakePrice(to: new DateOnly(2026, 4, 30)); + + price.IsActive.Should().BeFalse(); + } + + // ── §REQ-4.2: CoversDate — inclusive lower bound ────────────────────────── + + [Fact] + public void CoversDate_InclusiveLowerBound_ReturnsTrue() + { + // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidFrom. + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30)); + + price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue(); + } + + // ── §REQ-4.2: CoversDate — inclusive upper bound ────────────────────────── + + [Fact] + public void CoversDate_InclusiveUpperBound_ReturnsTrue() + { + // Row: 2026-04-01 → 2026-04-30. Query date = PriceValidTo. + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 30)); + + price.CoversDate(new DateOnly(2026, 4, 30)).Should().BeTrue(); + } + + // ── §REQ-4.2: CoversDate — exclusive outside range ──────────────────────── + + [Fact] + public void CoversDate_DateBeforePriceValidFrom_ReturnsFalse() + { + var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20)); + + price.CoversDate(new DateOnly(2026, 4, 9)).Should().BeFalse(); + } + + [Fact] + public void CoversDate_DateAfterPriceValidTo_ReturnsFalse() + { + var price = MakePrice(from: new DateOnly(2026, 4, 10), to: new DateOnly(2026, 4, 20)); + + price.CoversDate(new DateOnly(2026, 4, 21)).Should().BeFalse(); + } + + // ── §REQ-4.4: Active row (PriceValidTo = null) covers any date on or after PriceValidFrom ── + + [Fact] + public void ActiveRow_CoversAnyDateOnOrAfterPvf() + { + // Active row: PriceValidFrom = 2026-04-01, PriceValidTo = null + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null); + + // On PriceValidFrom itself + price.CoversDate(new DateOnly(2026, 4, 1)).Should().BeTrue(); + // Well in the future + price.CoversDate(new DateOnly(2030, 12, 31)).Should().BeTrue(); + } + + [Fact] + public void ActiveRow_DoesNotCoverDateBeforePvf() + { + var price = MakePrice(from: new DateOnly(2026, 4, 1), to: null); + + price.CoversDate(new DateOnly(2026, 3, 31)).Should().BeFalse(); + } + + // ── Record value equality (records compare by value) ───────────────────── + + [Fact] + public void Equality_TwoIdenticalRecords_AreEqual() + { + var a = MakePrice(id: 1, productId: 10, price: 1500m, + from: new DateOnly(2026, 4, 1), to: null, + fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + var b = MakePrice(id: 1, productId: 10, price: 1500m, + from: new DateOnly(2026, 4, 1), to: null, + fechaCreacion: new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + + a.Should().Be(b); + (a == b).Should().BeTrue(); + } + + [Fact] + public void Equality_DifferentId_AreNotEqual() + { + var a = MakePrice(id: 1); + var b = MakePrice(id: 2); + + a.Should().NotBe(b); + } + + // ── Immutability: record positional props are init-only (no regular set) ─ + + [Fact] + public void Record_PropertiesAreInitOnly_NotMutableSet() + { + // C# records expose init-only accessors, not regular set. + // IsExternalInit attribute on the setter's return type distinguishes init from set. + var type = typeof(ProductPrice); + var mutableSetters = type.GetProperties() + .Where(p => + { + var setter = p.GetSetMethod(nonPublic: false); + if (setter is null) return false; + // init-only setters have a modreq on IsExternalInit in their signature; + // a simple way to detect them: they are NOT init-only when + // ReturnParameter.GetRequiredCustomModifiers() does NOT contain IsExternalInit. + var modifiers = setter.ReturnParameter.GetRequiredCustomModifiers(); + bool isInitOnly = modifiers.Any(m => m.Name == "IsExternalInit"); + return !isInitOnly; // only flag non-init setters as mutable + }) + .ToList(); + + mutableSetters.Should().BeEmpty("ProductPrice must be immutable — all setters should be init-only."); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs new file mode 100644 index 0000000..78a4815 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandHandlerTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Application.Products.Prices.AddPrice; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Prices; + +/// +/// PRD-003 — AddProductPriceCommandHandler tests. +/// Covers: §REQ-6.1 (happy path + audit), §REQ-6.2 (audit fail → rollback), +/// §REQ-3.3 (producto inexistente → ProductNotFoundException), +/// §REQ-2.2 (ForwardOnly propagation), §REQ-2.1 (response con Closed). +/// NSubstitute, FakeTimeProvider. +/// +public class AddProductPriceCommandHandlerTests +{ + private readonly IProductPriceRepository _pricesRepo = Substitute.For(); + private readonly IProductRepository _productsRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero)); + private readonly AddProductPriceCommandHandler _handler; + + private static readonly DateOnly Today = new(2026, 4, 19); + private static readonly DateOnly Tomorrow = new(2026, 4, 20); + + // Producto activo de ejemplo + private static Product ActiveProduct(int id = 1) => Product.ForCreation( + "Test Product", medioId: 1, productTypeId: 2, + rubroId: null, basePrice: 100m, priceDurationDays: null, + TimeProvider.System); + + // ProductPrice stub de retorno + private static ProductPrice MakePrice(long id, int productId, decimal price, + DateOnly pvf, DateOnly? pvt = null) => + new(id, productId, price, pvf, pvt, + FechaCreacion: new DateTime(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc)); + + public AddProductPriceCommandHandlerTests() + { + // defaults: producto activo existe; AddAsync devuelve newId=10, closedId=null + _productsRepo.GetByIdAsync(1, Arg.Any()) + .Returns(ActiveProduct(1)); + + _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((10L, (long?)null)); + + _pricesRepo.GetByProductIdAsync(1, Arg.Any()) + .Returns(new List + { + MakePrice(10, 1, 150m, Today) + }.AsReadOnly() as IReadOnlyList); + + _handler = new AddProductPriceCommandHandler(_pricesRepo, _productsRepo, _audit, _time); + } + + private static AddProductPriceCommand ValidCmd(int productId = 1) => new( + ProductId: productId, + Price: 150m, + PriceValidFrom: Today); + + // ── Happy path ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsCreatedDto() + { + // §REQ-1.1 happy path — primer precio (sin activo previo) + var result = await _handler.Handle(ValidCmd()); + + result.Created.Should().NotBeNull(); + result.Created.Id.Should().Be(10); + result.Created.Price.Should().Be(150m); + result.Created.PriceValidFrom.Should().Be(Today); + result.Created.IsActive.Should().BeTrue(); + result.Closed.Should().BeNull(); + } + + [Fact] + public async Task Handle_HappyPath_CallsRepoAddAsync_Once() + { + await _handler.Handle(ValidCmd()); + + await _pricesRepo.Received(1).AddAsync( + 1, 150m, Today, Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_LogsAuditEvent_ProductPriceCreated() + { + // §REQ-6.1 — audit "product_price.created" llamado exactamente una vez + await _handler.Handle(ValidCmd()); + + await _audit.Received(1).LogAsync( + action: "product_price.created", + targetType: "ProductPrice", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_ClosesActivePrice_ResponseContainsClosed() + { + // §REQ-2.1 — POST crea nuevo y cierra el activo → response.Closed != null + _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((20L, (long?)5L)); // newId=20, closedId=5 + + _pricesRepo.GetByProductIdAsync(1, Arg.Any()) + .Returns(new List + { + MakePrice(20, 1, 200m, Tomorrow), + MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1)) + }.AsReadOnly() as IReadOnlyList); + + var result = await _handler.Handle(ValidCmd() with { Price = 200m, PriceValidFrom = Tomorrow }); + + result.Created.Id.Should().Be(20); + result.Closed.Should().NotBeNull(); + result.Closed!.Id.Should().Be(5); + result.Closed.IsActive.Should().BeFalse(); + } + + // ── Producto inexistente ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ProductNotFound_ThrowsProductNotFoundException() + { + // §REQ-3.3 + _productsRepo.GetByIdAsync(99, Arg.Any()) + .Returns((Product?)null); + + var act = async () => await _handler.Handle(ValidCmd(productId: 99)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_ProductNotFound_RepoAddAsync_NotCalled() + { + _productsRepo.GetByIdAsync(99, Arg.Any()) + .Returns((Product?)null); + + var act = async () => await _handler.Handle(ValidCmd(productId: 99)); + await act.Should().ThrowAsync(); + + await _pricesRepo.DidNotReceive().AddAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + // ── Producto inactivo ─────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ProductInactive_ThrowsProductNotFoundException() + { + // Producto inactivo → tratamos como 404 (invisible para clientes) + var activeFirst = Product.ForCreation( + "Inactive", medioId: 1, productTypeId: 2, + rubroId: null, basePrice: 100m, priceDurationDays: null, + TimeProvider.System); + var inactiveProduct = activeFirst.WithDeactivated(TimeProvider.System); + + _productsRepo.GetByIdAsync(2, Arg.Any()) + .Returns(inactiveProduct); + + var act = async () => await _handler.Handle(ValidCmd(productId: 2)); + + await act.Should().ThrowAsync(); + } + + // ── ForwardOnly violation ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrowsForwardOnlyException_PropagatesIt() + { + // §REQ-2.3 — SP lanza 50409 → repo lo mapea a ProductPriceForwardOnlyException → handler lo propaga + _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today)); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_RepoThrowsForwardOnlyException_AuditNotCalled() + { + _pricesRepo.AddAsync(1, Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today)); + + var act = async () => await _handler.Handle(ValidCmd()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── Audit fail → rollback ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AuditThrows_ExceptionPropagates_TransactionNotCompleted() + { + // §REQ-6.2 — audit falla → rollback total (no commit) + _audit.LogAsync( + action: Arg.Any(), + targetType: Arg.Any(), + targetId: Arg.Any(), + metadata: Arg.Any(), + ct: Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit DB error")); + + var act = async () => await _handler.Handle(ValidCmd()); + + await act.Should().ThrowAsync() + .WithMessage("Audit DB error"); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs new file mode 100644 index 0000000..4af2f71 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/AddProductPriceCommandValidatorTests.cs @@ -0,0 +1,115 @@ +using FluentValidation.TestHelper; +using Microsoft.Extensions.Time.Testing; +using SIGCM2.Application.Products.Prices.AddPrice; + +namespace SIGCM2.Application.Tests.Products.Prices; + +/// +/// PRD-003 — Validator tests for AddProductPriceCommand. +/// FakeTimeProvider fija hoy = 2026-04-19 (UTC-3, ART). +/// Covers: §REQ-3.1 (price > 0), §REQ-3.2 (priceValidFrom >= hoy_AR), productId > 0. +/// +public class AddProductPriceCommandValidatorTests +{ + // Hoy en ART (UTC-3): 2026-04-19T12:00:00 UTC → 2026-04-19T09:00:00 ART + // FakeTimeProvider se crea con un DateTimeOffset UTC; GetArgentinaToday() convierte a ART. + private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero)); + private readonly AddProductPriceCommandValidator _validator; + + public AddProductPriceCommandValidatorTests() + { + _validator = new AddProductPriceCommandValidator(_time); + } + + private static readonly DateOnly Today = new(2026, 4, 19); + private static readonly DateOnly Yesterday = new(2026, 4, 18); + private static readonly DateOnly Tomorrow = new(2026, 4, 20); + + // ── ProductId ──────────────────────────────────────────────────────────────── + + [Fact] + public void ProductId_Zero_FailsValidation() + { + var cmd = ValidCmd() with { ProductId = 0 }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductId); + } + + [Fact] + public void ProductId_Negative_FailsValidation() + { + var cmd = ValidCmd() with { ProductId = -1 }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductId); + } + + [Fact] + public void ProductId_Positive_Passes() + { + var cmd = ValidCmd() with { ProductId = 1 }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.ProductId); + } + + // ── Price ──────────────────────────────────────────────────────────────────── + + [Fact] + public void Price_Zero_FailsValidation() + { + // §REQ-3.1 — price debe ser > 0 + var cmd = ValidCmd() with { Price = 0m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Price_Negative_FailsValidation() + { + var cmd = ValidCmd() with { Price = -0.01m }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Price); + } + + [Fact] + public void Price_Positive_Passes() + { + var cmd = ValidCmd() with { Price = 100m }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.Price); + } + + // ── PriceValidFrom ──────────────────────────────────────────────────────────── + + [Fact] + public void PriceValidFrom_InPast_FailsValidation() + { + // §REQ-3.2 — fecha pasada → invalid + var cmd = ValidCmd() with { PriceValidFrom = Yesterday }; + _validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceValidFrom); + } + + [Fact] + public void PriceValidFrom_Today_Passes() + { + // Hoy mismo debe ser válido (inclusive lower bound) + var cmd = ValidCmd() with { PriceValidFrom = Today }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceValidFrom); + } + + [Fact] + public void PriceValidFrom_Future_Passes() + { + // §OQ-C — fechas futuras permitidas (programar próximo precio) + var cmd = ValidCmd() with { PriceValidFrom = Tomorrow }; + _validator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceValidFrom); + } + + // ── Happy path ──────────────────────────────────────────────────────────────── + + [Fact] + public void ValidCommand_PassesAllRules() + { + _validator.TestValidate(ValidCmd()).ShouldNotHaveAnyValidationErrors(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + private static AddProductPriceCommand ValidCmd() => new( + ProductId: 1, + Price: 150.00m, + PriceValidFrom: Today); +} diff --git a/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs new file mode 100644 index 0000000..311bcca --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Prices/GetProductPricesQueryHandlerTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.Prices; +using SIGCM2.Application.Products.Prices.GetHistory; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Products.Prices; + +/// +/// PRD-003 — GetProductPricesQueryHandler tests. +/// Covers: §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404). +/// +public class GetProductPricesQueryHandlerTests +{ + private readonly IProductPriceRepository _pricesRepo = Substitute.For(); + private readonly IProductRepository _productsRepo = Substitute.For(); + private readonly GetProductPricesQueryHandler _handler; + + private static readonly DateOnly Date1 = new(2026, 1, 1); + private static readonly DateOnly Date2 = new(2026, 2, 1); + private static readonly DateOnly Date3 = new(2026, 3, 1); + + private static Product ActiveProduct() => Product.ForCreation( + "Test", medioId: 1, productTypeId: 2, + rubroId: null, basePrice: 100m, priceDurationDays: null, + TimeProvider.System); + + private static ProductPrice MakePrice(long id, DateOnly pvf, DateOnly? pvt = null) => + new(id, ProductId: 1, Price: 100m * id, pvf, pvt, + FechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + public GetProductPricesQueryHandlerTests() + { + _productsRepo.GetByIdAsync(1, Arg.Any()) + .Returns(ActiveProduct()); + + // default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo) + _pricesRepo.GetByProductIdAsync(1, Arg.Any()) + .Returns(new List + { + MakePrice(3, Date3), // activo (pvt=null) + MakePrice(2, Date2, Date3.AddDays(-1)), // cerrado + MakePrice(1, Date1, Date2.AddDays(-1)) // cerrado más antiguo + }.AsReadOnly() as IReadOnlyList); + + _handler = new GetProductPricesQueryHandler(_pricesRepo, _productsRepo); + } + + // ── Orden descending ──────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ReturnsAllPrices_InDescendingOrder() + { + // §REQ-4.1 — historial completo ordenado descending por PriceValidFrom + var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); + + result.Should().HaveCount(3); + result[0].PriceValidFrom.Should().Be(Date3); // más reciente primero + result[1].PriceValidFrom.Should().Be(Date2); + result[2].PriceValidFrom.Should().Be(Date1); + } + + [Fact] + public async Task Handle_MapsToDto_WithIsActive() + { + var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); + + result[0].IsActive.Should().BeTrue(); // pvt=null → activo + result[1].IsActive.Should().BeFalse(); // pvt IS NOT NULL → cerrado + } + + // ── Lista vacía (nuevo producto sin precios) ───────────────────────────────── + + [Fact] + public async Task Handle_EmptyHistory_ReturnsEmptyList() + { + // §REQ-4.3 — nuevo producto aún no tiene precios → lista vacía (no 404) + _pricesRepo.GetByProductIdAsync(1, Arg.Any()) + .Returns(new List().AsReadOnly() as IReadOnlyList); + + var result = await _handler.Handle(new GetProductPricesQuery(ProductId: 1)); + + result.Should().BeEmpty(); + } + + // ── Producto inexistente ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ProductNotFound_ThrowsProductNotFoundException() + { + _productsRepo.GetByIdAsync(99, Arg.Any()) + .Returns((Product?)null); + + var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_ProductNotFound_RepoGetByProductId_NotCalled() + { + _productsRepo.GetByIdAsync(99, Arg.Any()) + .Returns((Product?)null); + + var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99)); + await act.Should().ThrowAsync(); + + await _pricesRepo.DidNotReceive() + .GetByProductIdAsync(99, Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs b/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs new file mode 100644 index 0000000..e58f47e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Pricing/ProductPricingServiceTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Products.Pricing; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Products.Pricing; + +/// +/// PRD-003 — ProductPricingService tests. +/// Covers: GetPriceAtAsync happy path, fecha sin precio → null (OQ-B contrato). +/// +public class ProductPricingServiceTests +{ + private readonly IProductPriceRepository _repo = Substitute.For(); + private readonly ProductPricingService _service; + + private static readonly DateOnly QueryDate = new(2026, 4, 19); + + private static ProductPrice ActivePrice(decimal price = 150m) => + new(Id: 1, ProductId: 1, Price: price, + PriceValidFrom: new DateOnly(2026, 1, 1), PriceValidTo: null, + FechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + public ProductPricingServiceTests() + { + _service = new ProductPricingService(_repo); + } + + // ── Happy path ───────────────────────────────────────────────────────────── + + [Fact] + public async Task GetPriceAtAsync_ActivePriceCoverDate_ReturnsPrice() + { + _repo.GetActiveAsync(1, QueryDate, Arg.Any()) + .Returns(ActivePrice(200m)); + + var result = await _service.GetPriceAtAsync(1, QueryDate); + + result.Should().Be(200m); + } + + [Fact] + public async Task GetPriceAtAsync_NoPriceForDate_ReturnsNull() + { + // OQ-B — si no hay historial para la fecha, retorna null. + // El consumidor (PRC-001) decide si usar BasePrice o lanzar excepción. + _repo.GetActiveAsync(1, QueryDate, Arg.Any()) + .Returns((ProductPrice?)null); + + var result = await _service.GetPriceAtAsync(1, QueryDate); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetPriceAtAsync_CallsGetActiveAsync_WithCorrectArgs() + { + _repo.GetActiveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ProductPrice?)null); + + await _service.GetPriceAtAsync(productId: 5, date: QueryDate); + + await _repo.Received(1).GetActiveAsync(5, QueryDate, Arg.Any()); + } + + [Fact] + public async Task GetPriceAtAsync_FutureDateOutsideWindow_ReturnsNull() + { + // Precio cerrado cuya ventana no cubre la fecha consultada → repo devuelve null + var futureDate = new DateOnly(2030, 1, 1); + _repo.GetActiveAsync(1, futureDate, Arg.Any()) + .Returns((ProductPrice?)null); + + var result = await _service.GetPriceAtAsync(1, futureDate); + + result.Should().BeNull(); + } +} 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..6459f9e --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Products/Repository/ProductPriceRepositoryIntegrationTests.cs @@ -0,0 +1,777 @@ +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SIGCM2.Domain.Exceptions; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; +using Xunit; + +namespace SIGCM2.Application.Tests.Products.Repository; + +/// +/// PRD-003 — Integration tests for dbo.ProductPrices + dbo.usp_AddProductPrice. +/// Batch 1: Direct SP invocation (RED until V019 applied). +/// Batch 4: Via ProductPriceRepository (Dapper) — RED until ProductPriceRepository 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"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Batch 4 — Via ProductPriceRepository (Dapper wrapper) + // ───────────────────────────────────────────────────────────────────────── + + // §REQ-4.1 — GetByProductIdAsync orders descending by PriceValidFrom + [Fact] + public async Task GetByProductIdAsync_MultipleRows_OrdersDescendingByPriceValidFrom() + { + // Seed 3 prices via SP + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 3, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 5, 1)); + + var repo = BuildRepository(); + var result = await repo.GetByProductIdAsync(_defaultProductId); + + result.Should().HaveCount(3); + result[0].PriceValidFrom.Should().Be(new DateOnly(2026, 5, 1), "most recent first"); + result[1].PriceValidFrom.Should().Be(new DateOnly(2026, 3, 1)); + result[2].PriceValidFrom.Should().Be(new DateOnly(2026, 1, 1)); + } + + // §REQ-4.3 — GetByProductIdAsync returns empty list when no history + [Fact] + public async Task GetByProductIdAsync_NoHistory_ReturnsEmptyList() + { + var repo = BuildRepository(); + var result = await repo.GetByProductIdAsync(_defaultProductId); + + result.Should().BeEmpty("product exists but has no price history yet"); + } + + // §REQ-4.4 — GetActiveAsync: exact boundary PriceValidFrom = query date → returns row + [Fact] + public async Task GetActiveAsync_ExactBoundaryPvf_ReturnsRow() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + var pvf = new DateOnly(2026, 4, 19); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, pvf); + + var repo = BuildRepository(); + var result = await repo.GetActiveAsync(_defaultProductId, pvf); + + result.Should().NotBeNull(); + result!.PriceValidFrom.Should().Be(pvf); + result.Price.Should().Be(150.00m); + result.IsActive.Should().BeTrue(); + } + + // §REQ-4.4 — GetActiveAsync: date after closed window returns null + [Fact] + public async Task GetActiveAsync_DateAfterClosedWindow_ReturnsNull() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Insert and then close via a forward price + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1)); + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 2, 1)); + // Now close the second one too by inserting a third further forward + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 300.00m, new DateOnly(2026, 3, 1)); + + var repo = BuildRepository(); + // Query for a date before ANY price (before the first PVF) + var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31)); + + result.Should().BeNull("date is before any recorded price window"); + } + + // §REQ-2.2 / SqlException mapping — AddAsync maps SqlException 50409 → ProductPriceForwardOnlyException + [Fact] + public async Task AddAsync_MapsSqlException50409_ToProductPriceForwardOnlyException() + { + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Insert an active price first + await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1)); + + var repo = BuildRepository(); + // Try a retroactive date — SP will THROW 50409 + var act = async () => await repo.AddAsync(_defaultProductId, 90.00m, new DateOnly(2026, 3, 15)); + + await act.Should().ThrowAsync( + "SqlException 50409 must be mapped to ProductPriceForwardOnlyException"); + } + + // §REQ-3.3 / SqlException mapping — AddAsync maps SqlException 50404 → ProductNotFoundException + [Fact] + public async Task AddAsync_MapsSqlException50404_ToProductNotFoundException() + { + var repo = BuildRepository(); + var act = async () => await repo.AddAsync(999999, 100.00m, new DateOnly(2026, 4, 19)); + + await act.Should().ThrowAsync( + "SqlException 50404 must be mapped to ProductNotFoundException"); + } + + // Defense-in-depth: unique index violation (2601/2627) → ProductPriceForwardOnlyException + [Fact] + public async Task AddAsync_MapsUniqueViolation2601_ToProductPriceForwardOnlyException() + { + // Force a 2601 by inserting a row directly that bypasses the SP's SERIALIZABLE guard. + // Then use the repo's AddAsync targeting same ProductId with NULL PriceValidTo active. + await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb); + await seedConn.OpenAsync(); + + // Insert an active row directly (bypassing SP) + await seedConn.ExecuteAsync(""" + INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo) + VALUES (@ProductId, 100.00, '2026-01-01', NULL) + """, new { ProductId = _defaultProductId }); + + // Now try to insert another active row for same ProductId directly (to force 2601/2627) + // We simulate this by temporarily disabling the SP and doing a direct INSERT via repo's AddAsync. + // Since AddAsync goes via the SP which uses SERIALIZABLE+UPDLOCK and will see the active row + // and THROW 50409 (forward-only), but the unique index test for 2601/2627 mapping is already + // covered by FilteredUniqueIndex_DirectDuplicateActiveInsert_ThrowsUniqueViolation above. + // + // Here we verify repository AddAsync handles 50409 consistently regardless of the trigger. + var act = async () => await new ProductPriceRepository( + new SqlConnectionFactory(TestConnectionStrings.AppTestDb)) + .AddAsync(_defaultProductId, 200.00m, new DateOnly(2025, 12, 1)); + + await act.Should().ThrowAsync( + "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 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( + "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( + "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( + "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( + "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( + "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( + "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( + """ + 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( + "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() + => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb)); + + // ───────────────────────────────────────────────────────────────────────── + // 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); + } }
Sin historial de precios. Este producto no tiene precios registrados.