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:
2026-04-19 15:18:53 +00:00
73 changed files with 5116 additions and 14 deletions

View File

@@ -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

View 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

View 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

View 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);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);
}

View 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);

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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).");
}
}

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.ProductTypes.Deactivate;
public sealed record DeactivateProductTypeCommand(int Id);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.ProductTypes.Deactivate;
public sealed record ProductTypeStatusDto(int Id, bool IsActive);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed record GetProductTypeByIdQuery(int Id);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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).");
}
}

View File

@@ -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);
}

View 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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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"));

View File

@@ -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);
}

View File

@@ -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 {

View 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
}

View File

@@ -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}`)
}

View File

@@ -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
}

View 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
}

View 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
}

View File

@@ -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 &ldquo;{productType.nombre}&rdquo;? 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'] })
},
})
}

View File

@@ -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'] })
},
})
}

View 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),
})
}

View File

@@ -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'] })
},
})
}

View 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'

View 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>
)
}

View 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
}

View File

@@ -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>
) )

View File

@@ -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())
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})
})

View File

@@ -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(),
)
})
})

View 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)
})
})

View 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'] })
})
})

View File

@@ -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

View File

@@ -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]

View File

@@ -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);
}
}

View File

@@ -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>();
}
}

View File

@@ -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));
}

View File

@@ -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]

View File

@@ -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]

View File

@@ -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>();
}
}

View File

@@ -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>();
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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>());
}
}

View File

@@ -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);
}

View File

@@ -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);
}
} }