Merge pull request 'feat: PRD-001 ProductType (flags + multimedia)' (#38) from feature/PRD-001 into main
This commit was merged in pull request #38.
This commit is contained in:
@@ -33,6 +33,7 @@ database/
|
|||||||
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
||||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||||
|
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
|
|||||||
71
database/migrations/V017_ROLLBACK.sql
Normal file
71
database/migrations/V017_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- V017_ROLLBACK.sql
|
||||||
|
-- Reversa de V017__create_product_type.sql.
|
||||||
|
-- PRD-001: ProductType rollback.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: Si PRD-002 ya fue mergeado (IProductQueryRepository real), hacer rollback
|
||||||
|
-- de PRD-002 primero (la interfaz es removida por esta rollback).
|
||||||
|
--
|
||||||
|
-- Idempotente: cada paso usa IF EXISTS guards.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 1. Desactivar SYSTEM_VERSIONING
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'ProductType: SYSTEM_VERSIONING = OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 2. Remover PERIOD FOR SYSTEM_TIME
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 3. Remover columnas HIDDEN + default constraints
|
||||||
|
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidFrom;
|
||||||
|
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidTo;
|
||||||
|
ALTER TABLE dbo.ProductType DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'ProductType: ValidFrom/ValidTo columns dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 4. Drop history table
|
||||||
|
IF OBJECT_ID(N'dbo.ProductType_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductType_History;
|
||||||
|
PRINT 'Table dbo.ProductType_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 5. Drop main table
|
||||||
|
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductType;
|
||||||
|
PRINT 'Table dbo.ProductType dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 6. Remover RolPermiso para catalogo:tipos:gestionar
|
||||||
|
DELETE rp FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'catalogo:tipos:gestionar';
|
||||||
|
PRINT 'RolPermiso rows for catalogo:tipos:gestionar deleted.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 7. Remover Permiso
|
||||||
|
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:tipos:gestionar';
|
||||||
|
PRINT 'Permiso catalogo:tipos:gestionar deleted.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V017 rolled back successfully.';
|
||||||
|
GO
|
||||||
158
database/migrations/V017__create_product_type.sql
Normal file
158
database/migrations/V017__create_product_type.sql
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
-- V017__create_product_type.sql
|
||||||
|
-- PRD-001: ProductType — tipología dinámica de productos con flags de comportamiento + límites multimedia.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.ProductType (flags + multimedia limits, SYSTEM_VERSIONING ON, retention 10 años).
|
||||||
|
-- 2. Índice filtrado unique UQ_ProductType_Nombre_Activo (unicidad CI entre activos).
|
||||||
|
-- 3. Índice cubriente IX_ProductType_IsActive_Cover.
|
||||||
|
-- 4. Permiso 'catalogo:tipos:gestionar' + asignación a rol 'admin'.
|
||||||
|
--
|
||||||
|
-- Patrón: V016 (dbo.Rubro con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V017_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - SIN seed de datos — PRD-008 (V018) seedea los 12 tipos legacy.
|
||||||
|
-- - SIN FK desde dbo.Product — PRD-002 agrega ALTER TABLE con FK.
|
||||||
|
-- - Invariante aplicada en Application: si AllowImages=0, los 4 campos multimedia son NULL (handler normaliza).
|
||||||
|
-- - MaxImages/MaxImageSizeMB/MaxImageWidth/MaxImageHeight: NULL = sin límite; >=1 = tope (validator rechaza <=0).
|
||||||
|
-- - Desviación del UDT: "0 = ilimitado" → usamos NULL (convención canónica). Ver PRD-001 archive-report.
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prd-001-product-type-flags-multimedia/design
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.ProductType
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductType (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
|
||||||
|
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||||
|
|
||||||
|
-- Flags de comportamiento
|
||||||
|
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
|
||||||
|
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
|
||||||
|
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
|
||||||
|
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
|
||||||
|
|
||||||
|
-- Multimedia (AllowImages=0 => handler normaliza los 4 siguientes a NULL)
|
||||||
|
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
|
||||||
|
MaxImages INT NULL, -- NULL = sin límite; >=1 tope (validator rechaza <=0)
|
||||||
|
MaxImageSizeMB DECIMAL(10,2) NULL, -- NULL = sin límite; DECIMAL(10,2) permite 0.5 MB, 2.75 MB
|
||||||
|
MaxImageWidth INT NULL, -- NULL = sin límite; >=1 px
|
||||||
|
MaxImageHeight INT NULL, -- NULL = sin límite; >=1 px
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.ProductType created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.ProductType already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — ProductType
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductType_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'ProductType: SYSTEM_VERSIONING = ON (history: dbo.ProductType_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'ProductType: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductType_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 = 'ProductType_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'ProductType_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Índices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
|
||||||
|
ON dbo.ProductType(Nombre)
|
||||||
|
WHERE IsActive = 1;
|
||||||
|
PRINT 'Index UQ_ProductType_Nombre_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductType_IsActive_Cover
|
||||||
|
ON dbo.ProductType(IsActive)
|
||||||
|
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
|
||||||
|
PRINT 'Index IX_ProductType_IsActive_Cover created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Permiso: catalogo:tipos:gestionar + asignación a rol 'admin'
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('catalogo:tipos:gestionar',
|
||||||
|
N'Gestionar tipos de producto',
|
||||||
|
N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)',
|
||||||
|
'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:tipos: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 'V017 applied — dbo.ProductType (temporal, retention 10y) + permiso catalogo:tipos:gestionar.';
|
||||||
|
PRINT 'Next: V018 (PRD-008 — seed 12 tipos legacy).';
|
||||||
|
GO
|
||||||
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
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.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-001: ProductType catalog management.
|
||||||
|
/// Read endpoints at /api/v1/product-types — require authentication (any role).
|
||||||
|
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public sealed class ProductTypesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateProductTypeCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
|
||||||
|
|
||||||
|
public ProductTypesController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateProductTypeCommand> createValidator,
|
||||||
|
IValidator<UpdateProductTypeCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/product-types")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> ListProductTypes(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = true,
|
||||||
|
[FromQuery] string? search = null)
|
||||||
|
{
|
||||||
|
var query = new ListProductTypesQuery(page, pageSize, activo, search);
|
||||||
|
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/product-types/{id:int}")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetProductTypeByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||||
|
[HttpPost("api/v1/admin/product-types")]
|
||||||
|
[RequirePermission("catalogo:tipos:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateProductTypeCommand(
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
HasDuration: request.HasDuration,
|
||||||
|
RequiresText: request.RequiresText,
|
||||||
|
RequiresCategory: request.RequiresCategory,
|
||||||
|
IsBundle: request.IsBundle,
|
||||||
|
AllowImages: request.AllowImages,
|
||||||
|
MaxImages: request.MaxImages,
|
||||||
|
MaxImageSizeMB: request.MaxImageSizeMB,
|
||||||
|
MaxImageWidth: request.MaxImageWidth,
|
||||||
|
MaxImageHeight: request.MaxImageHeight);
|
||||||
|
|
||||||
|
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<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||||
|
[HttpPut("api/v1/admin/product-types/{id:int}")]
|
||||||
|
[RequirePermission("catalogo:tipos:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateProductTypeCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
HasDuration: request.HasDuration,
|
||||||
|
RequiresText: request.RequiresText,
|
||||||
|
RequiresCategory: request.RequiresCategory,
|
||||||
|
IsBundle: request.IsBundle,
|
||||||
|
AllowImages: request.AllowImages,
|
||||||
|
MaxImages: request.MaxImages,
|
||||||
|
MaxImageSizeMB: request.MaxImageSizeMB,
|
||||||
|
MaxImageWidth: request.MaxImageWidth,
|
||||||
|
MaxImageHeight: request.MaxImageHeight);
|
||||||
|
|
||||||
|
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<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||||
|
[HttpDelete("api/v1/admin/product-types/{id:int}")]
|
||||||
|
[RequirePermission("catalogo:tipos:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateProductTypeCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>PRD-001: Create ProductType request body.</summary>
|
||||||
|
public sealed record CreateProductTypeRequest(
|
||||||
|
string? Nombre,
|
||||||
|
bool HasDuration = false,
|
||||||
|
bool RequiresText = false,
|
||||||
|
bool RequiresCategory = false,
|
||||||
|
bool IsBundle = false,
|
||||||
|
bool AllowImages = false,
|
||||||
|
int? MaxImages = null,
|
||||||
|
decimal? MaxImageSizeMB = null,
|
||||||
|
int? MaxImageWidth = null,
|
||||||
|
int? MaxImageHeight = null);
|
||||||
|
|
||||||
|
/// <summary>PRD-001: Update ProductType request body.</summary>
|
||||||
|
public sealed record UpdateProductTypeRequest(
|
||||||
|
string? Nombre,
|
||||||
|
bool HasDuration = false,
|
||||||
|
bool RequiresText = false,
|
||||||
|
bool RequiresCategory = false,
|
||||||
|
bool IsBundle = false,
|
||||||
|
bool AllowImages = false,
|
||||||
|
int? MaxImages = null,
|
||||||
|
decimal? MaxImageSizeMB = null,
|
||||||
|
int? MaxImageWidth = null,
|
||||||
|
int? MaxImageHeight = null);
|
||||||
@@ -414,6 +414,55 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// PRD-001: ProductType exceptions
|
||||||
|
case ProductTypeNotFoundException productTypeNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "product_type_not_found",
|
||||||
|
message = productTypeNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ProductTypeNombreDuplicadoException productTypeDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "product_type_nombre_duplicado",
|
||||||
|
message = productTypeDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ProductTypeEnUsoException productTypeEnUsoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "product_type_en_uso",
|
||||||
|
message = productTypeEnUsoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ProductTypeFlagsIncoherentesException productTypeFlagsEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "product_type_flags_incoherentes",
|
||||||
|
message = productTypeFlagsEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
// ADM-008: PuntoDeVenta exceptions
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
|
||||||
|
/// PRD-001 binds to NullProductQueryRepository (always returns false).
|
||||||
|
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductQueryRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if at least one active Product with the given ProductTypeId exists.
|
||||||
|
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-side repository for ProductType.
|
||||||
|
/// All reads needed by write handlers are included here.
|
||||||
|
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
|
||||||
|
/// </summary>
|
||||||
|
public interface IProductTypeRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
|
||||||
|
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
|
||||||
|
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
|
||||||
|
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Persists all changes to an existing ProductType row.</summary>
|
||||||
|
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if an active ProductType with the given nombre exists.
|
||||||
|
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
||||||
|
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProductTypesQuery(
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20,
|
||||||
|
bool? Activo = true,
|
||||||
|
string? Search = null);
|
||||||
@@ -69,6 +69,12 @@ using SIGCM2.Application.Rubros.GetById;
|
|||||||
using SIGCM2.Application.Rubros.Dtos;
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
using SIGCM2.Application.Avisos;
|
using SIGCM2.Application.Avisos;
|
||||||
|
using SIGCM2.Application.Products;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -165,6 +171,16 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||||
|
|
||||||
|
// ProductTypes (PRD-001)
|
||||||
|
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
||||||
|
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
||||||
|
|
||||||
|
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed record CreateProductTypeCommand(
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed class CreateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public CreateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
|
||||||
|
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
|
||||||
|
if (exists)
|
||||||
|
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||||
|
|
||||||
|
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
|
||||||
|
var entity = ProductType.ForCreation(
|
||||||
|
command.Nombre,
|
||||||
|
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
|
||||||
|
command.AllowImages,
|
||||||
|
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
|
||||||
|
_timeProvider);
|
||||||
|
|
||||||
|
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
var newId = await _repo.AddAsync(entity);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "producto_tipo.created",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
entity.Nombre,
|
||||||
|
entity.HasDuration,
|
||||||
|
entity.RequiresText,
|
||||||
|
entity.RequiresCategory,
|
||||||
|
entity.IsBundle,
|
||||||
|
entity.AllowImages,
|
||||||
|
entity.MaxImages,
|
||||||
|
entity.MaxImageSizeMB,
|
||||||
|
entity.MaxImageWidth,
|
||||||
|
entity.MaxImageHeight,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeCreatedDto(
|
||||||
|
newId, entity.Nombre,
|
||||||
|
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
|
||||||
|
entity.AllowImages,
|
||||||
|
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
|
||||||
|
entity.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
|
||||||
|
{
|
||||||
|
public CreateProductTypeCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||||
|
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImages)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||||
|
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageSizeMB)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||||
|
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageWidth)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||||
|
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageHeight)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||||
|
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Create;
|
||||||
|
|
||||||
|
public sealed record ProductTypeCreatedDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateProductTypeCommand(int Id);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed class DeactivateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IProductQueryRepository _productQuery;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeactivateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IProductQueryRepository productQuery,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_productQuery = productQuery;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load entity
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new ProductTypeNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// 2. Idempotent: already inactive → return without side effects (I7)
|
||||||
|
if (!target.IsActive)
|
||||||
|
return new ProductTypeStatusDto(command.Id, false);
|
||||||
|
|
||||||
|
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
|
||||||
|
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||||
|
if (inUse)
|
||||||
|
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
||||||
|
// Note: count=-1 sentinel because Products table doesn't exist in PRD-001.
|
||||||
|
// PRD-002 will update this with the actual count.
|
||||||
|
|
||||||
|
// 4. Deactivate (immutable — returns new instance)
|
||||||
|
var deactivated = target.WithDeactivated(_timeProvider);
|
||||||
|
|
||||||
|
// 5. Persist + audit (fail-closed)
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(deactivated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "producto_tipo.deactivated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
productTypeId = command.Id,
|
||||||
|
nombre = target.Nombre,
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
|
public sealed record GetProductTypeByIdQuery(int Id);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
|
public sealed class GetProductTypeByIdQueryHandler
|
||||||
|
: ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
|
||||||
|
public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeDetailDto> Handle(GetProductTypeByIdQuery query)
|
||||||
|
{
|
||||||
|
var pt = await _repo.GetByIdAsync(query.Id)
|
||||||
|
?? throw new ProductTypeNotFoundException(query.Id);
|
||||||
|
|
||||||
|
return new ProductTypeDetailDto(
|
||||||
|
pt.Id, pt.Nombre,
|
||||||
|
pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
|
||||||
|
pt.AllowImages,
|
||||||
|
pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
|
||||||
|
pt.IsActive,
|
||||||
|
pt.FechaCreacion, pt.FechaModificacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
|
public sealed record ProductTypeDetailDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.List;
|
||||||
|
|
||||||
|
public sealed record ListProductTypesQuery(
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20,
|
||||||
|
bool? Activo = true,
|
||||||
|
string? Search = null);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.List;
|
||||||
|
|
||||||
|
public sealed class ListProductTypesQueryHandler
|
||||||
|
: ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
|
||||||
|
public ListProductTypesQueryHandler(IProductTypeRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<ProductTypeListItemDto>> Handle(ListProductTypesQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
|
||||||
|
var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
|
||||||
|
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||||
|
|
||||||
|
var items = paged.Items.Select(p => new ProductTypeListItemDto(
|
||||||
|
p.Id, p.Nombre,
|
||||||
|
p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
|
||||||
|
p.AllowImages, p.IsActive)).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ProductTypeListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.List;
|
||||||
|
|
||||||
|
public sealed record ProductTypeListItemDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed record ProductTypeUpdatedDto(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateProductTypeCommand(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateProductTypeCommandHandler
|
||||||
|
: ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public UpdateProductTypeCommandHandler(
|
||||||
|
IProductTypeRepository repo,
|
||||||
|
IAuditLogger audit,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductTypeUpdatedDto> Handle(UpdateProductTypeCommand command)
|
||||||
|
{
|
||||||
|
// 1. Load entity (throws if not found)
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new ProductTypeNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// 2. If nombre changed, check for duplicate (skip call when same name — optimization)
|
||||||
|
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var duplicateExists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: command.Id);
|
||||||
|
if (duplicateExists)
|
||||||
|
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build updated entity via With* methods (immutable, each returns new instance)
|
||||||
|
var updated = target
|
||||||
|
.WithRenamed(command.Nombre, _timeProvider)
|
||||||
|
.WithUpdatedFlags(command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle, _timeProvider)
|
||||||
|
.WithUpdatedMultimedia(command.AllowImages, command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight, _timeProvider);
|
||||||
|
|
||||||
|
// 4. Persist + audit (fail-closed)
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new { target.Nombre, target.HasDuration, target.RequiresText, target.RequiresCategory, target.IsBundle, target.AllowImages },
|
||||||
|
after = new { updated.Nombre, updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle, updated.AllowImages }
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new ProductTypeUpdatedDto(
|
||||||
|
updated.Id, updated.Nombre,
|
||||||
|
updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle,
|
||||||
|
updated.AllowImages,
|
||||||
|
updated.MaxImages, updated.MaxImageSizeMB, updated.MaxImageWidth, updated.MaxImageHeight,
|
||||||
|
updated.IsActive, updated.FechaModificacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateProductTypeCommandValidator : AbstractValidator<UpdateProductTypeCommand>
|
||||||
|
{
|
||||||
|
public UpdateProductTypeCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("El Id debe ser un entero positivo.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||||
|
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImages)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||||
|
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageSizeMB)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||||
|
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageWidth)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||||
|
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxImageHeight)
|
||||||
|
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||||
|
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
186
src/api/SIGCM2.Domain/Entities/ProductType.cs
Normal file
186
src/api/SIGCM2.Domain/Entities/ProductType.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable product-type descriptor for the commercial catalog.
|
||||||
|
/// Flags drive form behavior (HasDuration, RequiresText, RequiresCategory, IsBundle).
|
||||||
|
/// Multimedia limits (Max*) are null = no limit.
|
||||||
|
/// Invariant: if AllowImages == false, the 4 Max* fields must be null
|
||||||
|
/// (enforced by ForCreation and WithUpdatedMultimedia).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductType
|
||||||
|
{
|
||||||
|
private const int NombreMaxLength = 200;
|
||||||
|
|
||||||
|
public int Id { get; }
|
||||||
|
public string Nombre { get; }
|
||||||
|
public bool HasDuration { get; }
|
||||||
|
public bool RequiresText { get; }
|
||||||
|
public bool RequiresCategory { get; }
|
||||||
|
public bool IsBundle { get; }
|
||||||
|
public bool AllowImages { get; }
|
||||||
|
public int? MaxImages { get; }
|
||||||
|
public decimal? MaxImageSizeMB { get; }
|
||||||
|
public int? MaxImageWidth { get; }
|
||||||
|
public int? MaxImageHeight { get; }
|
||||||
|
public bool IsActive { get; }
|
||||||
|
public DateTime FechaCreacion { get; }
|
||||||
|
public DateTime? FechaModificacion { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full hydration constructor — used by the repository to reconstruct from DB rows.
|
||||||
|
/// </summary>
|
||||||
|
public ProductType(
|
||||||
|
int id,
|
||||||
|
string nombre,
|
||||||
|
bool hasDuration,
|
||||||
|
bool requiresText,
|
||||||
|
bool requiresCategory,
|
||||||
|
bool isBundle,
|
||||||
|
bool allowImages,
|
||||||
|
int? maxImages,
|
||||||
|
decimal? maxImageSizeMB,
|
||||||
|
int? maxImageWidth,
|
||||||
|
int? maxImageHeight,
|
||||||
|
bool isActive,
|
||||||
|
DateTime fechaCreacion,
|
||||||
|
DateTime? fechaModificacion)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Nombre = nombre;
|
||||||
|
HasDuration = hasDuration;
|
||||||
|
RequiresText = requiresText;
|
||||||
|
RequiresCategory = requiresCategory;
|
||||||
|
IsBundle = isBundle;
|
||||||
|
AllowImages = allowImages;
|
||||||
|
MaxImages = maxImages;
|
||||||
|
MaxImageSizeMB = maxImageSizeMB;
|
||||||
|
MaxImageWidth = maxImageWidth;
|
||||||
|
MaxImageHeight = maxImageHeight;
|
||||||
|
IsActive = isActive;
|
||||||
|
FechaCreacion = fechaCreacion;
|
||||||
|
FechaModificacion = fechaModificacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for creating a new ProductType.
|
||||||
|
/// Id=0 — DB assigns via IDENTITY.
|
||||||
|
/// IsActive=true, FechaModificacion=null by default.
|
||||||
|
/// AllowImages=false normalizes all 4 Max* fields to null.
|
||||||
|
/// </summary>
|
||||||
|
public static ProductType ForCreation(
|
||||||
|
string nombre,
|
||||||
|
bool hasDuration,
|
||||||
|
bool requiresText,
|
||||||
|
bool requiresCategory,
|
||||||
|
bool isBundle,
|
||||||
|
bool allowImages,
|
||||||
|
int? maxImages,
|
||||||
|
decimal? maxImageSizeMB,
|
||||||
|
int? maxImageWidth,
|
||||||
|
int? maxImageHeight,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
ValidateNombre(nombre);
|
||||||
|
|
||||||
|
var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight);
|
||||||
|
|
||||||
|
return new ProductType(
|
||||||
|
id: 0,
|
||||||
|
nombre: nombre,
|
||||||
|
hasDuration: hasDuration,
|
||||||
|
requiresText: requiresText,
|
||||||
|
requiresCategory: requiresCategory,
|
||||||
|
isBundle: isBundle,
|
||||||
|
allowImages: allowImages,
|
||||||
|
maxImages: mi,
|
||||||
|
maxImageSizeMB: ms,
|
||||||
|
maxImageWidth: mw,
|
||||||
|
maxImageHeight: mh,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
|
||||||
|
fechaModificacion: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new ProductType with an updated Nombre and FechaModificacion.
|
||||||
|
/// Does NOT mutate the current instance.
|
||||||
|
/// </summary>
|
||||||
|
public ProductType WithRenamed(string nuevoNombre, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
ValidateNombre(nuevoNombre);
|
||||||
|
|
||||||
|
return new ProductType(
|
||||||
|
Id, nuevoNombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
|
||||||
|
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new ProductType with updated flags, preserving multimedia fields.
|
||||||
|
/// Does NOT mutate the current instance.
|
||||||
|
/// </summary>
|
||||||
|
public ProductType WithUpdatedFlags(
|
||||||
|
bool hasDuration,
|
||||||
|
bool requiresText,
|
||||||
|
bool requiresCategory,
|
||||||
|
bool isBundle,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
return new ProductType(
|
||||||
|
Id, Nombre, hasDuration, requiresText, requiresCategory, isBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
|
||||||
|
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new ProductType with updated multimedia limits.
|
||||||
|
/// AllowImages=false normalizes all 4 Max* fields to null.
|
||||||
|
/// Does NOT mutate the current instance.
|
||||||
|
/// </summary>
|
||||||
|
public ProductType WithUpdatedMultimedia(
|
||||||
|
bool allowImages,
|
||||||
|
int? maxImages,
|
||||||
|
decimal? maxImageSizeMB,
|
||||||
|
int? maxImageWidth,
|
||||||
|
int? maxImageHeight,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
var (mi, ms, mw, mh) = NormalizeMultimedia(allowImages, maxImages, maxImageSizeMB, maxImageWidth, maxImageHeight);
|
||||||
|
|
||||||
|
return new ProductType(
|
||||||
|
Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
allowImages, mi, ms, mw, mh,
|
||||||
|
IsActive, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new ProductType with IsActive=false and FechaModificacion updated.
|
||||||
|
/// Does NOT mutate the current instance.
|
||||||
|
/// </summary>
|
||||||
|
public ProductType WithDeactivated(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
return new ProductType(
|
||||||
|
Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
|
||||||
|
isActive: false, FechaCreacion, timeProvider.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (int?, decimal?, int?, int?) NormalizeMultimedia(
|
||||||
|
bool allow, int? mi, decimal? ms, int? mw, int? mh)
|
||||||
|
=> allow ? (mi, ms, mw, mh) : (null, null, null, null);
|
||||||
|
|
||||||
|
private static void ValidateNombre(string nombre)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(nombre))
|
||||||
|
throw new ArgumentException(
|
||||||
|
"El nombre del tipo de producto no puede estar vacío o ser solo espacios.",
|
||||||
|
nameof(nombre));
|
||||||
|
|
||||||
|
if (nombre.Length > NombreMaxLength)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"El nombre del tipo de producto no puede superar los {NombreMaxLength} caracteres.",
|
||||||
|
nameof(nombre));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when attempting to deactivate a ProductType that has active products. → HTTP 409
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductTypeEnUsoException : DomainException
|
||||||
|
{
|
||||||
|
public int ProductTypeId { get; }
|
||||||
|
public int ProductsActivos { get; }
|
||||||
|
|
||||||
|
public ProductTypeEnUsoException(int id, int productsActivos)
|
||||||
|
: base($"El tipo de producto con id={id} no puede desactivarse: tiene {productsActivos} producto(s) activo(s) asociados.")
|
||||||
|
{
|
||||||
|
ProductTypeId = id;
|
||||||
|
ProductsActivos = productsActivos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a combination of flags/multimedia is logically incoherent. → HTTP 422
|
||||||
|
/// Defensive exception — PRD-001 normalizes instead of throwing.
|
||||||
|
/// Reserved for future rules (e.g., PRD-004 IsBundle+HasDuration constraints).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductTypeFlagsIncoherentesException : DomainException
|
||||||
|
{
|
||||||
|
public string Reason { get; }
|
||||||
|
|
||||||
|
public ProductTypeFlagsIncoherentesException(string reason)
|
||||||
|
: base($"Combinación de flags/multimedia inválida: {reason}")
|
||||||
|
{
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a ProductType with the same active name already exists. → HTTP 409
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductTypeNombreDuplicadoException : DomainException
|
||||||
|
{
|
||||||
|
public string Nombre { get; }
|
||||||
|
|
||||||
|
public ProductTypeNombreDuplicadoException(string nombre)
|
||||||
|
: base($"Ya existe un tipo de producto activo con el nombre '{nombre}'.")
|
||||||
|
{
|
||||||
|
Nombre = nombre;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested ProductType does not exist. → HTTP 404
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProductTypeNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int ProductTypeId { get; }
|
||||||
|
|
||||||
|
public ProductTypeNotFoundException(int id)
|
||||||
|
: base($"El tipo de producto con id={id} no existe.")
|
||||||
|
{
|
||||||
|
ProductTypeId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
||||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||||
|
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using Dapper;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class ProductTypeRepository : IProductTypeRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _factory;
|
||||||
|
|
||||||
|
public ProductTypeRepository(SqlConnectionFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddAsync(ProductType productType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// DF handles: IsActive (1), FechaCreacion (SYSUTCDATETIME()).
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.ProductType (
|
||||||
|
Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight
|
||||||
|
)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES (
|
||||||
|
@Nombre, @HasDuration, @RequiresText, @RequiresCategory, @IsBundle,
|
||||||
|
@AllowImages, @MaxImages, @MaxImageSizeMB, @MaxImageWidth, @MaxImageHeight
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
productType.Nombre,
|
||||||
|
HasDuration = productType.HasDuration ? 1 : 0,
|
||||||
|
RequiresText = productType.RequiresText ? 1 : 0,
|
||||||
|
RequiresCategory = productType.RequiresCategory ? 1 : 0,
|
||||||
|
IsBundle = productType.IsBundle ? 1 : 0,
|
||||||
|
AllowImages = productType.AllowImages ? 1 : 0,
|
||||||
|
productType.MaxImages,
|
||||||
|
productType.MaxImageSizeMB,
|
||||||
|
productType.MaxImageWidth,
|
||||||
|
productType.MaxImageHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
|
||||||
|
IsActive, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.ProductType
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<ProductTypeRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<ProductType>> GetPagedAsync(
|
||||||
|
ProductTypesQuery query,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Build the WHERE clause dynamically.
|
||||||
|
var conditions = new List<string>();
|
||||||
|
if (query.Activo.HasValue)
|
||||||
|
conditions.Add("IsActive = @Activo");
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
conditions.Add("Nombre LIKE '%' + @Search + '%'");
|
||||||
|
|
||||||
|
var where = conditions.Count > 0
|
||||||
|
? "WHERE " + string.Join(" AND ", conditions)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var countSql = $"SELECT COUNT(1) FROM dbo.ProductType {where}";
|
||||||
|
var dataSql = $"""
|
||||||
|
SELECT Id, Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
|
||||||
|
AllowImages, MaxImages, MaxImageSizeMB, MaxImageWidth, MaxImageHeight,
|
||||||
|
IsActive, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.ProductType
|
||||||
|
{where}
|
||||||
|
ORDER BY Nombre
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
var offset = (query.Page - 1) * query.PageSize;
|
||||||
|
var parameters = new
|
||||||
|
{
|
||||||
|
Activo = query.Activo.HasValue ? (object)(query.Activo.Value ? 1 : 0) : null,
|
||||||
|
Search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search,
|
||||||
|
Offset = offset,
|
||||||
|
PageSize = query.PageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var total = await connection.ExecuteScalarAsync<int>(countSql, parameters);
|
||||||
|
var rows = await connection.QueryAsync<ProductTypeRow>(dataSql, parameters);
|
||||||
|
var items = rows.Select(MapRow).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<ProductType>(items, query.Page, query.PageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(ProductType productType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.ProductType
|
||||||
|
SET Nombre = @Nombre,
|
||||||
|
HasDuration = @HasDuration,
|
||||||
|
RequiresText = @RequiresText,
|
||||||
|
RequiresCategory = @RequiresCategory,
|
||||||
|
IsBundle = @IsBundle,
|
||||||
|
AllowImages = @AllowImages,
|
||||||
|
MaxImages = @MaxImages,
|
||||||
|
MaxImageSizeMB = @MaxImageSizeMB,
|
||||||
|
MaxImageWidth = @MaxImageWidth,
|
||||||
|
MaxImageHeight = @MaxImageHeight,
|
||||||
|
IsActive = @IsActive,
|
||||||
|
FechaModificacion = @FechaModificacion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
productType.Nombre,
|
||||||
|
HasDuration = productType.HasDuration ? 1 : 0,
|
||||||
|
RequiresText = productType.RequiresText ? 1 : 0,
|
||||||
|
RequiresCategory = productType.RequiresCategory ? 1 : 0,
|
||||||
|
IsBundle = productType.IsBundle ? 1 : 0,
|
||||||
|
AllowImages = productType.AllowImages ? 1 : 0,
|
||||||
|
productType.MaxImages,
|
||||||
|
productType.MaxImageSizeMB,
|
||||||
|
productType.MaxImageWidth,
|
||||||
|
productType.MaxImageHeight,
|
||||||
|
IsActive = productType.IsActive ? 1 : 0,
|
||||||
|
productType.FechaModificacion,
|
||||||
|
productType.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByNombreAsync(
|
||||||
|
string nombre,
|
||||||
|
int? excludeId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// DB collation is SQL_Latin1_General_CP1_CI_AI on Nombre (CI) — comparison is
|
||||||
|
// already case-insensitive; no need for UPPER(). The filtered unique index
|
||||||
|
// (UQ_ProductType_Nombre_Activo WHERE IsActive=1) aligns with this query.
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM dbo.ProductType
|
||||||
|
WHERE Nombre = @Nombre
|
||||||
|
AND IsActive = 1
|
||||||
|
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _factory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var count = await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
Nombre = nombre,
|
||||||
|
ExcludeId = excludeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static ProductType MapRow(ProductTypeRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
hasDuration: r.HasDuration,
|
||||||
|
requiresText: r.RequiresText,
|
||||||
|
requiresCategory: r.RequiresCategory,
|
||||||
|
isBundle: r.IsBundle,
|
||||||
|
allowImages: r.AllowImages,
|
||||||
|
maxImages: r.MaxImages,
|
||||||
|
maxImageSizeMB: r.MaxImageSizeMB,
|
||||||
|
maxImageWidth: r.MaxImageWidth,
|
||||||
|
maxImageHeight: r.MaxImageHeight,
|
||||||
|
isActive: r.IsActive,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record ProductTypeRow(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
bool HasDuration,
|
||||||
|
bool RequiresText,
|
||||||
|
bool RequiresCategory,
|
||||||
|
bool IsBundle,
|
||||||
|
bool AllowImages,
|
||||||
|
int? MaxImages,
|
||||||
|
decimal? MaxImageSizeMB,
|
||||||
|
int? MaxImageWidth,
|
||||||
|
int? MaxImageHeight,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Columns3,
|
Columns3,
|
||||||
Store,
|
Store,
|
||||||
Tag,
|
Tag,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -75,6 +76,12 @@ const adminItems: NavItem[] = [
|
|||||||
icon: Tag,
|
icon: Tag,
|
||||||
requiredPermission: 'catalogo:rubros:gestionar',
|
requiredPermission: 'catalogo:rubros:gestionar',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Tipos de Producto',
|
||||||
|
href: '/admin/product-types',
|
||||||
|
icon: Layers,
|
||||||
|
requiredPermission: 'catalogo:tipos:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
|
|||||||
12
src/web/src/features/product-types/api/createProductType.ts
Normal file
12
src/web/src/features/product-types/api/createProductType.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateProductTypeRequest, ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function createProductType(
|
||||||
|
payload: CreateProductTypeRequest,
|
||||||
|
): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.post<ProductTypeDetail>(
|
||||||
|
'/api/v1/admin/product-types',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateProductType(id: number): Promise<void> {
|
||||||
|
await axiosClient.delete(`/api/v1/admin/product-types/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getProductTypeById(id: number): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.get<ProductTypeDetail>(`/api/v1/product-types/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
12
src/web/src/features/product-types/api/listProductTypes.ts
Normal file
12
src/web/src/features/product-types/api/listProductTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { ListProductTypesParams, PagedResult, ProductTypeListItem } from '../types'
|
||||||
|
|
||||||
|
export async function listProductTypes(
|
||||||
|
params?: ListProductTypesParams,
|
||||||
|
): Promise<PagedResult<ProductTypeListItem>> {
|
||||||
|
const response = await axiosClient.get<PagedResult<ProductTypeListItem>>(
|
||||||
|
'/api/v1/product-types',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
13
src/web/src/features/product-types/api/updateProductType.ts
Normal file
13
src/web/src/features/product-types/api/updateProductType.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { UpdateProductTypeRequest, ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export async function updateProductType(
|
||||||
|
id: number,
|
||||||
|
payload: UpdateProductTypeRequest,
|
||||||
|
): Promise<ProductTypeDetail> {
|
||||||
|
const response = await axiosClient.put<ProductTypeDetail>(
|
||||||
|
`/api/v1/admin/product-types/${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 { ProductTypeListItem } 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 tipo de producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeactivateProductTypeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
productType: ProductTypeListItem
|
||||||
|
onConfirm: (id: number) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeactivateProductTypeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
productType,
|
||||||
|
onConfirm,
|
||||||
|
}: DeactivateProductTypeDialogProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setError(null)
|
||||||
|
setIsPending(true)
|
||||||
|
try {
|
||||||
|
await onConfirm(productType.id)
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(resolveDeactivateError(err))
|
||||||
|
} finally {
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Desactivar tipo de producto</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
¿Desactivar el tipo “{productType.nombre}”? Los productos asociados conservan
|
||||||
|
la referencia pero el tipo no aparecerá en 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function nullablePositiveInt() {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||||
|
.pipe(z.number().int().positive().nullable())
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullablePositiveDecimal() {
|
||||||
|
return z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v == null ? null : Number(v)))
|
||||||
|
.pipe(z.number().positive().nullable())
|
||||||
|
}
|
||||||
|
|
||||||
|
const productTypeFormSchema = z.object({
|
||||||
|
nombre: z.string().trim().min(1, 'Nombre requerido').max(100, 'Máximo 100 caracteres'),
|
||||||
|
hasDuration: z.boolean(),
|
||||||
|
requiresText: z.boolean(),
|
||||||
|
requiresCategory: z.boolean(),
|
||||||
|
isBundle: z.boolean(),
|
||||||
|
allowImages: z.boolean(),
|
||||||
|
maxImages: nullablePositiveInt(),
|
||||||
|
maxImageSizeMB: nullablePositiveDecimal(),
|
||||||
|
maxImageWidth: nullablePositiveInt(),
|
||||||
|
maxImageHeight: nullablePositiveInt(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Raw form field types (strings before zod transforms)
|
||||||
|
type ProductTypeFormRaw = {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: string
|
||||||
|
maxImageSizeMB: string
|
||||||
|
maxImageWidth: string
|
||||||
|
maxImageHeight: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output type after zod transforms (what onSubmit receives at runtime)
|
||||||
|
export type ProductTypeFormOutput = {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: number | null
|
||||||
|
maxImageSizeMB: number | null
|
||||||
|
maxImageWidth: number | null
|
||||||
|
maxImageHeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProductTypeFormDefaultValues {
|
||||||
|
nombre?: string
|
||||||
|
hasDuration?: boolean
|
||||||
|
requiresText?: boolean
|
||||||
|
requiresCategory?: boolean
|
||||||
|
isBundle?: boolean
|
||||||
|
allowImages?: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductTypeFormProps {
|
||||||
|
defaultValues?: ProductTypeFormDefaultValues
|
||||||
|
onSubmit: (values: ProductTypeFormOutput) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isPending?: boolean
|
||||||
|
isEdit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductTypeForm({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isPending = false,
|
||||||
|
isEdit = false,
|
||||||
|
}: ProductTypeFormProps) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const form = useForm<ProductTypeFormRaw>({
|
||||||
|
resolver: zodResolver(productTypeFormSchema) as any,
|
||||||
|
defaultValues: {
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
hasDuration: defaultValues?.hasDuration ?? false,
|
||||||
|
requiresText: defaultValues?.requiresText ?? false,
|
||||||
|
requiresCategory: defaultValues?.requiresCategory ?? false,
|
||||||
|
isBundle: defaultValues?.isBundle ?? false,
|
||||||
|
allowImages: defaultValues?.allowImages ?? false,
|
||||||
|
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
|
||||||
|
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
|
||||||
|
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
|
||||||
|
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const allowImages = form.watch('allowImages')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
nombre: defaultValues?.nombre ?? '',
|
||||||
|
hasDuration: defaultValues?.hasDuration ?? false,
|
||||||
|
requiresText: defaultValues?.requiresText ?? false,
|
||||||
|
requiresCategory: defaultValues?.requiresCategory ?? false,
|
||||||
|
isBundle: defaultValues?.isBundle ?? false,
|
||||||
|
allowImages: defaultValues?.allowImages ?? false,
|
||||||
|
maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '',
|
||||||
|
maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '',
|
||||||
|
maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '',
|
||||||
|
maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '',
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [defaultValues?.nombre, defaultValues?.allowImages])
|
||||||
|
|
||||||
|
function handleSubmit(data: ProductTypeFormOutput) {
|
||||||
|
// Normalize multimedia to null when allowImages=false
|
||||||
|
if (!data.allowImages) {
|
||||||
|
data = {
|
||||||
|
...data,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSubmit(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tipo de producto"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hasDuration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="hasDuration"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tiene duración"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="hasDuration" className="font-normal cursor-pointer">
|
||||||
|
Tiene duración
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requiresText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="requiresText"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Requiere texto"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="requiresText" className="font-normal cursor-pointer">
|
||||||
|
Requiere texto
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requiresCategory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="requiresCategory"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Requiere categoría"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="requiresCategory" className="font-normal cursor-pointer">
|
||||||
|
Requiere categoría
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isBundle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="isBundle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Es bundle"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="isBundle" className="font-normal cursor-pointer">
|
||||||
|
Es bundle
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allow Images toggle */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<input
|
||||||
|
id="allowImages"
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value as boolean}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Permite imágenes"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel htmlFor="allowImages" className="font-normal cursor-pointer">
|
||||||
|
Permite imágenes
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Multimedia fields — always rendered, disabled when allowImages=false */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 border rounded-md p-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Máx. imágenes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Máx. imágenes"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageSizeMB"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Máx. tamaño (MB)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={0.1}
|
||||||
|
step={0.1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Máx. tamaño (MB)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageWidth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ancho máx. (px)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Ancho máx. (px)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="maxImageHeight"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alto máx. (px)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || !allowImages}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
aria-label="Alto máx. (px)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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 { ProductTypeForm } from './ProductTypeForm'
|
||||||
|
import type { ProductTypeFormOutput } from './ProductTypeForm'
|
||||||
|
import { useCreateProductType } from '../hooks/useCreateProductType'
|
||||||
|
import { useUpdateProductType } from '../hooks/useUpdateProductType'
|
||||||
|
import type { ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
// ─── 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 tipo de producto'
|
||||||
|
}
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } }
|
||||||
|
if (errObj?.response?.data?.message) {
|
||||||
|
return errObj.response.data.message
|
||||||
|
}
|
||||||
|
return 'Error al guardar el tipo de producto'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProductTypeFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
productType?: ProductTypeDetail
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProductTypeFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
productType,
|
||||||
|
onSuccess,
|
||||||
|
}: ProductTypeFormDialogProps) {
|
||||||
|
const [backendError, setBackendError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEdit = !!productType
|
||||||
|
const { mutateAsync: createProductType, isPending: creating } = useCreateProductType()
|
||||||
|
const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType()
|
||||||
|
const isPending = creating || updating
|
||||||
|
|
||||||
|
async function handleSubmit(values: ProductTypeFormOutput) {
|
||||||
|
setBackendError(null)
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateProductType({ id: productType.id, data: values })
|
||||||
|
toast.success('Tipo de producto actualizado')
|
||||||
|
} else {
|
||||||
|
await createProductType(values)
|
||||||
|
toast.success('Tipo de 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 tipo de producto' : 'Error al crear tipo de producto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? `Modificá los datos del tipo "${productType?.nombre ?? ''}".`
|
||||||
|
: 'Completá los datos para crear un nuevo tipo de producto.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProductTypeForm
|
||||||
|
defaultValues={productType}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
isPending={isPending}
|
||||||
|
isEdit={isEdit}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createProductType } from '../api/createProductType'
|
||||||
|
import type { CreateProductTypeRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateProductTypeRequest) => createProductType(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateProductType } from '../api/deactivateProductType'
|
||||||
|
|
||||||
|
export function useDeactivateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateProductType(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/web/src/features/product-types/hooks/useProductTypes.ts
Normal file
10
src/web/src/features/product-types/hooks/useProductTypes.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listProductTypes } from '../api/listProductTypes'
|
||||||
|
import type { ListProductTypesParams } from '../types'
|
||||||
|
|
||||||
|
export function useProductTypes(params?: ListProductTypesParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['product-types', params],
|
||||||
|
queryFn: () => listProductTypes(params),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateProductType } from '../api/updateProductType'
|
||||||
|
import type { UpdateProductTypeRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateProductType() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateProductTypeRequest }) =>
|
||||||
|
updateProductType(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['product-types'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
3
src/web/src/features/product-types/index.ts
Normal file
3
src/web/src/features/product-types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// PRD-001 — product-types feature public API
|
||||||
|
export { ProductTypesPage } from './pages/ProductTypesPage'
|
||||||
|
export type { ProductTypeListItem, ProductTypeDetail, CreateProductTypeRequest, UpdateProductTypeRequest } from './types'
|
||||||
186
src/web/src/features/product-types/pages/ProductTypesPage.tsx
Normal file
186
src/web/src/features/product-types/pages/ProductTypesPage.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AlertCircle, Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useProductTypes } from '../hooks/useProductTypes'
|
||||||
|
import { useDeactivateProductType } from '../hooks/useDeactivateProductType'
|
||||||
|
import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog'
|
||||||
|
import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog'
|
||||||
|
import type { ProductTypeListItem, ProductTypeDetail } from '../types'
|
||||||
|
|
||||||
|
export function ProductTypesPage() {
|
||||||
|
// ── Create dialog state ──────────────────────────────────────────────────
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editingProductType, setEditingProductType] = useState<ProductTypeDetail | null>(null)
|
||||||
|
|
||||||
|
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||||
|
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||||
|
const [deactivatingProductType, setDeactivatingProductType] = useState<ProductTypeListItem | null>(null)
|
||||||
|
|
||||||
|
const { data: paged, isLoading, isError } = useProductTypes({ activo: true })
|
||||||
|
const { mutateAsync: deactivateProductType } = useDeactivateProductType()
|
||||||
|
|
||||||
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(pt: ProductTypeListItem) {
|
||||||
|
// Map list item to detail shape for pre-filling (multimedia nulls are fine for list item)
|
||||||
|
const detail: ProductTypeDetail = {
|
||||||
|
...pt,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: null,
|
||||||
|
fechaCreacion: '',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
setEditingProductType(detail)
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeactivate(pt: ProductTypeListItem) {
|
||||||
|
setDeactivatingProductType(pt)
|
||||||
|
setDeactivateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeactivate(id: number) {
|
||||||
|
await deactivateProductType(id)
|
||||||
|
toast.success('Tipo de producto desactivado')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 tipos de producto.</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">Tipos de Producto</h1>
|
||||||
|
<CanPerform permission="catalogo:tipos:gestionar">
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo Tipo
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||||
|
<p>No hay tipos de producto.</p>
|
||||||
|
<CanPerform permission="catalogo:tipos:gestionar">
|
||||||
|
<Button variant="outline" onClick={openCreate}>
|
||||||
|
Crear primer tipo
|
||||||
|
</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">Duración</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Texto</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Categoría</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Bundle</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Imágenes</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((pt: ProductTypeListItem) => (
|
||||||
|
<tr key={pt.id} className="border-b last:border-0 hover:bg-muted/25">
|
||||||
|
<td className="px-4 py-2 font-medium">{pt.nombre}</td>
|
||||||
|
<td className="px-4 py-2">{pt.hasDuration ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.requiresText ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.requiresCategory ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.isBundle ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">{pt.allowImages ? 'Sí' : 'No'}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={pt.isActive ? 'text-green-600' : 'text-red-500'}>
|
||||||
|
{pt.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<CanPerform permission="catalogo:tipos:gestionar">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEdit(pt)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openDeactivate(pt)}
|
||||||
|
disabled={!pt.isActive}
|
||||||
|
>
|
||||||
|
Desactivar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<ProductTypeFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit dialog */}
|
||||||
|
{editingProductType && (
|
||||||
|
<ProductTypeFormDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
productType={editingProductType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deactivate confirmation dialog */}
|
||||||
|
{deactivatingProductType && (
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={deactivateOpen}
|
||||||
|
onOpenChange={setDeactivateOpen}
|
||||||
|
productType={deactivatingProductType}
|
||||||
|
onConfirm={handleDeactivate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/web/src/features/product-types/types.ts
Normal file
69
src/web/src/features/product-types/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// PRD-001 — shared types for product-types feature
|
||||||
|
|
||||||
|
export interface ProductTypeListItem {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductTypeDetail {
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages: number | null
|
||||||
|
maxImageSizeMB: number | null
|
||||||
|
maxImageWidth: number | null
|
||||||
|
maxImageHeight: number | null
|
||||||
|
isActive: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductTypeRequest {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductTypeRequest {
|
||||||
|
nombre: string
|
||||||
|
hasDuration: boolean
|
||||||
|
requiresText: boolean
|
||||||
|
requiresCategory: boolean
|
||||||
|
isBundle: boolean
|
||||||
|
allowImages: boolean
|
||||||
|
maxImages?: number | null
|
||||||
|
maxImageSizeMB?: number | null
|
||||||
|
maxImageWidth?: number | null
|
||||||
|
maxImageHeight?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListProductTypesParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
activo?: boolean | null
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPunto
|
|||||||
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
|
||||||
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
|
||||||
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
import { RubrosPage } from './features/rubros/pages/RubrosPage'
|
||||||
|
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -309,6 +310,16 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ProductTypes routes — PRD-001 */}
|
||||||
|
<Route
|
||||||
|
path="/admin/product-types"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['catalogo:tipos:gestionar']}>
|
||||||
|
<ProductTypesPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import React from 'react'
|
||||||
|
import { DeactivateProductTypeDialog } from '../../../features/product-types/components/DeactivateProductTypeDialog'
|
||||||
|
import type { ProductTypeListItem } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const sampleProductType: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
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>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeactivateProductTypeDialog', () => {
|
||||||
|
it('renders confirmation message with product type name', () => {
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/Clasificados/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: /desactivar tipo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm with product type id when user confirms', async () => {
|
||||||
|
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
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(sampleProductType.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error when backend returns 409 EnUso', async () => {
|
||||||
|
const onConfirm = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
response: { status: 409, data: { message: 'No se puede desactivar: el tipo está en uso por Products.' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
wrap(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
productType={sampleProductType}
|
||||||
|
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(
|
||||||
|
<DeactivateProductTypeDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
productType={sampleProductType}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 { ProductTypeForm } from '../../../features/product-types/components/ProductTypeForm'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
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>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — field rendering ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — field rendering', () => {
|
||||||
|
it('renders nombre field and all flag checkboxes', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText(/nombre/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Tiene duración')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Requiere texto')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Requiere categoría')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Es bundle')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Permite imágenes')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders multimedia fields when allowImages is true', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: true }} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Ancho máx. (px)')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Alto máx. (px)')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables multimedia fields when allowImages is false', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Ancho máx. (px)')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('Alto máx. (px)')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — allowImages toggle ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — allowImages toggle', () => {
|
||||||
|
it('enables multimedia fields after toggling allowImages on', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
const allowImagesCheckbox = screen.getByLabelText('Permite imágenes')
|
||||||
|
await userEvent.click(allowImagesCheckbox)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Máx. imágenes')).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — validation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — zod validation', () => {
|
||||||
|
it('shows validation error when nombre is empty', async () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/nombre.*requerido/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with correct payload when form is valid', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Clasificados')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const payload = onSubmit.mock.calls[0][0]
|
||||||
|
expect(payload).toMatchObject({ nombre: 'Clasificados' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes multimedia fields to null when allowImages is false on submit', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Sin imágenes')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const payload = onSubmit.mock.calls[0][0]
|
||||||
|
expect(payload.maxImages).toBeNull()
|
||||||
|
expect(payload.maxImageSizeMB).toBeNull()
|
||||||
|
expect(payload.maxImageWidth).toBeNull()
|
||||||
|
expect(payload.maxImageHeight).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeForm — cancel ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeForm — cancel', () => {
|
||||||
|
it('calls onCancel when cancel button is clicked', async () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeForm onSubmit={vi.fn()} onCancel={onCancel} />,
|
||||||
|
)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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 { ProductTypeFormDialog } from '../../../features/product-types/components/ProductTypeFormDialog'
|
||||||
|
import type { ProductTypeDetail } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockDetail: ProductTypeDetail = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: 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>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProductTypeFormDialog — create mode ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeFormDialog — create mode', () => {
|
||||||
|
it('renders create dialog title when no productType prop', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has aria-describedby on DialogDescription (NFR8)', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
const desc = screen.getByText(/completá los datos/i)
|
||||||
|
expect(desc).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls create mutation and closes dialog on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
HttpResponse.json({ id: 5, nombre: 'Nuevo Tipo' }, { status: 201 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Tipo')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error when backend returns 409 NombreDuplicado', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ error: 'producto_tipo_nombre_duplicado', message: 'Ya existe un tipo con ese nombre' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||||
|
)
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/ya existe un tipo con ese nombre/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ProductTypeFormDialog — edit mode ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypeFormDialog — edit mode', () => {
|
||||||
|
it('renders edit dialog title and pre-fills nombre', () => {
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} productType={mockDetail} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument()
|
||||||
|
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Clasificados')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls update mutation and closes dialog on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/1`, () =>
|
||||||
|
HttpResponse.json({ ...mockDetail, nombre: 'Modificado' }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
wrap(
|
||||||
|
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} productType={mockDetail} />,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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 { ProductTypesPage } from '../../../features/product-types/pages/ProductTypesPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/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:tipos:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularUser = {
|
||||||
|
id: 2,
|
||||||
|
username: 'viewer',
|
||||||
|
nombre: 'Viewer',
|
||||||
|
rol: 'viewer',
|
||||||
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockItem: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
items: [mockItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
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/product-types']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/product-types" element={<ProductTypesPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading / Error / Data states ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypesPage — loading and error states', () => {
|
||||||
|
it('renders loading skeleton while fetching', () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, async () => {
|
||||||
|
await new Promise(() => {})
|
||||||
|
return HttpResponse.json(emptyPaged)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
// During loading, skeletons are shown — verify they are rendered
|
||||||
|
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/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state on fetch failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () =>
|
||||||
|
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/error al cargar tipos de producto/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no product types', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)),
|
||||||
|
)
|
||||||
|
renderPage()
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/no hay tipos de producto/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Create dialog ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypesPage — create dialog', () => {
|
||||||
|
it('opens create dialog when "Nuevo Tipo" button is clicked', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo tipo/i })).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /nuevo tipo/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens create dialog from empty state CTA', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /crear primer tipo/i })).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear primer tipo/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Nuevo Tipo" button when user lacks permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(regularUser)
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByRole('button', { name: /nuevo tipo/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Edit dialog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypesPage — edit dialog', () => {
|
||||||
|
it('opens edit dialog pre-filled when Edit button is clicked', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /editar/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||||
|
expect(input.value).toBe('Clasificados')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ProductTypesPage — deactivate dialog', () => {
|
||||||
|
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
renderPage(adminUser)
|
||||||
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('heading', { name: /desactivar tipo de producto/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
137
src/web/src/tests/features/product-types/api.test.ts
Normal file
137
src/web/src/tests/features/product-types/api.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { listProductTypes } from '../../../features/product-types/api/listProductTypes'
|
||||||
|
import { getProductTypeById } from '../../../features/product-types/api/getProductTypeById'
|
||||||
|
import { createProductType } from '../../../features/product-types/api/createProductType'
|
||||||
|
import { updateProductType } from '../../../features/product-types/api/updateProductType'
|
||||||
|
import { deactivateProductType } from '../../../features/product-types/api/deactivateProductType'
|
||||||
|
import type { ProductTypeListItem, ProductTypeDetail, PagedResult } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockListItem: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDetail: ProductTypeDetail = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: 5,
|
||||||
|
maxImageSizeMB: 2.5,
|
||||||
|
maxImageWidth: 800,
|
||||||
|
maxImageHeight: 600,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: '2026-04-19T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
items: [mockListItem],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
describe('listProductTypes', () => {
|
||||||
|
it('calls GET /api/v1/product-types and returns paged result', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const result = await listProductTypes()
|
||||||
|
expect(result).toEqual(mockPaged)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes query params when provided', async () => {
|
||||||
|
let capturedUrl = ''
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, ({ request }) => {
|
||||||
|
capturedUrl = request.url
|
||||||
|
return HttpResponse.json(mockPaged)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await listProductTypes({ page: 2, pageSize: 10, activo: true, search: 'test' })
|
||||||
|
expect(capturedUrl).toContain('page=2')
|
||||||
|
expect(capturedUrl).toContain('pageSize=10')
|
||||||
|
expect(capturedUrl).toContain('search=test')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getProductTypeById', () => {
|
||||||
|
it('calls GET /api/v1/product-types/:id and returns detail', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types/1`, () => HttpResponse.json(mockDetail)),
|
||||||
|
)
|
||||||
|
const result = await getProductTypeById(1)
|
||||||
|
expect(result).toEqual(mockDetail)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createProductType', () => {
|
||||||
|
it('calls POST /api/v1/admin/product-types with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail, { status: 201 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = {
|
||||||
|
nombre: 'Avisos Clasificados',
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
}
|
||||||
|
await createProductType(req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateProductType', () => {
|
||||||
|
it('calls PUT /api/v1/admin/product-types/:id with payload', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/1`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockDetail)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const req = { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false }
|
||||||
|
await updateProductType(1, req)
|
||||||
|
expect(capturedBody).toEqual(req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deactivateProductType', () => {
|
||||||
|
it('calls DELETE /api/v1/admin/product-types/:id', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/product-types/1`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await deactivateProductType(1)
|
||||||
|
expect(called).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
148
src/web/src/tests/features/product-types/hooks.test.ts
Normal file
148
src/web/src/tests/features/product-types/hooks.test.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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 { useProductTypes } from '../../../features/product-types/hooks/useProductTypes'
|
||||||
|
import { useCreateProductType } from '../../../features/product-types/hooks/useCreateProductType'
|
||||||
|
import { useUpdateProductType } from '../../../features/product-types/hooks/useUpdateProductType'
|
||||||
|
import { useDeactivateProductType } from '../../../features/product-types/hooks/useDeactivateProductType'
|
||||||
|
import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockItem: ProductTypeListItem = {
|
||||||
|
id: 1,
|
||||||
|
nombre: 'Avisos',
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
|
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('useProductTypes', () => {
|
||||||
|
it('returns paged data on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProductTypes(), { 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/product-types`, () =>
|
||||||
|
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { result } = renderHook(() => useProductTypes(), { wrapper: makeWrapper() })
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateProductType', () => {
|
||||||
|
it('calls create and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/product-types`, () =>
|
||||||
|
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(() => useCreateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({
|
||||||
|
nombre: 'Nuevo',
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useUpdateProductType', () => {
|
||||||
|
it('calls update and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.put(`${API_URL}/api/v1/admin/product-types/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(() => useUpdateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate({
|
||||||
|
id: 1,
|
||||||
|
data: { nombre: 'Modificado', hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false, allowImages: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeactivateProductType', () => {
|
||||||
|
it('calls deactivate and invalidates product-types queries on success', async () => {
|
||||||
|
server.use(
|
||||||
|
http.delete(`${API_URL}/api/v1/admin/product-types/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(() => useDeactivateProductType(), { wrapper })
|
||||||
|
await act(async () => {
|
||||||
|
result.current.mutate(1)
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-types'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -50,8 +50,9 @@ public class AuthControllerTests
|
|||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
Assert.Equal(25, permisos.GetArrayLength());
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||||
|
Assert.Equal(26, permisos.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario: invalid credentials return 401 with opaque error
|
// Scenario: invalid credentials return 401 with opaque error
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPermisos_WithAdmin_Returns200With25Items()
|
public async Task GetPermisos_WithAdmin_Returns200With26Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
|
||||||
@@ -140,8 +140,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
Assert.Equal(25, list.GetArrayLength());
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||||
|
Assert.Equal(26, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -184,7 +185,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetRolPermisos_AdminRol_Returns200With25Items()
|
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
|
||||||
{
|
{
|
||||||
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
|
||||||
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
|
||||||
@@ -195,8 +196,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
|
|||||||
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
// V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22
|
||||||
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
|
||||||
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
|
||||||
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total
|
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
|
||||||
Assert.Equal(25, list.GetArrayLength());
|
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
|
||||||
|
Assert.Equal(26, list.GetArrayLength());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Tests.ProductTypes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-001 — Integration tests for /api/v1/product-types and /api/v1/admin/product-types.
|
||||||
|
/// Read endpoints require authentication (any role).
|
||||||
|
/// Write endpoints require permission 'catalogo:tipos:gestionar'.
|
||||||
|
/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ApiIntegration")]
|
||||||
|
public sealed class ProductTypesControllerTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string ReadEndpoint = "/api/v1/product-types";
|
||||||
|
private const string AdminEndpoint = "/api/v1/admin/product-types";
|
||||||
|
private const string AdminUsername = "admin";
|
||||||
|
private const string AdminPassword = "@Diego550@";
|
||||||
|
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public ProductTypesControllerTests(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 / 403 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/admin/product-types ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithAdmin_Returns201WithId()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var uniqueName = $"Tipo_Create_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = uniqueName,
|
||||||
|
hasDuration = true,
|
||||||
|
requiresText = false,
|
||||||
|
requiresCategory = false,
|
||||||
|
isBundle = false,
|
||||||
|
allowImages = true,
|
||||||
|
maxImages = 3,
|
||||||
|
maxImageSizeMB = 1.5,
|
||||||
|
maxImageWidth = (int?)null,
|
||||||
|
maxImageHeight = (int?)null
|
||||||
|
}, 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_DuplicateNombre_Returns409()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var uniqueName = $"DupNombre_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = uniqueName,
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
var resp1 = await _client.SendAsync(req1);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
|
||||||
|
|
||||||
|
using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = uniqueName,
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
var resp2 = await _client.SendAsync(req2);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_InvalidBody_Returns400()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = string.Empty, // invalid
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/product-types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_WithAdmin_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/product-types/{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 uniqueName = $"Tipo_GetById_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = uniqueName,
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
var createResp = await _client.SendAsync(createReq);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||||
|
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var id = created.GetProperty("id").GetInt32();
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{id}", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||||
|
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
Assert.Equal(id, json.GetProperty("id").GetInt32());
|
||||||
|
Assert.Equal(uniqueName, json.GetProperty("nombre").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PUT /api/v1/admin/product-types/{id} ──────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_NotFound_Returns404()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new
|
||||||
|
{
|
||||||
|
nombre = "No Existe",
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/admin/product-types/{id} ───────────────────────────────
|
||||||
|
|
||||||
|
// TODO PRD-002: agregar e2e test para DELETE → 409 cuando IProductQueryRepository.IsInUseAsync retorna true.
|
||||||
|
// Bloqueado por W1 (RSA singleton sealed en TestWebApplicationFactory). Ref issue #36.
|
||||||
|
// Coverage compositional actual: DeactivateProductTypeCommandHandlerTests (unit) + ExceptionFilterTests (unit → 409).
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deactivate_ExistingActive_Returns204()
|
||||||
|
{
|
||||||
|
var token = await GetAdminTokenAsync();
|
||||||
|
var uniqueName = $"Tipo_Deactivate_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
|
||||||
|
{
|
||||||
|
nombre = uniqueName,
|
||||||
|
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
|
||||||
|
allowImages = false
|
||||||
|
}, token);
|
||||||
|
var createResp = await _client.SendAsync(createReq);
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
|
||||||
|
var created = await createResp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var id = created.GetProperty("id").GetInt32();
|
||||||
|
|
||||||
|
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{id}", bearerToken: token);
|
||||||
|
var resp = await _client.SendAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Domain.ProductTypes;
|
||||||
|
|
||||||
|
public class ProductTypeExceptionsTests
|
||||||
|
{
|
||||||
|
// ── ProductTypeNotFoundException ──────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNotFoundException_ContainsIdInMessage()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNotFoundException(42);
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("42");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNotFoundException_ExposesProductTypeId()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNotFoundException(99);
|
||||||
|
|
||||||
|
ex.ProductTypeId.Should().Be(99);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNotFoundException_IsSubclassOfDomainException()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNotFoundException(1);
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProductTypeNombreDuplicadoException ───────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNombreDuplicadoException_ContainsNombreInMessage()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNombreDuplicadoException("Clasificados");
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("Clasificados");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNombreDuplicadoException_ExposesNombre()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNombreDuplicadoException("Notables");
|
||||||
|
|
||||||
|
ex.Nombre.Should().Be("Notables");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeNombreDuplicadoException_IsSubclassOfDomainException()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeNombreDuplicadoException("x");
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProductTypeEnUsoException ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeEnUsoException_ContainsCountInMessage()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeEnUsoException(id: 5, productsActivos: 7);
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("7");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeEnUsoException_ExposesProductTypeIdAndCount()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeEnUsoException(id: 10, productsActivos: 3);
|
||||||
|
|
||||||
|
ex.ProductTypeId.Should().Be(10);
|
||||||
|
ex.ProductsActivos.Should().Be(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeEnUsoException_IsSubclassOfDomainException()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeEnUsoException(1, 0);
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProductTypeFlagsIncoherentesException ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeFlagsIncoherentesException_ContainsReasonInMessage()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeFlagsIncoherentesException("IsBundle sin hijos definidos");
|
||||||
|
|
||||||
|
ex.Message.Should().Contain("IsBundle sin hijos definidos");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeFlagsIncoherentesException_ExposesReason()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeFlagsIncoherentesException("razón técnica");
|
||||||
|
|
||||||
|
ex.Reason.Should().Be("razón técnica");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductTypeFlagsIncoherentesException_IsSubclassOfDomainException()
|
||||||
|
{
|
||||||
|
var ex = new ProductTypeFlagsIncoherentesException("x");
|
||||||
|
|
||||||
|
ex.Should().BeAssignableTo<DomainException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Domain.ProductTypes;
|
||||||
|
|
||||||
|
public class ProductTypeTests
|
||||||
|
{
|
||||||
|
private static readonly FakeTimeProvider FakeTime =
|
||||||
|
new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
// ── ForCreation: happy path ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_ValidInputs_ReturnsNewInstance_WithActiveTrue()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
"Clasificados",
|
||||||
|
hasDuration: true, requiresText: true, requiresCategory: false, isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||||
|
FakeTime);
|
||||||
|
|
||||||
|
pt.Id.Should().Be(0);
|
||||||
|
pt.Nombre.Should().Be("Clasificados");
|
||||||
|
pt.HasDuration.Should().BeTrue();
|
||||||
|
pt.RequiresText.Should().BeTrue();
|
||||||
|
pt.RequiresCategory.Should().BeFalse();
|
||||||
|
pt.IsBundle.Should().BeFalse();
|
||||||
|
pt.AllowImages.Should().BeFalse();
|
||||||
|
pt.IsActive.Should().BeTrue();
|
||||||
|
pt.FechaCreacion.Should().Be(FakeTime.GetUtcNow().UtcDateTime);
|
||||||
|
pt.FechaModificacion.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ForCreation: Nombre validations ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_NombreNull_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var act = () => ProductType.ForCreation(
|
||||||
|
null!, false, false, false, false, false,
|
||||||
|
null, null, null, null, FakeTime);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_NombreWhitespace_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var act = () => ProductType.ForCreation(
|
||||||
|
" ", false, false, false, false, false,
|
||||||
|
null, null, null, null, FakeTime);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_NombreOver200Chars_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
var nombre = new string('X', 201);
|
||||||
|
|
||||||
|
var act = () => ProductType.ForCreation(
|
||||||
|
nombre, false, false, false, false, false,
|
||||||
|
null, null, null, null, FakeTime);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ForCreation: multimedia normalization ────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_AllowImagesFalse_NormalizesAll4MultimediaFieldsToNull()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
"Clasificados",
|
||||||
|
false, false, false, false,
|
||||||
|
allowImages: false,
|
||||||
|
maxImages: 5, maxImageSizeMB: 2.5m, maxImageWidth: 1920, maxImageHeight: 1080,
|
||||||
|
FakeTime);
|
||||||
|
|
||||||
|
pt.MaxImages.Should().BeNull();
|
||||||
|
pt.MaxImageSizeMB.Should().BeNull();
|
||||||
|
pt.MaxImageWidth.Should().BeNull();
|
||||||
|
pt.MaxImageHeight.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_AllowImagesTrue_PreservesMultimediaValues()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
"Fotos",
|
||||||
|
false, false, false, false,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: 10, maxImageSizeMB: 5.0m, maxImageWidth: 800, maxImageHeight: 600,
|
||||||
|
FakeTime);
|
||||||
|
|
||||||
|
pt.AllowImages.Should().BeTrue();
|
||||||
|
pt.MaxImages.Should().Be(10);
|
||||||
|
pt.MaxImageSizeMB.Should().Be(5.0m);
|
||||||
|
pt.MaxImageWidth.Should().Be(800);
|
||||||
|
pt.MaxImageHeight.Should().Be(600);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForCreation_AllowImagesTrue_AllMaxNull_IsValid()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
"Fotos sin límite",
|
||||||
|
false, false, false, false,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
|
||||||
|
FakeTime);
|
||||||
|
|
||||||
|
pt.AllowImages.Should().BeTrue();
|
||||||
|
pt.MaxImages.Should().BeNull();
|
||||||
|
pt.MaxImageSizeMB.Should().BeNull();
|
||||||
|
pt.MaxImageWidth.Should().BeNull();
|
||||||
|
pt.MaxImageHeight.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WithRenamed ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRenamed_ValidNombre_ReturnsNewInstance_WithFechaModificacionUpdated()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, FakeTime);
|
||||||
|
var updated = FakeTimeProvider2();
|
||||||
|
var renamed = original.WithRenamed("Nuevo", updated);
|
||||||
|
|
||||||
|
renamed.Nombre.Should().Be("Nuevo");
|
||||||
|
renamed.FechaModificacion.Should().Be(updated.GetUtcNow().UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRenamed_DoesNotMutateOriginal()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, FakeTime);
|
||||||
|
_ = original.WithRenamed("Nuevo", FakeTime);
|
||||||
|
|
||||||
|
original.Nombre.Should().Be("Original");
|
||||||
|
original.FechaModificacion.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WithUpdatedFlags ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedFlags_SetsFlags_PreservesMultimedia()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation(
|
||||||
|
"Tipo", false, false, false, false,
|
||||||
|
allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: null, maxImageHeight: null,
|
||||||
|
FakeTime);
|
||||||
|
var updated = original.WithUpdatedFlags(true, true, true, true, FakeTime);
|
||||||
|
|
||||||
|
updated.HasDuration.Should().BeTrue();
|
||||||
|
updated.RequiresText.Should().BeTrue();
|
||||||
|
updated.RequiresCategory.Should().BeTrue();
|
||||||
|
updated.IsBundle.Should().BeTrue();
|
||||||
|
updated.AllowImages.Should().BeTrue();
|
||||||
|
updated.MaxImages.Should().Be(5);
|
||||||
|
updated.MaxImageSizeMB.Should().Be(2.0m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WithUpdatedMultimedia ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedMultimedia_AllowImagesFalse_NullifiesAllLimits()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation(
|
||||||
|
"Tipo", false, false, false, false,
|
||||||
|
allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: 1024, maxImageHeight: 768,
|
||||||
|
FakeTime);
|
||||||
|
var updated = original.WithUpdatedMultimedia(false, 3, 1.0m, 800, 600, FakeTime);
|
||||||
|
|
||||||
|
updated.AllowImages.Should().BeFalse();
|
||||||
|
updated.MaxImages.Should().BeNull();
|
||||||
|
updated.MaxImageSizeMB.Should().BeNull();
|
||||||
|
updated.MaxImageWidth.Should().BeNull();
|
||||||
|
updated.MaxImageHeight.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithUpdatedMultimedia_AllowImagesTrue_PreservesLimits()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation("Tipo", false, false, false, false, false, null, null, null, null, FakeTime);
|
||||||
|
var updated = original.WithUpdatedMultimedia(true, 8, 3.5m, 1920, 1080, FakeTime);
|
||||||
|
|
||||||
|
updated.AllowImages.Should().BeTrue();
|
||||||
|
updated.MaxImages.Should().Be(8);
|
||||||
|
updated.MaxImageSizeMB.Should().Be(3.5m);
|
||||||
|
updated.MaxImageWidth.Should().Be(1920);
|
||||||
|
updated.MaxImageHeight.Should().Be(1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WithDeactivated ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithDeactivated_SetsIsActiveFalse_AndFechaModificacion()
|
||||||
|
{
|
||||||
|
var original = ProductType.ForCreation("Tipo", false, false, false, false, false, null, null, null, null, FakeTime);
|
||||||
|
var tp2 = FakeTimeProvider2();
|
||||||
|
var deactivated = original.WithDeactivated(tp2);
|
||||||
|
|
||||||
|
deactivated.IsActive.Should().BeFalse();
|
||||||
|
deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
|
||||||
|
original.IsActive.Should().BeTrue(); // original not mutated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hydration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_HydrationRoundtrip_PreservesAllFields()
|
||||||
|
{
|
||||||
|
var fechaCreacion = new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var fechaModificacion = new DateTime(2026, 3, 10, 9, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var pt = new ProductType(
|
||||||
|
id: 42,
|
||||||
|
nombre: "Bundle Aniversario",
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: true,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: 12,
|
||||||
|
maxImageSizeMB: 2.75m,
|
||||||
|
maxImageWidth: 1920,
|
||||||
|
maxImageHeight: 1080,
|
||||||
|
isActive: false,
|
||||||
|
fechaCreacion: fechaCreacion,
|
||||||
|
fechaModificacion: fechaModificacion);
|
||||||
|
|
||||||
|
pt.Id.Should().Be(42);
|
||||||
|
pt.Nombre.Should().Be("Bundle Aniversario");
|
||||||
|
pt.HasDuration.Should().BeTrue();
|
||||||
|
pt.RequiresText.Should().BeFalse();
|
||||||
|
pt.RequiresCategory.Should().BeTrue();
|
||||||
|
pt.IsBundle.Should().BeTrue();
|
||||||
|
pt.AllowImages.Should().BeTrue();
|
||||||
|
pt.MaxImages.Should().Be(12);
|
||||||
|
pt.MaxImageSizeMB.Should().Be(2.75m);
|
||||||
|
pt.MaxImageWidth.Should().Be(1920);
|
||||||
|
pt.MaxImageHeight.Should().Be(1080);
|
||||||
|
pt.IsActive.Should().BeFalse();
|
||||||
|
pt.FechaCreacion.Should().Be(fechaCreacion);
|
||||||
|
pt.FechaModificacion.Should().Be(fechaModificacion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static FakeTimeProvider FakeTimeProvider2() =>
|
||||||
|
new(new DateTimeOffset(2026, 4, 20, 15, 0, 0, TimeSpan.Zero));
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
|||||||
// ── ListAsync ────────────────────────────────────────────────────────────
|
// ── ListAsync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListAsync_Returns25CanonicalSeeds()
|
public async Task ListAsync_Returns26CanonicalSeeds()
|
||||||
{
|
{
|
||||||
var list = await _repository.ListAsync();
|
var list = await _repository.ListAsync();
|
||||||
|
|
||||||
@@ -81,8 +81,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
|
|||||||
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
// + V011 (ADM-001) adds 'administracion:secciones:gestionar'
|
||||||
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
|
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
|
||||||
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
|
||||||
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total
|
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
|
||||||
Assert.Equal(25, list.Count);
|
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
|
||||||
|
Assert.Equal(26, list.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -173,16 +173,17 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
|
|||||||
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetByRolCodigoAsync_Admin_Returns25Permisos()
|
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos()
|
||||||
{
|
{
|
||||||
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
// admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006)
|
||||||
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
// + 1 from V011 (ADM-001): 'administracion:secciones:gestionar'
|
||||||
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
// + 1 from V013 (ADM-008): 'administracion:puntos_de_venta:gestionar'
|
||||||
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
// + 1 from V014 (ADM-009): 'administracion:fiscal:gestionar'
|
||||||
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar' = 25 total
|
// + 1 from V016 (CAT-001): 'catalogo:rubros:gestionar'
|
||||||
|
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total
|
||||||
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
var permisos = await _repository.GetByRolCodigoAsync("admin");
|
||||||
|
|
||||||
Assert.Equal(25, permisos.Count);
|
Assert.Equal(26, permisos.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Create;
|
||||||
|
|
||||||
|
public class CreateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly CreateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public CreateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(1);
|
||||||
|
_handler = new CreateProductTypeCommandHandler(_repo, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateProductTypeCommand ValidCommand(bool allowImages = false) => new(
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: true, RequiresText: true, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: allowImages,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(42);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
result.Id.Should().Be(42);
|
||||||
|
result.Nombre.Should().Be("Clasificados");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidCommand_CallsAddAsync()
|
||||||
|
{
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.Nombre == "Clasificados"),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SuccessfulInsert_LogsAuditEventProductoTipoCreated()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(7);
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.created",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "7",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PreservesFlagsAsProvided()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand() with { HasDuration = true, IsBundle = true };
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.HasDuration && pt.IsBundle),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AllowImagesTrue_WithAllMaxNull_PersistsAllNull()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand(allowImages: true);
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.AllowImages && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
|
||||||
|
{
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaCreacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsCreatedDtoWithPersistedId()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>()).Returns(99);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
result.Id.Should().Be(99);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AllowImages=false normalization ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AllowImagesFalse_MaxImagesInput5_PersistsWithMaxImagesNull()
|
||||||
|
{
|
||||||
|
var cmd = ValidCommand() with { AllowImages = false, MaxImages = 5 };
|
||||||
|
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).AddAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.AllowImages == false && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nombre duplicado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NombreDuplicado_ThrowsProductTypeNombreDuplicadoException()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync("Clasificados", Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NombreDuplicado_CheckedBeforeFactory()
|
||||||
|
{
|
||||||
|
// If duplicate check throws, AddAsync should never be called
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
try { await _handler.Handle(ValidCommand()); } catch (ProductTypeNombreDuplicadoException) { }
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback()
|
||||||
|
{
|
||||||
|
_repo.AddAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_TransactionRollback()
|
||||||
|
{
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit error"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public class DeactivateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IProductQueryRepository _productQuery = Substitute.For<IProductQueryRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly DeactivateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public DeactivateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_productQuery.ExistsActiveByProductTypeAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_handler = new DeactivateProductTypeCommandHandler(_repo, _productQuery, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType ActiveType(int id = 1) =>
|
||||||
|
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive: true, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
private static ProductType InactiveType(int id = 1) =>
|
||||||
|
new(id, "Clasificados", false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive: false, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
// ── Not found ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(99));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
|
||||||
|
.Where(e => e.ProductTypeId == 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Already inactive (idempotent) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AlreadyInactive_ReturnsIdempotentDto_NoAudit_NoRepoUpdate()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveType());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
result.Id.Should().Be(1);
|
||||||
|
result.IsActive.Should().BeFalse();
|
||||||
|
await _repo.DidNotReceive().UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In use guard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_InUseGuardReturnsTrue_ThrowsProductTypeEnUsoException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_productQuery.ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeEnUsoException>()
|
||||||
|
.Where(e => e.ProductTypeId == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_CallsIProductQueryRepository_Received1()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _productQuery.Received(1).ExistsActiveByProductTypeAsync(1, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidDeactivation_UpdatesAndAuditsProductoTipoDeactivated()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => !pt.IsActive),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.deactivated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UsesTimeProviderInDeactivate()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsDtoWithIdAndIsActiveFalse()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
result.Id.Should().Be(1);
|
||||||
|
result.IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveType());
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.GetById;
|
||||||
|
|
||||||
|
public class GetProductTypeByIdQueryHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly GetProductTypeByIdQueryHandler _handler;
|
||||||
|
|
||||||
|
public GetProductTypeByIdQueryHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetProductTypeByIdQueryHandler(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType FullProductType(int id = 5) =>
|
||||||
|
new(id, "Clasificados",
|
||||||
|
hasDuration: true, requiresText: true, requiresCategory: false, isBundle: false,
|
||||||
|
allowImages: true, maxImages: 10, maxImageSizeMB: 2.5m, maxImageWidth: 1920, maxImageHeight: 1080,
|
||||||
|
isActive: true,
|
||||||
|
fechaCreacion: new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
fechaModificacion: new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Found_ReturnsDetailDto()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(FullProductType(5));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
|
||||||
|
|
||||||
|
result.Id.Should().Be(5);
|
||||||
|
result.Nombre.Should().Be("Clasificados");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(999, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new GetProductTypeByIdQuery(999));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
|
||||||
|
.Where(e => e.ProductTypeId == 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_DetailContainsAllFields()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(FullProductType(5));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetProductTypeByIdQuery(5));
|
||||||
|
|
||||||
|
result.HasDuration.Should().BeTrue();
|
||||||
|
result.RequiresText.Should().BeTrue();
|
||||||
|
result.RequiresCategory.Should().BeFalse();
|
||||||
|
result.IsBundle.Should().BeFalse();
|
||||||
|
result.AllowImages.Should().BeTrue();
|
||||||
|
result.MaxImages.Should().Be(10);
|
||||||
|
result.MaxImageSizeMB.Should().Be(2.5m);
|
||||||
|
result.MaxImageWidth.Should().Be(1920);
|
||||||
|
result.MaxImageHeight.Should().Be(1080);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
result.FechaCreacion.Should().Be(new DateTime(2026, 1, 10, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
result.FechaModificacion.Should().Be(new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using NSubstitute;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.List;
|
||||||
|
|
||||||
|
public class ListProductTypesQueryHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly ListProductTypesQueryHandler _handler;
|
||||||
|
|
||||||
|
public ListProductTypesQueryHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ListProductTypesQueryHandler(_repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType MakeProductType(int id, string nombre, bool isActive = true) =>
|
||||||
|
new(id, nombre, false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive, DateTime.UtcNow, null);
|
||||||
|
|
||||||
|
private static PagedResult<ProductType> PagedOf(List<ProductType> items, int page = 1, int pageSize = 20, int? total = null) =>
|
||||||
|
new(items, page, pageSize, total ?? items.Count);
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_DefaultQuery_Returns20Active()
|
||||||
|
{
|
||||||
|
var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(PagedOf(items));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ListProductTypesQuery());
|
||||||
|
|
||||||
|
result.Items.Should().HaveCount(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PageLessThan1_ClampsToOne()
|
||||||
|
{
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(PagedOf([]));
|
||||||
|
|
||||||
|
await _handler.Handle(new ListProductTypesQuery(Page: 0));
|
||||||
|
|
||||||
|
await _repo.Received(1).GetPagedAsync(
|
||||||
|
Arg.Is<ProductTypesQuery>(q => q.Page == 1),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PageSizeOver100_ClampsTo100()
|
||||||
|
{
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(PagedOf([]));
|
||||||
|
|
||||||
|
await _handler.Handle(new ListProductTypesQuery(PageSize: 200));
|
||||||
|
|
||||||
|
await _repo.Received(1).GetPagedAsync(
|
||||||
|
Arg.Is<ProductTypesQuery>(q => q.PageSize == 100),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ActivoFalse_ReturnsInactives()
|
||||||
|
{
|
||||||
|
var inactive = new List<ProductType> { MakeProductType(99, "Inactivo", false) };
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(PagedOf(inactive));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ListProductTypesQuery(Activo: false));
|
||||||
|
|
||||||
|
await _repo.Received(1).GetPagedAsync(
|
||||||
|
Arg.Is<ProductTypesQuery>(q => q.Activo == false),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
result.Items.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SearchFilter_PassesThroughToRepo()
|
||||||
|
{
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(PagedOf([]));
|
||||||
|
|
||||||
|
await _handler.Handle(new ListProductTypesQuery(Search: "clasif"));
|
||||||
|
|
||||||
|
await _repo.Received(1).GetPagedAsync(
|
||||||
|
Arg.Is<ProductTypesQuery>(q => q.Search == "clasif"),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PagedResult_ReturnsItemsAndTotal()
|
||||||
|
{
|
||||||
|
var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList();
|
||||||
|
_repo.GetPagedAsync(Arg.Any<ProductTypesQuery>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new PagedResult<ProductType>(items, 2, 5, 15));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new ListProductTypesQuery(Page: 2, PageSize: 5));
|
||||||
|
|
||||||
|
result.Total.Should().Be(15);
|
||||||
|
result.Page.Should().Be(2);
|
||||||
|
result.PageSize.Should().Be(5);
|
||||||
|
result.Items.Should().HaveCount(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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,233 @@
|
|||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Infrastructure.Persistence;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for ProductTypeRepository against SIGCM2_Test_App.
|
||||||
|
/// Uses shared SqlTestFixture via [Collection("Database")] — fixture maneja Respawn + seeds.
|
||||||
|
/// Temporal: after UpdateAsync, dbo.ProductType_History MUST have ≥1 row for that Id.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class ProductTypeRepositoryTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
private ProductTypeRepository _repository = null!;
|
||||||
|
private TimeProvider _timeProvider = null!;
|
||||||
|
|
||||||
|
public ProductTypeRepositoryTests(SqlTestFixture db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _db.ResetAndSeedAsync();
|
||||||
|
|
||||||
|
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
|
||||||
|
_repository = new ProductTypeRepository(factory);
|
||||||
|
_timeProvider = TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_AndGetById_ReturnsAllFields()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
nombre: "Avisos Clasificados",
|
||||||
|
hasDuration: true,
|
||||||
|
requiresText: true,
|
||||||
|
requiresCategory: true,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: true,
|
||||||
|
maxImages: 5,
|
||||||
|
maxImageSizeMB: 2.5m,
|
||||||
|
maxImageWidth: 800,
|
||||||
|
maxImageHeight: 600,
|
||||||
|
timeProvider: _timeProvider);
|
||||||
|
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Id.Should().Be(id);
|
||||||
|
result.Nombre.Should().Be("Avisos Clasificados");
|
||||||
|
result.HasDuration.Should().BeTrue();
|
||||||
|
result.RequiresText.Should().BeTrue();
|
||||||
|
result.RequiresCategory.Should().BeTrue();
|
||||||
|
result.IsBundle.Should().BeFalse();
|
||||||
|
result.AllowImages.Should().BeTrue();
|
||||||
|
result.MaxImages.Should().Be(5);
|
||||||
|
result.MaxImageSizeMB.Should().Be(2.5m);
|
||||||
|
result.MaxImageWidth.Should().Be(800);
|
||||||
|
result.MaxImageHeight.Should().Be(600);
|
||||||
|
result.IsActive.Should().BeTrue();
|
||||||
|
result.FechaCreacion.Should().BeAfter(DateTime.MinValue);
|
||||||
|
result.FechaModificacion.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_NoImages_PersistsNullMultimedia()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation(
|
||||||
|
nombre: "Aviso Simple",
|
||||||
|
hasDuration: false,
|
||||||
|
requiresText: false,
|
||||||
|
requiresCategory: false,
|
||||||
|
isBundle: false,
|
||||||
|
allowImages: false,
|
||||||
|
maxImages: null,
|
||||||
|
maxImageSizeMB: null,
|
||||||
|
maxImageWidth: null,
|
||||||
|
maxImageHeight: null,
|
||||||
|
timeProvider: _timeProvider);
|
||||||
|
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.AllowImages.Should().BeFalse();
|
||||||
|
result.MaxImages.Should().BeNull();
|
||||||
|
result.MaxImageSizeMB.Should().BeNull();
|
||||||
|
result.MaxImageWidth.Should().BeNull();
|
||||||
|
result.MaxImageHeight.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = await _repository.GetByIdAsync(999999);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExistsByNombreAsync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreAsync_ExistingActiveNombre_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation("Tipo Unico", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
await _repository.AddAsync(pt);
|
||||||
|
|
||||||
|
var exists = await _repository.ExistsByNombreAsync("Tipo Unico");
|
||||||
|
|
||||||
|
exists.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreAsync_WithExcludeId_ExcludesSelf()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation("Auto-Excluido", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
|
||||||
|
var exists = await _repository.ExistsByNombreAsync("Auto-Excluido", excludeId: id);
|
||||||
|
|
||||||
|
exists.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsByNombreAsync_InactiveNombre_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation("Tipo Inactivo", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
var entity = await _repository.GetByIdAsync(id);
|
||||||
|
await _repository.UpdateAsync(entity!.WithDeactivated(_timeProvider));
|
||||||
|
|
||||||
|
var exists = await _repository.ExistsByNombreAsync("Tipo Inactivo");
|
||||||
|
|
||||||
|
exists.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateAsync ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_PersistsChanges()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation("Original", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
var loaded = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
var updated = loaded!
|
||||||
|
.WithRenamed("Renombrado", _timeProvider)
|
||||||
|
.WithUpdatedFlags(true, true, false, false, _timeProvider);
|
||||||
|
await _repository.UpdateAsync(updated);
|
||||||
|
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
result!.Nombre.Should().Be("Renombrado");
|
||||||
|
result.HasDuration.Should().BeTrue();
|
||||||
|
result.RequiresText.Should().BeTrue();
|
||||||
|
result.FechaModificacion.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_DeactivatesAndRecordsHistory()
|
||||||
|
{
|
||||||
|
var pt = ProductType.ForCreation("Para Baja", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
var id = await _repository.AddAsync(pt);
|
||||||
|
var loaded = await _repository.GetByIdAsync(id);
|
||||||
|
|
||||||
|
await _repository.UpdateAsync(loaded!.WithDeactivated(_timeProvider));
|
||||||
|
|
||||||
|
var result = await _repository.GetByIdAsync(id);
|
||||||
|
result!.IsActive.Should().BeFalse();
|
||||||
|
|
||||||
|
// Verify temporal history has at least 1 row
|
||||||
|
var historyCount = await _db.Connection.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductType_History WHERE Id = @Id", new { Id = id });
|
||||||
|
historyCount.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetPagedAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_FiltersActiveByDefault()
|
||||||
|
{
|
||||||
|
var active = ProductType.ForCreation("Activo Paginado", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
var inactive = ProductType.ForCreation("Inactivo Paginado", false, false, false, false, false, null, null, null, null, _timeProvider);
|
||||||
|
|
||||||
|
await _repository.AddAsync(active);
|
||||||
|
var inactiveId = await _repository.AddAsync(inactive);
|
||||||
|
var inactiveLoaded = await _repository.GetByIdAsync(inactiveId);
|
||||||
|
await _repository.UpdateAsync(inactiveLoaded!.WithDeactivated(_timeProvider));
|
||||||
|
|
||||||
|
var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 50, Activo: true);
|
||||||
|
var result = await _repository.GetPagedAsync(query);
|
||||||
|
|
||||||
|
result.Items.Should().Contain(x => x.Nombre == "Activo Paginado");
|
||||||
|
result.Items.Should().NotContain(x => x.Nombre == "Inactivo Paginado");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_SearchFilter_FiltersCorrectly()
|
||||||
|
{
|
||||||
|
await _repository.AddAsync(ProductType.ForCreation("Buscar ABC", false, false, false, false, false, null, null, null, null, _timeProvider));
|
||||||
|
await _repository.AddAsync(ProductType.ForCreation("Otro Tipo", false, false, false, false, false, null, null, null, null, _timeProvider));
|
||||||
|
|
||||||
|
var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 50, Activo: null, Search: "ABC");
|
||||||
|
var result = await _repository.GetPagedAsync(query);
|
||||||
|
|
||||||
|
result.Items.Should().Contain(x => x.Nombre == "Buscar ABC");
|
||||||
|
result.Items.Should().NotContain(x => x.Nombre == "Otro Tipo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_Pagination_RespectsPageSize()
|
||||||
|
{
|
||||||
|
for (var i = 1; i <= 5; i++)
|
||||||
|
await _repository.AddAsync(ProductType.ForCreation($"Paginado {i:D2}", false, false, false, false, false, null, null, null, null, _timeProvider));
|
||||||
|
|
||||||
|
var query = new SIGCM2.Application.Common.ProductTypesQuery(Page: 1, PageSize: 3, Activo: null);
|
||||||
|
var result = await _repository.GetPagedAsync(query);
|
||||||
|
|
||||||
|
result.Items.Should().HaveCount(3);
|
||||||
|
result.Total.Should().BeGreaterThanOrEqualTo(5);
|
||||||
|
result.Page.Should().Be(1);
|
||||||
|
result.PageSize.Should().Be(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NSubstitute;
|
||||||
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Update;
|
||||||
|
|
||||||
|
public class UpdateProductTypeCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IProductTypeRepository _repo = Substitute.For<IProductTypeRepository>();
|
||||||
|
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 4, 19, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
private readonly UpdateProductTypeCommandHandler _handler;
|
||||||
|
|
||||||
|
public UpdateProductTypeCommandHandlerTests()
|
||||||
|
{
|
||||||
|
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||||
|
_handler = new UpdateProductTypeCommandHandler(_repo, _audit, _timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProductType ExistingType(int id = 1, string nombre = "Clasificados", bool isActive = true) =>
|
||||||
|
new(id, nombre, false, false, false, false, false, null, null, null, null,
|
||||||
|
isActive, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null);
|
||||||
|
|
||||||
|
private static UpdateProductTypeCommand ValidCommand(int id = 1, string nombre = "Clasificados Actualizado") => new(
|
||||||
|
Id: id,
|
||||||
|
Nombre: nombre,
|
||||||
|
HasDuration: true, RequiresText: false, RequiresCategory: true, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
// ── Not found ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotFound_ThrowsProductTypeNotFoundException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNotFoundException>()
|
||||||
|
.Where(e => e.ProductTypeId == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nombre duplicado ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RenameToSameName_DoesNotCheckDuplicate()
|
||||||
|
{
|
||||||
|
// Same nombre → no duplicate check needed
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand(nombre: "Clasificados"));
|
||||||
|
|
||||||
|
await _repo.DidNotReceive().ExistsByNombreAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RenameToExistingActiveName_ThrowsProductTypeNombreDuplicadoException()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Clasificados"));
|
||||||
|
_repo.ExistsByNombreAsync("Notables", 1, Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand(nombre: "Notables"));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ProductTypeNombreDuplicadoException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Happy path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ValidUpdate_PersistsAndAuditsProductoTipoUpdated()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>());
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_TurnOffAllowImages_NullifiesMultimedia()
|
||||||
|
{
|
||||||
|
var existing = new ProductType(
|
||||||
|
1, "Tipo", false, false, false, false,
|
||||||
|
allowImages: true, maxImages: 5, maxImageSizeMB: 2.0m, maxImageWidth: 800, maxImageHeight: 600,
|
||||||
|
isActive: true, DateTime.UtcNow, null);
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
|
||||||
|
|
||||||
|
var cmd = ValidCommand() with { AllowImages = false };
|
||||||
|
await _handler.Handle(cmd);
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => !pt.AllowImages && pt.MaxImages == null),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UpdatesFechaModificacion_WithTimeProvider()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
var expectedDate = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.FechaModificacion == expectedDate),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PreservesIsActiveFromTarget()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(isActive: true));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await _repo.Received(1).UpdateAsync(
|
||||||
|
Arg.Is<ProductType>(pt => pt.IsActive),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollback scenarios ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_RepoThrows_NoAudit_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
_repo.UpdateAsync(Arg.Any<ProductType>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("DB"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
|
||||||
|
await _audit.DidNotReceive().LogAsync(
|
||||||
|
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditThrows_Rollback()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType());
|
||||||
|
_audit.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||||
|
Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||||
|
.ThrowsAsync(new InvalidOperationException("Audit"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(ValidCommand());
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AuditLoggerLogsBeforeAndAfter()
|
||||||
|
{
|
||||||
|
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ExistingType(nombre: "Antes"));
|
||||||
|
|
||||||
|
await _handler.Handle(ValidCommand(nombre: "Despues"));
|
||||||
|
|
||||||
|
await _audit.Received(1).LogAsync(
|
||||||
|
action: "producto_tipo.updated",
|
||||||
|
targetType: "ProductType",
|
||||||
|
targetId: "1",
|
||||||
|
metadata: Arg.Any<object?>(),
|
||||||
|
ct: Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation.TestHelper;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.ProductTypes.Validators;
|
||||||
|
|
||||||
|
public class ProductTypeValidatorsTests
|
||||||
|
{
|
||||||
|
private readonly CreateProductTypeCommandValidator _createValidator = new();
|
||||||
|
private readonly UpdateProductTypeCommandValidator _updateValidator = new();
|
||||||
|
|
||||||
|
// ── Create: Nombre validations ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreEmpty_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = "" };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreWhitespace_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = " " };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_NombreOver200Chars_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { Nombre = new string('A', 201) };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create: MaxImages validations ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImagesZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImagesNegative_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = true, MaxImages = -1 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageSizeMBZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageSizeMB = 0.0m };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageSizeMB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageWidthZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageWidth = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_MaxImageHeightZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidCreateCommand() with { MaxImageHeight = 0 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.MaxImageHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create: valid commands pass ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_ValidCommand_Passes()
|
||||||
|
{
|
||||||
|
var result = _createValidator.TestValidate(ValidCreateCommand());
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_AllowImagesFalse_WithMaxImagesSet_Passes()
|
||||||
|
{
|
||||||
|
// Normalization is handler's responsibility, not validator's
|
||||||
|
var cmd = ValidCreateCommand() with { AllowImages = false, MaxImages = 5 };
|
||||||
|
var result = _createValidator.TestValidate(cmd);
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update: inherits same rules + Id > 0 ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_IdZero_FailsValidation()
|
||||||
|
{
|
||||||
|
var cmd = ValidUpdateCommand() with { Id = 0 };
|
||||||
|
var result = _updateValidator.TestValidate(cmd);
|
||||||
|
result.ShouldHaveValidationErrorFor(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Update_ValidCommand_Passes()
|
||||||
|
{
|
||||||
|
var result = _updateValidator.TestValidate(ValidUpdateCommand());
|
||||||
|
result.ShouldNotHaveAnyValidationErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static CreateProductTypeCommand ValidCreateCommand() => new(
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
|
||||||
|
private static UpdateProductTypeCommand ValidUpdateCommand() => new(
|
||||||
|
Id: 1,
|
||||||
|
Nombre: "Clasificados",
|
||||||
|
HasDuration: false, RequiresText: false, RequiresCategory: false, IsBundle: false,
|
||||||
|
AllowImages: false,
|
||||||
|
MaxImages: null, MaxImageSizeMB: null, MaxImageWidth: null, MaxImageHeight: null);
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
|
// V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'.
|
||||||
await EnsureV016SchemaAsync();
|
await EnsureV016SchemaAsync();
|
||||||
|
|
||||||
|
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
|
||||||
|
await EnsureV017SchemaAsync();
|
||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
@@ -86,6 +89,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
new Respawn.Graph.Table("dbo", "IngresosBrutos"),
|
||||||
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
// CAT-001 (V016): Rubro es temporal — history no puede deletearse directo.
|
||||||
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
new Respawn.Graph.Table("dbo", "Rubro_History"),
|
||||||
|
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
|
||||||
|
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -933,4 +938,77 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
await _connection.ExecuteAsync(createUqIndex);
|
await _connection.ExecuteAsync(createUqIndex);
|
||||||
await _connection.ExecuteAsync(createCoveringIndex);
|
await _connection.ExecuteAsync(createCoveringIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureV017SchemaAsync()
|
||||||
|
{
|
||||||
|
const string createProductType = """
|
||||||
|
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductType (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
|
||||||
|
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||||
|
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
|
||||||
|
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
|
||||||
|
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
|
||||||
|
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
|
||||||
|
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
|
||||||
|
MaxImages INT NULL,
|
||||||
|
MaxImageSizeMB DECIMAL(10,2) NULL,
|
||||||
|
MaxImageWidth INT NULL,
|
||||||
|
MaxImageHeight INT NULL,
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL
|
||||||
|
);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string addProductTypePeriod = """
|
||||||
|
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string setProductTypeVersioning = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductType
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductType_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createUqIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
|
||||||
|
ON dbo.ProductType(Nombre)
|
||||||
|
WHERE IsActive = 1;
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createCoveringIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductType_IsActive_Cover
|
||||||
|
ON dbo.ProductType(IsActive)
|
||||||
|
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
await _connection.ExecuteAsync(createProductType);
|
||||||
|
await _connection.ExecuteAsync(addProductTypePeriod);
|
||||||
|
await _connection.ExecuteAsync(setProductTypeVersioning);
|
||||||
|
await _connection.ExecuteAsync(createUqIndex);
|
||||||
|
await _connection.ExecuteAsync(createCoveringIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user