diff --git a/database/README.md b/database/README.md index d505f3e..98c7b02 100644 --- a/database/README.md +++ b/database/README.md @@ -33,6 +33,7 @@ database/ | 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 | | **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 diff --git a/database/migrations/V017_ROLLBACK.sql b/database/migrations/V017_ROLLBACK.sql new file mode 100644 index 0000000..4ec52cf --- /dev/null +++ b/database/migrations/V017_ROLLBACK.sql @@ -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 diff --git a/database/migrations/V017__create_product_type.sql b/database/migrations/V017__create_product_type.sql new file mode 100644 index 0000000..0477778 --- /dev/null +++ b/database/migrations/V017__create_product_type.sql @@ -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 diff --git a/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs b/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs new file mode 100644 index 0000000..bfe3ea8 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/ProductTypesController.cs @@ -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; + +/// +/// 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'. +/// +[ApiController] +public sealed class ProductTypesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public ProductTypesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + // ── READ endpoints ───────────────────────────────────────────────────────── + + /// Returns a paginated list of ProductTypes. Requires authentication. + [HttpGet("api/v1/product-types")] + [Authorize] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task 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>(query); + return Ok(result); + } + + /// Returns a single ProductType by id. Requires authentication. + [HttpGet("api/v1/product-types/{id:int}")] + [Authorize] + [ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProductTypeById([FromRoute] int id) + { + var query = new GetProductTypeByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + // ── WRITE endpoints ──────────────────────────────────────────────────────── + + /// Creates a new ProductType. Requires catalogo:tipos:gestionar. + [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 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(command); + return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result); + } + + /// Updates a ProductType. Requires catalogo:tipos:gestionar. + [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 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(command); + return Ok(result); + } + + /// Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar. + [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 DeactivateProductType([FromRoute] int id) + { + var command = new DeactivateProductTypeCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// PRD-001: Create ProductType request body. +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); + +/// PRD-001: Update ProductType request body. +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); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index e936231..b40f2d3 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -414,6 +414,55 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; 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 case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: context.Result = new ObjectResult(new diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs new file mode 100644 index 0000000..e4418ba --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductQueryRepository.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// 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). +/// +public interface IProductQueryRepository +{ + /// + /// Returns true if at least one active Product with the given ProductTypeId exists. + /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products. + /// + Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs new file mode 100644 index 0000000..77b4e33 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IProductTypeRepository.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// Write-side repository for ProductType. +/// All reads needed by write handlers are included here. +/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery. +/// +public interface IProductTypeRepository +{ + /// Inserts a new ProductType and returns the DB-assigned Id. + Task AddAsync(ProductType productType, CancellationToken ct = default); + + /// Returns the ProductType with the given Id, or null if not found. + Task GetByIdAsync(int id, CancellationToken ct = default); + + /// Returns a paged result of ProductTypes matching the query. + Task> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default); + + /// Persists all changes to an existing ProductType row. + Task UpdateAsync(ProductType productType, CancellationToken ct = default); + + /// + /// 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). + /// + Task ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs new file mode 100644 index 0000000..1659f13 --- /dev/null +++ b/src/api/SIGCM2.Application/Common/ProductTypesQuery.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Application.Common; + +/// +/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync). +/// +public sealed record ProductTypesQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + string? Search = null); diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 2b10fbb..49bd760 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -69,6 +69,12 @@ using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Abstractions.Persistence; 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; @@ -165,6 +171,16 @@ public static class DependencyInjection services.AddScoped>, GetRubroTreeQueryHandler>(); services.AddScoped, GetRubroByIdQueryHandler>(); + // ProductTypes (PRD-001) + // PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product. + services.AddScoped(); + + services.AddScoped, CreateProductTypeCommandHandler>(); + services.AddScoped, UpdateProductTypeCommandHandler>(); + services.AddScoped, DeactivateProductTypeCommandHandler>(); + services.AddScoped>, ListProductTypesQueryHandler>(); + services.AddScoped, GetProductTypeByIdQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs new file mode 100644 index 0000000..dfb1c23 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs new file mode 100644 index 0000000..23c5d1c --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs new file mode 100644 index 0000000..0c4315d --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/CreateProductTypeCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace SIGCM2.Application.ProductTypes.Create; + +public sealed class CreateProductTypeCommandValidator : AbstractValidator +{ + 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)."); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs b/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs new file mode 100644 index 0000000..53a0fc9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Create/ProductTypeCreatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs new file mode 100644 index 0000000..d60fc33 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.Deactivate; + +public sealed record DeactivateProductTypeCommand(int Id); diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs new file mode 100644 index 0000000..66c4781 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/DeactivateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs b/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs new file mode 100644 index 0000000..6abfb6a --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Deactivate/ProductTypeStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.Deactivate; + +public sealed record ProductTypeStatusDto(int Id, bool IsActive); diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs new file mode 100644 index 0000000..f24f204 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.ProductTypes.GetById; + +public sealed record GetProductTypeByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs new file mode 100644 index 0000000..27b52e9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/GetProductTypeByIdQueryHandler.cs @@ -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 +{ + private readonly IProductTypeRepository _repo; + + public GetProductTypeByIdQueryHandler(IProductTypeRepository repo) + { + _repo = repo; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs new file mode 100644 index 0000000..52cafc2 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/GetById/ProductTypeDetailDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs new file mode 100644 index 0000000..bc4db25 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQuery.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs new file mode 100644 index 0000000..585b8eb --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ListProductTypesQueryHandler.cs @@ -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> +{ + private readonly IProductTypeRepository _repo; + + public ListProductTypesQueryHandler(IProductTypeRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs new file mode 100644 index 0000000..083df94 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/List/ProductTypeListItemDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs b/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs new file mode 100644 index 0000000..004878b --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/ProductTypeUpdatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs new file mode 100644 index 0000000..990b7c9 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs new file mode 100644 index 0000000..d2d6745 --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs new file mode 100644 index 0000000..1ce8b9a --- /dev/null +++ b/src/api/SIGCM2.Application/ProductTypes/Update/UpdateProductTypeCommandValidator.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace SIGCM2.Application.ProductTypes.Update; + +public sealed class UpdateProductTypeCommandValidator : AbstractValidator +{ + 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)."); + } +} diff --git a/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs new file mode 100644 index 0000000..0f9b509 --- /dev/null +++ b/src/api/SIGCM2.Application/Products/NullProductQueryRepository.cs @@ -0,0 +1,14 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Products; + +/// +/// 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. +/// +public sealed class NullProductQueryRepository : IProductQueryRepository +{ + public Task ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default) + => Task.FromResult(false); +} diff --git a/src/api/SIGCM2.Domain/Entities/ProductType.cs b/src/api/SIGCM2.Domain/Entities/ProductType.cs new file mode 100644 index 0000000..ea3de1a --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/ProductType.cs @@ -0,0 +1,186 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// 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). +/// +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; } + + /// + /// Full hydration constructor — used by the repository to reconstruct from DB rows. + /// + 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; + } + + /// + /// 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. + /// + 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); + } + + /// + /// Returns a new ProductType with an updated Nombre and FechaModificacion. + /// Does NOT mutate the current instance. + /// + 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); + } + + /// + /// Returns a new ProductType with updated flags, preserving multimedia fields. + /// Does NOT mutate the current instance. + /// + 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); + } + + /// + /// Returns a new ProductType with updated multimedia limits. + /// AllowImages=false normalizes all 4 Max* fields to null. + /// Does NOT mutate the current instance. + /// + 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); + } + + /// + /// Returns a new ProductType with IsActive=false and FechaModificacion updated. + /// Does NOT mutate the current instance. + /// + 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)); + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs new file mode 100644 index 0000000..b3cf72b --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeEnUsoException.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to deactivate a ProductType that has active products. → HTTP 409 +/// +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; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs new file mode 100644 index 0000000..7901594 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeFlagsIncoherentesException.cs @@ -0,0 +1,17 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// 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). +/// +public sealed class ProductTypeFlagsIncoherentesException : DomainException +{ + public string Reason { get; } + + public ProductTypeFlagsIncoherentesException(string reason) + : base($"Combinación de flags/multimedia inválida: {reason}") + { + Reason = reason; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs new file mode 100644 index 0000000..79a4914 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNombreDuplicadoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a ProductType with the same active name already exists. → HTTP 409 +/// +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; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs new file mode 100644 index 0000000..0507af8 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/ProductTypeNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested ProductType does not exist. → HTTP 404 +/// +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; + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 40ee1ab..809c334 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -39,6 +39,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs new file mode 100644 index 0000000..3ebab93 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/ProductTypeRepository.cs @@ -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 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(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 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(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task> GetPagedAsync( + ProductTypesQuery query, + CancellationToken ct = default) + { + // Build the WHERE clause dynamically. + var conditions = new List(); + 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(countSql, parameters); + var rows = await connection.QueryAsync(dataSql, parameters); + var items = rows.Select(MapRow).ToList(); + + return new PagedResult(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 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(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); +} diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index f587e89..3331aa9 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -16,6 +16,7 @@ import { Columns3, Store, Tag, + Layers, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -75,6 +76,12 @@ const adminItems: NavItem[] = [ icon: Tag, requiredPermission: 'catalogo:rubros:gestionar', }, + { + label: 'Tipos de Producto', + href: '/admin/product-types', + icon: Layers, + requiredPermission: 'catalogo:tipos:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/product-types/api/createProductType.ts b/src/web/src/features/product-types/api/createProductType.ts new file mode 100644 index 0000000..195ca30 --- /dev/null +++ b/src/web/src/features/product-types/api/createProductType.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateProductTypeRequest, ProductTypeDetail } from '../types' + +export async function createProductType( + payload: CreateProductTypeRequest, +): Promise { + const response = await axiosClient.post( + '/api/v1/admin/product-types', + payload, + ) + return response.data +} diff --git a/src/web/src/features/product-types/api/deactivateProductType.ts b/src/web/src/features/product-types/api/deactivateProductType.ts new file mode 100644 index 0000000..041ef7c --- /dev/null +++ b/src/web/src/features/product-types/api/deactivateProductType.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateProductType(id: number): Promise { + await axiosClient.delete(`/api/v1/admin/product-types/${id}`) +} diff --git a/src/web/src/features/product-types/api/getProductTypeById.ts b/src/web/src/features/product-types/api/getProductTypeById.ts new file mode 100644 index 0000000..6582d6e --- /dev/null +++ b/src/web/src/features/product-types/api/getProductTypeById.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ProductTypeDetail } from '../types' + +export async function getProductTypeById(id: number): Promise { + const response = await axiosClient.get(`/api/v1/product-types/${id}`) + return response.data +} diff --git a/src/web/src/features/product-types/api/listProductTypes.ts b/src/web/src/features/product-types/api/listProductTypes.ts new file mode 100644 index 0000000..de72971 --- /dev/null +++ b/src/web/src/features/product-types/api/listProductTypes.ts @@ -0,0 +1,12 @@ +import { axiosClient } from '@/api/axiosClient' +import type { ListProductTypesParams, PagedResult, ProductTypeListItem } from '../types' + +export async function listProductTypes( + params?: ListProductTypesParams, +): Promise> { + const response = await axiosClient.get>( + '/api/v1/product-types', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/product-types/api/updateProductType.ts b/src/web/src/features/product-types/api/updateProductType.ts new file mode 100644 index 0000000..bcaa6f3 --- /dev/null +++ b/src/web/src/features/product-types/api/updateProductType.ts @@ -0,0 +1,13 @@ +import { axiosClient } from '@/api/axiosClient' +import type { UpdateProductTypeRequest, ProductTypeDetail } from '../types' + +export async function updateProductType( + id: number, + payload: UpdateProductTypeRequest, +): Promise { + const response = await axiosClient.put( + `/api/v1/admin/product-types/${id}`, + payload, + ) + return response.data +} diff --git a/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx b/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx new file mode 100644 index 0000000..717b2e3 --- /dev/null +++ b/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx @@ -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 +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DeactivateProductTypeDialog({ + open, + onOpenChange, + productType, + onConfirm, +}: DeactivateProductTypeDialogProps) { + const [error, setError] = useState(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 ( + + + + Desactivar tipo de producto + + ¿Desactivar el tipo “{productType.nombre}”? Los productos asociados conservan + la referencia pero el tipo no aparecerá en listados activos. + + + + {error && ( + + + {error} + + )} + + + Cancelar + + {isPending ? 'Procesando...' : 'Desactivar'} + + + + + ) +} diff --git a/src/web/src/features/product-types/components/ProductTypeForm.tsx b/src/web/src/features/product-types/components/ProductTypeForm.tsx new file mode 100644 index 0000000..c592d9e --- /dev/null +++ b/src/web/src/features/product-types/components/ProductTypeForm.tsx @@ -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({ + 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 ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Nombre */} + ( + + Nombre + + + + + + )} + /> + + {/* Flags */} +
+ ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Tiene duración" + className="h-4 w-4 cursor-pointer" + /> + + + Tiene duración + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Requiere texto" + className="h-4 w-4 cursor-pointer" + /> + + + Requiere texto + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Requiere categoría" + className="h-4 w-4 cursor-pointer" + /> + + + Requiere categoría + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Es bundle" + className="h-4 w-4 cursor-pointer" + /> + + + Es bundle + + + )} + /> +
+ + {/* Allow Images toggle */} + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Permite imágenes" + className="h-4 w-4 cursor-pointer" + /> + + + Permite imágenes + + + )} + /> + + {/* Multimedia fields — always rendered, disabled when allowImages=false */} +
+ ( + + Máx. imágenes + + + + + + )} + /> + + ( + + Máx. tamaño (MB) + + + + + + )} + /> + + ( + + Ancho máx. (px) + + + + + + )} + /> + + ( + + Alto máx. (px) + + + + + + )} + /> +
+ +
+ + +
+ + + ) +} diff --git a/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx b/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx new file mode 100644 index 0000000..4a5c757 --- /dev/null +++ b/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx @@ -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(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 ( + + + + {isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'} + + {isEdit + ? `Modificá los datos del tipo "${productType?.nombre ?? ''}".` + : 'Completá los datos para crear un nuevo tipo de producto.'} + + + + {backendError && ( + + + {backendError} + + )} + + onOpenChange(false)} + isPending={isPending} + isEdit={isEdit} + /> + + + ) +} diff --git a/src/web/src/features/product-types/hooks/useCreateProductType.ts b/src/web/src/features/product-types/hooks/useCreateProductType.ts new file mode 100644 index 0000000..73cc225 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useCreateProductType.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/hooks/useDeactivateProductType.ts b/src/web/src/features/product-types/hooks/useDeactivateProductType.ts new file mode 100644 index 0000000..9e60cc1 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useDeactivateProductType.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/hooks/useProductTypes.ts b/src/web/src/features/product-types/hooks/useProductTypes.ts new file mode 100644 index 0000000..dd22848 --- /dev/null +++ b/src/web/src/features/product-types/hooks/useProductTypes.ts @@ -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), + }) +} diff --git a/src/web/src/features/product-types/hooks/useUpdateProductType.ts b/src/web/src/features/product-types/hooks/useUpdateProductType.ts new file mode 100644 index 0000000..ac2b68b --- /dev/null +++ b/src/web/src/features/product-types/hooks/useUpdateProductType.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/product-types/index.ts b/src/web/src/features/product-types/index.ts new file mode 100644 index 0000000..d8af869 --- /dev/null +++ b/src/web/src/features/product-types/index.ts @@ -0,0 +1,3 @@ +// PRD-001 — product-types feature public API +export { ProductTypesPage } from './pages/ProductTypesPage' +export type { ProductTypeListItem, ProductTypeDetail, CreateProductTypeRequest, UpdateProductTypeRequest } from './types' diff --git a/src/web/src/features/product-types/pages/ProductTypesPage.tsx b/src/web/src/features/product-types/pages/ProductTypesPage.tsx new file mode 100644 index 0000000..3f6c68e --- /dev/null +++ b/src/web/src/features/product-types/pages/ProductTypesPage.tsx @@ -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(null) + + // ── Deactivate dialog state ────────────────────────────────────────────── + const [deactivateOpen, setDeactivateOpen] = useState(false) + const [deactivatingProductType, setDeactivatingProductType] = useState(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 ( +
+ + +
+ ) + } + + if (isError) { + return ( + + + Error al cargar tipos de producto. + + ) + } + + const isEmpty = !paged?.items.length + + return ( +
+
+

Tipos de Producto

+ + + +
+ + {isEmpty ? ( +
+

No hay tipos de producto.

+ + + +
+ ) : ( +
+ + + + + + + + + + + + + + {paged.items.map((pt: ProductTypeListItem) => ( + + + + + + + + + + + ))} + +
NombreDuraciónTextoCategoríaBundleImágenesActivo +
{pt.nombre}{pt.hasDuration ? 'Sí' : 'No'}{pt.requiresText ? 'Sí' : 'No'}{pt.requiresCategory ? 'Sí' : 'No'}{pt.isBundle ? 'Sí' : 'No'}{pt.allowImages ? 'Sí' : 'No'} + + {pt.isActive ? 'Activo' : 'Inactivo'} + + + +
+ + +
+
+
+
+ )} + + {/* Create dialog */} + + + {/* Edit dialog */} + {editingProductType && ( + + )} + + {/* Deactivate confirmation dialog */} + {deactivatingProductType && ( + + )} +
+ ) +} diff --git a/src/web/src/features/product-types/types.ts b/src/web/src/features/product-types/types.ts new file mode 100644 index 0000000..380b128 --- /dev/null +++ b/src/web/src/features/product-types/types.ts @@ -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 { + items: T[] + page: number + pageSize: number + total: number +} + +export interface ListProductTypesParams { + page?: number + pageSize?: number + activo?: boolean | null + search?: string +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 94559d9..992cc76 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -28,6 +28,7 @@ import { EditPuntoDeVentaPage } from './features/puntos-de-venta/pages/EditPunto import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage' import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage' import { RubrosPage } from './features/rubros/pages/RubrosPage' +import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -309,6 +310,16 @@ export function AppRoutes() { } /> + {/* ProductTypes routes — PRD-001 */} + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx b/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx new file mode 100644 index 0000000..aed12d4 --- /dev/null +++ b/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx @@ -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( + + {children} + , + ) +} + +describe('DeactivateProductTypeDialog', () => { + it('renders confirmation message with product type name', () => { + wrap( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + await waitFor(() => expect(onOpenChange).toHaveBeenCalled()) + }) +}) diff --git a/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx b/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx new file mode 100644 index 0000000..4b7db5a --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx @@ -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( + + {children} + , + ) +} + +// ─── ProductTypeForm — field rendering ───────────────────────────────────── + +describe('ProductTypeForm — field rendering', () => { + it('renders nombre field and all flag checkboxes', () => { + wrap( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx b/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx new file mode 100644 index 0000000..aa4b3fe --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx @@ -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( + + {children} + , + ) +} + +// ─── ProductTypeFormDialog — create mode ────────────────────────────────── + +describe('ProductTypeFormDialog — create mode', () => { + it('renders create dialog title when no productType prop', () => { + wrap( + , + ) + expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument() + }) + + it('has aria-describedby on DialogDescription (NFR8)', () => { + wrap( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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) + }) + }) +}) diff --git a/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx new file mode 100644 index 0000000..b96fe1a --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx @@ -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 = { + items: [mockItem], + page: 1, + pageSize: 20, + total: 1, +} + +const emptyPaged: PagedResult = { + 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( + + + + } /> + + + , + ) +} + +// ─── 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(), + ) + }) +}) diff --git a/src/web/src/tests/features/product-types/api.test.ts b/src/web/src/tests/features/product-types/api.test.ts new file mode 100644 index 0000000..12c178d --- /dev/null +++ b/src/web/src/tests/features/product-types/api.test.ts @@ -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 = { + 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) + }) +}) diff --git a/src/web/src/tests/features/product-types/hooks.test.ts b/src/web/src/tests/features/product-types/hooks.test.ts new file mode 100644 index 0000000..4613183 --- /dev/null +++ b/src/web/src/tests/features/product-types/hooks.test.ts @@ -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 = { + 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'] }) + }) +}) diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index 5649e52..062b771 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -50,8 +50,9 @@ public class AuthControllerTests // V011 (ADM-001) adds 'administracion:secciones:gestionar' → 22 // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 - // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total - Assert.Equal(25, permisos.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index 4fa9e24..372509b 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos — catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With25Items() + public async Task GetPermisos_WithAdmin_Returns200With26Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); 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 // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 - // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total - Assert.Equal(25, list.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, list.GetArrayLength()); } [Fact] @@ -184,7 +185,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With25Items() + public async Task GetRolPermisos_AdminRol_Returns200With26Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); 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 // V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23 // V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24 - // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 total - Assert.Equal(25, list.GetArrayLength()); + // V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25 + // V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total + Assert.Equal(26, list.GetArrayLength()); } [Fact] diff --git a/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs new file mode 100644 index 0000000..8ddc743 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/ProductTypes/ProductTypesControllerTests.cs @@ -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; + +/// +/// 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. +/// +[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 GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs new file mode 100644 index 0000000..8b1f926 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeExceptionsTests.cs @@ -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(); + } + + // ── 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(); + } + + // ── 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(); + } + + // ── 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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs new file mode 100644 index 0000000..3332b89 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Domain/ProductTypes/ProductTypeTests.cs @@ -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(); + } + + [Fact] + public void ForCreation_NombreWhitespace_ThrowsArgumentException() + { + var act = () => ProductType.ForCreation( + " ", false, false, false, false, false, + null, null, null, null, FakeTime); + + act.Should().Throw(); + } + + [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(); + } + + // ── 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)); +} diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index a30e5a2..cf91367 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -73,7 +73,7 @@ public class PermisoRepositoryTests : IAsyncLifetime // ── ListAsync ──────────────────────────────────────────────────────────── [Fact] - public async Task ListAsync_Returns25CanonicalSeeds() + public async Task ListAsync_Returns26CanonicalSeeds() { var list = await _repository.ListAsync(); @@ -81,8 +81,9 @@ public class PermisoRepositoryTests : IAsyncLifetime // + V011 (ADM-001) adds 'administracion:secciones:gestionar' // + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' // + V014 (ADM-009) adds 'administracion:fiscal:gestionar' - // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' = 25 total - Assert.Equal(25, list.Count); + // + V016 (CAT-001) adds 'catalogo:rubros:gestionar' + // + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total + Assert.Equal(26, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index a387abc..71fdfd7 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -173,16 +173,17 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [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) // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' // + 1 from V013 (ADM-008): 'administracion:puntos_de_venta: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"); - Assert.Equal(25, permisos.Count); + Assert.Equal(26, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..98ab0a7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Create/CreateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).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(), Arg.Any()).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(pt => pt.Nombre == "Clasificados"), + Arg.Any()); + } + + [Fact] + public async Task Handle_SuccessfulInsert_LogsAuditEventProductoTipoCreated() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "producto_tipo.created", + targetType: "ProductType", + targetId: "7", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [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(pt => pt.HasDuration && pt.IsBundle), + Arg.Any()); + } + + [Fact] + public async Task Handle_AllowImagesTrue_WithAllMaxNull_PersistsAllNull() + { + var cmd = ValidCommand(allowImages: true); + + await _handler.Handle(cmd); + + await _repo.Received(1).AddAsync( + Arg.Is(pt => pt.AllowImages && pt.MaxImages == null), + Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProvider_NotDateTimeNow() + { + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).AddAsync( + Arg.Is(pt => pt.FechaCreacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsCreatedDtoWithPersistedId() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).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(pt => pt.AllowImages == false && pt.MaxImages == null), + Arg.Any()); + } + + // ── Nombre duplicado ───────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NombreDuplicado_ThrowsProductTypeNombreDuplicadoException() + { + _repo.ExistsByNombreAsync("Clasificados", Arg.Any(), Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_NombreDuplicado_CheckedBeforeFactory() + { + // If duplicate check throws, AddAsync should never be called + _repo.ExistsByNombreAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (ProductTypeNombreDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── Rollback scenarios ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback() + { + _repo.AddAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB error")); + + var act = async () => await _handler.Handle(ValidCommand()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AuditThrows_TransactionRollback() + { + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit error")); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..c1a0db7 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Deactivate/DeactivateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IProductQueryRepository _productQuery = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(99)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 99); + } + + // ── Already inactive (idempotent) ───────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyInactive_ReturnsIdempotentDto_NoAudit_NoRepoUpdate() + { + _repo.GetByIdAsync(1, Arg.Any()).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(), Arg.Any()); + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── In use guard ───────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_InUseGuardReturnsTrue_ThrowsProductTypeEnUsoException() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + _productQuery.ExistsActiveByProductTypeAsync(1, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 1); + } + + [Fact] + public async Task Handle_CallsIProductQueryRepository_Received1() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _productQuery.Received(1).ExistsActiveByProductTypeAsync(1, Arg.Any()); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidDeactivation_UpdatesAndAuditsProductoTipoDeactivated() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => !pt.IsActive), + Arg.Any()); + await _audit.Received(1).LogAsync( + action: "producto_tipo.deactivated", + targetType: "ProductType", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_UsesTimeProviderInDeactivate() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.FechaModificacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_ReturnsDtoWithIdAndIsActiveFalse() + { + _repo.GetByIdAsync(1, Arg.Any()).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()).Returns(ActiveType()); + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB")); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_AuditThrows_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ActiveType()); + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit")); + + var act = async () => await _handler.Handle(new DeactivateProductTypeCommand(1)); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs new file mode 100644 index 0000000..640d278 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/GetById/GetProductTypeByIdQueryHandlerTests.cs @@ -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(); + 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()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(new GetProductTypeByIdQuery(999)); + + await act.Should().ThrowAsync() + .Where(e => e.ProductTypeId == 999); + } + + [Fact] + public async Task Handle_DetailContainsAllFields() + { + _repo.GetByIdAsync(5, Arg.Any()).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)); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs new file mode 100644 index 0000000..1b525ab --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/List/ListProductTypesQueryHandlerTests.cs @@ -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(); + 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 PagedOf(List 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(), Arg.Any()) + .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(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(Page: 0)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } + + [Fact] + public async Task Handle_PageSizeOver100_ClampsTo100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(PageSize: 200)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActivoFalse_ReturnsInactives() + { + var inactive = new List { MakeProductType(99, "Inactivo", false) }; + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf(inactive)); + + var result = await _handler.Handle(new ListProductTypesQuery(Activo: false)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Activo == false), + Arg.Any()); + result.Items.Should().HaveCount(1); + } + + [Fact] + public async Task Handle_SearchFilter_PassesThroughToRepo() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(PagedOf([])); + + await _handler.Handle(new ListProductTypesQuery(Search: "clasif")); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Search == "clasif"), + Arg.Any()); + } + + [Fact] + public async Task Handle_PagedResult_ReturnsItemsAndTotal() + { + var items = Enumerable.Range(1, 5).Select(i => MakeProductType(i, $"Tipo{i}")).ToList(); + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult(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); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs new file mode 100644 index 0000000..175b4cc --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/NullProductQueryRepositoryTests.cs @@ -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(); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs new file mode 100644 index 0000000..fd294d4 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/ProductTypeRepositoryTests.cs @@ -0,0 +1,233 @@ +using Dapper; +using FluentAssertions; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; +using SIGCM2.TestSupport; + +namespace SIGCM2.Application.Tests.ProductTypes; + +/// +/// 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. +/// +[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( + "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); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs new file mode 100644 index 0000000..b6f0b58 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Update/UpdateProductTypeCommandHandlerTests.cs @@ -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(); + private readonly IAuditLogger _audit = Substitute.For(); + 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(), Arg.Any(), Arg.Any()).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()).Returns((ProductType?)null); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync() + .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()).Returns(ExistingType(nombre: "Clasificados")); + + await _handler.Handle(ValidCommand(nombre: "Clasificados")); + + await _repo.DidNotReceive().ExistsByNombreAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_RenameToExistingActiveName_ThrowsProductTypeNombreDuplicadoException() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType(nombre: "Clasificados")); + _repo.ExistsByNombreAsync("Notables", 1, Arg.Any()).Returns(true); + + var act = async () => await _handler.Handle(ValidCommand(nombre: "Notables")); + + await act.Should().ThrowAsync(); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ValidUpdate_PersistsAndAuditsProductoTipoUpdated() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + await _audit.Received(1).LogAsync( + action: "producto_tipo.updated", + targetType: "ProductType", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [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()).Returns(existing); + + var cmd = ValidCommand() with { AllowImages = false }; + await _handler.Handle(cmd); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => !pt.AllowImages && pt.MaxImages == null), + Arg.Any()); + } + + [Fact] + public async Task Handle_UpdatesFechaModificacion_WithTimeProvider() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + var expectedDate = _timeProvider.GetUtcNow().UtcDateTime; + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.FechaModificacion == expectedDate), + Arg.Any()); + } + + [Fact] + public async Task Handle_PreservesIsActiveFromTarget() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType(isActive: true)); + + await _handler.Handle(ValidCommand()); + + await _repo.Received(1).UpdateAsync( + Arg.Is(pt => pt.IsActive), + Arg.Any()); + } + + // ── Rollback scenarios ──────────────────────────────────────────────────── + + [Fact] + public async Task Handle_RepoThrows_NoAudit_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + _repo.UpdateAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("DB")); + + var act = async () => await _handler.Handle(ValidCommand()); + await act.Should().ThrowAsync(); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AuditThrows_Rollback() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(ExistingType()); + _audit.LogAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Audit")); + + var act = async () => await _handler.Handle(ValidCommand()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_AuditLoggerLogsBeforeAndAfter() + { + _repo.GetByIdAsync(1, Arg.Any()).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(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs b/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs new file mode 100644 index 0000000..a4d1f7d --- /dev/null +++ b/tests/SIGCM2.Application.Tests/ProductTypes/Validators/ProductTypeValidatorsTests.cs @@ -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); +} diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index c6bc106..f2737da 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -60,6 +60,9 @@ public sealed class SqlTestFixture : IAsyncLifetime // V016 (CAT-001): ensure dbo.Rubro + temporal + permiso 'catalogo:rubros:gestionar'. await EnsureV016SchemaAsync(); + // V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'. + await EnsureV017SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, @@ -86,6 +89,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "IngresosBrutos"), // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. 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(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); + } }