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;
|
||||
break;
|
||||
|
||||
// PRD-002: Product exceptions
|
||||
case ProductNotFoundException productNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_not_found",
|
||||
message = productNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_nombre_duplicado",
|
||||
message = productDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductTipoFlagsIncoherentesException productFlagsEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_flags_incoherentes",
|
||||
field = productFlagsEx.Field,
|
||||
message = productFlagsEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductTypeInactivoException productTypeInactivoEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_type_inactivo",
|
||||
message = productTypeInactivoEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroInactivoException rubroInactivoEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_inactivo",
|
||||
message = rubroInactivoEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-008: PuntoDeVenta exceptions
|
||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Product
|
||||
SELECT CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM dbo.Product
|
||||
WHERE ProductTypeId = @ProductTypeId
|
||||
AND IsActive = 1
|
||||
) THEN 1 ELSE 0
|
||||
END
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
||||
return count > 0;
|
||||
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
||||
return result == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Store,
|
||||
Tag,
|
||||
Layers,
|
||||
Package,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
|
||||
icon: Layers,
|
||||
requiredPermission: 'catalogo:tipos:gestionar',
|
||||
},
|
||||
{
|
||||
label: 'Productos',
|
||||
href: '/admin/products',
|
||||
icon: Package,
|
||||
requiredPermission: 'catalogo:productos:gestionar',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
12
src/web/src/features/products/api/createProduct.ts
Normal file
12
src/web/src/features/products/api/createProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { CreateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function createProduct(
|
||||
payload: CreateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.post<ProductDetail>(
|
||||
'/api/v1/admin/products',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
5
src/web/src/features/products/api/deactivateProduct.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
export async function deactivateProduct(id: number): Promise<void> {
|
||||
await axiosClient.delete(`/api/v1/admin/products/${id}`)
|
||||
}
|
||||
7
src/web/src/features/products/api/getProductById.ts
Normal file
7
src/web/src/features/products/api/getProductById.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ProductDetail } from '../types'
|
||||
|
||||
export async function getProductById(id: number): Promise<ProductDetail> {
|
||||
const response = await axiosClient.get<ProductDetail>(`/api/v1/products/${id}`)
|
||||
return response.data
|
||||
}
|
||||
12
src/web/src/features/products/api/listProducts.ts
Normal file
12
src/web/src/features/products/api/listProducts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { ListProductsParams, PagedResult, ProductListItem } from '../types'
|
||||
|
||||
export async function listProducts(
|
||||
params?: ListProductsParams,
|
||||
): Promise<PagedResult<ProductListItem>> {
|
||||
const response = await axiosClient.get<PagedResult<ProductListItem>>(
|
||||
'/api/v1/products',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
13
src/web/src/features/products/api/updateProduct.ts
Normal file
13
src/web/src/features/products/api/updateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
import type { UpdateProductRequest, ProductDetail } from '../types'
|
||||
|
||||
export async function updateProduct(
|
||||
id: number,
|
||||
payload: UpdateProductRequest,
|
||||
): Promise<ProductDetail> {
|
||||
const response = await axiosClient.put<ProductDetail>(
|
||||
`/api/v1/admin/products/${id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { ProductListItem } from '../types'
|
||||
|
||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveDeactivateError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al desactivar el producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeactivateProductDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product: ProductListItem
|
||||
onConfirm: (id: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DeactivateProductDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onConfirm,
|
||||
}: DeactivateProductDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
async function handleConfirm() {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
await onConfirm(product.id)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(resolveDeactivateError(err))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Desactivar producto</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Desactivar el producto “{product.nombre}”? El producto no
|
||||
aparecerá en los listados activos.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending ? 'Procesando...' : 'Desactivar'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
300
src/web/src/features/products/components/ProductForm.tsx
Normal file
300
src/web/src/features/products/components/ProductForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import type { ProductTypeListItem } from '@/features/product-types/types'
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function nullablePositiveInt() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||
.pipe(z.number().int().positive().nullable())
|
||||
}
|
||||
|
||||
const productFormSchema = z.object({
|
||||
nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'),
|
||||
medioId: z
|
||||
.string()
|
||||
.min(1, 'Medio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Medio requerido')),
|
||||
productTypeId: z
|
||||
.string()
|
||||
.min(1, 'Tipo de producto requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().int().positive('Tipo de producto requerido')),
|
||||
rubroId: nullablePositiveInt(),
|
||||
basePrice: z
|
||||
.string()
|
||||
.min(1, 'Precio requerido')
|
||||
.transform((v) => Number(v))
|
||||
.pipe(z.number().min(0, 'El precio no puede ser negativo')),
|
||||
priceDurationDays: nullablePositiveInt(),
|
||||
})
|
||||
|
||||
// Raw form field types (strings before zod transforms)
|
||||
type ProductFormRaw = {
|
||||
nombre: string
|
||||
medioId: string
|
||||
productTypeId: string
|
||||
rubroId: string
|
||||
basePrice: string
|
||||
priceDurationDays: string
|
||||
}
|
||||
|
||||
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||
export type ProductFormOutput = {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductFormDefaultValues {
|
||||
nombre?: string
|
||||
medioId?: number | null
|
||||
productTypeId?: number | null
|
||||
rubroId?: number | null
|
||||
basePrice?: number | null
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
productTypes: ProductTypeListItem[]
|
||||
defaultValues?: ProductFormDefaultValues
|
||||
onSubmit: (values: ProductFormOutput) => void
|
||||
onCancel: () => void
|
||||
isPending?: boolean
|
||||
isEdit?: boolean
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductForm({
|
||||
productTypes,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
isEdit = false,
|
||||
}: ProductFormProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const form = useForm<ProductFormRaw>({
|
||||
resolver: zodResolver(productFormSchema) as any,
|
||||
defaultValues: {
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
nombre: defaultValues?.nombre ?? '',
|
||||
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
|
||||
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
|
||||
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
|
||||
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
|
||||
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
|
||||
|
||||
// Derive selected ProductType flags
|
||||
const productTypeIdStr = form.watch('productTypeId')
|
||||
const selectedProductTypeId = productTypeIdStr ? Number(productTypeIdStr) : null
|
||||
const selectedProductType = productTypes.find((pt) => pt.id === selectedProductTypeId) ?? null
|
||||
const requiresCategory = selectedProductType?.requiresCategory ?? false
|
||||
const hasDuration = selectedProductType?.hasDuration ?? false
|
||||
|
||||
function handleSubmit(data: ProductFormOutput) {
|
||||
// Normalize conditional fields to null when not applicable
|
||||
const normalized: ProductFormOutput = {
|
||||
...data,
|
||||
rubroId: requiresCategory ? data.rubroId : null,
|
||||
priceDurationDays: hasDuration ? data.priceDurationDays : null,
|
||||
}
|
||||
onSubmit(normalized)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Nombre */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Medio ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="medioId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Medio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del medio"
|
||||
aria-label="ID de Medio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Product Type ID */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="productTypeId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Tipo de Producto</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || isEdit}
|
||||
placeholder="ID del tipo de producto"
|
||||
aria-label="ID de Tipo de Producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rubro ID — only shown when requiresCategory=true */}
|
||||
{requiresCategory && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rubroId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Rubro</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="ID del rubro"
|
||||
aria-label="ID de Rubro"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Base Price */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basePrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Precio base</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={isPending}
|
||||
placeholder="0.00"
|
||||
aria-label="Precio base"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price Duration Days — only shown when hasDuration=true */}
|
||||
{hasDuration && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priceDurationDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Días de duración del precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Días"
|
||||
aria-label="Días de duración del precio"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
132
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
132
src/web/src/features/products/components/ProductFormDialog.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ProductForm } from './ProductForm'
|
||||
import type { ProductFormOutput } from './ProductForm'
|
||||
import { useCreateProduct } from '../hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
||||
import type { ProductDetail } from '../types'
|
||||
import { useProductTypes } from '@/features/product-types/hooks/useProductTypes'
|
||||
|
||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveBackendError(err: unknown): string | null {
|
||||
if (!err) return null
|
||||
if (isAxiosError(err) && err.response?.data) {
|
||||
const data = err.response.data as { error?: string; message?: string }
|
||||
return data.message ?? data.error ?? 'Error al guardar el producto'
|
||||
}
|
||||
const errObj = err as { response?: { data?: { message?: string } } }
|
||||
if (errObj?.response?.data?.message) {
|
||||
return errObj.response.data.message
|
||||
}
|
||||
return 'Error al guardar el producto'
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
product?: ProductDetail
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
product,
|
||||
onSuccess,
|
||||
}: ProductFormDialogProps) {
|
||||
const [backendError, setBackendError] = useState<string | null>(null)
|
||||
|
||||
const isEdit = !!product
|
||||
const { mutateAsync: createProduct, isPending: creating } = useCreateProduct()
|
||||
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
||||
const isPending = creating || updating
|
||||
|
||||
// Fetch active product types so ProductForm can derive conditional fields
|
||||
const { data: productTypesPaged } = useProductTypes({ activo: true })
|
||||
const productTypes = productTypesPaged?.items ?? []
|
||||
|
||||
async function handleSubmit(values: ProductFormOutput) {
|
||||
setBackendError(null)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateProduct({
|
||||
id: product.id,
|
||||
data: {
|
||||
nombre: values.nombre,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
},
|
||||
})
|
||||
toast.success('Producto actualizado')
|
||||
} else {
|
||||
await createProduct({
|
||||
nombre: values.nombre,
|
||||
medioId: values.medioId,
|
||||
productTypeId: values.productTypeId,
|
||||
rubroId: values.rubroId,
|
||||
basePrice: values.basePrice,
|
||||
priceDurationDays: values.priceDurationDays,
|
||||
})
|
||||
toast.success('Producto creado')
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
const msg = resolveBackendError(err)
|
||||
setBackendError(msg)
|
||||
if (
|
||||
!isAxiosError(err) ||
|
||||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
|
||||
) {
|
||||
toast.error(isEdit ? 'Error al actualizar producto' : 'Error al crear producto')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar producto' : 'Nuevo producto'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Modificá los datos del producto "${product?.nombre ?? ''}".`
|
||||
: 'Completá los datos para crear un nuevo producto.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ProductForm
|
||||
productTypes={productTypes}
|
||||
defaultValues={product}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isPending={isPending}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
13
src/web/src/features/products/hooks/useCreateProduct.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createProduct } from '../api/createProduct'
|
||||
import type { CreateProductRequest } from '../types'
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateProductRequest) => createProduct(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
12
src/web/src/features/products/hooks/useDeactivateProduct.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { deactivateProduct } from '../api/deactivateProduct'
|
||||
|
||||
export function useDeactivateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deactivateProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
10
src/web/src/features/products/hooks/useProducts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listProducts } from '../api/listProducts'
|
||||
import type { ListProductsParams } from '../types'
|
||||
|
||||
export function useProducts(params?: ListProductsParams) {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => listProducts(params),
|
||||
})
|
||||
}
|
||||
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
14
src/web/src/features/products/hooks/useUpdateProduct.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateProduct } from '../api/updateProduct'
|
||||
import type { UpdateProductRequest } from '../types'
|
||||
|
||||
export function useUpdateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) =>
|
||||
updateProduct(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
9
src/web/src/features/products/index.ts
Normal file
9
src/web/src/features/products/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ProductsPage } from './pages/ProductsPage'
|
||||
export type {
|
||||
ProductListItem,
|
||||
ProductDetail,
|
||||
CreateProductRequest,
|
||||
UpdateProductRequest,
|
||||
PagedResult,
|
||||
ListProductsParams,
|
||||
} from './types'
|
||||
236
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
236
src/web/src/features/products/pages/ProductsPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
import { useProducts } from '../hooks/useProducts'
|
||||
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
|
||||
import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||
import type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function ProductsPage() {
|
||||
// ── Create dialog state ──────────────────────────────────────────────────
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<ProductDetail | null>(null)
|
||||
|
||||
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||
|
||||
// ── Pagination & filter state ────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
|
||||
|
||||
const { data: paged, isLoading, isError } = useProducts({
|
||||
activo: true,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
medioId: medioIdFilter,
|
||||
})
|
||||
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(p: ProductListItem) {
|
||||
const detail: ProductDetail = {
|
||||
...p,
|
||||
fechaCreacion: '',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
setEditingProduct(detail)
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
function openDeactivate(p: ProductListItem) {
|
||||
setDeactivatingProduct(p)
|
||||
setDeactivateOpen(true)
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: number) {
|
||||
await deactivateProduct(id)
|
||||
toast.success('Producto desactivado')
|
||||
}
|
||||
|
||||
function handleMedioFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value
|
||||
setPage(1)
|
||||
setMedioIdFilter(val ? Number(val) : undefined)
|
||||
}
|
||||
|
||||
const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1
|
||||
|
||||
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive" className="m-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Error al cargar productos.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = !paged?.items.length
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Productos</h1>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo Producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Filtrar por ID de Medio"
|
||||
aria-label="Filtrar por ID de Medio"
|
||||
className="w-52"
|
||||
onChange={handleMedioFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||
<p>No hay productos.</p>
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<Button variant="outline" onClick={openCreate}>
|
||||
Crear primer producto
|
||||
</Button>
|
||||
</CanPerform>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-2 text-left font-medium">Nombre</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Medio</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tipo</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Precio base</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Activo</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.items.map((p: ProductListItem) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||
<td className="px-4 py-2 font-medium">{p.nombre}</td>
|
||||
<td className="px-4 py-2">{p.medioId}</td>
|
||||
<td className="px-4 py-2">{p.productTypeId}</td>
|
||||
<td className="px-4 py-2">{p.basePrice}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={p.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||
{p.isActive ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<CanPerform permission="catalogo:productos:gestionar">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(p)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeactivate(p)}
|
||||
disabled={!p.isActive}
|
||||
>
|
||||
Desactivar
|
||||
</Button>
|
||||
</div>
|
||||
</CanPerform>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
aria-label="Página anterior"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Página siguiente"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<ProductFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{/* Edit dialog */}
|
||||
{editingProduct && (
|
||||
<ProductFormDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
product={editingProduct}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Deactivate confirmation dialog */}
|
||||
{deactivatingProduct && (
|
||||
<DeactivateProductDialog
|
||||
open={deactivateOpen}
|
||||
onOpenChange={setDeactivateOpen}
|
||||
product={deactivatingProduct}
|
||||
onConfirm={handleDeactivate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/web/src/features/products/types.ts
Normal file
58
src/web/src/features/products/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// PRD-002 — shared types for products feature
|
||||
|
||||
export interface ProductListItem {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: number
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId: number | null
|
||||
basePrice: number
|
||||
priceDurationDays: number | null
|
||||
isActive: boolean
|
||||
fechaCreacion: string
|
||||
fechaModificacion: string | null
|
||||
}
|
||||
|
||||
export interface CreateProductRequest {
|
||||
nombre: string
|
||||
medioId: number
|
||||
productTypeId: number
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateProductRequest {
|
||||
nombre: string
|
||||
rubroId?: number | null
|
||||
basePrice: number
|
||||
priceDurationDays?: number | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ListProductsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
activo?: boolean | null
|
||||
search?: string
|
||||
medioId?: number
|
||||
productTypeId?: number
|
||||
rubroId?: number
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||
import { ProductsPage } from './features/products/pages/ProductsPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -320,6 +321,16 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Products routes — PRD-002 */}
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['catalogo:productos:gestionar']}>
|
||||
<ProductsPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { DeactivateProductDialog } from '../../../features/products/components/DeactivateProductDialog'
|
||||
import type { ProductListItem } from '../../../features/products/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const sampleProduct: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.5,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DeactivateProductDialog', () => {
|
||||
it('renders confirmation message with product name', () => {
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/Clasificado Estándar/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm with product id when user confirms', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProduct.id))
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409', async () => {
|
||||
const onConfirm = vi.fn(() =>
|
||||
Promise.reject({
|
||||
response: { status: 409, data: { message: 'No se puede desactivar: el producto está en uso.' } },
|
||||
}),
|
||||
)
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={sampleProduct}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
|
||||
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
|
||||
await userEvent.click(confirmBtn)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/en uso/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when cancel is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(
|
||||
<DeactivateProductDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
product={sampleProduct}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal file
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductForm } from '../../../features/products/components/ProductForm'
|
||||
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const ptRequiresCategory: ProductTypeListItem = {
|
||||
id: 1,
|
||||
nombre: 'Con categoría',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: true,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const ptHasDuration: ProductTypeListItem = {
|
||||
id: 2,
|
||||
nombre: 'Con duración',
|
||||
hasDuration: true,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const ptSimple: ProductTypeListItem = {
|
||||
id: 3,
|
||||
nombre: 'Simple',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductForm — no ProductType selected ────────────────────────────────
|
||||
|
||||
describe('ProductForm — no ProductType selected', () => {
|
||||
it('hides RubroId and PriceDurationDays fields when no ProductType is selected', () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptRequiresCategory, ptHasDuration, ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText(/rubro/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — requiresCategory flag ─────────────────────────────────
|
||||
|
||||
describe('ProductForm — requiresCategory flag', () => {
|
||||
it('shows RubroId field when ProductType has requiresCategory=true', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptRequiresCategory, ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 1 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/id de rubro/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides RubroId field when ProductType has requiresCategory=false', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/id de rubro/i)).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — hasDuration flag ──────────────────────────────────────
|
||||
|
||||
describe('ProductForm — hasDuration flag', () => {
|
||||
it('shows PriceDurationDays when ProductType has hasDuration=true', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptHasDuration]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 2 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/días de duración/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides PriceDurationDays when ProductType has hasDuration=false', async () => {
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductForm — submit normalization ──────────────────────────────────
|
||||
|
||||
describe('ProductForm — submit normalization', () => {
|
||||
it('nulls out rubroId when requiresCategory=false on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const payload = onSubmit.mock.calls[0][0]
|
||||
expect(payload.rubroId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('nulls out priceDurationDays when hasDuration=false on submit', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
defaultValues={{ productTypeId: 3 }}
|
||||
/>,
|
||||
)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test2')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled()
|
||||
const payload = onSubmit.mock.calls[0][0]
|
||||
expect(payload.priceDurationDays).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
wrap(
|
||||
<ProductForm
|
||||
productTypes={[ptSimple]}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
194
src/web/src/tests/features/products/ProductFormDialog.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductFormDialog } from '../../../features/products/components/ProductFormDialog'
|
||||
import type { ProductDetail } from '../../../features/products/types'
|
||||
import type { PagedResult } from '../../../features/product-types/types'
|
||||
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const ptSimple: ProductTypeListItem = {
|
||||
id: 2,
|
||||
nombre: 'Simple',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
allowImages: false,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockProductTypesPaged: PagedResult<ProductTypeListItem> = {
|
||||
items: [ptSimple],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const mockProduct: ProductDetail = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.5,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function wrap(children: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function withProductTypesHandler() {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||
HttpResponse.json(mockProductTypesPaged),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductFormDialog — create mode ─────────────────────────────────────
|
||||
|
||||
describe('ProductFormDialog — create mode', () => {
|
||||
it('renders create dialog title when no product prop', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders DialogDescription (accessibility)', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByText(/completá los datos/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls create mutation and closes dialog on success', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json({ id: 10, nombre: 'Nuevo Producto' }, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={onOpenChange} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Producto')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'product_nombre_duplicado', message: 'Ya existe un producto con ese nombre' },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ya existe un producto con ese nombre/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows inline error when backend returns 409 ProductTypeInactivo', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(
|
||||
{ error: 'product_type_inactivo', message: 'El tipo de producto está inactivo' },
|
||||
{ status: 409 },
|
||||
),
|
||||
),
|
||||
)
|
||||
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
|
||||
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
|
||||
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
|
||||
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
|
||||
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tipo de producto está inactivo/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ProductFormDialog — edit mode ────────────────────────────────────────
|
||||
|
||||
describe('ProductFormDialog — edit mode', () => {
|
||||
it('renders edit dialog title and pre-fills nombre', () => {
|
||||
withProductTypesHandler()
|
||||
wrap(
|
||||
<ProductFormDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
product={mockProduct}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /editar producto/i })).toBeInTheDocument()
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
expect(input.value).toBe('Clasificado Estándar')
|
||||
})
|
||||
|
||||
it('calls update mutation and closes dialog on success', async () => {
|
||||
withProductTypesHandler()
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
HttpResponse.json({ ...mockProduct, nombre: 'Modificado' }),
|
||||
),
|
||||
)
|
||||
const onOpenChange = vi.fn()
|
||||
wrap(
|
||||
<ProductFormDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
product={mockProduct}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Modificado')
|
||||
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
283
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
283
src/web/src/tests/features/products/ProductsPage.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
import { ProductsPage } from '../../../features/products/pages/ProductsPage'
|
||||
import { useAuthStore } from '../../../stores/authStore'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
const adminUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nombre: 'Admin',
|
||||
rol: 'admin',
|
||||
permisos: ['catalogo:productos:gestionar'],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const regularUser = {
|
||||
id: 2,
|
||||
username: 'viewer',
|
||||
nombre: 'Viewer',
|
||||
rol: 'viewer',
|
||||
permisos: [],
|
||||
mustChangePassword: false,
|
||||
}
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const emptyPaged: PagedResult<ProductListItem> = {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
useAuthStore.getState().clearAuth()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage(user = adminUser) {
|
||||
useAuthStore.setState({ user })
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/admin/products']}>
|
||||
<Routes>
|
||||
<Route path="/admin/products" element={<ProductsPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Loading / Error / Data states ──────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — loading and error states', () => {
|
||||
it('renders loading skeleton while fetching', () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, async () => {
|
||||
await new Promise(() => {})
|
||||
return HttpResponse.json(emptyPaged)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders data when loaded', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows error state on fetch failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty state when no products', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Permission gating ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — permission gating', () => {
|
||||
it('shows "Nuevo Producto" button when user has permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides "Nuevo Producto" button when user lacks permission', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(regularUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Create dialog ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — create dialog', () => {
|
||||
it('opens create dialog when "Nuevo Producto" button is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — deactivate dialog', () => {
|
||||
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Pagination ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — pagination', () => {
|
||||
it('shows pagination controls when total > pageSize', async () => {
|
||||
// 21 total items but only 1 in this page → totalPages=2 → controls visible
|
||||
const pagedWith21Total: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const pagedPage1: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
const pagedPage2: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }],
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /siguiente/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Página 2')).toBeInTheDocument()
|
||||
})
|
||||
// Verify that at least one request was made with page=2
|
||||
const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2')
|
||||
expect(page2Requests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Filter by Medio ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — filter by Medio', () => {
|
||||
it('re-fetches with medioId when Medio filter is changed', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const filteredPaged: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const medioId = url.searchParams.get('medioId')
|
||||
return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
// Wait for initial empty state
|
||||
await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument())
|
||||
|
||||
const filterInput = screen.getByLabelText(/filtrar por id de medio/i)
|
||||
await userEvent.type(filterInput, '5')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Medio 5')).toBeInTheDocument()
|
||||
})
|
||||
const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5')
|
||||
expect(filteredRequests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
131
src/web/src/tests/features/products/api.test.ts
Normal file
131
src/web/src/tests/features/products/api.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { listProducts } from '../../../features/products/api/listProducts'
|
||||
import { getProductById } from '../../../features/products/api/getProductById'
|
||||
import { createProduct } from '../../../features/products/api/createProduct'
|
||||
import { updateProduct } from '../../../features/products/api/updateProduct'
|
||||
import { deactivateProduct } from '../../../features/products/api/deactivateProduct'
|
||||
import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockListItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockDetail: ProductDetail = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 100.50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||
fechaModificacion: null,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockListItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('listProducts', () => {
|
||||
it('calls GET /api/v1/products and returns paged result', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const result = await listProducts()
|
||||
expect(result).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('passes query params when provided', async () => {
|
||||
let capturedUrl = ''
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json(mockPaged)
|
||||
}),
|
||||
)
|
||||
await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 })
|
||||
expect(capturedUrl).toContain('page=2')
|
||||
expect(capturedUrl).toContain('pageSize=10')
|
||||
expect(capturedUrl).toContain('medioId=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProductById', () => {
|
||||
it('calls GET /api/v1/products/:id and returns detail', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)),
|
||||
)
|
||||
const result = await getProductById(1)
|
||||
expect(result).toEqual(mockDetail)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProduct', () => {
|
||||
it('calls POST /api/v1/admin/products with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail, { status: 201 })
|
||||
}),
|
||||
)
|
||||
const req = {
|
||||
nombre: 'Clasificado Estándar',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100.50,
|
||||
}
|
||||
await createProduct(req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProduct', () => {
|
||||
it('calls PUT /api/v1/admin/products/:id with payload', async () => {
|
||||
let capturedBody: unknown = null
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => {
|
||||
capturedBody = await request.json()
|
||||
return HttpResponse.json(mockDetail)
|
||||
}),
|
||||
)
|
||||
const req = { nombre: 'Modificado', basePrice: 200 }
|
||||
await updateProduct(1, req)
|
||||
expect(capturedBody).toEqual(req)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deactivateProduct', () => {
|
||||
it('calls DELETE /api/v1/admin/products/:id', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () => {
|
||||
called = true
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
await deactivateProduct(1)
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
})
|
||||
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
143
src/web/src/tests/features/products/hooks.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProducts } from '../../../features/products/hooks/useProducts'
|
||||
import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct'
|
||||
import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct'
|
||||
import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct'
|
||||
import type { ProductListItem, PagedResult } from '../../../features/products/types'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const mockItem: ProductListItem = {
|
||||
id: 1,
|
||||
nombre: 'Clasificado',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
rubroId: null,
|
||||
basePrice: 50,
|
||||
priceDurationDays: null,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const mockPaged: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
}
|
||||
|
||||
describe('useProducts', () => {
|
||||
it('returns paged data on success', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(mockPaged)
|
||||
})
|
||||
|
||||
it('returns error state on failure', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () =>
|
||||
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateProduct', () => {
|
||||
it('calls create and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}/api/v1/admin/products`, () =>
|
||||
HttpResponse.json(mockItem, { status: 201 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useCreateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({
|
||||
nombre: 'Nuevo Producto',
|
||||
medioId: 1,
|
||||
productTypeId: 2,
|
||||
basePrice: 100,
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpdateProduct', () => {
|
||||
it('calls update and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
HttpResponse.json(mockItem),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useUpdateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } })
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDeactivateProduct', () => {
|
||||
it('calls deactivate and invalidates products queries on success', async () => {
|
||||
server.use(
|
||||
http.delete(`${API_URL}/api/v1/admin/products/1`, () =>
|
||||
new HttpResponse(null, { status: 204 }),
|
||||
),
|
||||
)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
|
||||
const { result } = renderHook(() => useDeactivateProduct(), { wrapper })
|
||||
await act(async () => {
|
||||
result.current.mutate(1)
|
||||
})
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
|
||||
})
|
||||
})
|
||||
@@ -51,8 +51,9 @@ public class AuthControllerTests
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, permisos.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, permisos.GetArrayLength());
|
||||
}
|
||||
|
||||
// Scenario: invalid credentials return 401 with opaque error
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetPermisos_WithAdmin_Returns200With26Items()
|
||||
public async Task GetPermisos_WithAdmin_Returns200With27Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||
@@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, list.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
|
||||
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
|
||||
{
|
||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||
@@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||
Assert.Equal(26, list.GetArrayLength());
|
||||
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
|
||||
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
|
||||
Assert.Equal(27, list.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Products;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 Batch 8 — Guard proof: verifies that deactivating a ProductType with active Products
|
||||
/// returns HTTP 409 Conflict. This test closes the W1 (dormant guard) issue from PRD-001:
|
||||
/// - PRD-001: NullProductQueryRepository always returned false → guard never fired
|
||||
/// - PRD-002: ProductQueryRepository now queries dbo.Product → guard fires correctly
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class ProductTypeDeactivationGuardTests : IAsyncLifetime
|
||||
{
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ProductTypeDeactivationGuardTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// E2E proof: seed Medio + ProductType + active Product → DELETE product-type → expect 409.
|
||||
/// This verifies the IProductQueryRepository guard fires against real data in dbo.Product.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeactivateProductType_WithActiveProducts_Returns409Conflict()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// 1. Create a Medio
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"GD{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Guarda Medio {Guid.NewGuid():N}"[..30],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 2. Create a ProductType
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"Guardado PT {Guid.NewGuid():N}"[..30],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 3. Create an active Product for this ProductType
|
||||
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
|
||||
{
|
||||
nombre = $"Prod Guarda {Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 100m
|
||||
}, token));
|
||||
prodResp.EnsureSuccessStatusCode();
|
||||
|
||||
// 4. Attempt to deactivate the ProductType — should be blocked by guard
|
||||
using var deactivateReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivateResp = await _client.SendAsync(deactivateReq);
|
||||
|
||||
// CRITICAL ASSERTION: 409 Conflict — guard fires because Product is active
|
||||
Assert.Equal(HttpStatusCode.Conflict, deactivateResp.StatusCode);
|
||||
|
||||
var body = await deactivateResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify guard does NOT block deactivation when Products exist but are all inactive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeactivateProductType_WithOnlyInactiveProducts_Returns204()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
// 1. Create Medio
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"GI{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Guarda Inact {Guid.NewGuid():N}"[..28],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 2. Create ProductType
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"PT Inact {Guid.NewGuid():N}"[..28],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
// 3. Create then deactivate Product
|
||||
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
|
||||
{
|
||||
nombre = $"Prod Inact {Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 50m
|
||||
}, token));
|
||||
prodResp.EnsureSuccessStatusCode();
|
||||
var prodJson = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = prodJson.GetProperty("id").GetInt32();
|
||||
|
||||
// Deactivate the Product first
|
||||
using var deactivateProdReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/products/{productId}", bearerToken: token);
|
||||
var deactivateProdResp = await _client.SendAsync(deactivateProdReq);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivateProdResp.StatusCode);
|
||||
|
||||
// 4. Now deactivate ProductType — should succeed since no active products
|
||||
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
|
||||
}
|
||||
}
|
||||
367
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
367
tests/SIGCM2.Api.Tests/Products/ProductsControllerTests.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SIGCM2.TestSupport;
|
||||
|
||||
namespace SIGCM2.Api.Tests.Products;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products.
|
||||
/// Read endpoints require authentication (any role).
|
||||
/// Write endpoints require permission 'catalogo:productos:gestionar'.
|
||||
/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings.
|
||||
/// </summary>
|
||||
[Collection("ApiIntegration")]
|
||||
public sealed class ProductsControllerTests : IAsyncLifetime
|
||||
{
|
||||
private const string ReadEndpoint = "/api/v1/products";
|
||||
private const string AdminEndpoint = "/api/v1/admin/products";
|
||||
private const string AdminUsername = "admin";
|
||||
private const string AdminPassword = "@Diego550@";
|
||||
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ProductsControllerTests(TestWebAppFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> GetAdminTokenAsync()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
||||
{
|
||||
username = AdminUsername,
|
||||
password = AdminPassword
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return json.GetProperty("accessToken").GetString()!;
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
if (bearerToken is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token)
|
||||
{
|
||||
// Create a Medio via SQL (we don't have a Medio controller endpoint available here)
|
||||
// Use product-types endpoint to create a ProductType and insert Medio directly
|
||||
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
|
||||
{
|
||||
codigo = $"PM{Guid.NewGuid():N}"[..6],
|
||||
nombre = $"Medio Test {Guid.NewGuid():N}"[..30],
|
||||
tipo = 1
|
||||
}, token));
|
||||
medioResp.EnsureSuccessStatusCode();
|
||||
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var medioId = medioJson.GetProperty("id").GetInt32();
|
||||
|
||||
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
|
||||
{
|
||||
nombre = $"PT_{Guid.NewGuid():N}"[..30],
|
||||
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||
allowImages = false
|
||||
}, token));
|
||||
ptResp.EnsureSuccessStatusCode();
|
||||
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productTypeId = ptJson.GetProperty("id").GetInt32();
|
||||
|
||||
return (medioId, productTypeId);
|
||||
}
|
||||
|
||||
// ── 401 guards on READ endpoints ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, ReadEndpoint);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── 401 guards on WRITE endpoints ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" });
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m });
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_WithoutAuth_Returns401()
|
||||
{
|
||||
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1");
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/products ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithAdmin_Returns201WithId()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30];
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName,
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 100.50m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.GetProperty("id").GetInt32() > 0);
|
||||
Assert.Equal(uniqueName, json.GetProperty("nombre").GetString());
|
||||
Assert.True(json.GetProperty("isActive").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InvalidBody_Returns400()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = string.Empty, // invalid
|
||||
medioId = 1,
|
||||
productTypeId = 1,
|
||||
basePrice = 10m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateNombre_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30];
|
||||
|
||||
var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m };
|
||||
|
||||
using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
|
||||
var resp1 = await _client.SendAsync(req1);
|
||||
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
|
||||
|
||||
using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
|
||||
var resp2 = await _client.SendAsync(req2);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_MedioNotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = $"Prod_{Guid.NewGuid():N}"[..30],
|
||||
medioId = 999999,
|
||||
productTypeId = 1,
|
||||
basePrice = 50m
|
||||
}, token);
|
||||
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── GET /api/v1/products ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_WithAuth_Returns200WithPaginatedResult()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.True(json.TryGetProperty("items", out _));
|
||||
Assert.True(json.TryGetProperty("total", out _));
|
||||
}
|
||||
|
||||
// ── GET /api/v1/products/{id} ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ExistingId_Returns200()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 75m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token);
|
||||
var getResp = await _client.SendAsync(getReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
|
||||
var getJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(productId, getJson.GetProperty("id").GetInt32());
|
||||
Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString());
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/admin/products/{id} ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_ExistingId_Returns204()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Del_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token);
|
||||
var delResp = await _client.SendAsync(delReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deactivate_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/products/{id} ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ExistingProduct_Returns200()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||
|
||||
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var productId = createJson.GetProperty("id").GetInt32();
|
||||
|
||||
var newName = $"Upd2_{Guid.NewGuid():N}"[..28];
|
||||
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new
|
||||
{
|
||||
nombre = newName,
|
||||
basePrice = 200m
|
||||
}, token);
|
||||
var updateResp = await _client.SendAsync(updateReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
|
||||
var updateJson = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(newName, updateJson.GetProperty("nombre").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NotFound_Returns404()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new
|
||||
{
|
||||
nombre = "Test", basePrice = 10m
|
||||
}, token);
|
||||
var resp = await _client.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
// ── PRD-002 W1: ExceptionFilter 409 for ProductTypeInactivo ──────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithInactiveProductType_Returns409()
|
||||
{
|
||||
var token = await GetAdminTokenAsync();
|
||||
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
|
||||
|
||||
// Deactivate the ProductType first
|
||||
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
|
||||
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
|
||||
|
||||
// Now attempt to create a product with the inactive ProductType
|
||||
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||
{
|
||||
nombre = $"Prod_{Guid.NewGuid():N}"[..28],
|
||||
medioId,
|
||||
productTypeId,
|
||||
basePrice = 50m
|
||||
}, token);
|
||||
var createResp = await _client.SendAsync(createReq);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, createResp.StatusCode);
|
||||
var body = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("product_type_inactivo", body.GetProperty("error").GetString());
|
||||
}
|
||||
}
|
||||
@@ -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'.
|
||||
await EnsureV017SchemaAsync();
|
||||
|
||||
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
|
||||
await EnsureV018SchemaAsync();
|
||||
|
||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||
{
|
||||
DbAdapter = DbAdapter.SqlServer,
|
||||
@@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
||||
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
|
||||
new Respawn.Graph.Table("dbo", "Product_History"),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
-- V014 (ADM-009): permiso para tablas fiscales
|
||||
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
|
||||
-- V016 (CAT-001): permiso para gestionar árbol de rubros
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'),
|
||||
-- V017 (PRD-001): permiso para gestionar tipos de producto
|
||||
('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'),
|
||||
-- V018 (PRD-002): permiso para gestionar productos del catálogo
|
||||
('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
@@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
('admin', 'administracion:fiscal:gestionar'),
|
||||
-- V016 (CAT-001)
|
||||
('admin', 'catalogo:rubros:gestionar'),
|
||||
-- V017 (PRD-001)
|
||||
('admin', 'catalogo:tipos:gestionar'),
|
||||
-- V018 (PRD-002)
|
||||
('admin', 'catalogo:productos:gestionar'),
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
@@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
||||
await _connection.ExecuteAsync(createUqIndex);
|
||||
await _connection.ExecuteAsync(createCoveringIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes
|
||||
/// idempotentemente. Mirrors V018__create_product.sql.
|
||||
/// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran
|
||||
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
|
||||
/// </summary>
|
||||
public async Task EnsureV018SchemaAsync()
|
||||
{
|
||||
const string createProduct = """
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Product (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
|
||||
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
MedioId INT NOT NULL,
|
||||
ProductTypeId INT NOT NULL,
|
||||
RubroId INT NULL,
|
||||
BasePrice DECIMAL(18,4) NOT NULL,
|
||||
PriceDurationDays INT NULL,
|
||||
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
|
||||
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
|
||||
);
|
||||
END
|
||||
""";
|
||||
|
||||
const string addProductPeriod = """
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
END
|
||||
""";
|
||||
|
||||
const string setProductVersioning = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Product_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
END
|
||||
""";
|
||||
|
||||
const string createUqIndex = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
|
||||
ON dbo.Product (MedioId, ProductTypeId, Nombre)
|
||||
WHERE IsActive = 1;
|
||||
END
|
||||
""";
|
||||
|
||||
const string createProductTypeIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_ProductTypeId_IsActive
|
||||
ON dbo.Product (ProductTypeId, IsActive);
|
||||
END
|
||||
""";
|
||||
|
||||
const string createMedioIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_MedioId_IsActive
|
||||
ON dbo.Product (MedioId, IsActive);
|
||||
END
|
||||
""";
|
||||
|
||||
const string createRubroIdx = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_RubroId_IsActive
|
||||
ON dbo.Product (RubroId, IsActive)
|
||||
WHERE RubroId IS NOT NULL;
|
||||
END
|
||||
""";
|
||||
|
||||
await _connection.ExecuteAsync(createProduct);
|
||||
await _connection.ExecuteAsync(addProductPeriod);
|
||||
await _connection.ExecuteAsync(setProductVersioning);
|
||||
await _connection.ExecuteAsync(createUqIndex);
|
||||
await _connection.ExecuteAsync(createProductTypeIdx);
|
||||
await _connection.ExecuteAsync(createMedioIdx);
|
||||
await _connection.ExecuteAsync(createRubroIdx);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user