Compare commits

..

10 Commits

Author SHA1 Message Date
d7fb3105fa feat(bd): V018 crea dbo.Product + SqlTestFixture consolida V018 + permisos catalogo (PRD-002 W6) 2026-04-19 13:46:11 -03:00
b4f17d6961 refactor: eliminar NullProductQueryRepository dead code + EXISTS en ProductQueryRepository (PRD-002 S1 S2) 2026-04-19 13:37:10 -03:00
a7cfcdb683 test(frontend): ProductsPage pagination + filter tests (PRD-002 W5) 2026-04-19 13:36:48 -03:00
0f5455aba6 test(frontend): ProductFormDialog + DeactivateProductDialog tests (PRD-002 W3 W4) 2026-04-19 13:35:23 -03:00
2b79b6f769 feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2) 2026-04-19 13:33:53 -03:00
d262454b28 fix(api): ExceptionFilter 409 para ProductTypeInactivo y RubroInactivo (PRD-002 W1) 2026-04-19 13:31:38 -03:00
08a4738daf feat(frontend): Products feature — CRUD page, form, dialogs, hooks (PRD-002)
Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts,
useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm,
ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform
gating. Router entry at /admin/products and sidebar link added. 19 Vitest
tests GREEN (api, hooks, page).
2026-04-19 13:24:42 -03:00
a41a4ea341 test(api): guard proof — ProductType deactivation returns 409 when active Products exist (PRD-002) 2026-04-19 13:18:21 -03:00
165abc8245 feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002) 2026-04-19 13:17:31 -03:00
733ca0e2e2 test(infrastructure): ProductRepository integration tests — roundtrip, update, deactivate history, UQ (PRD-002) 2026-04-19 13:11:21 -03:00
36 changed files with 3313 additions and 55 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

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

@@ -19,16 +19,19 @@ public sealed class ProductQueryRepository : IProductQueryRepository
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.Product
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM dbo.Product
WHERE ProductTypeId = @ProductTypeId
AND IsActive = 1
) THEN 1 ELSE 0
END
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
return count > 0;
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
return result == 1;
}
}

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

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

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