Compare commits
15 Commits
0462970ea1
...
1730b0623e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1730b0623e | |||
| d7fb3105fa | |||
| b4f17d6961 | |||
| a7cfcdb683 | |||
| 0f5455aba6 | |||
| 2b79b6f769 | |||
| d262454b28 | |||
| 08a4738daf | |||
| a41a4ea341 | |||
| 165abc8245 | |||
| 733ca0e2e2 | |||
| 8c9a50504d | |||
| bb455be745 | |||
| 8b555e1f8b | |||
| 16197cf242 |
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;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
context.Result = new ObjectResult(new
|
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.Rubros.Dtos;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Avisos;
|
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.Create;
|
||||||
using SIGCM2.Application.ProductTypes.Update;
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
@@ -171,9 +175,15 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
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)
|
// ProductTypes (PRD-001)
|
||||||
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||||
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
|
||||||
|
|
||||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
|
|||||||
if (!target.IsActive)
|
if (!target.IsActive)
|
||||||
return new ProductTypeStatusDto(command.Id, false);
|
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);
|
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
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.
|
|
||||||
|
|
||||||
// 4. Deactivate (immutable — returns new instance)
|
// 4. Deactivate (immutable — returns new instance)
|
||||||
var deactivated = target.WithDeactivated(_timeProvider);
|
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<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||||
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
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
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
Store,
|
||||||
Tag,
|
Tag,
|
||||||
Layers,
|
Layers,
|
||||||
|
Package,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Layers,
|
icon: Layers,
|
||||||
requiredPermission: 'catalogo:tipos:gestionar',
|
requiredPermission: 'catalogo:tipos:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Productos',
|
||||||
|
href: '/admin/products',
|
||||||
|
icon: Package,
|
||||||
|
requiredPermission: 'catalogo:productos:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
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 { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||||
|
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</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
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, permisos.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, permisos.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: invalid credentials return 401 with opaque error
|
// Scenario: invalid credentials return 401 with opaque error
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPermisos_WithAdmin_Returns200With26Items()
|
public async Task GetPermisos_WithAdmin_Returns200With27Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
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
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, list.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
|
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
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
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||||
Assert.Equal(26, list.GetArrayLength());
|
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||||
|
Assert.Equal(27, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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'
|
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
|
||||||
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
||||||
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
|
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
|
||||||
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
|
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
|
||||||
Assert.Equal(26, list.Count);
|
// + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
|
||||||
|
Assert.Equal(27, list.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
|||||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[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)
|
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||||
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
||||||
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
||||||
// + 1 from V016 (CAT-001): 'catalogo:rubros: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");
|
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||||
|
|
||||||
Assert.Equal(26, permisos.Count);
|
Assert.Equal(27, permisos.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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'.
|
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
|
||||||
await EnsureV017SchemaAsync();
|
await EnsureV017SchemaAsync();
|
||||||
|
|
||||||
|
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
|
||||||
|
await EnsureV018SchemaAsync();
|
||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
@@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||||
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
||||||
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||||
|
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
|
||||||
|
new Respawn.Graph.Table("dbo", "Product_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
-- V014 (ADM-009): permiso para tablas fiscales
|
-- V014 (ADM-009): permiso para tablas fiscales
|
||||||
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
|
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
|
||||||
-- V016 (CAT-001): permiso para gestionar árbol de rubros
|
-- 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)
|
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||||
ON t.Codigo = s.Codigo
|
ON t.Codigo = s.Codigo
|
||||||
WHEN NOT MATCHED BY TARGET THEN
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
@@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
('admin', 'administracion:fiscal:gestionar'),
|
('admin', 'administracion:fiscal:gestionar'),
|
||||||
-- V016 (CAT-001)
|
-- V016 (CAT-001)
|
||||||
('admin', 'catalogo:rubros:gestionar'),
|
('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:crear'),
|
||||||
('cajero', 'ventas:contado:modificar'),
|
('cajero', 'ventas:contado:modificar'),
|
||||||
('cajero', 'ventas:contado:cobrar'),
|
('cajero', 'ventas:contado:cobrar'),
|
||||||
@@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
await _connection.ExecuteAsync(createUqIndex);
|
await _connection.ExecuteAsync(createUqIndex);
|
||||||
await _connection.ExecuteAsync(createCoveringIndex);
|
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