Merge pull request 'feat: PRD-002 Product CRUD' (#40) from feature/PRD-002 into main

This commit was merged in pull request #40.
This commit is contained in:
2026-04-19 16:49:58 +00:00
76 changed files with 5539 additions and 61 deletions

View File

@@ -0,0 +1,67 @@
-- V018_ROLLBACK.sql
-- Reversa de V018__create_product.sql — PRD-002.
--
-- Idempotente: cada paso usa IF EXISTS guards.
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. SYSTEM_VERSIONING OFF
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. DROP PERIOD
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
BEGIN
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
-- 3. Drop HIDDEN columns + default constraints
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
END
GO
-- 4. Drop history
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product_History;
PRINT 'Table dbo.Product_History dropped.';
END
GO
-- 5. Drop main
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product;
PRINT 'Table dbo.Product dropped.';
END
GO
-- 6. Remove RolPermiso / Permiso
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:productos:gestionar';
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
GO
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
PRINT 'Permiso catalogo:productos:gestionar deleted.';
GO
PRINT '';
PRINT 'V018 rolled back successfully.';
GO

View File

@@ -0,0 +1,172 @@
-- V018__create_product.sql
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
--
-- Cambios:
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V018_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
--
-- SDD Design: engram sdd/prd-002-product-crud/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Product
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Product (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
MedioId INT NOT NULL,
ProductTypeId INT NOT NULL,
RubroId INT NULL,
BasePrice DECIMAL(18,4) NOT NULL,
PriceDurationDays INT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
);
PRINT 'Table dbo.Product created.';
END
ELSE
PRINT 'Table dbo.Product already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — Product
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Product
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Product_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
END
ELSE
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Product_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Product_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
ON dbo.Product (MedioId, ProductTypeId, Nombre)
WHERE IsActive = 1;
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
END
GO
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_ProductTypeId_IsActive
ON dbo.Product (ProductTypeId, IsActive);
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
END
GO
-- Cover para list filtered by MedioId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_MedioId_IsActive
ON dbo.Product (MedioId, IsActive);
PRINT 'Index IX_Product_MedioId_IsActive created.';
END
GO
-- Cover para list filtered by RubroId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_RubroId_IsActive
ON dbo.Product (RubroId, IsActive)
WHERE RubroId IS NOT NULL;
PRINT 'Index IX_Product_RubroId_IsActive created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:productos:gestionar',
N'Gestionar productos del catálogo',
N'Crear, editar y desactivar productos del catálogo comercial',
'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
GO

View File

@@ -0,0 +1,169 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.Products.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-002: Product catalog management.
/// Read endpoints at /api/v1/products — require authentication (any role).
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductsController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductCommand> _createValidator;
private readonly IValidator<UpdateProductCommand> _updateValidator;
public ProductsController(
IDispatcher dispatcher,
IValidator<CreateProductCommand> createValidator,
IValidator<UpdateProductCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
[HttpGet("api/v1/products")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null,
[FromQuery] int? medioId = null,
[FromQuery] int? productTypeId = null,
[FromQuery] int? rubroId = null)
{
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Product by id. Requires authentication.</summary>
[HttpGet("api/v1/products/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductById([FromRoute] int id)
{
var query = new GetProductByIdQuery(id);
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
[HttpPost("api/v1/admin/products")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
var command = new CreateProductCommand(
Nombre: request.Nombre ?? string.Empty,
MedioId: request.MedioId,
ProductTypeId: request.ProductTypeId,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateProductCommand, ProductCreatedDto>(command);
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
}
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
[HttpPut("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
{
var command = new UpdateProductCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateProductCommand, ProductUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
[HttpDelete("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
{
var command = new DeactivateProductCommand(id);
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-002: Create Product request body.</summary>
public sealed record CreateProductRequest(
string? Nombre,
int MedioId = 0,
int ProductTypeId = 0,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);
/// <summary>PRD-002: Update Product request body.</summary>
public sealed record UpdateProductRequest(
string? Nombre,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);

View File

@@ -463,6 +463,68 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_not_found",
message = productNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
context.Result = new ObjectResult(new
{
error = "product_nombre_duplicado",
message = productDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTipoFlagsIncoherentesException productFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_flags_incoherentes",
field = productFlagsEx.Field,
message = productFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case ProductTypeInactivoException productTypeInactivoEx:
context.Result = new ObjectResult(new
{
error = "product_type_inactivo",
message = productTypeInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroInactivoException rubroInactivoEx:
context.Result = new ObjectResult(new
{
error = "rubro_inactivo",
message = rubroInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new

View File

@@ -0,0 +1,29 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for Product.
/// All reads needed by write handlers are included here.
/// </summary>
public interface IProductRepository
{
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
Task<int> AddAsync(Product product, CancellationToken ct = default);
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of Products matching the query.</summary>
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing Product row.</summary>
Task UpdateAsync(Product product, CancellationToken ct = default);
/// <summary>
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Common;
/// <summary>
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
/// </summary>
public sealed record ProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -69,7 +69,11 @@ 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.Products.Create;
using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate;
@@ -171,9 +175,15 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
// Products (PRD-002)
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
// ProductTypes (PRD-001)
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();

View File

@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
if (!target.IsActive)
return new ProductTypeStatusDto(command.Id, false);
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
// 3. Guard: check if any active product uses this type
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.
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
// 4. Deactivate (immutable — returns new instance)
var deactivated = target.WithDeactivated(_timeProvider);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Products.Create;
public sealed record CreateProductCommand(
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -0,0 +1,112 @@
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.Products.Create;
public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IMedioRepository _medioRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IMedioRepository medioRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_medioRepo = medioRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
{
// 1. Validate Medio exists and is active
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
?? throw new MedioNotFoundException(command.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(command.MedioId);
// 2. Validate ProductType exists and is active
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
if (!productType.IsActive)
throw new ProductTypeInactivoException(command.ProductTypeId);
// 3. Flags coherence: RequiresCategory → RubroId required
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
// 4. Flags coherence: HasDuration → PriceDurationDays required
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 5. Validate Rubro if provided: must be active
if (command.RubroId.HasValue)
{
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
if (rubro == null || !rubro.Activo)
throw new RubroInactivoException(command.RubroId.Value);
}
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
// 7. Build entity
var entity = Product.ForCreation(
command.Nombre, command.MedioId, command.ProductTypeId,
command.RubroId, command.BasePrice, command.PriceDurationDays,
_timeProvider);
// 8. Persist + audit (fail-closed)
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.created",
targetType: "Product",
targetId: newId.ToString(),
metadata: new
{
after = new
{
entity.Nombre,
entity.MedioId,
entity.ProductTypeId,
entity.RubroId,
entity.BasePrice,
entity.PriceDurationDays,
}
});
tx.Complete();
return new ProductCreatedDto(
newId, entity.Nombre,
entity.MedioId, entity.ProductTypeId, entity.RubroId,
entity.BasePrice, entity.PriceDurationDays,
entity.IsActive, entity.FechaCreacion);
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Create;
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del producto es requerido.")
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
RuleFor(x => x.MedioId)
.GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
RuleFor(x => x.ProductTypeId)
.GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
RuleFor(x => x.PriceDurationDays)
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Products.Create;
public sealed record ProductCreatedDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion);

View File

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

View File

@@ -0,0 +1,62 @@
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.Products.Deactivate;
public sealed class DeactivateProductCommandHandler
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
{
private readonly IProductRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateProductCommandHandler(
IProductRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Idempotent: already inactive → return without side effects
if (!target.IsActive)
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
// 3. Deactivate (immutable)
var deactivated = target.WithDeactivated(_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(deactivated);
await _audit.LogAsync(
action: "producto.deactivated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
productId = command.Id,
nombre = target.Nombre,
});
tx.Complete();
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Products.Deactivate;
public sealed record ProductStatusDto(
int Id,
bool IsActive,
DateTime? FechaModificacion);

View File

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

View File

@@ -0,0 +1,29 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.GetById;
public sealed class GetProductByIdQueryHandler
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
{
private readonly IProductRepository _repo;
public GetProductByIdQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
{
var product = await _repo.GetByIdAsync(query.Id)
?? throw new ProductNotFoundException(query.Id);
return new ProductDetailDto(
product.Id, product.Nombre,
product.MedioId, product.ProductTypeId, product.RubroId,
product.BasePrice, product.PriceDurationDays,
product.IsActive,
product.FechaCreacion, product.FechaModificacion);
}
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.GetById;
public sealed record ProductDetailDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Products.List;
public sealed record ListProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -0,0 +1,35 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Products.List;
public sealed class ListProductsQueryHandler
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
{
private readonly IProductRepository _repo;
public ListProductsQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new ProductsQuery(
page, pageSize, query.Activo, query.Search,
query.MedioId, query.ProductTypeId, query.RubroId);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items.Select(p => new ProductListItemDto(
p.Id, p.Nombre,
p.MedioId, p.ProductTypeId, p.RubroId,
p.BasePrice, p.PriceDurationDays,
p.IsActive)).ToList();
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Products.List;
public sealed record ProductListItemDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive);

View File

@@ -1,14 +0,0 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Products;
/// <summary>
/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product.
/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes.
/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002.
/// </summary>
public sealed class NullProductQueryRepository : IProductQueryRepository
{
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
=> Task.FromResult(false);
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.Update;
public sealed record ProductUpdatedDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Products.Update;
public sealed record UpdateProductCommand(
int Id,
string Nombre,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -0,0 +1,97 @@
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.Products.Update;
public sealed class UpdateProductCommandHandler
: ICommandHandler<UpdateProductCommand, ProductUpdatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public UpdateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductUpdatedDto> Handle(UpdateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation)
var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId)
?? throw new ProductTypeNotFoundException(target.ProductTypeId);
// 3. Flags coherence
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 4. Validate Rubro if provided: must be active
if (command.RubroId.HasValue)
{
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
if (rubro == null || !rubro.Activo)
throw new RubroInactivoException(command.RubroId.Value);
}
// 5. Duplicate nombre check (skip if name unchanged — optimization)
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
{
var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre);
}
// 6. Apply mutation (immutable)
var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider);
// 7. Persist + audit
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.updated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays },
after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays }
});
tx.Complete();
return new ProductUpdatedDto(
updated.Id, updated.Nombre,
updated.MedioId, updated.ProductTypeId, updated.RubroId,
updated.BasePrice, updated.PriceDurationDays,
updated.IsActive, updated.FechaCreacion, updated.FechaModificacion);
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Update;
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("Id debe ser un entero positivo.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del producto es requerido.")
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
RuleFor(x => x.PriceDurationDays)
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
}
}

View File

@@ -0,0 +1,172 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Immutable product entity for the commercial catalog.
/// Factory method ForCreation creates new products (Id=0).
/// Mutation methods (With*) return new instances — original is never modified.
/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers
/// at creation/update time against the ProductType, NOT here in the entity.
/// MedioId and ProductTypeId are immutable post-creation by design.
/// </summary>
public sealed class Product
{
private const int NombreMaxLength = 300;
public int Id { get; }
public string Nombre { get; }
public int MedioId { get; }
public int ProductTypeId { get; }
public int? RubroId { get; }
public decimal BasePrice { get; }
public int? PriceDurationDays { get; }
public bool IsActive { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>Full hydration constructor — used by the repository to reconstruct from DB rows.</summary>
public Product(
int id,
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
bool isActive,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Nombre = nombre;
MedioId = medioId;
ProductTypeId = productTypeId;
RubroId = rubroId;
BasePrice = basePrice;
PriceDurationDays = priceDurationDays;
IsActive = isActive;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
/// IsActive=true, FechaModificacion=null.
/// </summary>
public static Product ForCreation(
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateMedioId(medioId);
ValidateProductTypeId(productTypeId);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(
id: 0,
nombre: nombre.Trim(),
medioId: medioId,
productTypeId: productTypeId,
rubroId: rubroId,
basePrice: basePrice,
priceDurationDays: priceDurationDays,
isActive: true,
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null);
}
public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider)
{
ValidateNombre(nuevoNombre);
return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider)
{
ValidateRubroId(rubroId);
return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Combo mutator: renames, updates price and category in one call.
/// Used by UpdateProductCommandHandler.
/// </summary>
public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithDeactivated(TimeProvider timeProvider)
=> new(Id, Nombre, MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, isActive: false, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
// ── Private validators ────────────────────────────────────────────────────
private static void ValidateNombre(string nombre)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new ArgumentException(
"El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre));
if (nombre.Length > NombreMaxLength)
throw new ArgumentException(
$"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre));
}
private static void ValidateMedioId(int medioId)
{
if (medioId <= 0)
throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId));
}
private static void ValidateProductTypeId(int productTypeId)
{
if (productTypeId <= 0)
throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId));
}
private static void ValidateRubroId(int? rubroId)
{
if (rubroId.HasValue && rubroId.Value <= 0)
throw new ArgumentException(
"rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId));
}
private static void ValidateBasePrice(decimal basePrice)
{
if (basePrice < 0m)
throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice));
}
private static void ValidatePriceDurationDays(int? priceDurationDays)
{
if (priceDurationDays.HasValue && priceDurationDays.Value <= 0)
throw new ArgumentException(
"priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays));
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
/// </summary>
public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException
{
public int MedioId { get; }
public int ProductTypeId { get; }
public string Nombre { get; }
public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre)
: base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.")
{
MedioId = medioId;
ProductTypeId = productTypeId;
Nombre = nombre;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Product does not exist. → HTTP 404
/// </summary>
public sealed class ProductNotFoundException : DomainException
{
public int ProductId { get; }
public ProductNotFoundException(int id)
: base($"El producto con id={id} no existe.")
{
ProductId = id;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product's field violates the flags coherence rules of its ProductType
/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422
/// </summary>
public sealed class ProductTipoFlagsIncoherentesException : DomainException
{
public string Field { get; }
public ProductTipoFlagsIncoherentesException(string reason, string field)
: base($"Incoherencia de flags del tipo de producto: {reason}.")
{
Field = field;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
/// </summary>
public sealed class ProductTypeInactivoException : DomainException
{
public int ProductTypeId { get; }
public ProductTypeInactivoException(int productTypeId)
: base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.")
{
ProductTypeId = productTypeId;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
/// </summary>
public sealed class RubroInactivoException : DomainException
{
public int RubroId { get; }
public RubroInactivoException(int rubroId)
: base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.")
{
RubroId = rubroId;
}
}

View File

@@ -40,6 +40,9 @@ public static class DependencyInjection
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
services.AddScoped<IRubroRepository, RubroRepository>();
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
// PRD-002: replaces NullProductQueryRepository from Application DI
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,37 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product.
/// Replaces NullProductQueryRepository which was bound during PRD-001.
/// </summary>
public sealed class ProductQueryRepository : IProductQueryRepository
{
private readonly SqlConnectionFactory _factory;
public ProductQueryRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
{
const string sql = """
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM dbo.Product
WHERE ProductTypeId = @ProductTypeId
AND IsActive = 1
) THEN 1 ELSE 0
END
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
return result == 1;
}
}

View File

@@ -0,0 +1,201 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product.
/// Full implementation in Batch 6.
/// </summary>
public sealed class ProductRepository : IProductRepository
{
private readonly SqlConnectionFactory _factory;
public ProductRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<int> AddAsync(Product product, CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.Product (
Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays,
IsActive, FechaCreacion
)
OUTPUT INSERTED.Id
VALUES (
@Nombre, @MedioId, @ProductTypeId, @RubroId, @BasePrice, @PriceDurationDays,
1, @FechaCreacion
)
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new
{
product.Nombre,
product.MedioId,
product.ProductTypeId,
product.RubroId,
product.BasePrice,
product.PriceDurationDays,
product.FechaCreacion,
});
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
FROM dbo.Product
WHERE Id = @Id
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<ProductRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default)
{
var conditions = new List<string>();
if (query.Activo.HasValue)
conditions.Add("IsActive = @Activo");
if (!string.IsNullOrWhiteSpace(query.Search))
conditions.Add("Nombre LIKE '%' + @Search + '%'");
if (query.MedioId.HasValue)
conditions.Add("MedioId = @MedioId");
if (query.ProductTypeId.HasValue)
conditions.Add("ProductTypeId = @ProductTypeId");
if (query.RubroId.HasValue)
conditions.Add("RubroId = @RubroId");
var where = conditions.Count > 0
? "WHERE " + string.Join(" AND ", conditions)
: string.Empty;
var countSql = $"SELECT COUNT(1) FROM dbo.Product {where}";
var dataSql = $"""
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
FROM dbo.Product
{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,
query.MedioId,
query.ProductTypeId,
query.RubroId,
Offset = offset,
PageSize = query.PageSize,
};
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var total = await connection.ExecuteScalarAsync<int>(countSql, parameters);
var rows = await connection.QueryAsync<ProductRow>(dataSql, parameters);
var items = rows.Select(MapRow).ToList();
return new PagedResult<Product>(items, query.Page, query.PageSize, total);
}
public async Task UpdateAsync(Product product, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.Product
SET Nombre = @Nombre,
RubroId = @RubroId,
BasePrice = @BasePrice,
PriceDurationDays = @PriceDurationDays,
IsActive = @IsActive,
FechaModificacion = @FechaModificacion
WHERE Id = @Id
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(sql, new
{
product.Nombre,
product.RubroId,
product.BasePrice,
product.PriceDurationDays,
IsActive = product.IsActive ? 1 : 0,
product.FechaModificacion,
product.Id,
});
}
public async Task<bool> ExistsByNombreAsync(
string nombre,
int medioId,
int productTypeId,
int? excludeId = null,
CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.Product
WHERE Nombre = @Nombre
AND MedioId = @MedioId
AND ProductTypeId = @ProductTypeId
AND IsActive = 1
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new
{
Nombre = nombre,
MedioId = medioId,
ProductTypeId = productTypeId,
ExcludeId = excludeId,
});
return count > 0;
}
// ── Mapping ───────────────────────────────────────────────────────────────
private static Product MapRow(ProductRow r)
=> new(
id: r.Id,
nombre: r.Nombre,
medioId: r.MedioId,
productTypeId: r.ProductTypeId,
rubroId: r.RubroId,
basePrice: r.BasePrice,
priceDurationDays: r.PriceDurationDays,
isActive: r.IsActive,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private sealed record ProductRow(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);
}

View File

@@ -17,6 +17,7 @@ import {
Store,
Tag,
Layers,
Package,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -82,6 +83,12 @@ const adminItems: NavItem[] = [
icon: Layers,
requiredPermission: 'catalogo:tipos:gestionar',
},
{
label: 'Productos',
href: '/admin/products',
icon: Package,
requiredPermission: 'catalogo:productos:gestionar',
},
]
interface SidebarNavProps {

View File

@@ -0,0 +1,12 @@
import { axiosClient } from '@/api/axiosClient'
import type { CreateProductRequest, ProductDetail } from '../types'
export async function createProduct(
payload: CreateProductRequest,
): Promise<ProductDetail> {
const response = await axiosClient.post<ProductDetail>(
'/api/v1/admin/products',
payload,
)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function deactivateProduct(id: number): Promise<void> {
await axiosClient.delete(`/api/v1/admin/products/${id}`)
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { ProductDetail } from '../types'
export async function getProductById(id: number): Promise<ProductDetail> {
const response = await axiosClient.get<ProductDetail>(`/api/v1/products/${id}`)
return response.data
}

View File

@@ -0,0 +1,12 @@
import { axiosClient } from '@/api/axiosClient'
import type { ListProductsParams, PagedResult, ProductListItem } from '../types'
export async function listProducts(
params?: ListProductsParams,
): Promise<PagedResult<ProductListItem>> {
const response = await axiosClient.get<PagedResult<ProductListItem>>(
'/api/v1/products',
{ params },
)
return response.data
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
import type { UpdateProductRequest, ProductDetail } from '../types'
export async function updateProduct(
id: number,
payload: UpdateProductRequest,
): Promise<ProductDetail> {
const response = await axiosClient.put<ProductDetail>(
`/api/v1/admin/products/${id}`,
payload,
)
return response.data
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
import { AlertCircle } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ProductListItem } from '../types'
// ─── Error resolver ────────────────────────────────────────────────────────────
function resolveDeactivateError(err: unknown): string | null {
if (!err) return null
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
if (errObj?.response?.data?.message) {
return errObj.response.data.message
}
return 'Error al desactivar el producto'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface DeactivateProductDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
product: ProductListItem
onConfirm: (id: number) => Promise<void> | void
}
// ─── Component ────────────────────────────────────────────────────────────────
export function DeactivateProductDialog({
open,
onOpenChange,
product,
onConfirm,
}: DeactivateProductDialogProps) {
const [error, setError] = useState<string | null>(null)
const [isPending, setIsPending] = useState(false)
async function handleConfirm() {
setError(null)
setIsPending(true)
try {
await onConfirm(product.id)
onOpenChange(false)
} catch (err) {
setError(resolveDeactivateError(err))
} finally {
setIsPending(false)
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Desactivar producto</AlertDialogTitle>
<AlertDialogDescription>
¿Desactivar el producto &ldquo;{product.nombre}&rdquo;? El producto no
aparecerá en los listados activos.
</AlertDialogDescription>
</AlertDialogHeader>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Procesando...' : 'Desactivar'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,300 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import type { ProductTypeListItem } from '@/features/product-types/types'
// ─── Schema ───────────────────────────────────────────────────────────────────
function nullablePositiveInt() {
return z
.string()
.optional()
.transform((v) => (v === '' || v == null ? null : Number(v)))
.pipe(z.number().int().positive().nullable())
}
const productFormSchema = z.object({
nombre: z.string().trim().min(1, 'Nombre requerido').max(300, 'Máximo 300 caracteres'),
medioId: z
.string()
.min(1, 'Medio requerido')
.transform((v) => Number(v))
.pipe(z.number().int().positive('Medio requerido')),
productTypeId: z
.string()
.min(1, 'Tipo de producto requerido')
.transform((v) => Number(v))
.pipe(z.number().int().positive('Tipo de producto requerido')),
rubroId: nullablePositiveInt(),
basePrice: z
.string()
.min(1, 'Precio requerido')
.transform((v) => Number(v))
.pipe(z.number().min(0, 'El precio no puede ser negativo')),
priceDurationDays: nullablePositiveInt(),
})
// Raw form field types (strings before zod transforms)
type ProductFormRaw = {
nombre: string
medioId: string
productTypeId: string
rubroId: string
basePrice: string
priceDurationDays: string
}
// Output type after zod transforms (what onSubmit receives at runtime)
export type ProductFormOutput = {
nombre: string
medioId: number
productTypeId: number
rubroId: number | null
basePrice: number
priceDurationDays: number | null
}
// ─── Props ────────────────────────────────────────────────────────────────────
export interface ProductFormDefaultValues {
nombre?: string
medioId?: number | null
productTypeId?: number | null
rubroId?: number | null
basePrice?: number | null
priceDurationDays?: number | null
}
interface ProductFormProps {
productTypes: ProductTypeListItem[]
defaultValues?: ProductFormDefaultValues
onSubmit: (values: ProductFormOutput) => void
onCancel: () => void
isPending?: boolean
isEdit?: boolean
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductForm({
productTypes,
defaultValues,
onSubmit,
onCancel,
isPending = false,
isEdit = false,
}: ProductFormProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const form = useForm<ProductFormRaw>({
resolver: zodResolver(productFormSchema) as any,
defaultValues: {
nombre: defaultValues?.nombre ?? '',
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
},
})
useEffect(() => {
form.reset({
nombre: defaultValues?.nombre ?? '',
medioId: defaultValues?.medioId != null ? String(defaultValues.medioId) : '',
productTypeId: defaultValues?.productTypeId != null ? String(defaultValues.productTypeId) : '',
rubroId: defaultValues?.rubroId != null ? String(defaultValues.rubroId) : '',
basePrice: defaultValues?.basePrice != null ? String(defaultValues.basePrice) : '',
priceDurationDays: defaultValues?.priceDurationDays != null ? String(defaultValues.priceDurationDays) : '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
// Derive selected ProductType flags
const productTypeIdStr = form.watch('productTypeId')
const selectedProductTypeId = productTypeIdStr ? Number(productTypeIdStr) : null
const selectedProductType = productTypes.find((pt) => pt.id === selectedProductTypeId) ?? null
const requiresCategory = selectedProductType?.requiresCategory ?? false
const hasDuration = selectedProductType?.hasDuration ?? false
function handleSubmit(data: ProductFormOutput) {
// Normalize conditional fields to null when not applicable
const normalized: ProductFormOutput = {
...data,
rubroId: requiresCategory ? data.rubroId : null,
priceDurationDays: hasDuration ? data.priceDurationDays : null,
}
onSubmit(normalized)
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
)}
className="space-y-4"
noValidate
>
{/* Nombre */}
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending}
placeholder="Nombre del producto"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Medio ID */}
<FormField
control={form.control}
name="medioId"
render={({ field }) => (
<FormItem>
<FormLabel>ID de Medio</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || isEdit}
placeholder="ID del medio"
aria-label="ID de Medio"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Product Type ID */}
<FormField
control={form.control}
name="productTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>ID de Tipo de Producto</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending || isEdit}
placeholder="ID del tipo de producto"
aria-label="ID de Tipo de Producto"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Rubro ID — only shown when requiresCategory=true */}
{requiresCategory && (
<FormField
control={form.control}
name="rubroId"
render={({ field }) => (
<FormItem>
<FormLabel>ID de Rubro</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="ID del rubro"
aria-label="ID de Rubro"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Base Price */}
<FormField
control={form.control}
name="basePrice"
render={({ field }) => (
<FormItem>
<FormLabel>Precio base</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
step={0.01}
disabled={isPending}
placeholder="0.00"
aria-label="Precio base"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Price Duration Days — only shown when hasDuration=true */}
{hasDuration && (
<FormField
control={form.control}
name="priceDurationDays"
render={({ field }) => (
<FormItem>
<FormLabel>Días de duración del precio</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="Días"
aria-label="Días de duración del precio"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ProductForm } from './ProductForm'
import type { ProductFormOutput } from './ProductForm'
import { useCreateProduct } from '../hooks/useCreateProduct'
import { useUpdateProduct } from '../hooks/useUpdateProduct'
import type { ProductDetail } from '../types'
import { useProductTypes } from '@/features/product-types/hooks/useProductTypes'
// ─── Error resolver ────────────────────────────────────────────────────────────
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
return data.message ?? data.error ?? 'Error al guardar el producto'
}
const errObj = err as { response?: { data?: { message?: string } } }
if (errObj?.response?.data?.message) {
return errObj.response.data.message
}
return 'Error al guardar el producto'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface ProductFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
product?: ProductDetail
onSuccess?: () => void
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductFormDialog({
open,
onOpenChange,
product,
onSuccess,
}: ProductFormDialogProps) {
const [backendError, setBackendError] = useState<string | null>(null)
const isEdit = !!product
const { mutateAsync: createProduct, isPending: creating } = useCreateProduct()
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
const isPending = creating || updating
// Fetch active product types so ProductForm can derive conditional fields
const { data: productTypesPaged } = useProductTypes({ activo: true })
const productTypes = productTypesPaged?.items ?? []
async function handleSubmit(values: ProductFormOutput) {
setBackendError(null)
try {
if (isEdit) {
await updateProduct({
id: product.id,
data: {
nombre: values.nombre,
rubroId: values.rubroId,
basePrice: values.basePrice,
priceDurationDays: values.priceDurationDays,
},
})
toast.success('Producto actualizado')
} else {
await createProduct({
nombre: values.nombre,
medioId: values.medioId,
productTypeId: values.productTypeId,
rubroId: values.rubroId,
basePrice: values.basePrice,
priceDurationDays: values.priceDurationDays,
})
toast.success('Producto creado')
}
onOpenChange(false)
onSuccess?.()
} catch (err) {
const msg = resolveBackendError(err)
setBackendError(msg)
if (
!isAxiosError(err) ||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
) {
toast.error(isEdit ? 'Error al actualizar producto' : 'Error al crear producto')
}
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar producto' : 'Nuevo producto'}</DialogTitle>
<DialogDescription>
{isEdit
? `Modificá los datos del producto "${product?.nombre ?? ''}".`
: 'Completá los datos para crear un nuevo producto.'}
</DialogDescription>
</DialogHeader>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<ProductForm
productTypes={productTypes}
defaultValues={product}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
isPending={isPending}
isEdit={isEdit}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createProduct } from '../api/createProduct'
import type { CreateProductRequest } from '../types'
export function useCreateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: CreateProductRequest) => createProduct(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { deactivateProduct } from '../api/deactivateProduct'
export function useDeactivateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deactivateProduct(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}

View File

@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { listProducts } from '../api/listProducts'
import type { ListProductsParams } from '../types'
export function useProducts(params?: ListProductsParams) {
return useQuery({
queryKey: ['products', params],
queryFn: () => listProducts(params),
})
}

View File

@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateProduct } from '../api/updateProduct'
import type { UpdateProductRequest } from '../types'
export function useUpdateProduct() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateProductRequest }) =>
updateProduct(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
}

View File

@@ -0,0 +1,9 @@
export { ProductsPage } from './pages/ProductsPage'
export type {
ProductListItem,
ProductDetail,
CreateProductRequest,
UpdateProductRequest,
PagedResult,
ListProductsParams,
} from './types'

View File

@@ -0,0 +1,236 @@
import { useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CanPerform } from '@/components/auth/CanPerform'
import { useProducts } from '../hooks/useProducts'
import { useDeactivateProduct } from '../hooks/useDeactivateProduct'
import { ProductFormDialog } from '../components/ProductFormDialog'
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
import type { ProductListItem, ProductDetail } from '../types'
const PAGE_SIZE = 20
export function ProductsPage() {
// ── Create dialog state ──────────────────────────────────────────────────
const [createOpen, setCreateOpen] = useState(false)
// ── Edit dialog state ────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false)
const [editingProduct, setEditingProduct] = useState<ProductDetail | null>(null)
// ── Deactivate dialog state ──────────────────────────────────────────────
const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
// ── Pagination & filter state ────────────────────────────────────────────
const [page, setPage] = useState(1)
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
const { data: paged, isLoading, isError } = useProducts({
activo: true,
page,
pageSize: PAGE_SIZE,
medioId: medioIdFilter,
})
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
// ── Handlers ─────────────────────────────────────────────────────────────
function openCreate() {
setCreateOpen(true)
}
function openEdit(p: ProductListItem) {
const detail: ProductDetail = {
...p,
fechaCreacion: '',
fechaModificacion: null,
}
setEditingProduct(detail)
setEditOpen(true)
}
function openDeactivate(p: ProductListItem) {
setDeactivatingProduct(p)
setDeactivateOpen(true)
}
async function handleDeactivate(id: number) {
await deactivateProduct(id)
toast.success('Producto desactivado')
}
function handleMedioFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value
setPage(1)
setMedioIdFilter(val ? Number(val) : undefined)
}
const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1
// ── Loading / Error ───────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (isError) {
return (
<Alert variant="destructive" className="m-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Error al cargar productos.</AlertDescription>
</Alert>
)
}
const isEmpty = !paged?.items.length
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Productos</h1>
<CanPerform permission="catalogo:productos:gestionar">
<Button size="sm" onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Nuevo Producto
</Button>
</CanPerform>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<Input
type="number"
min={1}
placeholder="Filtrar por ID de Medio"
aria-label="Filtrar por ID de Medio"
className="w-52"
onChange={handleMedioFilterChange}
/>
</div>
{isEmpty ? (
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
<p>No hay productos.</p>
<CanPerform permission="catalogo:productos:gestionar">
<Button variant="outline" onClick={openCreate}>
Crear primer producto
</Button>
</CanPerform>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Nombre</th>
<th className="px-4 py-2 text-left font-medium">Medio</th>
<th className="px-4 py-2 text-left font-medium">Tipo</th>
<th className="px-4 py-2 text-left font-medium">Precio base</th>
<th className="px-4 py-2 text-left font-medium">Activo</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{paged.items.map((p: ProductListItem) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/25">
<td className="px-4 py-2 font-medium">{p.nombre}</td>
<td className="px-4 py-2">{p.medioId}</td>
<td className="px-4 py-2">{p.productTypeId}</td>
<td className="px-4 py-2">{p.basePrice}</td>
<td className="px-4 py-2">
<span className={p.isActive ? 'text-green-600' : 'text-red-500'}>
{p.isActive ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-4 py-2">
<CanPerform permission="catalogo:productos:gestionar">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(p)}
>
Editar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeactivate(p)}
disabled={!p.isActive}
>
Desactivar
</Button>
</div>
</CanPerform>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
aria-label="Página anterior"
>
Anterior
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
aria-label="Página siguiente"
>
Siguiente
</Button>
</div>
)}
{/* Create dialog */}
<ProductFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* Edit dialog */}
{editingProduct && (
<ProductFormDialog
open={editOpen}
onOpenChange={setEditOpen}
product={editingProduct}
/>
)}
{/* Deactivate confirmation dialog */}
{deactivatingProduct && (
<DeactivateProductDialog
open={deactivateOpen}
onOpenChange={setDeactivateOpen}
product={deactivatingProduct}
onConfirm={handleDeactivate}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
// PRD-002 — shared types for products feature
export interface ProductListItem {
id: number
nombre: string
medioId: number
productTypeId: number
rubroId: number | null
basePrice: number
priceDurationDays: number | null
isActive: boolean
}
export interface ProductDetail {
id: number
nombre: string
medioId: number
productTypeId: number
rubroId: number | null
basePrice: number
priceDurationDays: number | null
isActive: boolean
fechaCreacion: string
fechaModificacion: string | null
}
export interface CreateProductRequest {
nombre: string
medioId: number
productTypeId: number
rubroId?: number | null
basePrice: number
priceDurationDays?: number | null
}
export interface UpdateProductRequest {
nombre: string
rubroId?: number | null
basePrice: number
priceDurationDays?: number | null
}
export interface PagedResult<T> {
items: T[]
page: number
pageSize: number
total: number
}
export interface ListProductsParams {
page?: number
pageSize?: number
activo?: boolean | null
search?: string
medioId?: number
productTypeId?: number
rubroId?: number
}

View File

@@ -29,6 +29,7 @@ import { TiposDeIvaPage } from './features/fiscal/iva/pages/TiposDeIvaPage'
import { TiposDeIibbPage } from './features/fiscal/iibb/pages/TiposDeIibbPage'
import { RubrosPage } from './features/rubros/pages/RubrosPage'
import { ProductTypesPage } from './features/product-types/pages/ProductTypesPage'
import { ProductsPage } from './features/products/pages/ProductsPage'
import { HomePage } from './pages/HomePage'
import { PublicLayout } from './layouts/PublicLayout'
import { ProtectedLayout } from './layouts/ProtectedLayout'
@@ -320,6 +321,16 @@ export function AppRoutes() {
}
/>
{/* Products routes — PRD-002 */}
<Route
path="/admin/products"
element={
<ProtectedPage requiredPermissions={['catalogo:productos:gestionar']}>
<ProductsPage />
</ProtectedPage>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { DeactivateProductDialog } from '../../../features/products/components/DeactivateProductDialog'
import type { ProductListItem } from '../../../features/products/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const sampleProduct: ProductListItem = {
id: 1,
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.5,
priceDurationDays: null,
isActive: true,
}
function wrap(children: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>,
)
}
describe('DeactivateProductDialog', () => {
it('renders confirmation message with product name', () => {
wrap(
<DeactivateProductDialog
open={true}
onOpenChange={vi.fn()}
product={sampleProduct}
onConfirm={vi.fn()}
/>,
)
expect(screen.getByText(/Clasificado Estándar/i)).toBeInTheDocument()
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument()
})
it('calls onConfirm with product id when user confirms', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
wrap(
<DeactivateProductDialog
open={true}
onOpenChange={vi.fn()}
product={sampleProduct}
onConfirm={onConfirm}
/>,
)
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
await userEvent.click(confirmBtn)
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProduct.id))
})
it('shows inline error when backend returns 409', async () => {
const onConfirm = vi.fn(() =>
Promise.reject({
response: { status: 409, data: { message: 'No se puede desactivar: el producto está en uso.' } },
}),
)
wrap(
<DeactivateProductDialog
open={true}
onOpenChange={vi.fn()}
product={sampleProduct}
onConfirm={onConfirm}
/>,
)
const buttons = screen.getAllByRole('button', { name: /desactivar/i })
const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
await userEvent.click(confirmBtn)
await waitFor(() => {
expect(screen.getByText(/en uso/i)).toBeInTheDocument()
})
})
it('closes dialog when cancel is clicked', async () => {
const onOpenChange = vi.fn()
wrap(
<DeactivateProductDialog
open={true}
onOpenChange={onOpenChange}
product={sampleProduct}
onConfirm={vi.fn()}
/>,
)
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
})
})

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { ProductForm } from '../../../features/products/components/ProductForm'
import type { ProductTypeListItem } from '../../../features/product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const ptRequiresCategory: ProductTypeListItem = {
id: 1,
nombre: 'Con categoría',
hasDuration: false,
requiresText: false,
requiresCategory: true,
isBundle: false,
allowImages: false,
isActive: true,
}
const ptHasDuration: ProductTypeListItem = {
id: 2,
nombre: 'Con duración',
hasDuration: true,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
const ptSimple: ProductTypeListItem = {
id: 3,
nombre: 'Simple',
hasDuration: false,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
function wrap(children: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>,
)
}
// ─── ProductForm — no ProductType selected ────────────────────────────────
describe('ProductForm — no ProductType selected', () => {
it('hides RubroId and PriceDurationDays fields when no ProductType is selected', () => {
wrap(
<ProductForm
productTypes={[ptRequiresCategory, ptHasDuration, ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.queryByLabelText(/rubro/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument()
})
})
// ─── ProductForm — requiresCategory flag ─────────────────────────────────
describe('ProductForm — requiresCategory flag', () => {
it('shows RubroId field when ProductType has requiresCategory=true', async () => {
wrap(
<ProductForm
productTypes={[ptRequiresCategory, ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 1 }}
/>,
)
await waitFor(() =>
expect(screen.getByLabelText(/id de rubro/i)).toBeInTheDocument(),
)
})
it('hides RubroId field when ProductType has requiresCategory=false', async () => {
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await waitFor(() =>
expect(screen.queryByLabelText(/id de rubro/i)).not.toBeInTheDocument(),
)
})
})
// ─── ProductForm — hasDuration flag ──────────────────────────────────────
describe('ProductForm — hasDuration flag', () => {
it('shows PriceDurationDays when ProductType has hasDuration=true', async () => {
wrap(
<ProductForm
productTypes={[ptHasDuration]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 2 }}
/>,
)
await waitFor(() =>
expect(screen.getByLabelText(/días de duración/i)).toBeInTheDocument(),
)
})
it('hides PriceDurationDays when ProductType has hasDuration=false', async () => {
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await waitFor(() =>
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument(),
)
})
})
// ─── ProductForm — submit normalization ──────────────────────────────────
describe('ProductForm — submit normalization', () => {
it('nulls out rubroId when requiresCategory=false on submit', async () => {
const onSubmit = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={onSubmit}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload.rubroId).toBeNull()
})
})
it('nulls out priceDurationDays when hasDuration=false on submit', async () => {
const onSubmit = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={onSubmit}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test2')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload.priceDurationDays).toBeNull()
})
})
it('calls onCancel when cancel button is clicked', async () => {
const onCancel = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
)
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
expect(onCancel).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { ProductFormDialog } from '../../../features/products/components/ProductFormDialog'
import type { ProductDetail } from '../../../features/products/types'
import type { PagedResult } from '../../../features/product-types/types'
import type { ProductTypeListItem } from '../../../features/product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
const ptSimple: ProductTypeListItem = {
id: 2,
nombre: 'Simple',
hasDuration: false,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
const mockProductTypesPaged: PagedResult<ProductTypeListItem> = {
items: [ptSimple],
page: 1,
pageSize: 50,
total: 1,
}
const mockProduct: ProductDetail = {
id: 1,
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.5,
priceDurationDays: null,
isActive: true,
fechaCreacion: '2026-04-19T00:00:00Z',
fechaModificacion: null,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function wrap(children: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>,
)
}
function withProductTypesHandler() {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () =>
HttpResponse.json(mockProductTypesPaged),
),
)
}
// ─── ProductFormDialog — create mode ─────────────────────────────────────
describe('ProductFormDialog — create mode', () => {
it('renders create dialog title when no product prop', () => {
withProductTypesHandler()
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument()
})
it('renders DialogDescription (accessibility)', () => {
withProductTypesHandler()
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
expect(screen.getByText(/completá los datos/i)).toBeInTheDocument()
})
it('calls create mutation and closes dialog on success', async () => {
withProductTypesHandler()
server.use(
http.post(`${API_URL}/api/v1/admin/products`, () =>
HttpResponse.json({ id: 10, nombre: 'Nuevo Producto' }, { status: 201 }),
),
)
const onOpenChange = vi.fn()
wrap(<ProductFormDialog open={true} onOpenChange={onOpenChange} />)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Producto')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
it('shows inline error when backend returns 409', async () => {
withProductTypesHandler()
server.use(
http.post(`${API_URL}/api/v1/admin/products`, () =>
HttpResponse.json(
{ error: 'product_nombre_duplicado', message: 'Ya existe un producto con ese nombre' },
{ status: 409 },
),
),
)
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(screen.getByText(/ya existe un producto con ese nombre/i)).toBeInTheDocument()
})
})
it('shows inline error when backend returns 409 ProductTypeInactivo', async () => {
withProductTypesHandler()
server.use(
http.post(`${API_URL}/api/v1/admin/products`, () =>
HttpResponse.json(
{ error: 'product_type_inactivo', message: 'El tipo de producto está inactivo' },
{ status: 409 },
),
),
)
wrap(<ProductFormDialog open={true} onOpenChange={vi.fn()} />)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(screen.getByText(/tipo de producto está inactivo/i)).toBeInTheDocument()
})
})
})
// ─── ProductFormDialog — edit mode ────────────────────────────────────────
describe('ProductFormDialog — edit mode', () => {
it('renders edit dialog title and pre-fills nombre', () => {
withProductTypesHandler()
wrap(
<ProductFormDialog
open={true}
onOpenChange={vi.fn()}
product={mockProduct}
/>,
)
expect(screen.getByRole('heading', { name: /editar producto/i })).toBeInTheDocument()
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
expect(input.value).toBe('Clasificado Estándar')
})
it('calls update mutation and closes dialog on success', async () => {
withProductTypesHandler()
server.use(
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
HttpResponse.json({ ...mockProduct, nombre: 'Modificado' }),
),
)
const onOpenChange = vi.fn()
wrap(
<ProductFormDialog
open={true}
onOpenChange={onOpenChange}
product={mockProduct}
/>,
)
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
await userEvent.clear(input)
await userEvent.type(input, 'Modificado')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -0,0 +1,283 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import React from 'react'
import { ProductsPage } from '../../../features/products/pages/ProductsPage'
import { useAuthStore } from '../../../stores/authStore'
import type { ProductListItem, PagedResult } from '../../../features/products/types'
const API_URL = 'http://localhost:5000'
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const adminUser = {
id: 1,
username: 'admin',
nombre: 'Admin',
rol: 'admin',
permisos: ['catalogo:productos:gestionar'],
mustChangePassword: false,
}
const regularUser = {
id: 2,
username: 'viewer',
nombre: 'Viewer',
rol: 'viewer',
permisos: [],
mustChangePassword: false,
}
const mockItem: ProductListItem = {
id: 1,
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.50,
priceDurationDays: null,
isActive: true,
}
const mockPaged: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 1,
}
const emptyPaged: PagedResult<ProductListItem> = {
items: [],
page: 1,
pageSize: 20,
total: 0,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().clearAuth()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage(user = adminUser) {
useAuthStore.setState({ user })
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/products']}>
<Routes>
<Route path="/admin/products" element={<ProductsPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
// ─── Loading / Error / Data states ──────────────────────────────────────────
describe('ProductsPage — loading and error states', () => {
it('renders loading skeleton while fetching', () => {
server.use(
http.get(`${API_URL}/api/v1/products`, async () => {
await new Promise(() => {})
return HttpResponse.json(emptyPaged)
}),
)
renderPage()
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
expect(skeletons.length).toBeGreaterThan(0)
})
it('renders data when loaded', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
)
renderPage()
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
})
it('shows error state on fetch failure', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () =>
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText(/error al cargar productos/i)).toBeInTheDocument(),
)
})
it('shows empty state when no products', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
)
renderPage()
await waitFor(() =>
expect(screen.getByText(/no hay productos/i)).toBeInTheDocument(),
)
})
})
// ─── Permission gating ───────────────────────────────────────────────────────
describe('ProductsPage — permission gating', () => {
it('shows "Nuevo Producto" button when user has permission', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
)
renderPage(adminUser)
await waitFor(() =>
expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument(),
)
})
it('hides "Nuevo Producto" button when user lacks permission', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
)
renderPage(regularUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: /nuevo producto/i })).not.toBeInTheDocument()
})
})
// ─── Create dialog ───────────────────────────────────────────────────────────
describe('ProductsPage — create dialog', () => {
it('opens create dialog when "Nuevo Producto" button is clicked', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(emptyPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo producto/i })).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /nuevo producto/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument(),
)
})
})
// ─── Deactivate dialog ───────────────────────────────────────────────────────
describe('ProductsPage — deactivate dialog', () => {
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument(),
)
})
})
// ─── Pagination ──────────────────────────────────────────────────────────────
describe('ProductsPage — pagination', () => {
it('shows pagination controls when total > pageSize', async () => {
// 21 total items but only 1 in this page → totalPages=2 → controls visible
const pagedWith21Total: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 21,
}
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()
})
it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => {
const capturedRequests: URL[] = []
const pagedPage1: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 21,
}
const pagedPage2: PagedResult<ProductListItem> = {
items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }],
page: 2,
pageSize: 20,
total: 21,
}
server.use(
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
capturedRequests.push(new URL(request.url))
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1)
}),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /siguiente/i }))
await waitFor(() => {
expect(screen.getByText('Producto Página 2')).toBeInTheDocument()
})
// Verify that at least one request was made with page=2
const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2')
expect(page2Requests.length).toBeGreaterThan(0)
})
})
// ─── Filter by Medio ─────────────────────────────────────────────────────────
describe('ProductsPage — filter by Medio', () => {
it('re-fetches with medioId when Medio filter is changed', async () => {
const capturedRequests: URL[] = []
const filteredPaged: PagedResult<ProductListItem> = {
items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }],
page: 1,
pageSize: 20,
total: 1,
}
server.use(
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
capturedRequests.push(new URL(request.url))
const url = new URL(request.url)
const medioId = url.searchParams.get('medioId')
return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged)
}),
)
renderPage(adminUser)
// Wait for initial empty state
await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument())
const filterInput = screen.getByLabelText(/filtrar por id de medio/i)
await userEvent.type(filterInput, '5')
await waitFor(() => {
expect(screen.getByText('Producto Medio 5')).toBeInTheDocument()
})
const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5')
expect(filteredRequests.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { listProducts } from '../../../features/products/api/listProducts'
import { getProductById } from '../../../features/products/api/getProductById'
import { createProduct } from '../../../features/products/api/createProduct'
import { updateProduct } from '../../../features/products/api/updateProduct'
import { deactivateProduct } from '../../../features/products/api/deactivateProduct'
import type { ProductListItem, ProductDetail, PagedResult } from '../../../features/products/types'
const API_URL = 'http://localhost:5000'
const mockListItem: ProductListItem = {
id: 1,
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.50,
priceDurationDays: null,
isActive: true,
}
const mockDetail: ProductDetail = {
id: 1,
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.50,
priceDurationDays: null,
isActive: true,
fechaCreacion: '2026-04-19T00:00:00Z',
fechaModificacion: null,
}
const mockPaged: PagedResult<ProductListItem> = {
items: [mockListItem],
page: 1,
pageSize: 20,
total: 1,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('listProducts', () => {
it('calls GET /api/v1/products and returns paged result', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
)
const result = await listProducts()
expect(result).toEqual(mockPaged)
})
it('passes query params when provided', async () => {
let capturedUrl = ''
server.use(
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json(mockPaged)
}),
)
await listProducts({ page: 2, pageSize: 10, activo: true, medioId: 5 })
expect(capturedUrl).toContain('page=2')
expect(capturedUrl).toContain('pageSize=10')
expect(capturedUrl).toContain('medioId=5')
})
})
describe('getProductById', () => {
it('calls GET /api/v1/products/:id and returns detail', async () => {
server.use(
http.get(`${API_URL}/api/v1/products/1`, () => HttpResponse.json(mockDetail)),
)
const result = await getProductById(1)
expect(result).toEqual(mockDetail)
})
})
describe('createProduct', () => {
it('calls POST /api/v1/admin/products with payload', async () => {
let capturedBody: unknown = null
server.use(
http.post(`${API_URL}/api/v1/admin/products`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockDetail, { status: 201 })
}),
)
const req = {
nombre: 'Clasificado Estándar',
medioId: 1,
productTypeId: 2,
basePrice: 100.50,
}
await createProduct(req)
expect(capturedBody).toEqual(req)
})
})
describe('updateProduct', () => {
it('calls PUT /api/v1/admin/products/:id with payload', async () => {
let capturedBody: unknown = null
server.use(
http.put(`${API_URL}/api/v1/admin/products/1`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockDetail)
}),
)
const req = { nombre: 'Modificado', basePrice: 200 }
await updateProduct(1, req)
expect(capturedBody).toEqual(req)
})
})
describe('deactivateProduct', () => {
it('calls DELETE /api/v1/admin/products/:id', async () => {
let called = false
server.use(
http.delete(`${API_URL}/api/v1/admin/products/1`, () => {
called = true
return new HttpResponse(null, { status: 204 })
}),
)
await deactivateProduct(1)
expect(called).toBe(true)
})
})

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useProducts } from '../../../features/products/hooks/useProducts'
import { useCreateProduct } from '../../../features/products/hooks/useCreateProduct'
import { useUpdateProduct } from '../../../features/products/hooks/useUpdateProduct'
import { useDeactivateProduct } from '../../../features/products/hooks/useDeactivateProduct'
import type { ProductListItem, PagedResult } from '../../../features/products/types'
const API_URL = 'http://localhost:5000'
const mockItem: ProductListItem = {
id: 1,
nombre: 'Clasificado',
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 50,
priceDurationDays: null,
isActive: true,
}
const mockPaged: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 1,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
vi.clearAllMocks()
})
afterAll(() => server.close())
function makeWrapper() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
}
describe('useProducts', () => {
it('returns paged data on success', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(mockPaged)),
)
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockPaged)
})
it('returns error state on failure', async () => {
server.use(
http.get(`${API_URL}/api/v1/products`, () =>
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
),
)
const { result } = renderHook(() => useProducts(), { wrapper: makeWrapper() })
await waitFor(() => expect(result.current.isError).toBe(true))
})
})
describe('useCreateProduct', () => {
it('calls create and invalidates products queries on success', async () => {
server.use(
http.post(`${API_URL}/api/v1/admin/products`, () =>
HttpResponse.json(mockItem, { status: 201 }),
),
)
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
const { result } = renderHook(() => useCreateProduct(), { wrapper })
await act(async () => {
result.current.mutate({
nombre: 'Nuevo Producto',
medioId: 1,
productTypeId: 2,
basePrice: 100,
})
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
})
})
describe('useUpdateProduct', () => {
it('calls update and invalidates products queries on success', async () => {
server.use(
http.put(`${API_URL}/api/v1/admin/products/1`, () =>
HttpResponse.json(mockItem),
),
)
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
const { result } = renderHook(() => useUpdateProduct(), { wrapper })
await act(async () => {
result.current.mutate({ id: 1, data: { nombre: 'Modificado', basePrice: 200 } })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
})
})
describe('useDeactivateProduct', () => {
it('calls deactivate and invalidates products queries on success', async () => {
server.use(
http.delete(`${API_URL}/api/v1/admin/products/1`, () =>
new HttpResponse(null, { status: 204 }),
),
)
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children)
const { result } = renderHook(() => useDeactivateProduct(), { wrapper })
await act(async () => {
result.current.mutate(1)
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products'] })
})
})

View File

@@ -51,8 +51,9 @@ public class AuthControllerTests
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, permisos.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, permisos.GetArrayLength());
}
// Scenario: invalid credentials return 401 with opaque error

View File

@@ -129,7 +129,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/permisos — catalog ───────────────────────────────────────
[Fact]
public async Task GetPermisos_WithAdmin_Returns200With26Items()
public async Task GetPermisos_WithAdmin_Returns200With27Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token);
@@ -141,8 +141,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, list.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, list.GetArrayLength());
}
[Fact]
@@ -185,7 +186,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// ── GET /api/v1/roles/{codigo}/permisos ──────────────────────────────────
[Fact]
public async Task GetRolPermisos_AdminRol_Returns200With26Items()
public async Task GetRolPermisos_AdminRol_Returns200With27Items()
{
var token = await GetBearerTokenAsync(AdminUsername, AdminPassword);
using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token);
@@ -197,8 +198,9 @@ public sealed class PermisosEndpointTests : IAsyncLifetime
// V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar' → 23
// V014 (ADM-009) adds 'administracion:fiscal:gestionar' → 24
// V016 (CAT-001) adds 'catalogo:rubros:gestionar' → 25
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26 total
Assert.Equal(26, list.GetArrayLength());
// V017 (PRD-001) adds 'catalogo:tipos:gestionar' → 26
// V018 (PRD-002) adds 'catalogo:productos:gestionar' → 27 total
Assert.Equal(27, list.GetArrayLength());
}
[Fact]

View File

@@ -0,0 +1,158 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Products;
/// <summary>
/// PRD-002 Batch 8 — Guard proof: verifies that deactivating a ProductType with active Products
/// returns HTTP 409 Conflict. This test closes the W1 (dormant guard) issue from PRD-001:
/// - PRD-001: NullProductQueryRepository always returned false → guard never fired
/// - PRD-002: ProductQueryRepository now queries dbo.Product → guard fires correctly
/// </summary>
[Collection("ApiIntegration")]
public sealed class ProductTypeDeactivationGuardTests : IAsyncLifetime
{
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public ProductTypeDeactivationGuardTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = AdminUsername,
password = AdminPassword
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
/// <summary>
/// E2E proof: seed Medio + ProductType + active Product → DELETE product-type → expect 409.
/// This verifies the IProductQueryRepository guard fires against real data in dbo.Product.
/// </summary>
[Fact]
public async Task DeactivateProductType_WithActiveProducts_Returns409Conflict()
{
var token = await GetAdminTokenAsync();
// 1. Create a Medio
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
{
codigo = $"GD{Guid.NewGuid():N}"[..6],
nombre = $"Guarda Medio {Guid.NewGuid():N}"[..30],
tipo = 1
}, token));
medioResp.EnsureSuccessStatusCode();
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
var medioId = medioJson.GetProperty("id").GetInt32();
// 2. Create a ProductType
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
{
nombre = $"Guardado PT {Guid.NewGuid():N}"[..30],
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
allowImages = false
}, token));
ptResp.EnsureSuccessStatusCode();
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
var productTypeId = ptJson.GetProperty("id").GetInt32();
// 3. Create an active Product for this ProductType
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
{
nombre = $"Prod Guarda {Guid.NewGuid():N}"[..28],
medioId,
productTypeId,
basePrice = 100m
}, token));
prodResp.EnsureSuccessStatusCode();
// 4. Attempt to deactivate the ProductType — should be blocked by guard
using var deactivateReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
var deactivateResp = await _client.SendAsync(deactivateReq);
// CRITICAL ASSERTION: 409 Conflict — guard fires because Product is active
Assert.Equal(HttpStatusCode.Conflict, deactivateResp.StatusCode);
var body = await deactivateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("product_type_en_uso", body.GetProperty("error").GetString());
}
/// <summary>
/// Verify guard does NOT block deactivation when Products exist but are all inactive.
/// </summary>
[Fact]
public async Task DeactivateProductType_WithOnlyInactiveProducts_Returns204()
{
var token = await GetAdminTokenAsync();
// 1. Create Medio
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
{
codigo = $"GI{Guid.NewGuid():N}"[..6],
nombre = $"Guarda Inact {Guid.NewGuid():N}"[..28],
tipo = 1
}, token));
medioResp.EnsureSuccessStatusCode();
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
var medioId = medioJson.GetProperty("id").GetInt32();
// 2. Create ProductType
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
{
nombre = $"PT Inact {Guid.NewGuid():N}"[..28],
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
allowImages = false
}, token));
ptResp.EnsureSuccessStatusCode();
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
var productTypeId = ptJson.GetProperty("id").GetInt32();
// 3. Create then deactivate Product
var prodResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/products", new
{
nombre = $"Prod Inact {Guid.NewGuid():N}"[..28],
medioId,
productTypeId,
basePrice = 50m
}, token));
prodResp.EnsureSuccessStatusCode();
var prodJson = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = prodJson.GetProperty("id").GetInt32();
// Deactivate the Product first
using var deactivateProdReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/products/{productId}", bearerToken: token);
var deactivateProdResp = await _client.SendAsync(deactivateProdReq);
Assert.Equal(HttpStatusCode.NoContent, deactivateProdResp.StatusCode);
// 4. Now deactivate ProductType — should succeed since no active products
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
}
}

View File

@@ -0,0 +1,367 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using SIGCM2.TestSupport;
namespace SIGCM2.Api.Tests.Products;
/// <summary>
/// PRD-002 — Integration tests for /api/v1/products and /api/v1/admin/products.
/// Read endpoints require authentication (any role).
/// Write endpoints require permission 'catalogo:productos:gestionar'.
/// Verifies HTTP status codes, response shapes, and ExceptionFilter mappings.
/// </summary>
[Collection("ApiIntegration")]
public sealed class ProductsControllerTests : IAsyncLifetime
{
private const string ReadEndpoint = "/api/v1/products";
private const string AdminEndpoint = "/api/v1/admin/products";
private const string AdminUsername = "admin";
private const string AdminPassword = "@Diego550@";
private readonly HttpClient _client;
public ProductsControllerTests(TestWebAppFactory factory)
{
_client = factory.CreateClient();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<string> GetAdminTokenAsync()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = AdminUsername,
password = AdminPassword
});
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("accessToken").GetString()!;
}
private HttpRequestMessage BuildRequest(HttpMethod method, string url, object? body = null, string? bearerToken = null)
{
var request = new HttpRequestMessage(method, url);
if (bearerToken is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (body is not null)
request.Content = JsonContent.Create(body);
return request;
}
private async Task<(int MedioId, int ProductTypeId)> EnsureMedioAndProductTypeAsync(string token)
{
// Create a Medio via SQL (we don't have a Medio controller endpoint available here)
// Use product-types endpoint to create a ProductType and insert Medio directly
var medioResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/medios", new
{
codigo = $"PM{Guid.NewGuid():N}"[..6],
nombre = $"Medio Test {Guid.NewGuid():N}"[..30],
tipo = 1
}, token));
medioResp.EnsureSuccessStatusCode();
var medioJson = await medioResp.Content.ReadFromJsonAsync<JsonElement>();
var medioId = medioJson.GetProperty("id").GetInt32();
var ptResp = await _client.SendAsync(BuildRequest(HttpMethod.Post, "/api/v1/admin/product-types", new
{
nombre = $"PT_{Guid.NewGuid():N}"[..30],
hasDuration = false, requiresText = false, requiresCategory = false, isBundle = false,
allowImages = false
}, token));
ptResp.EnsureSuccessStatusCode();
var ptJson = await ptResp.Content.ReadFromJsonAsync<JsonElement>();
var productTypeId = ptJson.GetProperty("id").GetInt32();
return (medioId, productTypeId);
}
// ── 401 guards on READ endpoints ───────────────────────────────────────────
[Fact]
public async Task List_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Get, ReadEndpoint);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task GetById_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999");
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
// ── 401 guards on WRITE endpoints ──────────────────────────────────────────
[Fact]
public async Task Create_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new { nombre = "Test" });
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Update_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/1", new { nombre = "Test", basePrice = 10m });
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Deactivate_WithoutAuth_Returns401()
{
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/1");
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
// ── POST /api/v1/admin/products ───────────────────────────────────────────
[Fact]
public async Task Create_WithAdmin_Returns201WithId()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Prod_{Guid.NewGuid():N}"[..30];
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName,
medioId,
productTypeId,
basePrice = 100.50m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("id").GetInt32() > 0);
Assert.Equal(uniqueName, json.GetProperty("nombre").GetString());
Assert.True(json.GetProperty("isActive").GetBoolean());
}
[Fact]
public async Task Create_InvalidBody_Returns400()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = string.Empty, // invalid
medioId = 1,
productTypeId = 1,
basePrice = 10m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Create_DuplicateNombre_Returns409()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Dup_{Guid.NewGuid():N}"[..30];
var body = new { nombre = uniqueName, medioId, productTypeId, basePrice = 50m };
using var req1 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.Created, resp1.StatusCode);
using var req2 = BuildRequest(HttpMethod.Post, AdminEndpoint, body, token);
var resp2 = await _client.SendAsync(req2);
Assert.Equal(HttpStatusCode.Conflict, resp2.StatusCode);
}
[Fact]
public async Task Create_MedioNotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = $"Prod_{Guid.NewGuid():N}"[..30],
medioId = 999999,
productTypeId = 1,
basePrice = 50m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── GET /api/v1/products ──────────────────────────────────────────────────
[Fact]
public async Task List_WithAuth_Returns200WithPaginatedResult()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}?page=1&pageSize=10", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.TryGetProperty("items", out _));
Assert.True(json.TryGetProperty("total", out _));
}
// ── GET /api/v1/products/{id} ─────────────────────────────────────────────
[Fact]
public async Task GetById_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/999999999", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
[Fact]
public async Task GetById_ExistingId_Returns200()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"GetById_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 75m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
using var getReq = BuildRequest(HttpMethod.Get, $"{ReadEndpoint}/{productId}", bearerToken: token);
var getResp = await _client.SendAsync(getReq);
Assert.Equal(HttpStatusCode.OK, getResp.StatusCode);
var getJson = await getResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(productId, getJson.GetProperty("id").GetInt32());
Assert.Equal(uniqueName, getJson.GetProperty("nombre").GetString());
}
// ── DELETE /api/v1/admin/products/{id} ────────────────────────────────────
[Fact]
public async Task Deactivate_ExistingId_Returns204()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Del_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
using var delReq = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/{productId}", bearerToken: token);
var delResp = await _client.SendAsync(delReq);
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
}
[Fact]
public async Task Deactivate_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Delete, $"{AdminEndpoint}/999999999", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── PUT /api/v1/admin/products/{id} ───────────────────────────────────────
[Fact]
public async Task Update_ExistingProduct_Returns200()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
var uniqueName = $"Upd_{Guid.NewGuid():N}"[..28];
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = uniqueName, medioId, productTypeId, basePrice = 50m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var createJson = await createResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = createJson.GetProperty("id").GetInt32();
var newName = $"Upd2_{Guid.NewGuid():N}"[..28];
using var updateReq = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/{productId}", new
{
nombre = newName,
basePrice = 200m
}, token);
var updateResp = await _client.SendAsync(updateReq);
Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode);
var updateJson = await updateResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(newName, updateJson.GetProperty("nombre").GetString());
}
[Fact]
public async Task Update_NotFound_Returns404()
{
var token = await GetAdminTokenAsync();
using var req = BuildRequest(HttpMethod.Put, $"{AdminEndpoint}/999999999", new
{
nombre = "Test", basePrice = 10m
}, token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
}
// ── PRD-002 W1: ExceptionFilter 409 for ProductTypeInactivo ──────────────
[Fact]
public async Task Create_WithInactiveProductType_Returns409()
{
var token = await GetAdminTokenAsync();
var (medioId, productTypeId) = await EnsureMedioAndProductTypeAsync(token);
// Deactivate the ProductType first
using var deactivatePtReq = BuildRequest(HttpMethod.Delete, $"/api/v1/admin/product-types/{productTypeId}", bearerToken: token);
var deactivatePtResp = await _client.SendAsync(deactivatePtReq);
Assert.Equal(HttpStatusCode.NoContent, deactivatePtResp.StatusCode);
// Now attempt to create a product with the inactive ProductType
using var createReq = BuildRequest(HttpMethod.Post, AdminEndpoint, new
{
nombre = $"Prod_{Guid.NewGuid():N}"[..28],
medioId,
productTypeId,
basePrice = 50m
}, token);
var createResp = await _client.SendAsync(createReq);
Assert.Equal(HttpStatusCode.Conflict, createResp.StatusCode);
var body = await createResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("product_type_inactivo", body.GetProperty("error").GetString());
}
}

View File

@@ -0,0 +1,218 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
/// <summary>
/// PRD-002 — Domain entity tests for Product.
/// Validates factory method, mutation methods, and validation rules.
/// </summary>
public class ProductTests
{
private static readonly FakeTimeProvider _time =
new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
private static Product ValidProduct(int? rubroId = null, int? priceDurationDays = null)
=> Product.ForCreation(
nombre: "Clasificado Estándar",
medioId: 1,
productTypeId: 2,
rubroId: rubroId,
basePrice: 100.50m,
priceDurationDays: priceDurationDays,
timeProvider: _time);
// ── R1-S1: ForCreation happy path ─────────────────────────────────────────
[Fact]
public void ForCreation_ValidData_ReturnsEntityWithDefaults()
{
var p = ValidProduct();
p.IsActive.Should().BeTrue();
p.Id.Should().Be(0);
p.FechaCreacion.Should().Be(_time.GetUtcNow().UtcDateTime);
p.FechaModificacion.Should().BeNull();
p.Nombre.Should().Be("Clasificado Estándar");
p.MedioId.Should().Be(1);
p.ProductTypeId.Should().Be(2);
p.RubroId.Should().BeNull();
p.BasePrice.Should().Be(100.50m);
p.PriceDurationDays.Should().BeNull();
}
// ── R1-S2: Nombre vacío ────────────────────────────────────────────────────
[Fact]
public void ForCreation_EmptyNombre_ThrowsArgumentException()
{
var act = () => Product.ForCreation("", 1, 2, null, 10m, null, _time);
act.Should().Throw<ArgumentException>()
.WithMessage("*Nombre*");
}
// ── R1-S3: Nombre solo espacios ────────────────────────────────────────────
[Fact]
public void ForCreation_WhitespaceNombre_ThrowsArgumentException()
{
var act = () => Product.ForCreation(" ", 1, 2, null, 10m, null, _time);
act.Should().Throw<ArgumentException>();
}
// ── R1-S4: BasePrice negativo ──────────────────────────────────────────────
[Fact]
public void ForCreation_NegativeBasePrice_ThrowsArgumentException()
{
var act = () => Product.ForCreation("Test", 1, 2, null, -1m, null, _time);
act.Should().Throw<ArgumentException>()
.WithMessage("*basePrice*");
}
// ── R1-S5: BasePrice = 0 es válido ─────────────────────────────────────────
[Fact]
public void ForCreation_ZeroBasePrice_DoesNotThrow()
{
var act = () => Product.ForCreation("Test", 1, 2, null, 0m, null, _time);
act.Should().NotThrow();
}
// ── R1-S6: PriceDurationDays = 0 ──────────────────────────────────────────
[Fact]
public void ForCreation_ZeroPriceDurationDays_ThrowsArgumentException()
{
var act = () => Product.ForCreation("Test", 1, 2, null, 10m, 0, _time);
act.Should().Throw<ArgumentException>()
.WithMessage("*priceDurationDays*");
}
// ── R1-S7: PriceDurationDays negativo ─────────────────────────────────────
[Fact]
public void ForCreation_NegativePriceDurationDays_ThrowsArgumentException()
{
var act = () => Product.ForCreation("Test", 1, 2, null, 10m, -5, _time);
act.Should().Throw<ArgumentException>();
}
// ── R1-S8: PriceDurationDays válido ───────────────────────────────────────
[Fact]
public void ForCreation_ValidPriceDurationDays_SetsValue()
{
var p = Product.ForCreation("Test", 1, 2, null, 10m, 30, _time);
p.PriceDurationDays.Should().Be(30);
}
// ── R1-S9: WithDeactivated ─────────────────────────────────────────────────
[Fact]
public void WithDeactivated_ActiveProduct_ReturnsInactiveWithModDate()
{
var product = ValidProduct();
var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
var deactivated = product.WithDeactivated(tp2);
deactivated.IsActive.Should().BeFalse();
deactivated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
// Immutability: original unchanged
product.IsActive.Should().BeTrue();
}
// ── R1-S10: WithDeactivated idempotente ───────────────────────────────────
[Fact]
public void WithDeactivated_AlreadyInactive_ReturnsInactiveNoProblem()
{
var product = ValidProduct();
var deactivated = product.WithDeactivated(_time);
var act = () => deactivated.WithDeactivated(_time);
act.Should().NotThrow();
act().IsActive.Should().BeFalse();
}
// ── R1-S11: WithUpdated ───────────────────────────────────────────────────
[Fact]
public void WithUpdated_ValidFields_ReturnsNewInstanceWithUpdatedValues()
{
var product = ValidProduct();
var tp2 = new FakeTimeProvider(new DateTimeOffset(2026, 4, 20, 12, 0, 0, TimeSpan.Zero));
var updated = product.WithUpdated("Nuevo Nombre", rubroId: null, basePrice: 200m, priceDurationDays: 15, tp2);
updated.Nombre.Should().Be("Nuevo Nombre");
updated.BasePrice.Should().Be(200m);
updated.PriceDurationDays.Should().Be(15);
updated.FechaModificacion.Should().Be(tp2.GetUtcNow().UtcDateTime);
// Immutability: original unchanged
product.Nombre.Should().Be("Clasificado Estándar");
product.BasePrice.Should().Be(100.50m);
}
// ── Immutability: With* return new instances ──────────────────────────────
[Fact]
public void WithRenamed_ReturnsNewInstance()
{
var p = ValidProduct();
var renamed = p.WithRenamed("Nuevo", _time);
renamed.Should().NotBeSameAs(p);
renamed.Nombre.Should().Be("Nuevo");
}
[Fact]
public void WithUpdatedPrice_ReturnsNewInstance()
{
var p = ValidProduct();
var updated = p.WithUpdatedPrice(999m, null, _time);
updated.Should().NotBeSameAs(p);
updated.BasePrice.Should().Be(999m);
}
[Fact]
public void WithUpdatedCategory_ReturnsNewInstance()
{
var p = ValidProduct();
var updated = p.WithUpdatedCategory(5, _time);
updated.Should().NotBeSameAs(p);
updated.RubroId.Should().Be(5);
}
// ── MedioId and ProductTypeId are immutable ───────────────────────────────
[Fact]
public void Product_HasNoMethodToChangeMedioId()
{
var type = typeof(Product);
var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
methods.Should().NotContain(m => m.Name.Contains("Medio") && m.Name.StartsWith("With"));
}
[Fact]
public void Product_HasNoMethodToChangeProductTypeId()
{
var type = typeof(Product);
var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
methods.Should().NotContain(m => m.Name.Contains("ProductType") && m.Name.StartsWith("With"));
}
}

View File

@@ -82,8 +82,9 @@ public class PermisoRepositoryTests : IAsyncLifetime
// + V013 (ADM-008) adds 'administracion:puntos_de_venta:gestionar'
// + V014 (ADM-009) adds 'administracion:fiscal:gestionar'
// + V016 (CAT-001) adds 'catalogo:rubros:gestionar'
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar' = 26 total
Assert.Equal(26, list.Count);
// + V017 (PRD-001) adds 'catalogo:tipos:gestionar'
// + V018 (PRD-002) adds 'catalogo:productos:gestionar' = 27 total
Assert.Equal(27, list.Count);
}
[Fact]

View File

@@ -173,17 +173,18 @@ public class RolPermisoRepositoryTests : IAsyncLifetime
// ── GetByRolCodigoAsync ──────────────────────────────────────────────────
[Fact]
public async Task GetByRolCodigoAsync_Admin_Returns26Permisos()
public async Task GetByRolCodigoAsync_Admin_Returns27Permisos()
{
// 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'
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar' = 26 total
// + 1 from V017 (PRD-001): 'catalogo:tipos:gestionar'
// + 1 from V018 (PRD-002): 'catalogo:productos:gestionar' = 27 total
var permisos = await _repository.GetByRolCodigoAsync("admin");
Assert.Equal(26, permisos.Count);
Assert.Equal(27, permisos.Count);
}
[Fact]

View File

@@ -1,26 +0,0 @@
using FluentAssertions;
using SIGCM2.Application.Products;
namespace SIGCM2.Application.Tests.ProductTypes;
public class NullProductQueryRepositoryTests
{
private readonly NullProductQueryRepository _sut = new();
[Fact]
public async Task ExistsActiveByProductTypeAsync_AlwaysReturnsFalse()
{
var result = await _sut.ExistsActiveByProductTypeAsync(productTypeId: 1);
result.Should().BeFalse();
}
[Fact]
public async Task ExistsActiveByProductTypeAsync_WithCancellationToken_DoesNotThrow()
{
using var cts = new CancellationTokenSource();
var act = async () => await _sut.ExistsActiveByProductTypeAsync(productTypeId: 999, ct: cts.Token);
await act.Should().NotThrowAsync();
}
}

View File

@@ -0,0 +1,246 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Products.Create;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Create;
/// <summary>
/// PRD-002 — CreateProductCommandHandler tests.
/// Covers: happy path, flags coherence, duplicate nombre, inactive Medio/ProductType/Rubro,
/// audit, immutability, rollback.
/// </summary>
public class CreateProductCommandHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
private readonly IMedioRepository _medioRepo = Substitute.For<IMedioRepository>();
private readonly IRubroRepository _rubroRepo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
private readonly CreateProductCommandHandler _handler;
private static readonly ProductType _activePtNoFlags = new(
id: 2, nombre: "Clasificado",
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static readonly ProductType _activePtRequiresCategory = new(
id: 3, nombre: "Con Rubro",
hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static readonly ProductType _activePtHasDuration = new(
id: 4, nombre: "Con Duración",
hasDuration: true, requiresText: false, requiresCategory: false, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static readonly ProductType _inactivePt = new(
id: 5, nombre: "Inactivo",
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: false,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static Medio ActiveMedio(int id = 1) => new(
id: id, codigo: "ELD", nombre: "El Día",
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
activo: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static Medio InactiveMedio(int id = 1) => new(
id: id, codigo: "ELD", nombre: "El Día",
tipo: TipoMedio.Diario, plataformaEmpresaId: null,
activo: false,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static Rubro ActiveRubro(int id = 10) => new(
id: id, parentId: null, nombre: "Clasificados", orden: 1,
activo: true, tarifarioBaseId: null,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static Rubro InactiveRubro(int id = 10) => new(
id: id, parentId: null, nombre: "Clasificados", orden: 1,
activo: false, tarifarioBaseId: null,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
public CreateProductCommandHandlerTests()
{
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(1));
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns(_activePtNoFlags);
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(1);
_handler = new CreateProductCommandHandler(_repo, _ptRepo, _medioRepo, _rubroRepo, _audit, _time);
}
private static CreateProductCommand ValidCmd(int productTypeId = 2) => new(
Nombre: "Clasificado Estándar",
MedioId: 1,
ProductTypeId: productTypeId,
RubroId: null,
BasePrice: 100.50m,
PriceDurationDays: null);
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ValidCommand_InsertsAndReturnsDto()
{
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(42);
var result = await _handler.Handle(ValidCmd());
result.Id.Should().Be(42);
result.Nombre.Should().Be("Clasificado Estándar");
result.IsActive.Should().BeTrue();
}
[Fact]
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoCreated()
{
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>()).Returns(7);
await _handler.Handle(ValidCmd());
await _audit.Received(1).LogAsync(
action: "producto.created",
targetType: "Product",
targetId: "7",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsesTimeProvider_NotDateTimeNow()
{
var expectedDate = _time.GetUtcNow().UtcDateTime;
await _handler.Handle(ValidCmd());
await _repo.Received(1).AddAsync(
Arg.Is<Product>(p => p.FechaCreacion == expectedDate),
Arg.Any<CancellationToken>());
}
// ── Medio not found / inactive ────────────────────────────────────────────
[Fact]
public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException()
{
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns((Medio?)null);
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<MedioNotFoundException>();
}
[Fact]
public async Task Handle_MedioInactivo_ThrowsMedioInactivoException()
{
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveMedio(1));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<MedioInactivoException>();
}
// ── ProductType not found / inactive ──────────────────────────────────────
[Fact]
public async Task Handle_ProductTypeNotFound_ThrowsProductTypeNotFoundException()
{
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns((ProductType?)null);
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductTypeNotFoundException>();
}
[Fact]
public async Task Handle_ProductTypeInactivo_ThrowsProductTypeInactivoException()
{
_ptRepo.GetByIdAsync(5, Arg.Any<CancellationToken>()).Returns(_inactivePt);
_medioRepo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveMedio(1));
var act = async () => await _handler.Handle(ValidCmd(productTypeId: 5));
await act.Should().ThrowAsync<ProductTypeInactivoException>();
}
// ── Flags coherence ───────────────────────────────────────────────────────
[Fact]
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
{
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
var cmd = ValidCmd(productTypeId: 3) with { RubroId = null };
var act = async () => await _handler.Handle(cmd);
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
}
[Fact]
public async Task Handle_HasDurationTrue_PriceDurationDaysNull_ThrowsFlagsException()
{
_ptRepo.GetByIdAsync(4, Arg.Any<CancellationToken>()).Returns(_activePtHasDuration);
var cmd = ValidCmd(productTypeId: 4) with { PriceDurationDays = null };
var act = async () => await _handler.Handle(cmd);
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
}
// ── Rubro validation ──────────────────────────────────────────────────────
[Fact]
public async Task Handle_RubroProvided_RubroInactivo_ThrowsRubroInactivoException()
{
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
_rubroRepo.GetByIdAsync(10, Arg.Any<CancellationToken>()).Returns(InactiveRubro(10));
var cmd = ValidCmd(productTypeId: 3) with { RubroId = 10 };
var act = async () => await _handler.Handle(cmd);
await act.Should().ThrowAsync<RubroInactivoException>();
}
// ── Duplicate nombre ──────────────────────────────────────────────────────
[Fact]
public async Task Handle_NombreDuplicadoEnMedioTipo_ThrowsProductNombreDuplicadoException()
{
_repo.ExistsByNombreAsync("Clasificado Estándar", 1, 2, null, Arg.Any<CancellationToken>()).Returns(true);
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductNombreDuplicadoEnMedioTipoException>();
}
// ── Rollback ──────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_AuditNotCalled_TransactionRollback()
{
_repo.AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,133 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Deactivate;
/// <summary>
/// PRD-002 — DeactivateProductCommandHandler tests.
/// </summary>
public class DeactivateProductCommandHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 14, 0, 0, TimeSpan.Zero));
private readonly DeactivateProductCommandHandler _handler;
public DeactivateProductCommandHandlerTests()
{
_handler = new DeactivateProductCommandHandler(_repo, _audit, _time);
}
private static Product ActiveProduct(int id = 1) => new(
id: id, nombre: "Clasificado Estándar",
medioId: 1, productTypeId: 2, rubroId: null,
basePrice: 100.50m, priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
private static Product InactiveProduct(int id = 1) => new(
id: id, nombre: "Clasificado Estándar",
medioId: 1, productTypeId: 2, rubroId: null,
basePrice: 100.50m, priceDurationDays: null,
isActive: false,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
// ── Not found ────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsProductNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
var act = async () => await _handler.Handle(new DeactivateProductCommand(99));
await act.Should().ThrowAsync<ProductNotFoundException>()
.Where(e => e.ProductId == 99);
}
// ── Already inactive (idempotent) ─────────────────────────────────────────
[Fact]
public async Task Handle_AlreadyInactive_ReturnsDto_NoAudit_NoRepoUpdate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(InactiveProduct());
var result = await _handler.Handle(new DeactivateProductCommand(1));
result.Id.Should().Be(1);
result.IsActive.Should().BeFalse();
await _repo.DidNotReceive().UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ActiveProduct_DeactivatesAndAudits()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
await _handler.Handle(new DeactivateProductCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Product>(p => !p.IsActive),
Arg.Any<CancellationToken>());
await _audit.Received(1).LogAsync(
action: "producto.deactivated",
targetType: "Product",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_UsesTimeProviderInDeactivate()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
var expectedDate = _time.GetUtcNow().UtcDateTime;
await _handler.Handle(new DeactivateProductCommand(1));
await _repo.Received(1).UpdateAsync(
Arg.Is<Product>(p => p.FechaModificacion == expectedDate),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ReturnsDtoWithIsActiveFalse()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
var result = await _handler.Handle(new DeactivateProductCommand(1));
result.IsActive.Should().BeFalse();
}
// ── Rollback ──────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_AuditNotCalled()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(ActiveProduct());
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var act = async () => await _handler.Handle(new DeactivateProductCommand(1));
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Exceptions;
/// <summary>
/// PRD-002 — Tests for new Product domain exceptions.
/// Verifies message content and property values.
/// </summary>
public class ProductExceptionsTests
{
[Fact]
public void ProductNotFoundException_ContainsId_InMessage()
{
var ex = new ProductNotFoundException(5);
ex.Message.Should().Contain("5");
ex.ProductId.Should().Be(5);
}
[Fact]
public void ProductNombreDuplicadoEnMedioTipoException_ContainsDetails()
{
var ex = new ProductNombreDuplicadoEnMedioTipoException(2, 3, "Clasificado");
ex.Message.Should().Contain("Clasificado");
ex.Message.Should().Contain("2");
ex.Message.Should().Contain("3");
ex.MedioId.Should().Be(2);
ex.ProductTypeId.Should().Be(3);
ex.Nombre.Should().Be("Clasificado");
}
[Fact]
public void ProductTipoFlagsIncoherentesException_FieldAndMessage()
{
var ex = new ProductTipoFlagsIncoherentesException("requiere RubroId", "rubroId");
ex.Field.Should().Be("rubroId");
ex.Message.Should().Contain("requiere RubroId");
}
[Fact]
public void ProductTypeInactivoException_ContainsProductTypeId()
{
var ex = new ProductTypeInactivoException(7);
ex.Message.Should().Contain("7");
ex.ProductTypeId.Should().Be(7);
}
[Fact]
public void RubroInactivoException_ContainsRubroId()
{
var ex = new RubroInactivoException(12);
ex.Message.Should().Contain("12");
ex.RubroId.Should().Be(12);
}
}

View File

@@ -0,0 +1,60 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.GetById;
/// <summary>
/// PRD-002 — GetProductByIdQueryHandler tests.
/// </summary>
public class GetProductByIdQueryHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly GetProductByIdQueryHandler _handler;
public GetProductByIdQueryHandlerTests()
{
_handler = new GetProductByIdQueryHandler(_repo);
}
private static Product AProduct(int id = 1) => new(
id: id,
nombre: "Clasificado Estándar",
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 100.50m,
priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
[Fact]
public async Task Handle_ExistingId_ReturnsMappedDto()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
var result = await _handler.Handle(new GetProductByIdQuery(1));
result.Id.Should().Be(1);
result.Nombre.Should().Be("Clasificado Estándar");
result.MedioId.Should().Be(1);
result.ProductTypeId.Should().Be(2);
result.BasePrice.Should().Be(100.50m);
result.IsActive.Should().BeTrue();
}
[Fact]
public async Task Handle_NotFound_ThrowsProductNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
var act = async () => await _handler.Handle(new GetProductByIdQuery(99));
await act.Should().ThrowAsync<ProductNotFoundException>()
.Where(e => e.ProductId == 99);
}
}

View File

@@ -0,0 +1,88 @@
using FluentAssertions;
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.List;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Products.List;
/// <summary>
/// PRD-002 — ListProductsQueryHandler tests.
/// </summary>
public class ListProductsQueryHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly ListProductsQueryHandler _handler;
public ListProductsQueryHandlerTests()
{
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Product>([], 1, 20, 0));
_handler = new ListProductsQueryHandler(_repo);
}
private static Product AProduct(int id = 1) => new(
id: id,
nombre: "Clasificado",
medioId: 1,
productTypeId: 2,
rubroId: null,
basePrice: 50m,
priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
[Fact]
public async Task Handle_EmptyPage_ReturnsPaged_WithZeroTotal()
{
var result = await _handler.Handle(new ListProductsQuery());
result.Total.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task Handle_WithItems_ReturnsMappedListItemDtos()
{
_repo.GetPagedAsync(Arg.Any<ProductsQuery>(), Arg.Any<CancellationToken>())
.Returns(new PagedResult<Product>([AProduct(1), AProduct(2)], 1, 20, 2));
var result = await _handler.Handle(new ListProductsQuery());
result.Items.Should().HaveCount(2);
result.Items[0].Id.Should().Be(1);
result.Items[1].Id.Should().Be(2);
}
[Fact]
public async Task Handle_PageNormalization_ClampsPageSizeTo100()
{
await _handler.Handle(new ListProductsQuery(Page: 1, PageSize: 200));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PageBelowOne_NormalizesToOne()
{
await _handler.Handle(new ListProductsQuery(Page: -1));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.Page == 1),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PassesFiltersToRepo()
{
await _handler.Handle(new ListProductsQuery(MedioId: 5, ProductTypeId: 3, RubroId: 7));
await _repo.Received(1).GetPagedAsync(
Arg.Is<ProductsQuery>(q => q.MedioId == 5 && q.ProductTypeId == 3 && q.RubroId == 7),
Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,120 @@
using Dapper;
using FluentAssertions;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Products.Repository;
/// <summary>
/// PRD-002 — Integration tests for ProductQueryRepository against SIGCM2_Test_App.
/// These tests verify the real Dapper implementation replaces NullProductQueryRepository.
/// </summary>
[Collection("Database")]
public class ProductQueryRepositoryTests : IAsyncLifetime
{
private readonly SqlTestFixture _db;
private ProductQueryRepository _repository = null!;
public ProductQueryRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
await _db.ResetAndSeedAsync();
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new ProductQueryRepository(factory);
}
public Task DisposeAsync() => Task.CompletedTask;
// ── ExistsActiveByProductTypeAsync ───────────────────────────────────────
[Fact]
public async Task ExistsActiveByProductTypeAsync_NoProducts_ReturnsFalse()
{
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId: 999);
result.Should().BeFalse();
}
[Fact]
public async Task ExistsActiveByProductTypeAsync_WithActiveProduct_ReturnsTrue()
{
// Arrange: insert a ProductType and an active Product referencing it
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertActiveProductAsync(medioId, productTypeId);
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
result.Should().BeTrue();
}
[Fact]
public async Task ExistsActiveByProductTypeAsync_WithOnlyInactiveProduct_ReturnsFalse()
{
// Arrange: insert an inactive product
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertInactiveProductAsync(medioId, productTypeId);
var result = await _repository.ExistsActiveByProductTypeAsync(productTypeId);
result.Should().BeFalse();
}
[Fact]
public async Task ExistsActiveByProductTypeAsync_DifferentProductType_ReturnsFalse()
{
// Arrange: insert active product for productTypeId=A, query for productTypeId=B
var (medioId, productTypeId) = await InsertMedioAndProductTypeAsync();
await InsertActiveProductAsync(medioId, productTypeId);
var otherProductTypeId = productTypeId + 100;
var result = await _repository.ExistsActiveByProductTypeAsync(otherProductTypeId);
result.Should().BeFalse();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<(int MedioId, int ProductTypeId)> InsertMedioAndProductTypeAsync()
{
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var medioId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
OUTPUT INSERTED.Id
VALUES ('TM', 'Test Medio', 1, 1)
""");
var productTypeId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
OUTPUT INSERTED.Id
VALUES ('Test Type', 0, 0, 0, 0, 0)
""");
return (medioId, productTypeId);
}
private async Task InsertActiveProductAsync(int medioId, int productTypeId)
{
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
await conn.ExecuteAsync("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion)
VALUES ('Producto Activo', @MedioId, @ProductTypeId, 100, 1, SYSUTCDATETIME())
""", new { MedioId = medioId, ProductTypeId = productTypeId });
}
private async Task InsertInactiveProductAsync(int medioId, int productTypeId)
{
await using var conn = new Microsoft.Data.SqlClient.SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
await conn.ExecuteAsync("""
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, BasePrice, IsActive, FechaCreacion)
VALUES ('Producto Inactivo', @MedioId, @ProductTypeId, 100, 0, SYSUTCDATETIME())
""", new { MedioId = medioId, ProductTypeId = productTypeId });
}
}

View File

@@ -0,0 +1,205 @@
using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
namespace SIGCM2.Application.Tests.Products.Repository;
/// <summary>
/// PRD-002 — Integration tests for ProductRepository against SIGCM2_Test_App.
/// Uses shared SqlTestFixture via [Collection("Database")] — fixture manages Respawn + seeds.
/// Verifies full CRUD, paged listing, UQ constraint, and temporal history.
/// </summary>
[Collection("Database")]
public class ProductRepositoryTests : IAsyncLifetime
{
private readonly SqlTestFixture _db;
private ProductRepository _repository = null!;
private int _defaultMedioId;
private int _defaultProductTypeId;
public ProductRepositoryTests(SqlTestFixture db)
{
_db = db;
}
public async Task InitializeAsync()
{
await _db.ResetAndSeedAsync();
var factory = new SqlConnectionFactory(TestConnectionStrings.AppTestDb);
_repository = new ProductRepository(factory);
// Insert Medio and ProductType for use across all tests
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
_defaultMedioId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
OUTPUT INSERTED.Id
VALUES ('PR', 'Prueba Medio', 1, 1)
""");
_defaultProductTypeId = await conn.ExecuteScalarAsync<int>("""
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
OUTPUT INSERTED.Id
VALUES ('Tipo Prueba', 0, 0, 0, 0, 0)
""");
}
public Task DisposeAsync() => Task.CompletedTask;
private Product AProduct(string nombre = "Clasificado Test") =>
Product.ForCreation(
nombre: nombre,
medioId: _defaultMedioId,
productTypeId: _defaultProductTypeId,
rubroId: null,
basePrice: 100.50m,
priceDurationDays: null,
timeProvider: TimeProvider.System);
// ── AddAsync + GetByIdAsync roundtrip ─────────────────────────────────────
[Fact]
public async Task AddAsync_AndGetById_ReturnsAllFields()
{
var product = AProduct("Mi Producto");
var id = await _repository.AddAsync(product);
var result = await _repository.GetByIdAsync(id);
result.Should().NotBeNull();
result!.Id.Should().Be(id);
result.Nombre.Should().Be("Mi Producto");
result.MedioId.Should().Be(_defaultMedioId);
result.ProductTypeId.Should().Be(_defaultProductTypeId);
result.RubroId.Should().BeNull();
result.BasePrice.Should().Be(100.50m);
result.PriceDurationDays.Should().BeNull();
result.IsActive.Should().BeTrue();
result.FechaCreacion.Should().BeAfter(DateTime.MinValue);
result.FechaModificacion.Should().BeNull();
}
// ── GetByIdAsync null for unknown ─────────────────────────────────────────
[Fact]
public async Task GetByIdAsync_UnknownId_ReturnsNull()
{
var result = await _repository.GetByIdAsync(999999);
result.Should().BeNull();
}
// ── UpdateAsync ────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateAsync_ChangesNombreAndBasePrice()
{
var id = await _repository.AddAsync(AProduct("Original"));
var product = await _repository.GetByIdAsync(id);
var updated = product!.WithUpdated("Renombrado", null, 200m, null, TimeProvider.System);
await _repository.UpdateAsync(updated);
var result = await _repository.GetByIdAsync(id);
result!.Nombre.Should().Be("Renombrado");
result.BasePrice.Should().Be(200m);
result.FechaModificacion.Should().NotBeNull();
}
// ── WithDeactivated creates history row ────────────────────────────────────
[Fact]
public async Task UpdateAsync_Deactivate_CreatesHistoryRow()
{
var id = await _repository.AddAsync(AProduct("Para Desactivar"));
var product = await _repository.GetByIdAsync(id);
var deactivated = product!.WithDeactivated(TimeProvider.System);
await _repository.UpdateAsync(deactivated);
// Verify temporal history: ProductType_History should have at least 1 row
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
await conn.OpenAsync();
var historyCount = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM dbo.Product_History WHERE Id = @Id", new { Id = id });
historyCount.Should().BeGreaterThanOrEqualTo(1);
}
// ── GetPagedAsync ─────────────────────────────────────────────────────────
[Fact]
public async Task GetPagedAsync_DefaultQuery_ReturnsActiveProducts()
{
await _repository.AddAsync(AProduct("Producto A"));
await _repository.AddAsync(AProduct("Producto B"));
var result = await _repository.GetPagedAsync(new ProductsQuery(Page: 1, PageSize: 20, Activo: true));
result.Items.Should().HaveCountGreaterThanOrEqualTo(2);
result.Items.Should().AllSatisfy(p => p.IsActive.Should().BeTrue());
}
[Fact]
public async Task GetPagedAsync_FilterByMedioId_ReturnsOnlyMatching()
{
await _repository.AddAsync(AProduct("Producto Filtrado"));
var result = await _repository.GetPagedAsync(
new ProductsQuery(Page: 1, PageSize: 20, Activo: null, MedioId: _defaultMedioId));
result.Items.Should().AllSatisfy(p => p.MedioId.Should().Be(_defaultMedioId));
}
// ── ExistsByNombreAsync ────────────────────────────────────────────────────
[Fact]
public async Task ExistsByNombreAsync_ExistingActiveProduct_ReturnsTrue()
{
var nombre = "Nombre Unico Test";
await _repository.AddAsync(AProduct(nombre));
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId);
result.Should().BeTrue();
}
[Fact]
public async Task ExistsByNombreAsync_ExcludeSelf_ReturnsFalse()
{
var nombre = "Nombre Self Excluido";
var id = await _repository.AddAsync(AProduct(nombre));
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId, excludeId: id);
result.Should().BeFalse();
}
[Fact]
public async Task ExistsByNombreAsync_NonExisting_ReturnsFalse()
{
var result = await _repository.ExistsByNombreAsync("Nombre Que No Existe XYZ", _defaultMedioId, _defaultProductTypeId);
result.Should().BeFalse();
}
// ── UQ index: deactivated allows reuse of name ────────────────────────────
[Fact]
public async Task ExistsByNombreAsync_DeactivatedProduct_ReturnsFalse_AllowsReuse()
{
var nombre = "Nombre Reutilizable";
var id = await _repository.AddAsync(AProduct(nombre));
var product = await _repository.GetByIdAsync(id);
await _repository.UpdateAsync(product!.WithDeactivated(TimeProvider.System));
// After deactivation, name should be available again
var result = await _repository.ExistsByNombreAsync(nombre, _defaultMedioId, _defaultProductTypeId);
result.Should().BeFalse();
}
}

View File

@@ -0,0 +1,149 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Products.Update;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.Products.Update;
/// <summary>
/// PRD-002 — UpdateProductCommandHandler tests.
/// </summary>
public class UpdateProductCommandHandlerTests
{
private readonly IProductRepository _repo = Substitute.For<IProductRepository>();
private readonly IProductTypeRepository _ptRepo = Substitute.For<IProductTypeRepository>();
private readonly IRubroRepository _rubroRepo = Substitute.For<IRubroRepository>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly FakeTimeProvider _time = new(new DateTimeOffset(2026, 4, 19, 10, 0, 0, TimeSpan.Zero));
private readonly UpdateProductCommandHandler _handler;
private static readonly ProductType _activePtNoFlags = new(
id: 2, nombre: "Clasificado",
hasDuration: false, requiresText: false, requiresCategory: false, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static readonly ProductType _activePtRequiresCategory = new(
id: 3, nombre: "Con Rubro",
hasDuration: false, requiresText: false, requiresCategory: true, isBundle: false,
allowImages: false, maxImages: null, maxImageSizeMB: null, maxImageWidth: null, maxImageHeight: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), fechaModificacion: null);
private static Product AProduct(int id = 1, int productTypeId = 2) => new(
id: id, nombre: "Clasificado Estándar",
medioId: 1, productTypeId: productTypeId, rubroId: null,
basePrice: 100.50m, priceDurationDays: null,
isActive: true,
fechaCreacion: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
fechaModificacion: null);
public UpdateProductCommandHandlerTests()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1));
_ptRepo.GetByIdAsync(2, Arg.Any<CancellationToken>()).Returns(_activePtNoFlags);
_repo.ExistsByNombreAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>()).Returns(false);
_handler = new UpdateProductCommandHandler(_repo, _ptRepo, _rubroRepo, _audit, _time);
}
private static UpdateProductCommand ValidCmd() => new(
Id: 1,
Nombre: "Nuevo Nombre",
RubroId: null,
BasePrice: 200m,
PriceDurationDays: null);
// ── Happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_ValidCommand_UpdatesAndReturnsDto()
{
var result = await _handler.Handle(ValidCmd());
result.Id.Should().Be(1);
result.Nombre.Should().Be("Nuevo Nombre");
result.BasePrice.Should().Be(200m);
}
[Fact]
public async Task Handle_ValidCommand_CallsUpdateAsync()
{
await _handler.Handle(ValidCmd());
await _repo.Received(1).UpdateAsync(
Arg.Is<Product>(p => p.Nombre == "Nuevo Nombre"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_ValidCommand_LogsAuditEvent_ProductoUpdated()
{
await _handler.Handle(ValidCmd());
await _audit.Received(1).LogAsync(
action: "producto.updated",
targetType: "Product",
targetId: "1",
metadata: Arg.Any<object?>(),
ct: Arg.Any<CancellationToken>());
}
// ── Not found ────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_NotFound_ThrowsProductNotFoundException()
{
_repo.GetByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Product?)null);
var act = async () => await _handler.Handle(ValidCmd() with { Id = 99 });
await act.Should().ThrowAsync<ProductNotFoundException>();
}
// ── Flags coherence ───────────────────────────────────────────────────────
[Fact]
public async Task Handle_RequiresCategoryTrue_RubroIdNull_ThrowsFlagsException()
{
_repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(AProduct(1, productTypeId: 3));
_ptRepo.GetByIdAsync(3, Arg.Any<CancellationToken>()).Returns(_activePtRequiresCategory);
var act = async () => await _handler.Handle(ValidCmd() with { RubroId = null });
await act.Should().ThrowAsync<ProductTipoFlagsIncoherentesException>();
}
// ── Duplicate nombre ──────────────────────────────────────────────────────
[Fact]
public async Task Handle_NombreDuplicado_ThrowsProductNombreDuplicadoException()
{
_repo.ExistsByNombreAsync("Nuevo Nombre", 1, 2, 1, Arg.Any<CancellationToken>()).Returns(true);
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<ProductNombreDuplicadoEnMedioTipoException>();
}
// ── Rollback ──────────────────────────────────────────────────────────────
[Fact]
public async Task Handle_RepoThrows_AuditNotCalled()
{
_repo.UpdateAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var act = async () => await _handler.Handle(ValidCmd());
await act.Should().ThrowAsync<InvalidOperationException>();
await _audit.DidNotReceive().LogAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<object?>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,132 @@
using FluentValidation.TestHelper;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Update;
namespace SIGCM2.Application.Tests.Products.Validators;
/// <summary>
/// PRD-002 — Validator tests for CreateProductCommand and UpdateProductCommand.
/// </summary>
public class ProductValidatorsTests
{
private readonly CreateProductCommandValidator _createValidator = new();
private readonly UpdateProductCommandValidator _updateValidator = new();
// ── Create: Nombre ────────────────────────────────────────────────────────
[Fact]
public void Create_NombreEmpty_FailsValidation()
{
var cmd = ValidCreate() with { Nombre = "" };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
}
[Fact]
public void Create_NombreWhitespace_FailsValidation()
{
var cmd = ValidCreate() with { Nombre = " " };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
}
[Fact]
public void Create_NombreOver300Chars_FailsValidation()
{
var cmd = ValidCreate() with { Nombre = new string('X', 301) };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Nombre);
}
// ── Create: MedioId / ProductTypeId ───────────────────────────────────────
[Fact]
public void Create_MedioIdZero_FailsValidation()
{
var cmd = ValidCreate() with { MedioId = 0 };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.MedioId);
}
[Fact]
public void Create_ProductTypeIdZero_FailsValidation()
{
var cmd = ValidCreate() with { ProductTypeId = 0 };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ProductTypeId);
}
// ── Create: BasePrice ─────────────────────────────────────────────────────
[Fact]
public void Create_NegativeBasePrice_FailsValidation()
{
var cmd = ValidCreate() with { BasePrice = -0.01m };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.BasePrice);
}
[Fact]
public void Create_ZeroBasePrice_Passes()
{
var cmd = ValidCreate() with { BasePrice = 0m };
_createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.BasePrice);
}
// ── Create: PriceDurationDays ──────────────────────────────────────────────
[Fact]
public void Create_PriceDurationDaysZero_FailsValidation()
{
var cmd = ValidCreate() with { PriceDurationDays = 0 };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
}
[Fact]
public void Create_PriceDurationDaysNegative_FailsValidation()
{
var cmd = ValidCreate() with { PriceDurationDays = -1 };
_createValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PriceDurationDays);
}
[Fact]
public void Create_PriceDurationDaysNull_Passes()
{
var cmd = ValidCreate() with { PriceDurationDays = null };
_createValidator.TestValidate(cmd).ShouldNotHaveValidationErrorFor(x => x.PriceDurationDays);
}
// ── Create: valid ─────────────────────────────────────────────────────────
[Fact]
public void Create_ValidCommand_Passes()
{
_createValidator.TestValidate(ValidCreate()).ShouldNotHaveAnyValidationErrors();
}
// ── Update: Id must be > 0 ────────────────────────────────────────────────
[Fact]
public void Update_IdZero_FailsValidation()
{
var cmd = ValidUpdate() with { Id = 0 };
_updateValidator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Id);
}
[Fact]
public void Update_ValidCommand_Passes()
{
_updateValidator.TestValidate(ValidUpdate()).ShouldNotHaveAnyValidationErrors();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static CreateProductCommand ValidCreate() => new(
Nombre: "Clasificado Estándar",
MedioId: 1,
ProductTypeId: 2,
RubroId: null,
BasePrice: 100.50m,
PriceDurationDays: null);
private static UpdateProductCommand ValidUpdate() => new(
Id: 1,
Nombre: "Clasificado Estándar",
RubroId: null,
BasePrice: 100.50m,
PriceDurationDays: null);
}

View File

@@ -63,6 +63,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V017 (PRD-001): ensure dbo.ProductType + temporal + permiso 'catalogo:tipos:gestionar'.
await EnsureV017SchemaAsync();
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
await EnsureV018SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -91,6 +94,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
new Respawn.Graph.Table("dbo", "Rubro_History"),
// PRD-001 (V017): ProductType es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "ProductType_History"),
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
new Respawn.Graph.Table("dbo", "Product_History"),
]
});
@@ -213,7 +218,11 @@ public sealed class SqlTestFixture : IAsyncLifetime
-- V014 (ADM-009): permiso para tablas fiscales
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion'),
-- V016 (CAT-001): permiso para gestionar árbol de rubros
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo'),
-- V017 (PRD-001): permiso para gestionar tipos de producto
('catalogo:tipos:gestionar', N'Gestionar tipos de producto', N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)', 'catalogo'),
-- V018 (PRD-002): permiso para gestionar productos del catálogo
('catalogo:productos:gestionar', N'Gestionar productos del catálogo', N'Crear, editar y desactivar productos del catálogo comercial', 'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -261,6 +270,10 @@ public sealed class SqlTestFixture : IAsyncLifetime
('admin', 'administracion:fiscal:gestionar'),
-- V016 (CAT-001)
('admin', 'catalogo:rubros:gestionar'),
-- V017 (PRD-001)
('admin', 'catalogo:tipos:gestionar'),
-- V018 (PRD-002)
('admin', 'catalogo:productos:gestionar'),
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
@@ -1011,4 +1024,102 @@ public sealed class SqlTestFixture : IAsyncLifetime
await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createCoveringIndex);
}
/// <summary>
/// PRD-002 (V018): applies dbo.Product schema + temporal + filtered UQ + covering indexes
/// idempotentemente. Mirrors V018__create_product.sql.
/// Permiso 'catalogo:productos:gestionar' y asignación a admin se siembran
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
/// </summary>
public async Task EnsureV018SchemaAsync()
{
const string createProduct = """
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Product (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
MedioId INT NOT NULL,
ProductTypeId INT NOT NULL,
RubroId INT NULL,
BasePrice DECIMAL(18,4) NOT NULL,
PriceDurationDays INT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
);
END
""";
const string addProductPeriod = """
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Product
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
END
""";
const string setProductVersioning = """
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Product_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
END
""";
const string createUqIndex = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
ON dbo.Product (MedioId, ProductTypeId, Nombre)
WHERE IsActive = 1;
END
""";
const string createProductTypeIdx = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_ProductTypeId_IsActive
ON dbo.Product (ProductTypeId, IsActive);
END
""";
const string createMedioIdx = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_MedioId_IsActive
ON dbo.Product (MedioId, IsActive);
END
""";
const string createRubroIdx = """
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_RubroId_IsActive
ON dbo.Product (RubroId, IsActive)
WHERE RubroId IS NOT NULL;
END
""";
await _connection.ExecuteAsync(createProduct);
await _connection.ExecuteAsync(addProductPeriod);
await _connection.ExecuteAsync(setProductVersioning);
await _connection.ExecuteAsync(createUqIndex);
await _connection.ExecuteAsync(createProductTypeIdx);
await _connection.ExecuteAsync(createMedioIdx);
await _connection.ExecuteAsync(createRubroIdx);
}
}