feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45

Merged
dmolinari merged 7 commits from feature/PRD-003 into main 2026-04-19 22:07:22 +00:00
44 changed files with 4268 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;
/// <summary>
/// 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'.
/// </summary>
[ApiController]
public sealed class ProductPricesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AddProductPriceCommand> _addValidator;
public ProductPricesController(
IDispatcher dispatcher,
IValidator<AddProductPriceCommand> addValidator)
{
_dispatcher = dispatcher;
_addValidator = addValidator;
}
// ── READ endpoint ──────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[HttpGet("api/v1/products/{id:int}/prices")]
[Authorize]
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
{
var query = new GetProductPricesQuery(id);
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
return Ok(result);
}
// ── WRITE endpoint ─────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<IActionResult> 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<AddProductPriceCommand, AddProductPriceResponse>(command);
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
}
}
// ── Request body record ───────────────────────────────────────────────────────
/// <summary>PRD-003: Add ProductPrice request body.</summary>
public sealed record AddProductPriceRequest(
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -475,6 +475,45 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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 // PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx: case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new

View File

@@ -0,0 +1,40 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-003 — Write + query access to dbo.ProductPrices.
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
/// </summary>
public interface IProductPriceRepository
{
/// <summary>
/// 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.
/// </summary>
Task<(long NewId, long? ClosedId)> AddAsync(
int productId,
decimal price,
DateOnly priceValidFrom,
CancellationToken ct = default);
/// <summary>
/// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
/// Returns empty list when the product has no price history.
/// </summary>
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
int productId,
CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<ProductPrice?> GetActiveAsync(
int productId,
DateOnly date,
CancellationToken ct = default);
}

View File

@@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate; using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById; using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List; 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.Create;
using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate; using SIGCM2.Application.ProductTypes.Deactivate;
@@ -182,6 +186,11 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>(); services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
// ProductPrices (PRD-003)
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
services.AddScoped<IProductPricingService, ProductPricingService>();
// ProductTypes (PRD-001) // ProductTypes (PRD-001)
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
/// </summary>
public sealed record AddProductPriceCommand(
int ProductId,
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class AddProductPriceCommandHandler
: ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>
{
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<AddProductPriceResponse> 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);
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
/// FakeTimeProvider en tests garantiza determinismo.
/// </summary>
public sealed class AddProductPriceCommandValidator : AbstractValidator<AddProductPriceCommand>
{
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.");
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — Respuesta del comando AddProductPrice.
/// Closed es null si era el primer precio registrado para el producto.
/// </summary>
public sealed record AddProductPriceResponse(
ProductPriceDto Created,
ProductPriceDto? Closed);

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Products.Prices.GetHistory;
/// <summary>
/// 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.
/// </summary>
public sealed record GetProductPricesQuery(int ProductId);

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
public sealed class GetProductPricesQueryHandler
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
{
private readonly IProductPriceRepository _pricesRepo;
private readonly IProductRepository _productsRepo;
public GetProductPricesQueryHandler(
IProductPriceRepository pricesRepo,
IProductRepository productsRepo)
{
_pricesRepo = pricesRepo;
_productsRepo = productsRepo;
}
public async Task<IReadOnlyList<ProductPriceDto>> 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();
}
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.Prices;
/// <summary>
/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
/// </summary>
public sealed record ProductPriceDto(
long Id,
int ProductId,
decimal Price,
DateOnly PriceValidFrom,
DateOnly? PriceValidTo,
bool IsActive);

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Application.Products.Pricing;
/// <summary>
/// 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).
/// </summary>
public interface IProductPricingService
{
/// <summary>
/// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
/// o null si ningún registro de precio cubre esa fecha.
/// </summary>
Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
}

View File

@@ -0,0 +1,24 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Products.Pricing;
/// <summary>
/// PRD-003 — Implementación de IProductPricingService.
/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil.
/// </summary>
public sealed class ProductPricingService : IProductPricingService
{
private readonly IProductPriceRepository _repo;
public ProductPricingService(IProductPriceRepository repo)
{
_repo = repo;
}
/// <inheritdoc />
public async Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default)
{
var price = await _repo.GetActiveAsync(productId, date, ct);
return price?.Price;
}
}

View File

@@ -0,0 +1,25 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// 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.
/// </summary>
public sealed record ProductPrice(
long Id,
int ProductId,
decimal Price,
DateOnly PriceValidFrom,
DateOnly? PriceValidTo,
DateTime FechaCreacion)
{
/// <summary>True if this row is the currently active price (PriceValidTo is null).</summary>
public bool IsActive => PriceValidTo is null;
/// <summary>
/// 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.
/// </summary>
public bool CoversDate(DateOnly date)
=> PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date);
}

View File

@@ -0,0 +1,23 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly
/// greater than the currently active price's PriceValidFrom. → HTTP 409
/// </summary>
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;
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a ProductPrice value fails domain business validation
/// (e.g., Price &lt;= 0, PriceValidFrom in the past). → HTTP 400
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View File

@@ -43,6 +43,8 @@ public static class DependencyInjection
services.AddScoped<IProductRepository, ProductRepository>(); services.AddScoped<IProductRepository, ProductRepository>();
// PRD-002: replaces NullProductQueryRepository from Application DI // PRD-002: replaces NullProductQueryRepository from Application DI
services.AddScoped<IProductQueryRepository, ProductQueryRepository>(); services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
// PRD-003: ProductPrices históricos
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt")); services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class ProductPriceRepository : IProductPriceRepository
{
private readonly SqlConnectionFactory _factory;
public ProductPriceRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
/// <inheritdoc/>
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<long>("@NewId");
var closedId = p.Get<long?>("@ClosedId");
return (newId, closedId);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ProductPrice>> 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<ProductPriceRow>(
new CommandDefinition(sql, new { ProductId = productId }, cancellationToken: ct));
return rows.Select(MapRow).ToList();
}
/// <inheritdoc/>
public async Task<ProductPrice?> 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<ProductPriceRow>(
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);
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
import type { AddProductPriceRequest, AddProductPriceResponse } from '../types'
export async function addProductPrice(
productId: number,
payload: AddProductPriceRequest,
): Promise<AddProductPriceResponse> {
const res = await axiosClient.post<AddProductPriceResponse>(
`/api/v1/admin/products/${productId}/prices`,
payload,
)
return res.data
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { ProductPrice } from '../types'
export async function getProductPrices(productId: number): Promise<ProductPrice[]> {
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`)
return res.data
}

View File

@@ -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<typeof addPriceSchema>
// ─── 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<AddPriceFormRaw>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Programar nuevo precio</DialogTitle>
<DialogDescription>
Ingresá el nuevo precio y la fecha desde la que estará vigente. El
precio actual quedará cerrado.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
)}
className="space-y-4"
noValidate
>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
{/* Precio */}
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Precio</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.01"
min="0.01"
placeholder="0.00"
aria-label="Precio"
disabled={mutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Vigente desde */}
<FormField
control={form.control}
name="priceValidFrom"
render={({ field }) => (
<FormItem>
<FormLabel>Vigente desde</FormLabel>
<FormControl>
<Input
{...field}
type="date"
min={today}
aria-label="Vigente desde"
disabled={mutation.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
)
}
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Error al cargar precios del producto.</AlertDescription>
</Alert>
)
}
const isEmpty = !prices?.length
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Historial de precios</h2>
<CanPerform permission="catalogo:productos:gestionar">
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Programar nuevo precio
</Button>
</CanPerform>
</div>
{isEmpty ? (
<div className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
<p>Sin historial de precios. Este producto no tiene precios registrados.</p>
<CanPerform permission="catalogo:productos:gestionar">
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Programar nuevo precio
</Button>
</CanPerform>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Desde</TableHead>
<TableHead>Hasta</TableHead>
<TableHead>Precio</TableHead>
<TableHead>Estado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{prices.map((p) => (
<TableRow key={p.id}>
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
<TableCell>
{p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'}
</TableCell>
<TableCell>{formatCurrency(p.price)}</TableCell>
<TableCell>
{p.isActive ? (
<Badge variant="default">Vigente</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<AddProductPriceDialog
open={addOpen}
onOpenChange={setAddOpen}
productId={productId}
/>
</div>
)
}

View File

@@ -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'] })
},
})
}

View File

@@ -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,
})
}

View File

@@ -6,4 +6,7 @@ export type {
UpdateProductRequest, UpdateProductRequest,
PagedResult, PagedResult,
ListProductsParams, ListProductsParams,
ProductPrice,
AddProductPriceRequest,
AddProductPriceResponse,
} from './types' } from './types'

View File

@@ -5,11 +5,18 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CanPerform } from '@/components/auth/CanPerform' import { CanPerform } from '@/components/auth/CanPerform'
import { useProducts } from '../hooks/useProducts' import { useProducts } from '../hooks/useProducts'
import { useDeactivateProduct } from '../hooks/useDeactivateProduct' import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
import { ProductFormDialog } from '../components/ProductFormDialog' import { ProductFormDialog } from '../components/ProductFormDialog'
import { DeactivateProductDialog } from '../components/DeactivateProductDialog' import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
import { ProductPriceHistory } from '../components/ProductPriceHistory'
import type { ProductListItem, ProductDetail } from '../types' import type { ProductListItem, ProductDetail } from '../types'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -26,6 +33,11 @@ export function ProductsPage() {
const [deactivateOpen, setDeactivateOpen] = useState(false) const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null) const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
// ── Prices dialog state (PRD-003) ────────────────────────────────────────
const [pricesOpen, setPricesOpen] = useState(false)
const [pricesProductId, setPricesProductId] = useState<number | null>(null)
const [pricesProductName, setPricesProductName] = useState<string>('')
// ── Pagination & filter state ──────────────────────────────────────────── // ── Pagination & filter state ────────────────────────────────────────────
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined) const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
@@ -59,6 +71,12 @@ export function ProductsPage() {
setDeactivateOpen(true) setDeactivateOpen(true)
} }
function openPrices(p: ProductListItem) {
setPricesProductId(p.id)
setPricesProductName(p.nombre)
setPricesOpen(true)
}
async function handleDeactivate(id: number) { async function handleDeactivate(id: number) {
await deactivateProduct(id) await deactivateProduct(id)
toast.success('Producto desactivado') toast.success('Producto desactivado')
@@ -153,8 +171,16 @@ export function ProductsPage() {
</span> </span>
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<CanPerform permission="catalogo:productos:gestionar"> <div className="flex gap-1">
<div className="flex gap-1"> <Button
variant="ghost"
size="sm"
onClick={() => openPrices(p)}
aria-label={`Ver precios de ${p.nombre}`}
>
Ver precios
</Button>
<CanPerform permission="catalogo:productos:gestionar">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -170,8 +196,8 @@ export function ProductsPage() {
> >
Desactivar Desactivar
</Button> </Button>
</div> </CanPerform>
</CanPerform> </div>
</td> </td>
</tr> </tr>
))} ))}
@@ -231,6 +257,18 @@ export function ProductsPage() {
onConfirm={handleDeactivate} onConfirm={handleDeactivate}
/> />
)} )}
{/* Prices history dialog (PRD-003) */}
{pricesProductId !== null && (
<Dialog open={pricesOpen} onOpenChange={setPricesOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Precios {pricesProductName}</DialogTitle>
</DialogHeader>
<ProductPriceHistory productId={pricesProductId} />
</DialogContent>
</Dialog>
)}
</div> </div>
) )
} }

View File

@@ -56,3 +56,24 @@ export interface ListProductsParams {
productTypeId?: number productTypeId?: number
rubroId?: 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
}

View File

@@ -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)
}

View File

@@ -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(
<QueryClientProvider client={qc}>
<AddProductPriceDialog
open={true}
onOpenChange={onOpenChange}
productId={1}
/>
</QueryClientProvider>,
),
}
}
// ─── 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 },
)
})
})

View File

@@ -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(
<QueryClientProvider client={qc}>
<ProductPriceHistory productId={productId} />
</QueryClientProvider>,
)
}
// ─── 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()
})
})

View File

@@ -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))
})
})

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<int>(
"SELECT TOP 1 Id FROM dbo.Medio WHERE Activo = 1 ORDER BY Id");
var ptId = await conn.QuerySingleOrDefaultAsync<int?>(
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
if (ptId is null)
{
ptId = await conn.QuerySingleAsync<int>("""
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<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = productId });
var auditCountBefore = await conn.ExecuteScalarAsync<int>(
"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<IAuditLogger>();
throwingAuditLogger
.LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>())
.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<IAuditLogger>();
services.AddScoped<IAuditLogger>(_ => throwingAuditLogger);
});
// Generate admin token (use factory services, not child)
var jwt = _factory.Services.GetRequiredService<IJwtService>();
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<int>(
"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<int>(
"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<int> SeedProductAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var nombre = $"PP_AF_{Guid.NewGuid():N}"[..35];
return await conn.QuerySingleAsync<int>("""
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 });
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<int>(
"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<int?>(
"SELECT TOP 1 Id FROM dbo.ProductType WHERE IsActive = 1 ORDER BY Id");
if (ptId is null)
{
ptId = await conn.QuerySingleAsync<int>("""
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 ──────────────────────────────────────────────────────────
/// <summary>Generates a bearer token for admin (has catalogo:productos:gestionar via 'admin' role).</summary>
private string GetAdminToken()
{
var jwt = _factory.Services.GetRequiredService<IJwtService>();
return jwt.GenerateAccessToken(new Usuario(
id: 1, username: "admin", passwordHash: "x",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: """{"grant":[],"deny":[]}""", activo: true));
}
/// <summary>Generates a bearer token for cajero (does NOT have catalogo:productos:gestionar).</summary>
private string GetCajeroToken()
{
var jwt = _factory.Services.GetRequiredService<IJwtService>();
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 ───────────────────────────────────────────────────────
/// <summary>Seeds a unique product and returns its Id.</summary>
private async Task<int> 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<int>("""
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 });
}
/// <summary>
/// Inserts a ProductPrice row directly (bypasses SP forward-only guard) to set up test scenarios.
/// </summary>
private async Task<long> SeedPriceDirectAsync(
int productId, decimal price, DateOnly pvf, DateOnly? pvt)
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
return await conn.QuerySingleAsync<long>("""
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<JsonElement>();
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<int>("""
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<JsonElement>();
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);
}
}

View File

@@ -0,0 +1,128 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Domain;
/// <summary>
/// PRD-003 — Domain unit tests for ProductPrice-related exceptions.
/// Verifies constructor props, message content, and DomainException inheritance.
/// </summary>
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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
// ── 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<DomainException>();
}
}

View File

@@ -0,0 +1,158 @@
using FluentAssertions;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
/// <summary>
/// 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).
/// </summary>
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.");
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class AddProductPriceCommandHandlerTests
{
private readonly IProductPriceRepository _pricesRepo = Substitute.For<IProductPriceRepository>();
private readonly IProductRepository _productsRepo = Substitute.For<IProductRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
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<CancellationToken>())
.Returns(ActiveProduct(1));
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((10L, (long?)null));
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ProductPrice>
{
MakePrice(10, 1, 150m, Today)
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
_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<CancellationToken>());
}
[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<object?>(),
ct: Arg.Any<CancellationToken>());
}
[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<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((20L, (long?)5L)); // newId=20, closedId=5
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ProductPrice>
{
MakePrice(20, 1, 200m, Tomorrow),
MakePrice(5, 1, 150m, Today, pvt: Tomorrow.AddDays(-1))
}.AsReadOnly() as IReadOnlyList<ProductPrice>);
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<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(ValidCmd(productId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
}
[Fact]
public async Task Handle_ProductNotFound_RepoAddAsync_NotCalled()
{
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(ValidCmd(productId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
await _pricesRepo.DidNotReceive().AddAsync(
Arg.Any<int>(), Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>());
}
// ── 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<CancellationToken>())
.Returns(inactiveProduct);
var act = async () => await _handler.Handle(ValidCmd(productId: 2));
await act.Should().ThrowAsync<ProductNotFoundException>();
}
// ── 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<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>();
}
[Fact]
public async Task Handle_RepoThrowsForwardOnlyException_AuditNotCalled()
{
_pricesRepo.AddAsync(1, Arg.Any<decimal>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new ProductPriceForwardOnlyException(1, Today, Today));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductPriceForwardOnlyException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── 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<string>(),
targetType: Arg.Any<string>(),
targetId: Arg.Any<string>(),
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Audit DB error"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Audit DB error");
}
}

View File

@@ -0,0 +1,115 @@
using FluentValidation.TestHelper;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Application.Products.Prices.AddPrice;
namespace SIGCM2.Application.Tests.Products.Prices;
/// <summary>
/// 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.
/// </summary>
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);
}

View File

@@ -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;
/// <summary>
/// PRD-003 — GetProductPricesQueryHandler tests.
/// Covers: §REQ-4.1 (historial descending), §REQ-4.3 (lista vacía), §REQ-3.3 (producto no existe → 404).
/// </summary>
public class GetProductPricesQueryHandlerTests
{
private readonly IProductPriceRepository _pricesRepo = Substitute.For<IProductPriceRepository>();
private readonly IProductRepository _productsRepo = Substitute.For<IProductRepository>();
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<CancellationToken>())
.Returns(ActiveProduct());
// default: lista con 2 precios, el repo ya los devuelve descending (responsabilidad del repo)
_pricesRepo.GetByProductIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ProductPrice>
{
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<ProductPrice>);
_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<CancellationToken>())
.Returns(new List<ProductPrice>().AsReadOnly() as IReadOnlyList<ProductPrice>);
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<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
}
[Fact]
public async Task Handle_ProductNotFound_RepoGetByProductId_NotCalled()
{
_productsRepo.GetByIdAsync(99, Arg.Any<CancellationToken>())
.Returns((Product?)null);
var act = async () => await _handler.Handle(new GetProductPricesQuery(ProductId: 99));
await act.Should().ThrowAsync<ProductNotFoundException>();
await _pricesRepo.DidNotReceive()
.GetByProductIdAsync(99, Arg.Any<CancellationToken>());
}
}

View File

@@ -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;
/// <summary>
/// PRD-003 — ProductPricingService tests.
/// Covers: GetPriceAtAsync happy path, fecha sin precio → null (OQ-B contrato).
/// </summary>
public class ProductPricingServiceTests
{
private readonly IProductPriceRepository _repo = Substitute.For<IProductPriceRepository>();
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<CancellationToken>())
.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<CancellationToken>())
.Returns((ProductPrice?)null);
var result = await _service.GetPriceAtAsync(1, QueryDate);
result.Should().BeNull();
}
[Fact]
public async Task GetPriceAtAsync_CallsGetActiveAsync_WithCorrectArgs()
{
_repo.GetActiveAsync(Arg.Any<int>(), Arg.Any<DateOnly>(), Arg.Any<CancellationToken>())
.Returns((ProductPrice?)null);
await _service.GetPriceAtAsync(productId: 5, date: QueryDate);
await _repo.Received(1).GetActiveAsync(5, QueryDate, Arg.Any<CancellationToken>());
}
[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<CancellationToken>())
.Returns((ProductPrice?)null);
var result = await _service.GetPriceAtAsync(1, futureDate);
result.Should().BeNull();
}
}

View File

@@ -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;
/// <summary>
/// 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
/// </summary>
[Collection("Database")]
public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
{
private readonly SqlTestFixture _db;
private int _defaultProductId;
private int _defaultMedioId;
private int _defaultProductTypeId;
public ProductPriceRepositoryIntegrationTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
await _db.ResetAndSeedAsync();
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Ensure V019 schema is present (table + SP + indexes).
await EnsureV019SchemaAsync(conn);
// Create a Medio, ProductType, and an active Product for use in all tests.
_defaultMedioId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
OUTPUT INSERTED.Id
VALUES ('PP', 'Medio ProductPrices', 1, 1)
""");
_defaultProductTypeId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
OUTPUT INSERTED.Id
VALUES ('Tipo PP', 0, 0, 0, 0, 0)
""");
_defaultProductId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays, IsActive)
OUTPUT INSERTED.Id
VALUES ('Producto Precios Test', @MedioId, @ProductTypeId, NULL, 100.00, NULL, 1)
""", new { MedioId = _defaultMedioId, ProductTypeId = _defaultProductTypeId });
}
public Task DisposeAsync() => Task.CompletedTask;
// ── Helper: invoke SP directly ────────────────────────────────────────────
private static async Task<(long NewId, long? ClosedId)> ExecAddPriceSpAsync(
SqlConnection conn,
int productId,
decimal price,
DateOnly priceValidFrom)
{
var p = new DynamicParameters();
p.Add("@ProductId", productId);
p.Add("@Price", price);
p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), System.Data.DbType.Date);
p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
await conn.ExecuteAsync("dbo.usp_AddProductPrice", p,
commandType: System.Data.CommandType.StoredProcedure);
var newId = p.Get<long>("@NewId");
var closedId = p.Get<long?>("@ClosedId");
return (newId, closedId);
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-1.1 — Primer precio: ClosedId es null
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task FirstPrice_NoActiveToClose_ClosedIdIsNull()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var pvf = new DateOnly(2026, 4, 19);
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 150.00m, pvf);
newId.Should().BeGreaterThan(0);
closedId.Should().BeNull();
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(
"SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
new { Id = newId });
((object?)row).Should().NotBeNull();
((int)row!.ProductId).Should().Be(_defaultProductId);
((decimal)row.Price).Should().Be(150.00m);
((DateTime)row.PriceValidFrom).Should().Be(new DateTime(2026, 4, 19));
((object?)row.PriceValidTo).Should().BeNull();
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-1.1 — Happy path: cierra activo, inserta nuevo
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task HappyPath_ClosesActivePriceAndInsertsNew()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Primera inserción
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Segunda inserción (forward)
var pvf2 = new DateOnly(2026, 4, 20);
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, pvf2);
// El nuevo es activo (PVT = NULL)
var newRow = await conn.QuerySingleAsync<dynamic>(
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = newId });
((object?)newRow.PriceValidTo).Should().BeNull();
// El anterior está cerrado con PVT = pvf2 - 1 día
closedId.Should().Be(firstId);
var closedRow = await conn.QuerySingleAsync<dynamic>(
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = firstId });
((DateTime)closedRow.PriceValidTo).Should().Be(new DateTime(2026, 4, 19));
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-2.2 — ForwardOnly: fecha retroactiva → THROW 50409
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task ForwardOnly_RetroactiveDate_ThrowsSql50409()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Precio activo desde 2026-04-01
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Intento retroactivo
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 90.00m, new DateOnly(2026, 3, 15));
await act.Should().ThrowAsync<SqlException>()
.Where(ex => ex.Number == 50409);
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-2.3 — ForwardOnly: misma fecha → THROW 50409
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task ForwardOnly_EqualPriceValidFrom_ThrowsSql50409()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var pvf = new DateOnly(2026, 4, 19);
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, pvf);
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 120.00m, pvf);
await act.Should().ThrowAsync<SqlException>()
.Where(ex => ex.Number == 50409);
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-3.3 — Producto inactivo → THROW 50404
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task InactiveProduct_ThrowsSql50404()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Desactivar el producto
await conn.ExecuteAsync(
"UPDATE dbo.Product SET IsActive = 0 WHERE Id = @Id",
new { Id = _defaultProductId });
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 19));
await act.Should().ThrowAsync<SqlException>()
.Where(ex => ex.Number == 50404);
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-3.3 — Producto inexistente → THROW 50404
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task NonExistentProduct_ThrowsSql50404()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var act = async () => await ExecAddPriceSpAsync(conn, 999999, 100.00m, new DateOnly(2026, 4, 19));
await act.Should().ThrowAsync<SqlException>()
.Where(ex => ex.Number == 50404);
}
// ─────────────────────────────────────────────────────────────────────────
// §REQ-1.2 — Concurrencia: solo un ganador, el perdedor lanza excepción
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Concurrency_OnlyOneSucceeds_OneThrows()
{
// Ambas conexiones intentan agregar precio al mismo producto simultáneamente.
// Con SERIALIZABLE + UPDLOCK, exactamente una debe tener éxito.
var pvf = new DateOnly(2026, 5, 1);
var task1 = Task.Run(async () =>
{
await using var conn1 = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn1.OpenAsync();
return await ExecAddPriceSpAsync(conn1, _defaultProductId, 111.00m, pvf);
});
var task2 = Task.Run(async () =>
{
await using var conn2 = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn2.OpenAsync();
return await ExecAddPriceSpAsync(conn2, _defaultProductId, 222.00m, pvf);
});
// Exactamente una debe lanzar (50409, 2601, 2627, o deadlock 1205)
Exception? caughtEx = null;
try
{
await Task.WhenAll(task1, task2);
}
catch (SqlException ex)
{
caughtEx = ex;
}
catch (AggregateException aex) when (aex.InnerExceptions.All(e => e is SqlException))
{
caughtEx = aex;
}
caughtEx.Should().NotBeNull("one concurrent insert must fail");
// Exactamente 1 activo debe existir (PriceValidTo IS NULL)
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await verifyConn.OpenAsync();
var activeCount = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL",
new { ProductId = _defaultProductId });
activeCount.Should().Be(1, "only one active price must survive concurrency");
}
// ─────────────────────────────────────────────────────────────────────────
// SYSTEM_VERSIONING: UPDATE del activo produce row en history
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Cierra el activo con una segunda inserción
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
// dbo.ProductPrices_History debe tener al menos 1 row para el ID cerrado
var histCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
new { Id = firstId });
histCount.Should().BeGreaterThanOrEqualTo(1,
"SYSTEM_VERSIONING must produce a history row when the active price is closed");
}
// ─────────────────────────────────────────────────────────────────────────
// Filtered unique index: two simultaneous direct INSERTs → 2601/2627
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task FilteredUniqueIndex_DirectDuplicateActiveInsert_ThrowsUniqueViolation()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// Insert an active price directly (bypassing SP to force a duplicate)
const string insertSql = """
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
VALUES (@ProductId, @Price, @PriceValidFrom, NULL)
""";
await conn.ExecuteAsync(insertSql, new
{
ProductId = _defaultProductId,
Price = 100m,
PriceValidFrom = new DateTime(2026, 4, 1)
});
var act = async () => await conn.ExecuteAsync(insertSql, new
{
ProductId = _defaultProductId,
Price = 150m,
PriceValidFrom = new DateTime(2026, 4, 10)
});
await act.Should().ThrowAsync<SqlException>()
.Where(ex => ex.Number == 2601 || ex.Number == 2627,
"filtered unique index UX_ProductPrices_Active must prevent two NULL PriceValidTo for same ProductId");
}
// ─────────────────────────────────────────────────────────────────────────
// 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<ProductPriceForwardOnlyException>(
"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<ProductNotFoundException>(
"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<ProductPriceForwardOnlyException>(
"retroactive date behind existing active row triggers SP 50409 → repository maps to ProductPriceForwardOnlyException");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.1: Concurrency con 3 tasks + SemaphoreSlim barrier
// §REQ-1.2 — Exactamente 1 ganador, 2 perdedores lanzan excepción manejable
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Concurrency_ThreeConcurrentInserts_ExactlyOneSucceeds()
{
// Barrera: todos esperan en el semáforo; cuando se liberan juntos, la race es auténtica.
var barrier = new SemaphoreSlim(0, 3);
var pvf = new DateOnly(2027, 6, 1);
async Task<Exception?> TryInsert(decimal price)
{
// Cada task espera en la barrera antes de ejecutar
await barrier.WaitAsync();
try
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
await ExecAddPriceSpAsync(conn, _defaultProductId, price, pvf);
return null; // éxito
}
catch (SqlException ex)
{
return ex; // perdedor: 50409, 2601, 2627 o deadlock 1205
}
}
var t1 = Task.Run(() => TryInsert(111.00m));
var t2 = Task.Run(() => TryInsert(222.00m));
var t3 = Task.Run(() => TryInsert(333.00m));
// Liberar las 3 tasks simultáneamente
barrier.Release(3);
var results = await Task.WhenAll(t1, t2, t3);
// Exactamente 1 éxito (null), exactamente 2 fallos
var successes = results.Count(r => r is null);
var failures = results.Count(r => r is not null);
successes.Should().Be(1, "exactly one concurrent insert must succeed");
failures.Should().Be(2, "the other two must fail with a SqlException");
// Verificar que el estado final es exactamente 1 activo (PriceValidTo IS NULL)
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await verifyConn.OpenAsync();
var activeCount = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL",
new { ProductId = _defaultProductId });
activeCount.Should().Be(1, "only one active price (PriceValidTo IS NULL) must survive the race");
// Sin duplicados: COUNT(*) para este producto debe ser 1 (solo la ganadora)
var totalCount = await verifyConn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId",
new { ProductId = _defaultProductId });
totalCount.Should().Be(1, "no duplicate rows must exist for the same ProductId");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.2: SYSTEM_VERSIONING — no history before close, 1 row after
// Verifica que el SP produce exactamente 1 row en dbo.ProductPrices_History
// al cerrar el activo, y que antes del cierre la tabla está vacía para ese Id.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task SystemVersioning_BeforeClose_HistoryTableIsEmpty()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Antes del UPDATE (cierre), history debe estar vacía para este Id
var histCountBefore = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
new { Id = firstId });
histCountBefore.Should().Be(0,
"SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE, not on INSERT");
}
[Fact]
public async Task SystemVersioning_AfterClose_ExactlyOneHistoryRow()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Cierra el activo con una segunda inserción más futura
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
// dbo.ProductPrices_History debe tener exactamente 1 row para el Id cerrado
var histCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
new { Id = firstId });
histCount.Should().Be(1,
"SYSTEM_VERSIONING must produce exactly one history row when the active price is closed via UPDATE");
// El row activo en dbo.ProductPrices debe tener PriceValidTo <> NULL
var pvt = await conn.ExecuteScalarAsync<DateTime?>(
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
new { Id = firstId });
pvt.Should().NotBeNull("the closed row in dbo.ProductPrices must have PriceValidTo set");
pvt!.Value.Date.Should().Be(new DateTime(2026, 4, 19),
"PriceValidTo = new PVF - 1 day = 2026-04-19");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.3: FOR SYSTEM_TIME AS OF — snapshot temporal
// Verifica que la history table preserva el estado del activo en el instante
// pre-cierre y que la query temporal devuelve el precio correcto.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0()
{
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
// T0: insertar precio1 y capturar el instante UTC antes de cerrarlo
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
// Capturar T0 inmediatamente después del INSERT (SYSTEM_VERSIONING usa DATETIME2 UTC)
var t0 = await conn.ExecuteScalarAsync<DateTime>(
"SELECT SYSUTCDATETIME()");
// Esperar 200ms para que DATETIME2(3) avance y el registro de history tenga un rango claro
await Task.Delay(200);
// Insertar precio2 que cierra precio1 — esto produce el row en history
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 5, 1));
// T7.3.a — Query FOR SYSTEM_TIME AS OF T0: debe devolver precio1 con PriceValidTo = NULL
// (estado del registro tal como estaba en T0, antes del cierre)
var snapshotRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
"""
SELECT Id, Price, PriceValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME AS OF @T0
WHERE ProductId = @ProductId
AND Id = @Id
""",
new { T0 = t0, ProductId = _defaultProductId, Id = firstId });
((object?)snapshotRow).Should().NotBeNull(
"FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before close)");
((decimal)snapshotRow!.Price).Should().Be(100.00m);
((object?)snapshotRow.PriceValidTo).Should().BeNull(
"at T0 the row was still active (PriceValidTo IS NULL)");
// T7.3.b — Query actual (sin FOR SYSTEM_TIME): precio1 debe tener PriceValidTo != NULL
var currentRow = await conn.QuerySingleOrDefaultAsync<dynamic>(
"SELECT Id, Price, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
new { Id = firstId });
((object?)currentRow).Should().NotBeNull();
((object?)currentRow!.PriceValidTo).Should().NotBeNull(
"in current state, the first price is closed (PriceValidTo IS NOT NULL)");
}
// ─────────────────────────────────────────────────────────────────────────
// Batch 7 — T7.5: GetActiveAsync boundary cases (ventanas civiles inclusivas)
// §REQ-4.4 — Inclusive en ambos extremos (PVF y PVT)
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetActiveAsync_BeforeFirstPrice_ReturnsNull()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31] (cerrado)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2025, 12, 31));
result.Should().BeNull("date is before the first PriceValidFrom");
}
[Fact]
public async Task GetActiveAsync_ExactMatchPvf_ReturnsPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// Exact match en PVF
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 1, 1));
result.Should().NotBeNull("date equals PriceValidFrom → inclusive lower bound");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_MiddleOfRange_ReturnsPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 2, 15));
result.Should().NotBeNull("date is in the middle of precio1 window");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_ExactMatchPvt_ReturnsClosedPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31] — PVT=2026-03-31 (día anterior a 2026-04-01)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// Exact match en PVT del precio1 → inclusive upper bound
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 3, 31));
result.Should().NotBeNull("date equals PriceValidTo → inclusive upper bound");
result!.Price.Should().Be(100.00m);
}
[Fact]
public async Task GetActiveAsync_ExactMatchNextPvf_ReturnsNextPrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Precio1: [2026-01-01 .. 2026-03-31], Precio2: [2026-04-01 ..]
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 100.00m, new DateOnly(2026, 1, 1));
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 150.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
// PVF del precio2 → debe devolver precio2
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2026, 4, 1));
result.Should().NotBeNull("date equals PriceValidFrom of precio2 → inclusive lower bound");
result!.Price.Should().Be(150.00m);
}
[Fact]
public async Task GetActiveAsync_FarFuture_ReturnsActivePrice()
{
await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
await seedConn.OpenAsync();
// Solo precio activo desde 2026-04-01 (PriceValidTo = NULL)
await ExecAddPriceSpAsync(seedConn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 1));
var repo = BuildRepository();
var result = await repo.GetActiveAsync(_defaultProductId, new DateOnly(2099, 12, 31));
result.Should().NotBeNull("far-future date should return the open-ended active price");
result!.Price.Should().Be(200.00m);
result.IsActive.Should().BeTrue();
result.PriceValidTo.Should().BeNull();
}
// ── Helper: build a real ProductPriceRepository using the test DB ─────────
private static ProductPriceRepository BuildRepository()
=> 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<int>("""
SELECT COUNT(1) FROM sys.tables
WHERE object_id = OBJECT_ID(N'dbo.ProductPrices', N'U')
""");
if (tableExists == 0)
throw new InvalidOperationException(
"dbo.ProductPrices does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
var spExists = await conn.ExecuteScalarAsync<int>("""
SELECT COUNT(1) FROM sys.objects
WHERE object_id = OBJECT_ID(N'dbo.usp_AddProductPrice', N'P')
""");
if (spExists == 0)
throw new InvalidOperationException(
"dbo.usp_AddProductPrice does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
}
}

View File

@@ -66,6 +66,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'. // V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
await EnsureV018SchemaAsync(); await EnsureV018SchemaAsync();
// V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice.
await EnsureV019SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{ {
DbAdapter = DbAdapter.SqlServer, DbAdapter = DbAdapter.SqlServer,
@@ -96,6 +99,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "ProductType_History"), new Respawn.Graph.Table("dbo", "ProductType_History"),
// PRD-002 (V018): Product es temporal — history no puede deletearse directo. // PRD-002 (V018): Product es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Product_History"), 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(createMedioIdx);
await _connection.ExecuteAsync(createRubroIdx); await _connection.ExecuteAsync(createRubroIdx);
} }
/// <summary>
/// PRD-003 (V019): applies dbo.ProductPrices + SYSTEM_VERSIONING + indexes + SP usp_AddProductPrice
/// idempotently to the test database. Mirrors V019__create_product_prices.sql.
/// </summary>
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);
}
} }