feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45
71
database/migrations/V019_ROLLBACK.sql
Normal file
71
database/migrations/V019_ROLLBACK.sql
Normal 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
|
||||||
196
database/migrations/V019__create_product_prices.sql
Normal file
196
database/migrations/V019__create_product_prices.sql
Normal 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
|
||||||
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal 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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/api/SIGCM2.Domain/Entities/ProductPrice.cs
Normal file
25
src/api/SIGCM2.Domain/Entities/ProductPrice.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a ProductPrice value fails domain business validation
|
||||||
|
/// (e.g., Price <= 0, PriceValidFrom in the past). → HTTP 400
|
||||||
|
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
13
src/web/src/features/products/api/addProductPrice.ts
Normal file
13
src/web/src/features/products/api/addProductPrice.ts
Normal 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
|
||||||
|
}
|
||||||
7
src/web/src/features/products/api/getProductPrices.ts
Normal file
7
src/web/src/features/products/api/getProductPrices.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/web/src/features/products/components/ProductPriceHistory.tsx
Normal file
114
src/web/src/features/products/components/ProductPriceHistory.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/products/hooks/useAddProductPrice.ts
Normal file
13
src/web/src/features/products/hooks/useAddProductPrice.ts
Normal 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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/web/src/features/products/hooks/useProductPrices.ts
Normal file
11
src/web/src/features/products/hooks/useProductPrices.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,4 +6,7 @@ export type {
|
|||||||
UpdateProductRequest,
|
UpdateProductRequest,
|
||||||
PagedResult,
|
PagedResult,
|
||||||
ListProductsParams,
|
ListProductsParams,
|
||||||
|
ProductPrice,
|
||||||
|
AddProductPriceRequest,
|
||||||
|
AddProductPriceResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
17
src/web/src/lib/numberFormat.ts
Normal file
17
src/web/src/lib/numberFormat.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
199
src/web/src/tests/features/products/ProductPriceHistory.test.tsx
Normal file
199
src/web/src/tests/features/products/ProductPriceHistory.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
104
src/web/src/tests/features/products/productPrices.hooks.test.ts
Normal file
104
src/web/src/tests/features/products/productPrices.hooks.test.ts
Normal 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))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
450
tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs
Normal file
450
tests/SIGCM2.Api.Tests/Products/ProductPricesControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
158
tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
Normal file
158
tests/SIGCM2.Application.Tests/Domain/ProductPriceTests.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user