Compare commits
8 Commits
e735afb5b4
...
dd0e5e4fe8
| Author | SHA1 | Date | |
|---|---|---|---|
| dd0e5e4fe8 | |||
| 7cabb677f3 | |||
| 6a9818b0ae | |||
| f6f24bc4be | |||
| 2d2e90fa3c | |||
| 4b0567d252 | |||
| 54b0265994 | |||
| 59f30cddfb |
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;
|
||||
break;
|
||||
|
||||
// PRD-003: ProductPrices exceptions
|
||||
case ProductPriceForwardOnlyException forwardOnlyEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_forward_only",
|
||||
message = forwardOnlyEx.Message,
|
||||
productId = forwardOnlyEx.ProductId
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductPriceInvalidException priceInvalidEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_invalid",
|
||||
message = priceInvalidEx.Message,
|
||||
field = priceInvalidEx.Field
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductSinPrecioActivoException sinPrecioEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_sin_precio_activo",
|
||||
message = sinPrecioEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-002: Product exceptions
|
||||
case ProductNotFoundException productNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -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.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
using SIGCM2.Application.Products.Pricing;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
@@ -182,6 +186,11 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||
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)
|
||||
// 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>();
|
||||
// PRD-002: replaces NullProductQueryRepository from Application DI
|
||||
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
|
||||
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,
|
||||
PagedResult,
|
||||
ListProductsParams,
|
||||
ProductPrice,
|
||||
AddProductPriceRequest,
|
||||
AddProductPriceResponse,
|
||||
} from './types'
|
||||
|
||||
@@ -5,11 +5,18 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { useProducts } from '../hooks/useProducts'
|
||||
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
|
||||
import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||
import { ProductPriceHistory } from '../components/ProductPriceHistory'
|
||||
import type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -26,6 +33,11 @@ export function ProductsPage() {
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<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 ────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
|
||||
@@ -59,6 +71,12 @@ export function ProductsPage() {
|
||||
setDeactivateOpen(true)
|
||||
}
|
||||
|
||||
function openPrices(p: ProductListItem) {
|
||||
setPricesProductId(p.id)
|
||||
setPricesProductName(p.nombre)
|
||||
setPricesOpen(true)
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: number) {
|
||||
await deactivateProduct(id)
|
||||
toast.success('Producto desactivado')
|
||||
@@ -153,8 +171,16 @@ export function ProductsPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -170,8 +196,8 @@ export function ProductsPage() {
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -231,6 +257,18 @@ export function ProductsPage() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,3 +56,24 @@ export interface ListProductsParams {
|
||||
productTypeId?: number
|
||||
rubroId?: number
|
||||
}
|
||||
|
||||
// PRD-003 — ProductPrices históricos
|
||||
|
||||
export interface ProductPrice {
|
||||
id: number
|
||||
productId: number
|
||||
price: number
|
||||
priceValidFrom: string // "yyyy-MM-dd" (Cat2)
|
||||
priceValidTo: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface AddProductPriceRequest {
|
||||
price: number
|
||||
priceValidFrom: string // "yyyy-MM-dd"
|
||||
}
|
||||
|
||||
export interface AddProductPriceResponse {
|
||||
created: ProductPrice
|
||||
closed: ProductPrice | null
|
||||
}
|
||||
|
||||
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'.
|
||||
await EnsureV018SchemaAsync();
|
||||
|
||||
// V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice.
|
||||
await EnsureV019SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -96,6 +99,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
|
||||
new Respawn.Graph.Table("dbo", "Product_History"),
|
||||
// PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING.
|
||||
new Respawn.Graph.Table("dbo", "ProductPrices_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1122,4 +1127,144 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(createMedioIdx);
|
||||
await _connection.ExecuteAsync(createRubroIdx);
|
||||
}
|
||||
|
||||
/// <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