Merge pull request 'feat: PRD-002 Product CRUD' (#40) from feature/PRD-002 into main
This commit was merged in pull request #40.
This commit is contained in:
67
database/migrations/V018_ROLLBACK.sql
Normal file
67
database/migrations/V018_ROLLBACK.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- V018_ROLLBACK.sql
|
||||
-- Reversa de V018__create_product.sql — PRD-002.
|
||||
--
|
||||
-- Idempotente: cada paso usa IF EXISTS guards.
|
||||
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. SYSTEM_VERSIONING OFF
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. DROP PERIOD
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Drop HIDDEN columns + default constraints
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
|
||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
|
||||
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 4. Drop history
|
||||
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Product_History;
|
||||
PRINT 'Table dbo.Product_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 5. Drop main
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Product;
|
||||
PRINT 'Table dbo.Product dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 6. Remove RolPermiso / Permiso
|
||||
DELETE rp FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'catalogo:productos:gestionar';
|
||||
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
|
||||
PRINT 'Permiso catalogo:productos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V018 rolled back successfully.';
|
||||
GO
|
||||
172
database/migrations/V018__create_product.sql
Normal file
172
database/migrations/V018__create_product.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- V018__create_product.sql
|
||||
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
|
||||
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
|
||||
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
|
||||
--
|
||||
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V018_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Notas:
|
||||
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
|
||||
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
|
||||
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
|
||||
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
|
||||
--
|
||||
-- SDD Design: engram sdd/prd-002-product-crud/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.Product
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Product (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
|
||||
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
MedioId INT NOT NULL,
|
||||
ProductTypeId INT NOT NULL,
|
||||
RubroId INT NULL,
|
||||
BasePrice DECIMAL(18,4) NOT NULL,
|
||||
PriceDurationDays INT NULL,
|
||||
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
|
||||
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
|
||||
);
|
||||
PRINT 'Table dbo.Product created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Product already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — Product
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Product_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_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 = 'Product_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Product_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
|
||||
ON dbo.Product (MedioId, ProductTypeId, Nombre)
|
||||
WHERE IsActive = 1;
|
||||
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_ProductTypeId_IsActive
|
||||
ON dbo.Product (ProductTypeId, IsActive);
|
||||
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para list filtered by MedioId
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_MedioId_IsActive
|
||||
ON dbo.Product (MedioId, IsActive);
|
||||
PRINT 'Index IX_Product_MedioId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para list filtered by RubroId
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_RubroId_IsActive
|
||||
ON dbo.Product (RubroId, IsActive)
|
||||
WHERE RubroId IS NOT NULL;
|
||||
PRINT 'Index IX_Product_RubroId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('catalogo:productos:gestionar',
|
||||
N'Gestionar productos del catálogo',
|
||||
N'Crear, editar y desactivar productos del catálogo comercial',
|
||||
'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
|
||||
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
|
||||
GO
|
||||
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002: Product catalog management.
|
||||
/// Read endpoints at /api/v1/products — require authentication (any role).
|
||||
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductsController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateProductCommand> _createValidator;
|
||||
private readonly IValidator<UpdateProductCommand> _updateValidator;
|
||||
|
||||
public ProductsController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateProductCommand> createValidator,
|
||||
IValidator<UpdateProductCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/products")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> ListProducts(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool? activo = true,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] int? medioId = null,
|
||||
[FromQuery] int? productTypeId = null,
|
||||
[FromQuery] int? rubroId = null)
|
||||
{
|
||||
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
|
||||
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Returns a single Product by id. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/products/{id:int}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetProductByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpPost("api/v1/admin/products")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
|
||||
{
|
||||
var command = new CreateProductCommand(
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
MedioId: request.MedioId,
|
||||
ProductTypeId: request.ProductTypeId,
|
||||
RubroId: request.RubroId,
|
||||
BasePrice: request.BasePrice,
|
||||
PriceDurationDays: request.PriceDurationDays);
|
||||
|
||||
var validation = await _createValidator.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<CreateProductCommand, ProductCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpPut("api/v1/admin/products/{id:int}")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
|
||||
{
|
||||
var command = new UpdateProductCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
RubroId: request.RubroId,
|
||||
BasePrice: request.BasePrice,
|
||||
PriceDurationDays: request.PriceDurationDays);
|
||||
|
||||
var validation = await _updateValidator.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<UpdateProductCommand, ProductUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpDelete("api/v1/admin/products/{id:int}")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateProductCommand(id);
|
||||
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-002: Create Product request body.</summary>
|
||||
public sealed record CreateProductRequest(
|
||||
string? Nombre,
|
||||
int MedioId = 0,
|
||||
int ProductTypeId = 0,
|
||||
int? RubroId = null,
|
||||
decimal BasePrice = 0m,
|
||||
int? PriceDurationDays = null);
|
||||
|
||||
/// <summary>PRD-002: Update Product request body.</summary>
|
||||
public sealed record UpdateProductRequest(
|
||||
string? Nombre,
|
||||
int? RubroId = null,
|
||||
decimal BasePrice = 0m,
|
||||
int? PriceDurationDays = null);
|
||||
@@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-002: Product exceptions
|
||||
case ProductNotFoundException productNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_not_found",
|
||||
message = productNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_nombre_duplicado",
|
||||
message = productDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductTipoFlagsIncoherentesException productFlagsEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_flags_incoherentes",
|
||||
field = productFlagsEx.Field,
|
||||
message = productFlagsEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductTypeInactivoException productTypeInactivoEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_type_inactivo",
|
||||
message = productTypeInactivoEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroInactivoException rubroInactivoEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_inactivo",
|
||||
message = rubroInactivoEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-008: PuntoDeVenta exceptions
|
||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Write-side repository for Product.
|
||||
/// All reads needed by write handlers are included here.
|
||||
/// </summary>
|
||||
public interface IProductRepository
|
||||
{
|
||||
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
|
||||
Task<int> AddAsync(Product product, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
|
||||
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns a paged result of Products matching the query.</summary>
|
||||
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Persists all changes to an existing Product row.</summary>
|
||||
Task UpdateAsync(Product product, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
|
||||
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
||||
/// </summary>
|
||||
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
|
||||
}
|
||||
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
|
||||
/// </summary>
|
||||
public sealed record ProductsQuery(
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
bool? Activo = true,
|
||||
string? Search = null,
|
||||
int? MedioId = null,
|
||||
int? ProductTypeId = null,
|
||||
int? RubroId = null);
|
||||
@@ -69,7 +69,11 @@ using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
using SIGCM2.Application.Products;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
@@ -171,9 +175,15 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||
|
||||
// Products (PRD-002)
|
||||
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
||||
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||
|
||||
@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
|
||||
if (!target.IsActive)
|
||||
return new ProductTypeStatusDto(command.Id, false);
|
||||
|
||||
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
|
||||
// 3. Guard: check if any active product uses this type
|
||||
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||
if (inUse)
|
||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
||||
// Note: count=-1 sentinel because Products table doesn't exist in PRD-001.
|
||||
// PRD-002 will update this with the actual count.
|
||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
|
||||
|
||||
// 4. Deactivate (immutable — returns new instance)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Products.Create;
|
||||
|
||||
public sealed record CreateProductCommand(
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays);
|
||||
@@ -0,0 +1,112 @@
|
||||
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.Create;
|
||||
|
||||
public sealed class CreateProductCommandHandler
|
||||
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IProductTypeRepository _ptRepo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IRubroRepository _rubroRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IProductTypeRepository ptRepo,
|
||||
IMedioRepository medioRepo,
|
||||
IRubroRepository rubroRepo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_ptRepo = ptRepo;
|
||||
_medioRepo = medioRepo;
|
||||
_rubroRepo = rubroRepo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
|
||||
{
|
||||
// 1. Validate Medio exists and is active
|
||||
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
|
||||
?? throw new MedioNotFoundException(command.MedioId);
|
||||
if (!medio.Activo)
|
||||
throw new MedioInactivoException(command.MedioId);
|
||||
|
||||
// 2. Validate ProductType exists and is active
|
||||
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
|
||||
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
|
||||
if (!productType.IsActive)
|
||||
throw new ProductTypeInactivoException(command.ProductTypeId);
|
||||
|
||||
// 3. Flags coherence: RequiresCategory → RubroId required
|
||||
if (productType.RequiresCategory && !command.RubroId.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
|
||||
|
||||
// 4. Flags coherence: HasDuration → PriceDurationDays required
|
||||
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
|
||||
|
||||
// 5. Validate Rubro if provided: must be active
|
||||
if (command.RubroId.HasValue)
|
||||
{
|
||||
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
|
||||
if (rubro == null || !rubro.Activo)
|
||||
throw new RubroInactivoException(command.RubroId.Value);
|
||||
}
|
||||
|
||||
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
|
||||
if (exists)
|
||||
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
|
||||
|
||||
// 7. Build entity
|
||||
var entity = Product.ForCreation(
|
||||
command.Nombre, command.MedioId, command.ProductTypeId,
|
||||
command.RubroId, command.BasePrice, command.PriceDurationDays,
|
||||
_timeProvider);
|
||||
|
||||
// 8. Persist + audit (fail-closed)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var newId = await _repo.AddAsync(entity);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.created",
|
||||
targetType: "Product",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
entity.Nombre,
|
||||
entity.MedioId,
|
||||
entity.ProductTypeId,
|
||||
entity.RubroId,
|
||||
entity.BasePrice,
|
||||
entity.PriceDurationDays,
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductCreatedDto(
|
||||
newId, entity.Nombre,
|
||||
entity.MedioId, entity.ProductTypeId, entity.RubroId,
|
||||
entity.BasePrice, entity.PriceDurationDays,
|
||||
entity.IsActive, entity.FechaCreacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.Products.Create;
|
||||
|
||||
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
||||
{
|
||||
public CreateProductCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre del producto es requerido.")
|
||||
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
|
||||
|
||||
RuleFor(x => x.MedioId)
|
||||
.GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.ProductTypeId)
|
||||
.GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.BasePrice)
|
||||
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
|
||||
|
||||
RuleFor(x => x.PriceDurationDays)
|
||||
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
|
||||
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Application.Products.Create;
|
||||
|
||||
public sealed record ProductCreatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Products.Deactivate;
|
||||
|
||||
public sealed record DeactivateProductCommand(int Id);
|
||||
@@ -0,0 +1,62 @@
|
||||
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.Deactivate;
|
||||
|
||||
public sealed class DeactivateProductCommandHandler
|
||||
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductNotFoundException(command.Id);
|
||||
|
||||
// 2. Idempotent: already inactive → return without side effects
|
||||
if (!target.IsActive)
|
||||
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
|
||||
|
||||
// 3. Deactivate (immutable)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
// 4. Persist + audit (fail-closed)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(deactivated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.deactivated",
|
||||
targetType: "Product",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
productId = command.Id,
|
||||
nombre = target.Nombre,
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Products.Deactivate;
|
||||
|
||||
public sealed record ProductStatusDto(
|
||||
int Id,
|
||||
bool IsActive,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Products.GetById;
|
||||
|
||||
public sealed record GetProductByIdQuery(int Id);
|
||||
@@ -0,0 +1,29 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Products.GetById;
|
||||
|
||||
public sealed class GetProductByIdQueryHandler
|
||||
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
|
||||
public GetProductByIdQueryHandler(IProductRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
|
||||
{
|
||||
var product = await _repo.GetByIdAsync(query.Id)
|
||||
?? throw new ProductNotFoundException(query.Id);
|
||||
|
||||
return new ProductDetailDto(
|
||||
product.Id, product.Nombre,
|
||||
product.MedioId, product.ProductTypeId, product.RubroId,
|
||||
product.BasePrice, product.PriceDurationDays,
|
||||
product.IsActive,
|
||||
product.FechaCreacion, product.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Products.GetById;
|
||||
|
||||
public sealed record ProductDetailDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Products.List;
|
||||
|
||||
public sealed record ListProductsQuery(
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
bool? Activo = true,
|
||||
string? Search = null,
|
||||
int? MedioId = null,
|
||||
int? ProductTypeId = null,
|
||||
int? RubroId = null);
|
||||
@@ -0,0 +1,35 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Products.List;
|
||||
|
||||
public sealed class ListProductsQueryHandler
|
||||
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
|
||||
public ListProductsQueryHandler(IProductRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var repoQuery = new ProductsQuery(
|
||||
page, pageSize, query.Activo, query.Search,
|
||||
query.MedioId, query.ProductTypeId, query.RubroId);
|
||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||
|
||||
var items = paged.Items.Select(p => new ProductListItemDto(
|
||||
p.Id, p.Nombre,
|
||||
p.MedioId, p.ProductTypeId, p.RubroId,
|
||||
p.BasePrice, p.PriceDurationDays,
|
||||
p.IsActive)).ToList();
|
||||
|
||||
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM2.Application.Products.List;
|
||||
|
||||
public sealed record ProductListItemDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive);
|
||||
@@ -1,14 +0,0 @@
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Products;
|
||||
|
||||
/// <summary>
|
||||
/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product.
|
||||
/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes.
|
||||
/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002.
|
||||
/// </summary>
|
||||
public sealed class NullProductQueryRepository : IProductQueryRepository
|
||||
{
|
||||
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Products.Update;
|
||||
|
||||
public sealed record ProductUpdatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Products.Update;
|
||||
|
||||
public sealed record UpdateProductCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays);
|
||||
@@ -0,0 +1,97 @@
|
||||
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.Update;
|
||||
|
||||
public sealed class UpdateProductCommandHandler
|
||||
: ICommandHandler<UpdateProductCommand, ProductUpdatedDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IProductTypeRepository _ptRepo;
|
||||
private readonly IRubroRepository _rubroRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UpdateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IProductTypeRepository ptRepo,
|
||||
IRubroRepository rubroRepo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_ptRepo = ptRepo;
|
||||
_rubroRepo = rubroRepo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductUpdatedDto> Handle(UpdateProductCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductNotFoundException(command.Id);
|
||||
|
||||
// 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation)
|
||||
var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId)
|
||||
?? throw new ProductTypeNotFoundException(target.ProductTypeId);
|
||||
|
||||
// 3. Flags coherence
|
||||
if (productType.RequiresCategory && !command.RubroId.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
|
||||
|
||||
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
|
||||
|
||||
// 4. Validate Rubro if provided: must be active
|
||||
if (command.RubroId.HasValue)
|
||||
{
|
||||
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
|
||||
if (rubro == null || !rubro.Activo)
|
||||
throw new RubroInactivoException(command.RubroId.Value);
|
||||
}
|
||||
|
||||
// 5. Duplicate nombre check (skip if name unchanged — optimization)
|
||||
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id);
|
||||
if (exists)
|
||||
throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre);
|
||||
}
|
||||
|
||||
// 6. Apply mutation (immutable)
|
||||
var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider);
|
||||
|
||||
// 7. Persist + audit
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.updated",
|
||||
targetType: "Product",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays },
|
||||
after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays }
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductUpdatedDto(
|
||||
updated.Id, updated.Nombre,
|
||||
updated.MedioId, updated.ProductTypeId, updated.RubroId,
|
||||
updated.BasePrice, updated.PriceDurationDays,
|
||||
updated.IsActive, updated.FechaCreacion, updated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.Products.Update;
|
||||
|
||||
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
|
||||
{
|
||||
public UpdateProductCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0).WithMessage("Id debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre del producto es requerido.")
|
||||
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
|
||||
|
||||
RuleFor(x => x.BasePrice)
|
||||
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
|
||||
|
||||
RuleFor(x => x.PriceDurationDays)
|
||||
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
|
||||
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
|
||||
}
|
||||
}
|
||||
172
src/api/SIGCM2.Domain/Entities/Product.cs
Normal file
172
src/api/SIGCM2.Domain/Entities/Product.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
namespace SIGCM2.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable product entity for the commercial catalog.
|
||||
/// Factory method ForCreation creates new products (Id=0).
|
||||
/// Mutation methods (With*) return new instances — original is never modified.
|
||||
/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers
|
||||
/// at creation/update time against the ProductType, NOT here in the entity.
|
||||
/// MedioId and ProductTypeId are immutable post-creation by design.
|
||||
/// </summary>
|
||||
public sealed class Product
|
||||
{
|
||||
private const int NombreMaxLength = 300;
|
||||
|
||||
public int Id { get; }
|
||||
public string Nombre { get; }
|
||||
public int MedioId { get; }
|
||||
public int ProductTypeId { get; }
|
||||
public int? RubroId { get; }
|
||||
public decimal BasePrice { get; }
|
||||
public int? PriceDurationDays { get; }
|
||||
public bool IsActive { get; }
|
||||
public DateTime FechaCreacion { get; }
|
||||
public DateTime? FechaModificacion { get; }
|
||||
|
||||
/// <summary>Full hydration constructor — used by the repository to reconstruct from DB rows.</summary>
|
||||
public Product(
|
||||
int id,
|
||||
string nombre,
|
||||
int medioId,
|
||||
int productTypeId,
|
||||
int? rubroId,
|
||||
decimal basePrice,
|
||||
int? priceDurationDays,
|
||||
bool isActive,
|
||||
DateTime fechaCreacion,
|
||||
DateTime? fechaModificacion)
|
||||
{
|
||||
Id = id;
|
||||
Nombre = nombre;
|
||||
MedioId = medioId;
|
||||
ProductTypeId = productTypeId;
|
||||
RubroId = rubroId;
|
||||
BasePrice = basePrice;
|
||||
PriceDurationDays = priceDurationDays;
|
||||
IsActive = isActive;
|
||||
FechaCreacion = fechaCreacion;
|
||||
FechaModificacion = fechaModificacion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
|
||||
/// IsActive=true, FechaModificacion=null.
|
||||
/// </summary>
|
||||
public static Product ForCreation(
|
||||
string nombre,
|
||||
int medioId,
|
||||
int productTypeId,
|
||||
int? rubroId,
|
||||
decimal basePrice,
|
||||
int? priceDurationDays,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ValidateNombre(nombre);
|
||||
ValidateMedioId(medioId);
|
||||
ValidateProductTypeId(productTypeId);
|
||||
ValidateRubroId(rubroId);
|
||||
ValidateBasePrice(basePrice);
|
||||
ValidatePriceDurationDays(priceDurationDays);
|
||||
|
||||
return new Product(
|
||||
id: 0,
|
||||
nombre: nombre.Trim(),
|
||||
medioId: medioId,
|
||||
productTypeId: productTypeId,
|
||||
rubroId: rubroId,
|
||||
basePrice: basePrice,
|
||||
priceDurationDays: priceDurationDays,
|
||||
isActive: true,
|
||||
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
|
||||
fechaModificacion: null);
|
||||
}
|
||||
|
||||
public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider)
|
||||
{
|
||||
ValidateNombre(nuevoNombre);
|
||||
return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
|
||||
timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
|
||||
{
|
||||
ValidateBasePrice(basePrice);
|
||||
ValidatePriceDurationDays(priceDurationDays);
|
||||
return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
basePrice, priceDurationDays, IsActive, FechaCreacion,
|
||||
timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider)
|
||||
{
|
||||
ValidateRubroId(rubroId);
|
||||
return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
|
||||
timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combo mutator: renames, updates price and category in one call.
|
||||
/// Used by UpdateProductCommandHandler.
|
||||
/// </summary>
|
||||
public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
|
||||
{
|
||||
ValidateNombre(nombre);
|
||||
ValidateRubroId(rubroId);
|
||||
ValidateBasePrice(basePrice);
|
||||
ValidatePriceDurationDays(priceDurationDays);
|
||||
return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId,
|
||||
basePrice, priceDurationDays, IsActive, FechaCreacion,
|
||||
timeProvider.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
public Product WithDeactivated(TimeProvider timeProvider)
|
||||
=> new(Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, isActive: false, FechaCreacion,
|
||||
timeProvider.GetUtcNow().UtcDateTime);
|
||||
|
||||
// ── Private validators ────────────────────────────────────────────────────
|
||||
|
||||
private static void ValidateNombre(string nombre)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nombre))
|
||||
throw new ArgumentException(
|
||||
"El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre));
|
||||
if (nombre.Length > NombreMaxLength)
|
||||
throw new ArgumentException(
|
||||
$"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre));
|
||||
}
|
||||
|
||||
private static void ValidateMedioId(int medioId)
|
||||
{
|
||||
if (medioId <= 0)
|
||||
throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId));
|
||||
}
|
||||
|
||||
private static void ValidateProductTypeId(int productTypeId)
|
||||
{
|
||||
if (productTypeId <= 0)
|
||||
throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId));
|
||||
}
|
||||
|
||||
private static void ValidateRubroId(int? rubroId)
|
||||
{
|
||||
if (rubroId.HasValue && rubroId.Value <= 0)
|
||||
throw new ArgumentException(
|
||||
"rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId));
|
||||
}
|
||||
|
||||
private static void ValidateBasePrice(decimal basePrice)
|
||||
{
|
||||
if (basePrice < 0m)
|
||||
throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice));
|
||||
}
|
||||
|
||||
private static void ValidatePriceDurationDays(int? priceDurationDays)
|
||||
{
|
||||
if (priceDurationDays.HasValue && priceDurationDays.Value <= 0)
|
||||
throw new ArgumentException(
|
||||
"priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
|
||||
/// </summary>
|
||||
public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException
|
||||
{
|
||||
public int MedioId { get; }
|
||||
public int ProductTypeId { get; }
|
||||
public string Nombre { get; }
|
||||
|
||||
public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre)
|
||||
: base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.")
|
||||
{
|
||||
MedioId = medioId;
|
||||
ProductTypeId = productTypeId;
|
||||
Nombre = nombre;
|
||||
}
|
||||
}
|
||||
15
src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/ProductNotFoundException.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a requested Product does not exist. → HTTP 404
|
||||
/// </summary>
|
||||
public sealed class ProductNotFoundException : DomainException
|
||||
{
|
||||
public int ProductId { get; }
|
||||
|
||||
public ProductNotFoundException(int id)
|
||||
: base($"El producto con id={id} no existe.")
|
||||
{
|
||||
ProductId = id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a Product's field violates the flags coherence rules of its ProductType
|
||||
/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422
|
||||
/// </summary>
|
||||
public sealed class ProductTipoFlagsIncoherentesException : DomainException
|
||||
{
|
||||
public string Field { get; }
|
||||
|
||||
public ProductTipoFlagsIncoherentesException(string reason, string field)
|
||||
: base($"Incoherencia de flags del tipo de producto: {reason}.")
|
||||
{
|
||||
Field = field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
|
||||
/// </summary>
|
||||
public sealed class ProductTypeInactivoException : DomainException
|
||||
{
|
||||
public int ProductTypeId { get; }
|
||||
|
||||
public ProductTypeInactivoException(int productTypeId)
|
||||
: base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.")
|
||||
{
|
||||
ProductTypeId = productTypeId;
|
||||
}
|
||||
}
|
||||
15
src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/RubroInactivoException.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
|
||||
/// </summary>
|
||||
public sealed class RubroInactivoException : DomainException
|
||||
{
|
||||
public int RubroId { get; }
|
||||
|
||||
public RubroInactivoException(int rubroId)
|
||||
: base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.")
|
||||
{
|
||||
RubroId = rubroId;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
||||
services.AddScoped<IProductRepository, ProductRepository>();
|
||||
// PRD-002: replaces NullProductQueryRepository from Application DI
|
||||
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product.
|
||||
/// Replaces NullProductQueryRepository which was bound during PRD-001.
|
||||
/// </summary>
|
||||
public sealed class ProductQueryRepository : IProductQueryRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public ProductQueryRepository(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM dbo.Product
|
||||
WHERE ProductTypeId = @ProductTypeId
|
||||
AND IsActive = 1
|
||||
) THEN 1 ELSE 0
|
||||
END
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
||||
return result == 1;
|
||||
}
|
||||
}
|
||||
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal file
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product.
|
||||
/// Full implementation in Batch 6.
|
||||
/// </summary>
|
||||
public sealed class ProductRepository : IProductRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public ProductRepository(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<int> AddAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO dbo.Product (
|
||||
Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays,
|
||||
IsActive, FechaCreacion
|
||||
)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES (
|
||||
@Nombre, @MedioId, @ProductTypeId, @RubroId, @BasePrice, @PriceDurationDays,
|
||||
1, @FechaCreacion
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
product.Nombre,
|
||||
product.MedioId,
|
||||
product.ProductTypeId,
|
||||
product.RubroId,
|
||||
product.BasePrice,
|
||||
product.PriceDurationDays,
|
||||
product.FechaCreacion,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Product
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var row = await connection.QuerySingleOrDefaultAsync<ProductRow>(sql, new { Id = id });
|
||||
return row is null ? null : MapRow(row);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
if (query.Activo.HasValue)
|
||||
conditions.Add("IsActive = @Activo");
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
conditions.Add("Nombre LIKE '%' + @Search + '%'");
|
||||
if (query.MedioId.HasValue)
|
||||
conditions.Add("MedioId = @MedioId");
|
||||
if (query.ProductTypeId.HasValue)
|
||||
conditions.Add("ProductTypeId = @ProductTypeId");
|
||||
if (query.RubroId.HasValue)
|
||||
conditions.Add("RubroId = @RubroId");
|
||||
|
||||
var where = conditions.Count > 0
|
||||
? "WHERE " + string.Join(" AND ", conditions)
|
||||
: string.Empty;
|
||||
|
||||
var countSql = $"SELECT COUNT(1) FROM dbo.Product {where}";
|
||||
var dataSql = $"""
|
||||
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Product
|
||||
{where}
|
||||
ORDER BY Nombre
|
||||
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||
""";
|
||||
|
||||
var offset = (query.Page - 1) * query.PageSize;
|
||||
var parameters = new
|
||||
{
|
||||
Activo = query.Activo.HasValue ? (object)(query.Activo.Value ? 1 : 0) : null,
|
||||
Search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search,
|
||||
query.MedioId,
|
||||
query.ProductTypeId,
|
||||
query.RubroId,
|
||||
Offset = offset,
|
||||
PageSize = query.PageSize,
|
||||
};
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var total = await connection.ExecuteScalarAsync<int>(countSql, parameters);
|
||||
var rows = await connection.QueryAsync<ProductRow>(dataSql, parameters);
|
||||
var items = rows.Select(MapRow).ToList();
|
||||
|
||||
return new PagedResult<Product>(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE dbo.Product
|
||||
SET Nombre = @Nombre,
|
||||
RubroId = @RubroId,
|
||||
BasePrice = @BasePrice,
|
||||
PriceDurationDays = @PriceDurationDays,
|
||||
IsActive = @IsActive,
|
||||
FechaModificacion = @FechaModificacion
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
product.Nombre,
|
||||
product.RubroId,
|
||||
product.BasePrice,
|
||||
product.PriceDurationDays,
|
||||
IsActive = product.IsActive ? 1 : 0,
|
||||
product.FechaModificacion,
|
||||
product.Id,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByNombreAsync(
|
||||
string nombre,
|
||||
int medioId,
|
||||
int productTypeId,
|
||||
int? excludeId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Product
|
||||
WHERE Nombre = @Nombre
|
||||
AND MedioId = @MedioId
|
||||
AND ProductTypeId = @ProductTypeId
|
||||
AND IsActive = 1
|
||||
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
Nombre = nombre,
|
||||
MedioId = medioId,
|
||||
ProductTypeId = productTypeId,
|
||||
ExcludeId = excludeId,
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// ── Mapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Product MapRow(ProductRow r)
|
||||
=> new(
|
||||
id: r.Id,
|
||||
nombre: r.Nombre,
|
||||
medioId: r.MedioId,
|
||||
productTypeId: r.ProductTypeId,
|
||||
rubroId: r.RubroId,
|
||||
basePrice: r.BasePrice,
|
||||
priceDurationDays: r.PriceDurationDays,
|
||||
isActive: r.IsActive,
|
||||
fechaCreacion: r.FechaCreacion,
|
||||
fechaModificacion: r.FechaModificacion);
|
||||
|
||||
private sealed record ProductRow(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Store,
|
||||
Tag,
|
||||
Layers,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Layers,
|
||||
requiredPermission: 'catalogo:tipos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Productos',
|
||||
href: '/admin/products',
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
12
src/web/src/features/products/api/createProduct.ts
Normal file
12
src/web/src/features/products/api/createProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { CreateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function createProduct(
|
||||
payload: CreateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.post<ProductDetail>(
|
||||
'/api/v1/admin/products',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
export async function deactivateProduct(id: number): Promise<void> {
|
||||
await axiosClient.delete(`/api/v1/admin/products/${id}`)
|
||||
}
|
||||
7
src/web/src/features/products/api/getProductById.ts
Normal file
7
src/web/src/features/products/api/getProductById.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ProductDetail } from '../types'
|
||||
|
||||
export async function getProductById(id: number): Promise<ProductDetail> {
|
||||
const response = await axiosClient.get<ProductDetail>(`/api/v1/products/${id}`)
|
||||
return response.data
|
||||
}
|
||||
12
src/web/src/features/products/api/listProducts.ts
Normal file
12
src/web/src/features/products/api/listProducts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ListProductsParams, PagedResult, ProductListItem } from '../types'
|
||||
|
||||
export async function listProducts(
|
||||
params?: ListProductsParams,
|
||||
): Promise<PagedResult<ProductListItem>> {
|
||||
const response = await axiosClient.get<PagedResult<ProductListItem>>(
|
||||
'/api/v1/products',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
13
src/web/src/features/products/api/updateProduct.ts
Normal file
13
src/web/src/features/products/api/updateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UpdateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function updateProduct(
|
||||
id: number,
|
||||
payload: UpdateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.put<ProductDetail>(
|
||||
`/api/v1/admin/products/${id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { ProductListItem } from '../types'
|
||||
|
||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveDeactivateError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al desactivar el producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeactivateProductDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product: ProductListItem
|
||||
onConfirm: (id: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DeactivateProductDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onConfirm,
|
||||
}: DeactivateProductDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
await onConfirm(product.id)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(resolveDeactivateError(err))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Desactivar producto</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Desactivar el producto “{product.nombre}”? El producto no
|
||||
aparecerá en los listados activos.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending ? 'Procesando...' : 'Desactivar'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
300
src/web/src/features/products/components/ProductForm.tsx
Normal file
300
src/web/src/features/products/components/ProductForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import type { ProductTypeListItem } from '@/features/product-types/types'
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function nullablePositiveInt() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||
.pipe(z.number().int().positive().nullable())
|
||||
}
|
||||
|
||||
const productFormSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'),
|
||||
medioId: z
|
||||
.string()
|
||||
.min(1, 'Medio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Medio requerido')),
|
||||
productTypeId: z
|
||||
.string()
|
||||
.min(1, 'Tipo de producto requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Tipo de producto requerido')),
|
||||
rubroId: nullablePositiveInt(),
|
||||
basePrice: z
|
||||
.string()
|
||||
.min(1, 'Precio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().min(0, 'El precio no puede ser negativo')),
|
||||
priceDurationDays: nullablePositiveInt(),
|
||||
})
|
||||
|
||||
// Raw form field types (strings before zod transforms)
|
||||
type ProductFormRaw = {
|
||||
nombre: string
|
||||
medioId: string
|
||||
productTypeId: string
|
||||
rubroId: string
|
||||
basePrice: string
|
||||
priceDurationDays: string
|
||||
}
|
||||
|
||||
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||
export type ProductFormOutput = {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductFormDefaultValues {
|
||||
nombre?: string
|
||||
medioId?: number | null
|
||||
productTypeId?: number | null
|
||||
rubroId?: number | null
|
||||
basePrice?: number | null
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
productTypes: ProductTypeListItem[]
|
||||
defaultValues?: ProductFormDefaultValues
|
||||
onSubmit: (values: ProductFormOutput) => void
|
||||
onCancel: () => void
|
||||
isPending?: boolean
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductForm({
|
||||
productTypes,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
isEdit = false,
|
||||
}: ProductFormProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ProductFormRaw>({
|
||||
resolver: zodResolver(productFormSchema) as any,
|
||||
defaultValues: {
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
|
||||
|
||||
// Derive selected ProductType flags
|
||||
const productTypeIdStr = form.watch('productTypeId')
|
||||
const selectedProductTypeId = productTypeIdStr ? Number(productTypeIdStr) : null
|
||||
const selectedProductType = productTypes.find((pt) => pt.id === selectedProductTypeId) ?? null
|
||||
const requiresCategory = selectedProductType?.requiresCategory ?? false
|
||||
const hasDuration = selectedProductType?.hasDuration ?? false
|
||||
|
||||
function handleSubmit(data: ProductFormOutput) {
|
||||
// Normalize conditional fields to null when not applicable
|
||||
const normalized: ProductFormOutput = {
|
||||
...data,
|
||||
rubroId: requiresCategory ? data.rubroId : null,
|
||||
priceDurationDays: hasDuration ? data.priceDurationDays : null,
|
||||
}
|
||||
onSubmit(normalized)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Nombre */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Medio ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="medioId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Medio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del medio"
|
||||
aria-label="ID de Medio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Product Type ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="productTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Tipo de Producto</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del tipo de producto"
|
||||
aria-label="ID de Tipo de Producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rubro ID — only shown when requiresCategory=true */}
|
||||
{requiresCategory && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rubroId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Rubro</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="ID del rubro"
|
||||
aria-label="ID de Rubro"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Base Price */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basePrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio base</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={isPending}
|
||||
placeholder="0.00"
|
||||
aria-label="Precio base"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price Duration Days — only shown when hasDuration=true */}
|
||||
{hasDuration && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priceDurationDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Días de duración del precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Días"
|
||||
aria-label="Días de duración del precio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
132
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
132
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ProductForm } from './ProductForm'
|
||||
import type { ProductFormOutput } from './ProductForm'
|
||||
import { useCreateProduct } from '../hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
||||
import type { ProductDetail } from '../types'
|
||||
import { useProductTypes } from '@/features/product-types/hooks/useProductTypes'
|
||||
|
||||
// ─── 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 }
|
||||
return data.message ?? data.error ?? 'Error al guardar el producto'
|
||||
}
|
||||
const errObj = err as { response?: { data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al guardar el producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product?: ProductDetail
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onSuccess,
|
||||
}: ProductFormDialogProps) {
|
||||
const [backendError, setBackendError] = useState<string | null>(null)
|
||||
|
||||
const isEdit = !!product
|
||||
const { mutateAsync: createProduct, isPending: creating } = useCreateProduct()
|
||||
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
||||
const isPending = creating || updating
|
||||
|
||||
// Fetch active product types so ProductForm can derive conditional fields
|
||||
const { data: productTypesPaged } = useProductTypes({ activo: true })
|
||||
const productTypes = productTypesPaged?.items ?? []
|
||||
|
||||
async function handleSubmit(values: ProductFormOutput) {
|
||||
setBackendError(null)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateProduct({
|
||||
id: product.id,
|
||||
data: {
|
||||
nombre: values.nombre,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
},
|
||||
})
|
||||
toast.success('Producto actualizado')
|
||||
} else {
|
||||
await createProduct({
|
||||
nombre: values.nombre,
|
||||
medioId: values.medioId,
|
||||
productTypeId: values.productTypeId,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
})
|
||||
toast.success('Producto creado')
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
const msg = resolveBackendError(err)
|
||||
setBackendError(msg)
|
||||
if (
|
||||
!isAxiosError(err) ||
|
||||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
|
||||
) {
|
||||
toast.error(isEdit ? 'Error al actualizar producto' : 'Error al crear producto')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar producto' : 'Nuevo producto'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Modificá los datos del producto "${product?.nombre ?? ''}".`
|
||||
: 'Completá los datos para crear un nuevo producto.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ProductForm
|
||||
productTypes={productTypes}
|
||||
defaultValues={product}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isPending={isPending}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createProduct } from '../api/createProduct'
|
||||
import type { CreateProductRequest } from '../types'
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateProductRequest) => createProduct(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateProduct } from '../api/deactivateProduct'
|
||||
|
||||
export function useDeactivateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deactivateProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listProducts } from '../api/listProducts'
|
||||
import type { ListProductsParams } from '../types'
|
||||
|
||||
export function useProducts(params?: ListProductsParams) {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => listProducts(params),
|
||||
})
|
||||
}
|
||||
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateProduct } from '../api/updateProduct'
|
||||
import type { UpdateProductRequest } from '../types'
|
||||
|
||||
export function useUpdateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) =>
|
||||
updateProduct(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
9
src/web/src/features/products/index.ts
Normal file
9
src/web/src/features/products/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ProductsPage } from './pages/ProductsPage'
|
||||
export type {
|
||||
ProductListItem,
|
||||
ProductDetail,
|
||||
CreateProductRequest,
|
||||
UpdateProductRequest,
|
||||
PagedResult,
|
||||
ListProductsParams,
|
||||
} from './types'
|
||||
236
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
236
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
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 { 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 type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function ProductsPage() {
|
||||
// ── Create dialog state ──────────────────────────────────────────────────
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<ProductDetail | null>(null)
|
||||
|
||||
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||
|
||||
// ── Pagination & filter state ────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
|
||||
|
||||
const { data: paged, isLoading, isError } = useProducts({
|
||||
activo: true,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
medioId: medioIdFilter,
|
||||
})
|
||||
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(p: ProductListItem) {
|
||||
const detail: ProductDetail = {
|
||||
...p,
|
||||
fechaCreacion: '',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
setEditingProduct(detail)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDeactivate(p: ProductListItem) {
|
||||
setDeactivatingProduct(p)
|
||||
setDeactivateOpen(true)
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: number) {
|
||||
await deactivateProduct(id)
|
||||
toast.success('Producto desactivado')
|
||||
}
|
||||
|
||||
function handleMedioFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value
|
||||
setPage(1)
|
||||
setMedioIdFilter(val ? Number(val) : undefined)
|
||||
}
|
||||
|
||||
const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1
|
||||
|
||||
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Error al cargar productos.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !paged?.items.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Productos</h1>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Filtrar por ID de Medio"
|
||||
aria-label="Filtrar por ID de Medio"
|
||||
className="w-52"
|
||||
onChange={handleMedioFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||
<p>No hay productos.</p>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button variant="outline" onClick={openCreate}>
|
||||
Crear primer producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Nombre</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Medio</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tipo</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Precio base</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Activo</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.items.map((p: ProductListItem) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||
<td className="px-4 py-2 font-medium">{p.nombre}</td>
|
||||
<td className="px-4 py-2">{p.medioId}</td>
|
||||
<td className="px-4 py-2">{p.productTypeId}</td>
|
||||
<td className="px-4 py-2">{p.basePrice}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={p.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||
{p.isActive ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(p)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeactivate(p)}
|
||||
disabled={!p.isActive}
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
aria-label="Página anterior"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Página siguiente"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<ProductFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{/* Edit dialog */}
|
||||
{editingProduct && (
|
||||
<ProductFormDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
product={editingProduct}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Deactivate confirmation dialog */}
|
||||
{deactivatingProduct && (
|
||||
<DeactivateProductDialog
|
||||
open={deactivateOpen}
|
||||
onOpenChange={setDeactivateOpen}
|
||||
product={deactivatingProduct}
|
||||
onConfirm={handleDeactivate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/web/src/features/products/types.ts
Normal file
58
src/web/src/features/products/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// PRD-002 — shared types for products feature
|
||||
|
||||
export interface ProductListItem {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
fechaCreacion: string
|
||||
fechaModificacion: string | null
|
||||
}
|
||||
|
||||
export interface CreateProductRequest {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateProductRequest {
|
||||
nombre: string
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ListProductsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
activo?: boolean | null
|
||||
search?: string
|
||||
medioId?: number
|
||||
productTypeId?: number
|
||||
rubroId?: number
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -320,6 +321,16 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Products routes — PRD-002 */}
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['catalogo:productos:gestionar']}>
|
||||
<ProductsPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { DeactivateProductDialog } from '../../../features/products/components/DeactivateProductDialog'
|
||||
import type { ProductListItem } from '../../../features/products/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const sampleProduct: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.5,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DeactivateProductDialog', () => {
|
||||
it('renders confirmation message with product name', () => {
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Clasificado Estándar/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm with product id when user confirms', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProduct.id))
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409', async () => {
|
||||
const onConfirm = vi.fn(() =>
|
||||
Promise.reject({
|
||||
response: { status: 409, data: { message: 'No se puede desactivar: el producto está en uso.' } },
|
||||
}),
|
||||
)
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/en uso/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when cancel is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
product={sampleProduct}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal file
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductForm } from '../../../features/products/components/ProductForm'
|
||||
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const ptRequiresCategory: ProductTypeListItem = {
|
||||
id: 1,
|
||||
nombre: 'Con categoría',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: true,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const ptHasDuration: ProductTypeListItem = {
|
||||
id: 2,
|
||||
nombre: 'Con duración',
|
||||
hasDuration: true,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const ptSimple: ProductTypeListItem = {
|
||||
id: 3,
|
||||
nombre: 'Simple',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductForm — no ProductType selected ────────────────────────────────
|
||||
|
||||
describe('ProductForm — no ProductType selected', () => {
|
||||
it('hides RubroId and PriceDurationDays fields when no ProductType is selected', () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptRequiresCategory, ptHasDuration, ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText(/rubro/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — requiresCategory flag ─────────────────────────────────
|
||||
|
||||
describe('ProductForm — requiresCategory flag', () => {
|
||||
it('shows RubroId field when ProductType has requiresCategory=true', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptRequiresCategory, ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 1 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/id de rubro/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides RubroId field when ProductType has requiresCategory=false', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/id de rubro/i)).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — hasDuration flag ──────────────────────────────────────
|
||||
|
||||
describe('ProductForm — hasDuration flag', () => {
|
||||
it('shows PriceDurationDays when ProductType has hasDuration=true', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptHasDuration]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 2 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/días de duración/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides PriceDurationDays when ProductType has hasDuration=false', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — submit normalization ──────────────────────────────────
|
||||
|
||||
describe('ProductForm — submit normalization', () => {
|
||||
it('nulls out rubroId when requiresCategory=false on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const payload = onSubmit.mock.calls[0][0]
|
||||
expect(payload.rubroId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('nulls out priceDurationDays when hasDuration=false on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test2')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const payload = onSubmit.mock.calls[0][0]
|
||||
expect(payload.priceDurationDays).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } 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 { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductFormDialog } from '../../../features/products/components/ProductFormDialog'
|
||||
import type { ProductDetail } from '../../../features/products/types'
|
||||
import type { PagedResult } from '../../../features/product-types/types'
|
||||
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const ptSimple: ProductTypeListItem = {
|
||||
id: 2,
|
||||
nombre: 'Simple',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockProductTypesPaged: PagedResult<ProductTypeListItem> = {
|
||||
items: [ptSimple],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const mockProduct: ProductDetail = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.5,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function withProductTypesHandler() {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||
HttpResponse.json(mockProductTypesPaged),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductFormDialog — create mode ─────────────────────────────────────
|
||||
|
||||
describe('ProductFormDialog — create mode', () => {
|
||||
it('renders create dialog title when no product prop', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders DialogDescription (accessibility)', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByText(/completá los datos/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls create mutation and closes dialog on success', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json({ id: 10, nombre: 'Nuevo Producto' }, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={onOpenChange} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Producto')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'product_nombre_duplicado', message: 'Ya existe un producto con ese nombre' },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ya existe un producto con ese nombre/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409 ProductTypeInactivo', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'product_type_inactivo', message: 'El tipo de producto está inactivo' },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tipo de producto está inactivo/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductFormDialog — edit mode ────────────────────────────────────────
|
||||
|
||||
describe('ProductFormDialog — edit mode', () => {
|
||||
it('renders edit dialog title and pre-fills nombre', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(
|
||||
<ProductFormDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={mockProduct}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /editar producto/i })).toBeInTheDocument()
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
expect(input.value).toBe('Clasificado Estándar')
|
||||
})
|
||||
|
||||
it('calls update mutation and closes dialog on success', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
HttpResponse.json({ ...mockProduct, nombre: 'Modificado' }),
|
||||
),
|
||||
)
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(
|
||||
<ProductFormDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
product={mockProduct}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Modificado')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
283
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
283
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
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 { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductsPage } from '../../../features/products/pages/ProductsPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const emptyPaged: PagedResult<ProductListItem> = {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage(user = adminUser) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/products']}>
|
||||
<Routes>
|
||||
<Route path="/admin/products" element={<ProductsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading / Error / Data states ──────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — loading and error states', () => {
|
||||
it('renders loading skeleton while fetching', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, async () => {
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json(emptyPaged)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders data when loaded', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no products', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Permission gating ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — permission gating', () => {
|
||||
it('shows "Nuevo Producto" button when user has permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Nuevo Producto" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(regularUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Create dialog ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — create dialog', () => {
|
||||
it('opens create dialog when "Nuevo Producto" button is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — deactivate dialog', () => {
|
||||
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Pagination ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — pagination', () => {
|
||||
it('shows pagination controls when total > pageSize', async () => {
|
||||
// 21 total items but only 1 in this page → totalPages=2 → controls visible
|
||||
const pagedWith21Total: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const pagedPage1: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
const pagedPage2: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }],
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /siguiente/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Página 2')).toBeInTheDocument()
|
||||
})
|
||||
// Verify that at least one request was made with page=2
|
||||
const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2')
|
||||
expect(page2Requests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Filter by Medio ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — filter by Medio', () => {
|
||||
it('re-fetches with medioId when Medio filter is changed', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const filteredPaged: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const medioId = url.searchParams.get('medioId')
|
||||
return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
// Wait for initial empty state
|
||||
await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument())
|
||||
|
||||
const filterInput = screen.getByLabelText(/filtrar por id de medio/i)
|
||||
await userEvent.type(filterInput, '5')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Medio 5')).toBeInTheDocument()
|
||||
})
|
||||
const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5')
|
||||
expect(filteredRequests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
131
src/web/src/tests/features/products/api.test.ts
Normal file
131
src/web/src/tests/features/products/api.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { listProducts } from '../../../features/products/api/listProducts'
|
||||
import { getProductById } from '../../../features/products/api/getProductById'
|
||||
import { createProduct } from '../../../features/products/api/createProduct'
|
||||
import { updateProduct } from '../../../features/products/api/updateProduct'
|
||||
import { deactivateProduct } from '../../../features/products/api/deactivateProduct'
|
||||
import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockListItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockDetail: ProductDetail = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockListItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('listProducts', () => {
|
||||
it('calls GET /api/v1/products and returns paged result', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const result = await listProducts()
|
||||
expect(result).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('passes query params when provided', async () => {
|
||||
let capturedUrl = ''
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockPaged)
|
||||
}),
|
||||
)
|
||||
await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 })
|
||||
expect(capturedUrl).toContain('page=2')
|
||||
expect(capturedUrl).toContain('pageSize=10')
|
||||
expect(capturedUrl).toContain('medioId=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProductById', () => {
|
||||
it('calls GET /api/v1/products/:id and returns detail', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)),
|
||||
)
|
||||
const result = await getProductById(1)
|
||||
expect(result).toEqual(mockDetail)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProduct', () => {
|
||||
it('calls POST /api/v1/admin/products with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail, { status: 201 })
|
||||
}),
|
||||
)
|
||||
const req = {
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100.50,
|
||||
}
|
||||
await createProduct(req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProduct', () => {
|
||||
it('calls PUT /api/v1/admin/products/:id with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail)
|
||||
}),
|
||||
)
|
||||
const req = { nombre: 'Modificado', basePrice: 200 }
|
||||
await updateProduct(1, req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deactivateProduct', () => {
|
||||
it('calls DELETE /api/v1/admin/products/:id', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () => {
|
||||
called = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await deactivateProduct(1)
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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 { useProducts } from '../../../features/products/hooks/useProducts'
|
||||
import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct'
|
||||
import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
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 ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
}
|
||||
|
||||
describe('useProducts', () => {
|
||||
it('returns paged data on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('returns error state on failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateProduct', () => {
|
||||
it('calls create and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(mockItem, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useCreateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({
|
||||
nombre: 'Nuevo Producto',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100,
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateProduct', () => {
|
||||
it('calls update and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
HttpResponse.json(mockItem),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useUpdateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeactivateProduct', () => {
|
||||
it('calls deactivate and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useDeactivateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate(1)
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
@@ -51,8 +51,9 @@ public class AuthControllerTests
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, permisos.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, permisos.GetArrayLength());
|
||||
}
|
||||
|
||||
// Scenario: invalid credentials return 401 with opaque error
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With26Items()
|
||||
public async Task GetPermisos_WithAdmin_Returns200With27Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
@@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, list.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
@@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, list.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Products;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 Batch 8 — Guard proof: verifies that deactivating a ProductType with active Products
|
||||
/// returns HTTP 409 Conflict. This test closes the W1 (dormant guard) issue from PRD-001:
|
||||
/// - PRD-001: NullProductQueryRepository always returned false → guard never fired
|
||||
/// - PRD-002: ProductQueryRepository now queries dbo.Product → guard fires correctly
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class ProductTypeDeactivationGuardTests : IAsyncLifetime
|
||||
{
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ProductTypeDeactivationGuardTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// E2E proof: seed Medio + ProductType + active Product → DELETE product-type → expect 409.
|
||||
/// This verifies the IProductQueryRepository guard fires against real data in dbo.Product.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeactivateProductType_WithActiveProducts_Returns409Conflict()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// 1. Create a Medio
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"GD{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Guarda Medio {Guid.NewGuid():N}"[..30],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 2. Create a ProductType
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"Guardado PT {Guid.NewGuid():N}"[..30],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 3. Create an active Product for this ProductType
|
||||
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
|
||||
{
|
||||
nombre = $"Prod Guarda {Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 100m
|
||||
}, token));
|
||||
prodResp.EnsureSuccessStatusCode();
|
||||
|
||||
// 4. Attempt to deactivate the ProductType — should be blocked by guard
|
||||
using var deactivateReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivateResp = await _client.SendAsync(deactivateReq);
|
||||
|
||||
// CRITICAL ASSERTION: 409 Conflict — guard fires because Product is active
|
||||
Assert.Equal(HttpStatusCode.Conflict, deactivateResp.StatusCode);
|
||||
|
||||
var body = await deactivateResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify guard does NOT block deactivation when Products exist but are all inactive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeactivateProductType_WithOnlyInactiveProducts_Returns204()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// 1. Create Medio
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"GI{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Guarda Inact {Guid.NewGuid():N}"[..28],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 2. Create ProductType
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"PT Inact {Guid.NewGuid():N}"[..28],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 3. Create then deactivate Product
|
||||
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
|
||||
{
|
||||
nombre = $"Prod Inact {Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 50m
|
||||
}, token));
|
||||
prodResp.EnsureSuccessStatusCode();
|
||||
var prodJson = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = prodJson.GetProperty("id").GetInt32();
|
||||
|
||||
// Deactivate the Product first
|
||||
using var deactivateProdReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/products/{productId}", bearerToken: token);
|
||||
var deactivateProdResp = await _client.SendAsync(deactivateProdReq);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivateProdResp.StatusCode);
|
||||
|
||||
// 4. Now deactivate ProductType — should succeed since no active products
|
||||
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
|
||||
}
|
||||
}
|
||||
367
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
367
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Products;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products.
|
||||
/// Read endpoints require authentication (any role).
|
||||
/// Write endpoints require permission 'catalogo:productos:gestionar'.
|
||||
/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings.
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class ProductsControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string ReadEndpoint = "/api/v1/products";
|
||||
private const string AdminEndpoint = "/api/v1/admin/products";
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ProductsControllerTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token)
|
||||
{
|
||||
// Create a Medio via SQL (we don't have a Medio controller endpoint available here)
|
||||
// Use product-types endpoint to create a ProductType and insert Medio directly
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"PM{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Medio Test {Guid.NewGuid():N}"[..30],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"PT_{Guid.NewGuid():N}"[..30],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
return (medioId, productTypeId);
|
||||
}
|
||||
|
||||
// ── 401 guards on READ endpoints ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, ReadEndpoint);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── 401 guards on WRITE endpoints ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" });
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m });
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/products ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithAdmin_Returns201WithId()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30];
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName,
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 100.50m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.GetProperty("id").GetInt32() > 0);
|
||||
Assert.Equal(uniqueName, json.GetProperty("nombre").GetString());
|
||||
Assert.True(json.GetProperty("isActive").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InvalidBody_Returns400()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = string.Empty, // invalid
|
||||
medioId = 1,
|
||||
productTypeId = 1,
|
||||
basePrice = 10m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateNombre_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30];
|
||||
|
||||
var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m };
|
||||
|
||||
using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
|
||||
var resp1 = await _client.SendAsync(req1);
|
||||
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
|
||||
|
||||
using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
|
||||
var resp2 = await _client.SendAsync(req2);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MedioNotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = $"Prod_{Guid.NewGuid():N}"[..30],
|
||||
medioId = 999999,
|
||||
productTypeId = 1,
|
||||
basePrice = 50m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── GET /api/v1/products ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithAuth_Returns200WithPaginatedResult()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.TryGetProperty("items", out _));
|
||||
Assert.True(json.TryGetProperty("total", out _));
|
||||
}
|
||||
|
||||
// ── GET /api/v1/products/{id} ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ExistingId_Returns200()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 75m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token);
|
||||
var getResp = await _client.SendAsync(getReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
|
||||
var getJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(productId, getJson.GetProperty("id").GetInt32());
|
||||
Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString());
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/admin/products/{id} ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_ExistingId_Returns204()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Del_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token);
|
||||
var delResp = await _client.SendAsync(delReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/products/{id} ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ExistingProduct_Returns200()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
var newName = $"Upd2_{Guid.NewGuid():N}"[..28];
|
||||
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new
|
||||
{
|
||||
nombre = newName,
|
||||
basePrice = 200m
|
||||
}, token);
|
||||
var updateResp = await _client.SendAsync(updateReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
|
||||
var updateJson = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(newName, updateJson.GetProperty("nombre").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new
|
||||
{
|
||||
nombre = "Test", basePrice = 10m
|
||||
}, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PRD-002 W1: ExceptionFilter 409 for ProductTypeInactivo ──────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithInactiveProductType_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
|
||||
// Deactivate the ProductType first
|
||||
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
|
||||
|
||||
// Now attempt to create a product with the inactive ProductType
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = $"Prod_{Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, createResp.StatusCode);
|
||||
var body = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("product_type_inactivo", body.GetProperty("error").GetString());
|
||||
}
|
||||
}
|
||||
218
tests/SIGCM2.Application.Tests/Domain/ProductTests.cs
Normal file
218
tests/SIGCM2.Application.Tests/Domain/ProductTests.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Domain entity tests for Product.
|
||||
/// Validates factory method, mutation methods, and validation rules.
|
||||
/// </summary>
|
||||
public class ProductTests
|
||||
{
|
||||
private static readonly FakeTimeProvider _time =
|
||||
new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private static Product ValidProduct(int? rubroId = null, int? priceDurationDays = null)
|
||||
=> Product.ForCreation(
|
||||
nombre: "Clasificado Estándar",
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: rubroId,
|
||||
basePrice: 100.50m,
|
||||
priceDurationDays: priceDurationDays,
|
||||
timeProvider: _time);
|
||||
|
||||
// ── R1-S1: ForCreation happy path ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_ValidData_ReturnsEntityWithDefaults()
|
||||
{
|
||||
var p = ValidProduct();
|
||||
|
||||
p.IsActive.Should().BeTrue();
|
||||
p.Id.Should().Be(0);
|
||||
p.FechaCreacion.Should().Be(_time.GetUtcNow().UtcDateTime);
|
||||
p.FechaModificacion.Should().BeNull();
|
||||
p.Nombre.Should().Be("Clasificado Estándar");
|
||||
p.MedioId.Should().Be(1);
|
||||
p.ProductTypeId.Should().Be(2);
|
||||
p.RubroId.Should().BeNull();
|
||||
p.BasePrice.Should().Be(100.50m);
|
||||
p.PriceDurationDays.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── R1-S2: Nombre vacío ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_EmptyNombre_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => Product.ForCreation("", 1, 2, null, 10m, null, _time);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*Nombre*");
|
||||
}
|
||||
|
||||
// ── R1-S3: Nombre solo espacios ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_WhitespaceNombre_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => Product.ForCreation(" ", 1, 2, null, 10m, null, _time);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── R1-S4: BasePrice negativo ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_NegativeBasePrice_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => Product.ForCreation("Test", 1, 2, null, -1m, null, _time);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*basePrice*");
|
||||
}
|
||||
|
||||
// ── R1-S5: BasePrice = 0 es válido ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_ZeroBasePrice_DoesNotThrow()
|
||||
{
|
||||
var act = () => Product.ForCreation("Test", 1, 2, null, 0m, null, _time);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
// ── R1-S6: PriceDurationDays = 0 ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_ZeroPriceDurationDays_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => Product.ForCreation("Test", 1, 2, null, 10m, 0, _time);
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*priceDurationDays*");
|
||||
}
|
||||
|
||||
// ── R1-S7: PriceDurationDays negativo ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_NegativePriceDurationDays_ThrowsArgumentException()
|
||||
{
|
||||
var act = () => Product.ForCreation("Test", 1, 2, null, 10m, -5, _time);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── R1-S8: PriceDurationDays válido ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForCreation_ValidPriceDurationDays_SetsValue()
|
||||
{
|
||||
var p = Product.ForCreation("Test", 1, 2, null, 10m, 30, _time);
|
||||
|
||||
p.PriceDurationDays.Should().Be(30);
|
||||
}
|
||||
|
||||
// ── R1-S9: WithDeactivated ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithDeactivated_ActiveProduct_ReturnsInactiveWithModDate()
|
||||
{
|
||||
var product = ValidProduct();
|
||||
var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var deactivated = product.WithDeactivated(tp2);
|
||||
|
||||
deactivated.IsActive.Should().BeFalse();
|
||||
deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
|
||||
// Immutability: original unchanged
|
||||
product.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── R1-S10: WithDeactivated idempotente ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithDeactivated_AlreadyInactive_ReturnsInactiveNoProblem()
|
||||
{
|
||||
var product = ValidProduct();
|
||||
var deactivated = product.WithDeactivated(_time);
|
||||
|
||||
var act = () => deactivated.WithDeactivated(_time);
|
||||
|
||||
act.Should().NotThrow();
|
||||
act().IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── R1-S11: WithUpdated ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithUpdated_ValidFields_ReturnsNewInstanceWithUpdatedValues()
|
||||
{
|
||||
var product = ValidProduct();
|
||||
var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var updated = product.WithUpdated("Nuevo Nombre", rubroId: null, basePrice: 200m, priceDurationDays: 15, tp2);
|
||||
|
||||
updated.Nombre.Should().Be("Nuevo Nombre");
|
||||
updated.BasePrice.Should().Be(200m);
|
||||
updated.PriceDurationDays.Should().Be(15);
|
||||
updated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
|
||||
// Immutability: original unchanged
|
||||
product.Nombre.Should().Be("Clasificado Estándar");
|
||||
product.BasePrice.Should().Be(100.50m);
|
||||
}
|
||||
|
||||
// ── Immutability: With* return new instances ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void WithRenamed_ReturnsNewInstance()
|
||||
{
|
||||
var p = ValidProduct();
|
||||
var renamed = p.WithRenamed("Nuevo", _time);
|
||||
|
||||
renamed.Should().NotBeSameAs(p);
|
||||
renamed.Nombre.Should().Be("Nuevo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithUpdatedPrice_ReturnsNewInstance()
|
||||
{
|
||||
var p = ValidProduct();
|
||||
var updated = p.WithUpdatedPrice(999m, null, _time);
|
||||
|
||||
updated.Should().NotBeSameAs(p);
|
||||
updated.BasePrice.Should().Be(999m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithUpdatedCategory_ReturnsNewInstance()
|
||||
{
|
||||
var p = ValidProduct();
|
||||
var updated = p.WithUpdatedCategory(5, _time);
|
||||
|
||||
updated.Should().NotBeSameAs(p);
|
||||
updated.RubroId.Should().Be(5);
|
||||
}
|
||||
|
||||
// ── MedioId and ProductTypeId are immutable ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Product_HasNoMethodToChangeMedioId()
|
||||
{
|
||||
var type = typeof(Product);
|
||||
var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
methods.Should().NotContain(m => m.Name.Contains("Medio") && m.Name.StartsWith("With"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Product_HasNoMethodToChangeProductTypeId()
|
||||
{
|
||||
var type = typeof(Product);
|
||||
var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
methods.Should().NotContain(m => m.Name.Contains("ProductType") && m.Name.StartsWith("With"));
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
||||
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
|
||||
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
||||
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
|
||||
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
|
||||
Assert.Equal(26, list.Count);
|
||||
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
|
||||
// + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
|
||||
Assert.Equal(27, list.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos()
|
||||
public async Task GetByRolCodigoAsync_Admin_Returns27Permisos()
|
||||
{
|
||||
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
||||
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
||||
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
|
||||
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total
|
||||
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar'
|
||||
// + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total
|
||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||
|
||||
Assert.Equal(26, permisos.Count);
|
||||
Assert.Equal(27, permisos.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Application.Products;
|
||||
|
||||
namespace SIGCM2.Application.Tests.ProductTypes;
|
||||
|
||||
public class NullProductQueryRepositoryTests
|
||||
{
|
||||
private readonly NullProductQueryRepository _sut = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_AlwaysReturnsFalse()
|
||||
{
|
||||
var result = await _sut.ExistsActiveByProductTypeAsync(productTypeId: 1);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_WithCancellationToken_DoesNotThrow()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var act = async () => await _sut.ExistsActiveByProductTypeAsync(productTypeId: 999, ct: cts.Token);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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.Create;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — CreateProductCommandHandler tests.
|
||||
/// Covers: happy path, flags coherence, duplicate nombre, inactive Medio/ProductType/Rubro,
|
||||
/// audit, immutability, rollback.
|
||||
/// </summary>
|
||||
public class CreateProductCommandHandlerTests
|
||||
{
|
||||
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||||
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
|
||||
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
|
||||
private readonly IRubroRepository _rubroRepo = Substitute.For<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
private readonly CreateProductCommandHandler _handler;
|
||||
|
||||
private static readonly ProductType _activePtNoFlags = new(
|
||||
id: 2, nombre: "Clasificado",
|
||||
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static readonly ProductType _activePtRequiresCategory = new(
|
||||
id: 3, nombre: "Con Rubro",
|
||||
hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static readonly ProductType _activePtHasDuration = new(
|
||||
id: 4, nombre: "Con Duración",
|
||||
hasDuration: true, requiresText: false, requiresCategory: false, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static readonly ProductType _inactivePt = new(
|
||||
id: 5, nombre: "Inactivo",
|
||||
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: false,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static Medio ActiveMedio(int id = 1) => new(
|
||||
id: id, codigo: "ELD", nombre: "El Día",
|
||||
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
|
||||
activo: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static Medio InactiveMedio(int id = 1) => new(
|
||||
id: id, codigo: "ELD", nombre: "El Día",
|
||||
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
|
||||
activo: false,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static Rubro ActiveRubro(int id = 10) => new(
|
||||
id: id, parentId: null, nombre: "Clasificados", orden: 1,
|
||||
activo: true, tarifarioBaseId: null,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static Rubro InactiveRubro(int id = 10) => new(
|
||||
id: id, parentId: null, nombre: "Clasificados", orden: 1,
|
||||
activo: false, tarifarioBaseId: null,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
public CreateProductCommandHandlerTests()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(1));
|
||||
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns(_activePtNoFlags);
|
||||
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||
_handler = new CreateProductCommandHandler(_repo, _ptRepo, _medioRepo, _rubroRepo, _audit, _time);
|
||||
}
|
||||
|
||||
private static CreateProductCommand ValidCmd(int productTypeId = 2) => new(
|
||||
Nombre: "Clasificado Estándar",
|
||||
MedioId: 1,
|
||||
ProductTypeId: productTypeId,
|
||||
RubroId: null,
|
||||
BasePrice: 100.50m,
|
||||
PriceDurationDays: null);
|
||||
|
||||
// ── Happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
|
||||
{
|
||||
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(42);
|
||||
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Id.Should().Be(42);
|
||||
result.Nombre.Should().Be("Clasificado Estándar");
|
||||
result.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoCreated()
|
||||
{
|
||||
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(7);
|
||||
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "producto.created",
|
||||
targetType: "Product",
|
||||
targetId: "7",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
|
||||
{
|
||||
var expectedDate = _time.GetUtcNow().UtcDateTime;
|
||||
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).AddAsync(
|
||||
Arg.Is<Product>(p => p.FechaCreacion == expectedDate),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Medio not found / inactive ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((Medio?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<MedioNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
|
||||
{
|
||||
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveMedio(1));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<MedioInactivoException>();
|
||||
}
|
||||
|
||||
// ── ProductType not found / inactive ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ProductTypeNotFound_ThrowsProductTypeNotFoundException()
|
||||
{
|
||||
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<ProductTypeNotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ProductTypeInactivo_ThrowsProductTypeInactivoException()
|
||||
{
|
||||
_ptRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(_inactivePt);
|
||||
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(1));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd(productTypeId: 5));
|
||||
|
||||
await act.Should().ThrowAsync<ProductTypeInactivoException>();
|
||||
}
|
||||
|
||||
// ── Flags coherence ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
|
||||
{
|
||||
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||||
var cmd = ValidCmd(productTypeId: 3) with { RubroId = null };
|
||||
|
||||
var act = async () => await _handler.Handle(cmd);
|
||||
|
||||
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_HasDurationTrue_PriceDurationDaysNull_ThrowsFlagsException()
|
||||
{
|
||||
_ptRepo.GetByIdAsync(4, Arg.Any<CancellationToken>()).Returns(_activePtHasDuration);
|
||||
var cmd = ValidCmd(productTypeId: 4) with { PriceDurationDays = null };
|
||||
|
||||
var act = async () => await _handler.Handle(cmd);
|
||||
|
||||
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||||
}
|
||||
|
||||
// ── Rubro validation ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RubroProvided_RubroInactivo_ThrowsRubroInactivoException()
|
||||
{
|
||||
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||||
_rubroRepo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(InactiveRubro(10));
|
||||
var cmd = ValidCmd(productTypeId: 3) with { RubroId = 10 };
|
||||
|
||||
var act = async () => await _handler.Handle(cmd);
|
||||
|
||||
await act.Should().ThrowAsync<RubroInactivoException>();
|
||||
}
|
||||
|
||||
// ── Duplicate nombre ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NombreDuplicadoEnMedioTipo_ThrowsProductNombreDuplicadoException()
|
||||
{
|
||||
_repo.ExistsByNombreAsync("Clasificado Estándar", 1, 2, null, Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<ProductNombreDuplicadoEnMedioTipoException>();
|
||||
}
|
||||
|
||||
// ── Rollback ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback()
|
||||
{
|
||||
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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.Deactivate;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Deactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — DeactivateProductCommandHandler tests.
|
||||
/// </summary>
|
||||
public class DeactivateProductCommandHandlerTests
|
||||
{
|
||||
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
|
||||
private readonly DeactivateProductCommandHandler _handler;
|
||||
|
||||
public DeactivateProductCommandHandlerTests()
|
||||
{
|
||||
_handler = new DeactivateProductCommandHandler(_repo, _audit, _time);
|
||||
}
|
||||
|
||||
private static Product ActiveProduct(int id = 1) => new(
|
||||
id: id, nombre: "Clasificado Estándar",
|
||||
medioId: 1, productTypeId: 2, rubroId: null,
|
||||
basePrice: 100.50m, priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
fechaModificacion: null);
|
||||
|
||||
private static Product InactiveProduct(int id = 1) => new(
|
||||
id: id, nombre: "Clasificado Estándar",
|
||||
medioId: 1, productTypeId: 2, rubroId: null,
|
||||
basePrice: 100.50m, priceDurationDays: null,
|
||||
isActive: false,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
fechaModificacion: null);
|
||||
|
||||
// ── Not found ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsProductNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(new DeactivateProductCommand(99));
|
||||
|
||||
await act.Should().ThrowAsync<ProductNotFoundException>()
|
||||
.Where(e => e.ProductId == 99);
|
||||
}
|
||||
|
||||
// ── Already inactive (idempotent) ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlreadyInactive_ReturnsDto_NoAudit_NoRepoUpdate()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveProduct());
|
||||
|
||||
var result = await _handler.Handle(new DeactivateProductCommand(1));
|
||||
|
||||
result.Id.Should().Be(1);
|
||||
result.IsActive.Should().BeFalse();
|
||||
await _repo.DidNotReceive().UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ActiveProduct_DeactivatesAndAudits()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
|
||||
|
||||
await _handler.Handle(new DeactivateProductCommand(1));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<Product>(p => !p.IsActive),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "producto.deactivated",
|
||||
targetType: "Product",
|
||||
targetId: "1",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UsesTimeProviderInDeactivate()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
|
||||
var expectedDate = _time.GetUtcNow().UtcDateTime;
|
||||
|
||||
await _handler.Handle(new DeactivateProductCommand(1));
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<Product>(p => p.FechaModificacion == expectedDate),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsDtoWithIsActiveFalse()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
|
||||
|
||||
var result = await _handler.Handle(new DeactivateProductCommand(1));
|
||||
|
||||
result.IsActive.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Rollback ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RepoThrows_AuditNotCalled()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
|
||||
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||||
|
||||
var act = async () => await _handler.Handle(new DeactivateProductCommand(1));
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Tests for new Product domain exceptions.
|
||||
/// Verifies message content and property values.
|
||||
/// </summary>
|
||||
public class ProductExceptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductNotFoundException_ContainsId_InMessage()
|
||||
{
|
||||
var ex = new ProductNotFoundException(5);
|
||||
|
||||
ex.Message.Should().Contain("5");
|
||||
ex.ProductId.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductNombreDuplicadoEnMedioTipoException_ContainsDetails()
|
||||
{
|
||||
var ex = new ProductNombreDuplicadoEnMedioTipoException(2, 3, "Clasificado");
|
||||
|
||||
ex.Message.Should().Contain("Clasificado");
|
||||
ex.Message.Should().Contain("2");
|
||||
ex.Message.Should().Contain("3");
|
||||
ex.MedioId.Should().Be(2);
|
||||
ex.ProductTypeId.Should().Be(3);
|
||||
ex.Nombre.Should().Be("Clasificado");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductTipoFlagsIncoherentesException_FieldAndMessage()
|
||||
{
|
||||
var ex = new ProductTipoFlagsIncoherentesException("requiere RubroId", "rubroId");
|
||||
|
||||
ex.Field.Should().Be("rubroId");
|
||||
ex.Message.Should().Contain("requiere RubroId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductTypeInactivoException_ContainsProductTypeId()
|
||||
{
|
||||
var ex = new ProductTypeInactivoException(7);
|
||||
|
||||
ex.Message.Should().Contain("7");
|
||||
ex.ProductTypeId.Should().Be(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RubroInactivoException_ContainsRubroId()
|
||||
{
|
||||
var ex = new RubroInactivoException(12);
|
||||
|
||||
ex.Message.Should().Contain("12");
|
||||
ex.RubroId.Should().Be(12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.GetById;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — GetProductByIdQueryHandler tests.
|
||||
/// </summary>
|
||||
public class GetProductByIdQueryHandlerTests
|
||||
{
|
||||
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||||
private readonly GetProductByIdQueryHandler _handler;
|
||||
|
||||
public GetProductByIdQueryHandlerTests()
|
||||
{
|
||||
_handler = new GetProductByIdQueryHandler(_repo);
|
||||
}
|
||||
|
||||
private static Product AProduct(int id = 1) => new(
|
||||
id: id,
|
||||
nombre: "Clasificado Estándar",
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50m,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
fechaModificacion: null);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingId_ReturnsMappedDto()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
|
||||
|
||||
var result = await _handler.Handle(new GetProductByIdQuery(1));
|
||||
|
||||
result.Id.Should().Be(1);
|
||||
result.Nombre.Should().Be("Clasificado Estándar");
|
||||
result.MedioId.Should().Be(1);
|
||||
result.ProductTypeId.Should().Be(2);
|
||||
result.BasePrice.Should().Be(100.50m);
|
||||
result.IsActive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsProductNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(new GetProductByIdQuery(99));
|
||||
|
||||
await act.Should().ThrowAsync<ProductNotFoundException>()
|
||||
.Where(e => e.ProductId == 99);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.List;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — ListProductsQueryHandler tests.
|
||||
/// </summary>
|
||||
public class ListProductsQueryHandlerTests
|
||||
{
|
||||
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||||
private readonly ListProductsQueryHandler _handler;
|
||||
|
||||
public ListProductsQueryHandlerTests()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<Product>([], 1, 20, 0));
|
||||
_handler = new ListProductsQueryHandler(_repo);
|
||||
}
|
||||
|
||||
private static Product AProduct(int id = 1) => new(
|
||||
id: id,
|
||||
nombre: "Clasificado",
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 50m,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
fechaModificacion: null);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyPage_ReturnsPaged_WithZeroTotal()
|
||||
{
|
||||
var result = await _handler.Handle(new ListProductsQuery());
|
||||
|
||||
result.Total.Should().Be(0);
|
||||
result.Items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithItems_ReturnsMappedListItemDtos()
|
||||
{
|
||||
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedResult<Product>([AProduct(1), AProduct(2)], 1, 20, 2));
|
||||
|
||||
var result = await _handler.Handle(new ListProductsQuery());
|
||||
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items[0].Id.Should().Be(1);
|
||||
result.Items[1].Id.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageNormalization_ClampsPageSizeTo100()
|
||||
{
|
||||
await _handler.Handle(new ListProductsQuery(Page: 1, PageSize: 200));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<ProductsQuery>(q => q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PageBelowOne_NormalizesToOne()
|
||||
{
|
||||
await _handler.Handle(new ListProductsQuery(Page: -1));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<ProductsQuery>(q => q.Page == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesFiltersToRepo()
|
||||
{
|
||||
await _handler.Handle(new ListProductsQuery(MedioId: 5, ProductTypeId: 3, RubroId: 7));
|
||||
|
||||
await _repo.Received(1).GetPagedAsync(
|
||||
Arg.Is<ProductsQuery>(q => q.MedioId == 5 && q.ProductTypeId == 3 && q.RubroId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App.
|
||||
/// These tests verify the real Dapper implementation replaces NullProductQueryRepository.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class ProductQueryRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqlTestFixture _db;
|
||||
private ProductQueryRepository _repository = null!;
|
||||
|
||||
public ProductQueryRepositoryTests(SqlTestFixture db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _db.ResetAndSeedAsync();
|
||||
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
|
||||
_repository = new ProductQueryRepository(factory);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── ExistsActiveByProductTypeAsync ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_NoProducts_ReturnsFalse()
|
||||
{
|
||||
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId: 999);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_WithActiveProduct_ReturnsTrue()
|
||||
{
|
||||
// Arrange: insert a ProductType and an active Product referencing it
|
||||
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
|
||||
await InsertActiveProductAsync(medioId, productTypeId);
|
||||
|
||||
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_WithOnlyInactiveProduct_ReturnsFalse()
|
||||
{
|
||||
// Arrange: insert an inactive product
|
||||
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
|
||||
await InsertInactiveProductAsync(medioId, productTypeId);
|
||||
|
||||
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsActiveByProductTypeAsync_DifferentProductType_ReturnsFalse()
|
||||
{
|
||||
// Arrange: insert active product for productTypeId=A, query for productTypeId=B
|
||||
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
|
||||
await InsertActiveProductAsync(medioId, productTypeId);
|
||||
var otherProductTypeId = productTypeId + 100;
|
||||
|
||||
var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync()
|
||||
{
|
||||
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var medioId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('TM', 'Test Medio', 1, 1)
|
||||
""");
|
||||
|
||||
var productTypeId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('Test Type', 0, 0, 0, 0, 0)
|
||||
""");
|
||||
|
||||
return (medioId, productTypeId);
|
||||
}
|
||||
|
||||
private async Task InsertActiveProductAsync(int medioId, int productTypeId)
|
||||
{
|
||||
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion)
|
||||
VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME())
|
||||
""", new { MedioId = medioId, ProductTypeId = productTypeId });
|
||||
}
|
||||
|
||||
private async Task InsertInactiveProductAsync(int medioId, int productTypeId)
|
||||
{
|
||||
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
await conn.ExecuteAsync("""
|
||||
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion)
|
||||
VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME())
|
||||
""", new { MedioId = medioId, ProductTypeId = productTypeId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Integration tests for ProductRepository against SIGCM2_Test_App.
|
||||
/// Uses shared SqlTestFixture via [Collection("Database")] — fixture manages Respawn + seeds.
|
||||
/// Verifies full CRUD, paged listing, UQ constraint, and temporal history.
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class ProductRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SqlTestFixture _db;
|
||||
private ProductRepository _repository = null!;
|
||||
private int _defaultMedioId;
|
||||
private int _defaultProductTypeId;
|
||||
|
||||
public ProductRepositoryTests(SqlTestFixture db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _db.ResetAndSeedAsync();
|
||||
|
||||
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
|
||||
_repository = new ProductRepository(factory);
|
||||
|
||||
// Insert Medio and ProductType for use across all tests
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
|
||||
_defaultMedioId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('PR', 'Prueba Medio', 1, 1)
|
||||
""");
|
||||
|
||||
_defaultProductTypeId = await conn.ExecuteScalarAsync<int>("""
|
||||
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES ('Tipo Prueba', 0, 0, 0, 0, 0)
|
||||
""");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private Product AProduct(string nombre = "Clasificado Test") =>
|
||||
Product.ForCreation(
|
||||
nombre: nombre,
|
||||
medioId: _defaultMedioId,
|
||||
productTypeId: _defaultProductTypeId,
|
||||
rubroId: null,
|
||||
basePrice: 100.50m,
|
||||
priceDurationDays: null,
|
||||
timeProvider: TimeProvider.System);
|
||||
|
||||
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_AndGetById_ReturnsAllFields()
|
||||
{
|
||||
var product = AProduct("Mi Producto");
|
||||
var id = await _repository.AddAsync(product);
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(id);
|
||||
result.Nombre.Should().Be("Mi Producto");
|
||||
result.MedioId.Should().Be(_defaultMedioId);
|
||||
result.ProductTypeId.Should().Be(_defaultProductTypeId);
|
||||
result.RubroId.Should().BeNull();
|
||||
result.BasePrice.Should().Be(100.50m);
|
||||
result.PriceDurationDays.Should().BeNull();
|
||||
result.IsActive.Should().BeTrue();
|
||||
result.FechaCreacion.Should().BeAfter(DateTime.MinValue);
|
||||
result.FechaModificacion.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── GetByIdAsync null for unknown ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
var result = await _repository.GetByIdAsync(999999);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── UpdateAsync ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ChangesNombreAndBasePrice()
|
||||
{
|
||||
var id = await _repository.AddAsync(AProduct("Original"));
|
||||
var product = await _repository.GetByIdAsync(id);
|
||||
var updated = product!.WithUpdated("Renombrado", null, 200m, null, TimeProvider.System);
|
||||
|
||||
await _repository.UpdateAsync(updated);
|
||||
var result = await _repository.GetByIdAsync(id);
|
||||
|
||||
result!.Nombre.Should().Be("Renombrado");
|
||||
result.BasePrice.Should().Be(200m);
|
||||
result.FechaModificacion.Should().NotBeNull();
|
||||
}
|
||||
|
||||
// ── WithDeactivated creates history row ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_Deactivate_CreatesHistoryRow()
|
||||
{
|
||||
var id = await _repository.AddAsync(AProduct("Para Desactivar"));
|
||||
var product = await _repository.GetByIdAsync(id);
|
||||
var deactivated = product!.WithDeactivated(TimeProvider.System);
|
||||
|
||||
await _repository.UpdateAsync(deactivated);
|
||||
|
||||
// Verify temporal history: ProductType_History should have at least 1 row
|
||||
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||
await conn.OpenAsync();
|
||||
var historyCount = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM dbo.Product_History WHERE Id = @Id", new { Id = id });
|
||||
historyCount.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// ── GetPagedAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_DefaultQuery_ReturnsActiveProducts()
|
||||
{
|
||||
await _repository.AddAsync(AProduct("Producto A"));
|
||||
await _repository.AddAsync(AProduct("Producto B"));
|
||||
|
||||
var result = await _repository.GetPagedAsync(new ProductsQuery(Page: 1, PageSize: 20, Activo: true));
|
||||
|
||||
result.Items.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
result.Items.Should().AllSatisfy(p => p.IsActive.Should().BeTrue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_FilterByMedioId_ReturnsOnlyMatching()
|
||||
{
|
||||
await _repository.AddAsync(AProduct("Producto Filtrado"));
|
||||
|
||||
var result = await _repository.GetPagedAsync(
|
||||
new ProductsQuery(Page: 1, PageSize: 20, Activo: null, MedioId: _defaultMedioId));
|
||||
|
||||
result.Items.Should().AllSatisfy(p => p.MedioId.Should().Be(_defaultMedioId));
|
||||
}
|
||||
|
||||
// ── ExistsByNombreAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreAsync_ExistingActiveProduct_ReturnsTrue()
|
||||
{
|
||||
var nombre = "Nombre Unico Test";
|
||||
await _repository.AddAsync(AProduct(nombre));
|
||||
|
||||
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId);
|
||||
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreAsync_ExcludeSelf_ReturnsFalse()
|
||||
{
|
||||
var nombre = "Nombre Self Excluido";
|
||||
var id = await _repository.AddAsync(AProduct(nombre));
|
||||
|
||||
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId, excludeId: id);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreAsync_NonExisting_ReturnsFalse()
|
||||
{
|
||||
var result = await _repository.ExistsByNombreAsync("Nombre Que No Existe XYZ", _defaultMedioId, _defaultProductTypeId);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── UQ index: deactivated allows reuse of name ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsByNombreAsync_DeactivatedProduct_ReturnsFalse_AllowsReuse()
|
||||
{
|
||||
var nombre = "Nombre Reutilizable";
|
||||
var id = await _repository.AddAsync(AProduct(nombre));
|
||||
var product = await _repository.GetByIdAsync(id);
|
||||
await _repository.UpdateAsync(product!.WithDeactivated(TimeProvider.System));
|
||||
|
||||
// After deactivation, name should be available again
|
||||
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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.Update;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Update;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — UpdateProductCommandHandler tests.
|
||||
/// </summary>
|
||||
public class UpdateProductCommandHandlerTests
|
||||
{
|
||||
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
|
||||
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
|
||||
private readonly IRubroRepository _rubroRepo = Substitute.For<IRubroRepository>();
|
||||
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||
private readonly UpdateProductCommandHandler _handler;
|
||||
|
||||
private static readonly ProductType _activePtNoFlags = new(
|
||||
id: 2, nombre: "Clasificado",
|
||||
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static readonly ProductType _activePtRequiresCategory = new(
|
||||
id: 3, nombre: "Con Rubro",
|
||||
hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false,
|
||||
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
|
||||
|
||||
private static Product AProduct(int id = 1, int productTypeId = 2) => new(
|
||||
id: id, nombre: "Clasificado Estándar",
|
||||
medioId: 1, productTypeId: productTypeId, rubroId: null,
|
||||
basePrice: 100.50m, priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
fechaModificacion: null);
|
||||
|
||||
public UpdateProductCommandHandlerTests()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
|
||||
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns(_activePtNoFlags);
|
||||
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
_handler = new UpdateProductCommandHandler(_repo, _ptRepo, _rubroRepo, _audit, _time);
|
||||
}
|
||||
|
||||
private static UpdateProductCommand ValidCmd() => new(
|
||||
Id: 1,
|
||||
Nombre: "Nuevo Nombre",
|
||||
RubroId: null,
|
||||
BasePrice: 200m,
|
||||
PriceDurationDays: null);
|
||||
|
||||
// ── Happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCommand_UpdatesAndReturnsDto()
|
||||
{
|
||||
var result = await _handler.Handle(ValidCmd());
|
||||
|
||||
result.Id.Should().Be(1);
|
||||
result.Nombre.Should().Be("Nuevo Nombre");
|
||||
result.BasePrice.Should().Be(200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCommand_CallsUpdateAsync()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _repo.Received(1).UpdateAsync(
|
||||
Arg.Is<Product>(p => p.Nombre == "Nuevo Nombre"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoUpdated()
|
||||
{
|
||||
await _handler.Handle(ValidCmd());
|
||||
|
||||
await _audit.Received(1).LogAsync(
|
||||
action: "producto.updated",
|
||||
targetType: "Product",
|
||||
targetId: "1",
|
||||
metadata: Arg.Any<object?>(),
|
||||
ct: Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Not found ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotFound_ThrowsProductNotFoundException()
|
||||
{
|
||||
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd() with { Id = 99 });
|
||||
|
||||
await act.Should().ThrowAsync<ProductNotFoundException>();
|
||||
}
|
||||
|
||||
// ── Flags coherence ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
|
||||
{
|
||||
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1, productTypeId: 3));
|
||||
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd() with { RubroId = null });
|
||||
|
||||
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
|
||||
}
|
||||
|
||||
// ── Duplicate nombre ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NombreDuplicado_ThrowsProductNombreDuplicadoException()
|
||||
{
|
||||
_repo.ExistsByNombreAsync("Nuevo Nombre", 1, 2, 1, Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
|
||||
await act.Should().ThrowAsync<ProductNombreDuplicadoEnMedioTipoException>();
|
||||
}
|
||||
|
||||
// ── Rollback ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RepoThrows_AuditNotCalled()
|
||||
{
|
||||
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||||
|
||||
var act = async () => await _handler.Handle(ValidCmd());
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
|
||||
await _audit.DidNotReceive().LogAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
|
||||
namespace SIGCM2.Application.Tests.Products.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Validator tests for CreateProductCommand and UpdateProductCommand.
|
||||
/// </summary>
|
||||
public class ProductValidatorsTests
|
||||
{
|
||||
private readonly CreateProductCommandValidator _createValidator = new();
|
||||
private readonly UpdateProductCommandValidator _updateValidator = new();
|
||||
|
||||
// ── Create: Nombre ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_NombreEmpty_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { Nombre = "" };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_NombreWhitespace_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { Nombre = " " };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_NombreOver300Chars_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { Nombre = new string('X', 301) };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||
}
|
||||
|
||||
// ── Create: MedioId / ProductTypeId ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_MedioIdZero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { MedioId = 0 };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.MedioId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ProductTypeIdZero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { ProductTypeId = 0 };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductTypeId);
|
||||
}
|
||||
|
||||
// ── Create: BasePrice ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_NegativeBasePrice_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { BasePrice = -0.01m };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.BasePrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ZeroBasePrice_Passes()
|
||||
{
|
||||
var cmd = ValidCreate() with { BasePrice = 0m };
|
||||
_createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.BasePrice);
|
||||
}
|
||||
|
||||
// ── Create: PriceDurationDays ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_PriceDurationDaysZero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { PriceDurationDays = 0 };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_PriceDurationDaysNegative_FailsValidation()
|
||||
{
|
||||
var cmd = ValidCreate() with { PriceDurationDays = -1 };
|
||||
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_PriceDurationDaysNull_Passes()
|
||||
{
|
||||
var cmd = ValidCreate() with { PriceDurationDays = null };
|
||||
_createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceDurationDays);
|
||||
}
|
||||
|
||||
// ── Create: valid ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Create_ValidCommand_Passes()
|
||||
{
|
||||
_createValidator.TestValidate(ValidCreate()).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
// ── Update: Id must be > 0 ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Update_IdZero_FailsValidation()
|
||||
{
|
||||
var cmd = ValidUpdate() with { Id = 0 };
|
||||
_updateValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ValidCommand_Passes()
|
||||
{
|
||||
_updateValidator.TestValidate(ValidUpdate()).ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static CreateProductCommand ValidCreate() => new(
|
||||
Nombre: "Clasificado Estándar",
|
||||
MedioId: 1,
|
||||
ProductTypeId: 2,
|
||||
RubroId: null,
|
||||
BasePrice: 100.50m,
|
||||
PriceDurationDays: null);
|
||||
|
||||
private static UpdateProductCommand ValidUpdate() => new(
|
||||
Id: 1,
|
||||
Nombre: "Clasificado Estándar",
|
||||
RubroId: null,
|
||||
BasePrice: 100.50m,
|
||||
PriceDurationDays: null);
|
||||
}
|
||||
@@ -63,6 +63,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
|
||||
await EnsureV017SchemaAsync();
|
||||
|
||||
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
|
||||
await EnsureV018SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
||||
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"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
-- V014 (ADM-009): permiso para tablas fiscales
|
||||
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
|
||||
-- V016 (CAT-001): permiso para gestionar árbol de rubros
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'),
|
||||
-- V017 (PRD-001): permiso para gestionar tipos de producto
|
||||
('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'),
|
||||
-- V018 (PRD-002): permiso para gestionar productos del catálogo
|
||||
('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'administracion:fiscal:gestionar'),
|
||||
-- V016 (CAT-001)
|
||||
('admin', 'catalogo:rubros:gestionar'),
|
||||
-- V017 (PRD-001)
|
||||
('admin', 'catalogo:tipos:gestionar'),
|
||||
-- V018 (PRD-002)
|
||||
('admin', 'catalogo:productos:gestionar'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
@@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(createUqIndex);
|
||||
await _connection.ExecuteAsync(createCoveringIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes
|
||||
/// idempotentemente. Mirrors V018__create_product.sql.
|
||||
/// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran
|
||||
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||
/// </summary>
|
||||
public async Task EnsureV018SchemaAsync()
|
||||
{
|
||||
const string createProduct = """
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Product (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
|
||||
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
MedioId INT NOT NULL,
|
||||
ProductTypeId INT NOT NULL,
|
||||
RubroId INT NULL,
|
||||
BasePrice DECIMAL(18,4) NOT NULL,
|
||||
PriceDurationDays INT NULL,
|
||||
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
|
||||
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
|
||||
);
|
||||
END
|
||||
""";
|
||||
|
||||
const string addProductPeriod = """
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
END
|
||||
""";
|
||||
|
||||
const string setProductVersioning = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Product_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
END
|
||||
""";
|
||||
|
||||
const string createUqIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
|
||||
ON dbo.Product (MedioId, ProductTypeId, Nombre)
|
||||
WHERE IsActive = 1;
|
||||
END
|
||||
""";
|
||||
|
||||
const string createProductTypeIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_ProductTypeId_IsActive
|
||||
ON dbo.Product (ProductTypeId, IsActive);
|
||||
END
|
||||
""";
|
||||
|
||||
const string createMedioIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_MedioId_IsActive
|
||||
ON dbo.Product (MedioId, IsActive);
|
||||
END
|
||||
""";
|
||||
|
||||
const string createRubroIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_RubroId_IsActive
|
||||
ON dbo.Product (RubroId, IsActive)
|
||||
WHERE RubroId IS NOT NULL;
|
||||
END
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(createProduct);
|
||||
await _connection.ExecuteAsync(addProductPeriod);
|
||||
await _connection.ExecuteAsync(setProductVersioning);
|
||||
await _connection.ExecuteAsync(createUqIndex);
|
||||
await _connection.ExecuteAsync(createProductTypeIdx);
|
||||
await _connection.ExecuteAsync(createMedioIdx);
|
||||
await _connection.ExecuteAsync(createRubroIdx);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user