Compare commits
10 Commits
8c9a50504d
...
d7fb3105fa
| Author | SHA1 | Date | |
|---|---|---|---|
| d7fb3105fa | |||
| b4f17d6961 | |||
| a7cfcdb683 | |||
| 0f5455aba6 | |||
| 2b79b6f769 | |||
| d262454b28 | |||
| 08a4738daf | |||
| a41a4ea341 | |||
| 165abc8245 | |||
| 733ca0e2e2 |
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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -19,16 +19,19 @@ public sealed class ProductQueryRepository : IProductQueryRepository
|
|||||||
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT COUNT(1)
|
SELECT CASE
|
||||||
FROM dbo.Product
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM dbo.Product
|
||||||
WHERE ProductTypeId = @ProductTypeId
|
WHERE ProductTypeId = @ProductTypeId
|
||||||
AND IsActive = 1
|
AND IsActive = 1
|
||||||
|
) THEN 1 ELSE 0
|
||||||
|
END
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = _factory.CreateConnection();
|
await using var connection = _factory.CreateConnection();
|
||||||
await connection.OpenAsync(ct);
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
||||||
return count > 0;
|
return result == 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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