From ff7d8986fd0e45344b9a4e068f7448000781b558 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 18:13:54 -0300 Subject: [PATCH 1/9] =?UTF-8?q?feat(db):=20Medio=20+=20Seccion=20(temporal?= =?UTF-8?q?=20tables=20+=20seed)=20=E2=80=94=20ADM-001=20B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V011 crea dbo.Medio y dbo.Seccion con SYSTEM_VERSIONING ON (retention 10 anios) y PAGE compression en history; siembra el permiso 'administracion:secciones:gestionar' y lo asigna a rol admin. El permiso 'administracion:medios:gestionar' ya existia desde V005. V012 siembra Medios fundacionales ELDIA y ELPLATA (MERGE idempotente). Rollbacks V011/V012 validados estructuralmente; aplicacion y reaplicacion verificadas en SIGCM2_Test y SIGCM2. Fixture de tests actualizado: EnsureV011SchemaAsync, SeedMediosCanonicalAsync, ignora Medio_History y Seccion_History en Respawner. --- database/README.md | 12 + database/migrations/V011_ROLLBACK.sql | 118 ++++++++++ .../migrations/V011__create_medio_seccion.sql | 206 ++++++++++++++++++ database/migrations/V012_ROLLBACK.sql | 30 +++ database/migrations/V012__seed_medios.sql | 27 +++ tests/SIGCM2.TestSupport/SqlTestFixture.cs | 156 ++++++++++++- 6 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 database/migrations/V011_ROLLBACK.sql create mode 100644 database/migrations/V011__create_medio_seccion.sql create mode 100644 database/migrations/V012_ROLLBACK.sql create mode 100644 database/migrations/V012__seed_medios.sql diff --git a/database/README.md b/database/README.md index c8d18a7..2b3f549 100644 --- a/database/README.md +++ b/database/README.md @@ -27,6 +27,8 @@ database/ | V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol | | V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | MigraciΓ³n shape `PermisosJson` `{grant, deny}` | | **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditorΓ­a + Temporal Tables. Ver nota abajo.** | +| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` | +| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA | ## Convenciones @@ -78,6 +80,16 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5. **CatΓ‘logo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catΓ‘logo y activar `SYSTEM_VERSIONING` en su migraciΓ³n. +### V011/V012 β€” ADM-001 Medios y Secciones + +**Alcance**: crea `dbo.Medio` y `dbo.Seccion` con Temporal Tables (retention 10 aΓ±os), el permiso `administracion:secciones:gestionar` (y lo asigna a rol `admin`), y siembra los dos Medios fundacionales `ELDIA` y `ELPLATA`. + +**Notas**: +- `administracion:medios:gestionar` ya existΓ­a desde V005 β€” no se toca. +- `PlataformaEmpresaId` es `INT NULL` sin FK; la FK se agrega en INT-003 cuando se cree la tabla `PlataformaEmpresa`. +- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003. +- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) β†’ `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados. + ## Recursos - Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md` diff --git a/database/migrations/V011_ROLLBACK.sql b/database/migrations/V011_ROLLBACK.sql new file mode 100644 index 0000000..db381d2 --- /dev/null +++ b/database/migrations/V011_ROLLBACK.sql @@ -0,0 +1,118 @@ +-- V011_ROLLBACK.sql +-- Reversa de V011__create_medio_seccion.sql. +-- +-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal, +-- el permiso 'administracion:secciones:gestionar' y sus asignaciones. +-- ('administracion:medios:gestionar' NO se toca β€” es pre-existente de V005.) +-- +-- Uso intended: ROLLBACK en entornos NO-productivos. +-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario). +-- Si ADM-008, ADM-009 o PRC-* ya estΓ‘n aplicados, este rollback falla β€” usar backup. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio +-- ═══════════════════════════════════════════════════════════════════════ + +-- Seccion primero (FK al Medio) +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF); + PRINT 'Seccion: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion')) +BEGIN + ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom; + ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo; + ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo; + PRINT 'Seccion: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Seccion_History; + PRINT 'Seccion_History dropped.'; +END +GO + +-- Medio +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF); + PRINT 'Medio: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio')) +BEGIN + ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME; + PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom; + ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo; + ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo; + PRINT 'Medio: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Medio_History; + PRINT 'Medio_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. Drop Seccion y Medio (Seccion primero por FK) +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Seccion; + PRINT 'Table dbo.Seccion dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.Medio; + PRINT 'Table dbo.Medio dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso +-- ═══════════════════════════════════════════════════════════════════════ + +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'administracion:secciones:gestionar'; +GO + +DELETE FROM dbo.Permiso +WHERE Codigo = 'administracion:secciones:gestionar'; +GO + +PRINT ''; +PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.'; +PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).'; +GO diff --git a/database/migrations/V011__create_medio_seccion.sql b/database/migrations/V011__create_medio_seccion.sql new file mode 100644 index 0000000..dfbb8c7 --- /dev/null +++ b/database/migrations/V011__create_medio_seccion.sql @@ -0,0 +1,206 @@ +-- V011__create_medio_seccion.sql +-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones β€” catΓ‘logo fundacional. +-- +-- Cambios: +-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON). +-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON). +-- 3. Permiso 'administracion:secciones:gestionar' + asignaciΓ³n a rol 'admin'. +-- El permiso 'administracion:medios:gestionar' ya existΓ­a desde V005. +-- +-- PatrΓ³n: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 aΓ±os + PAGE compression en history). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V011_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 πŸ“‹ UDTs MΓ³dulo AdministraciΓ³n.md (ADM-001) +-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏒 Medio.md +-- AuditorΓ­a: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 πŸ“‹ AuditorΓ­a.md + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.Medio +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Medio ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster + PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping) + Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo), + CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4) + ); + PRINT 'Table dbo.Medio created.'; +END +ELSE + PRINT 'Table dbo.Medio already exists β€” skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio')) +BEGIN + CREATE INDEX IX_Medio_Activo_Tipo + ON dbo.Medio(Activo, Tipo) + INCLUDE (Codigo, Nombre, PlataformaEmpresaId); + PRINT 'Index IX_Medio_Activo_Tipo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 2. dbo.Seccion +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Seccion ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY, + MedioId INT NOT NULL, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos' + Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo), + CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos')) + ); + PRINT 'Table dbo.Seccion created.'; +END +ELSE + PRINT 'Table dbo.Seccion already exists β€” skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion')) +BEGIN + CREATE INDEX IX_Seccion_MedioId_Activo + ON dbo.Seccion(MedioId, Activo) + INCLUDE (Codigo, Nombre, Tipo); + PRINT 'Index IX_Seccion_MedioId_Activo created.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 3. SYSTEM_VERSIONING β€” Medio +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Medio + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Medio + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Medio_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).'; +END +ELSE + PRINT 'Medio: SYSTEM_VERSIONING already ON β€” skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_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 = 'Medio_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Medio_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SYSTEM_VERSIONING β€” Seccion +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.Seccion + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.Seccion + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Seccion_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'Seccion: SYSTEM_VERSIONING = ON.'; +END +ELSE + PRINT 'Seccion: SYSTEM_VERSIONING already ON β€” skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_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 = 'Seccion_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'Seccion_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Permiso nuevo: administracion:secciones:gestionar +-- ('administracion:medios:gestionar' ya fue sembrado en V005 β€” no se toca). +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion') +) 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', 'administracion:secciones: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 'V011 applied successfully β€” dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.'; +PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).'; +GO diff --git a/database/migrations/V012_ROLLBACK.sql b/database/migrations/V012_ROLLBACK.sql new file mode 100644 index 0000000..658caf8 --- /dev/null +++ b/database/migrations/V012_ROLLBACK.sql @@ -0,0 +1,30 @@ +-- V012_ROLLBACK.sql +-- Reversa de V012__seed_medios.sql. +-- +-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas. +-- Si alguna secciΓ³n depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- Falla temprano si hay secciones vivas apuntando a estos Medios. +IF EXISTS ( + SELECT 1 + FROM dbo.Seccion s + JOIN dbo.Medio m ON m.Id = s.MedioId + WHERE m.Codigo IN ('ELDIA', 'ELPLATA') +) +BEGIN + RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1); + RETURN; +END +GO + +DELETE FROM dbo.Medio +WHERE Codigo IN ('ELDIA', 'ELPLATA'); +GO + +PRINT 'V012 rolled back β€” seed Medios ELDIA y ELPLATA removed.'; +GO diff --git a/database/migrations/V012__seed_medios.sql b/database/migrations/V012__seed_medios.sql new file mode 100644 index 0000000..28a2e6b --- /dev/null +++ b/database/migrations/V012__seed_medios.sql @@ -0,0 +1,27 @@ +-- V012__seed_medios.sql +-- ADM-001: seed inicial de Medios ELDIA y ELPLATA. +-- +-- Idempotente via MERGE por Codigo. +-- Tipo = 1 (Diario) per enum TipoMedio. +-- PlataformaEmpresaId = NULL (INT-003 lo poblarΓ‘ cuando exista el mapeo IMAC). +-- +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +MERGE dbo.Medio AS t +USING (VALUES + ('ELDIA', N'El DΓ­a', 1), + ('ELPLATA', N'El Plata', 1) +) AS s (Codigo, Nombre, Tipo) +ON t.Codigo = s.Codigo +WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo) + VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1); +GO + +PRINT 'V012 applied β€” Medios ELDIA y ELPLATA seeded (idempotent).'; +GO diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs index d09c8c5..5c959e4 100644 --- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs +++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs @@ -36,12 +36,15 @@ public sealed class SqlTestFixture : IAsyncLifetime // Applied manually via: sqlcmd ... -i database/migrations/V010__audit_infrastructure.sql await EnsureV010SchemaAsync(); + // V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'. + await EnsureV011SchemaAsync(); + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer, // Rol is a lookup table seeded by migration V003 β€” never wipe or Usuario FK breaks. // Permiso and RolPermiso are seeded by V005/V006 β€” never wipe or integration tests lose the permission catalog. - // *_History tables: UDT-010 system-versioned β€” Respawn cannot DELETE them directly (engine rejects). + // *_History tables: UDT-010/ADM-001 system-versioned β€” Respawn cannot DELETE them directly (engine rejects). TablesToIgnore = [ new Respawn.Graph.Table("dbo", "Rol"), @@ -51,6 +54,8 @@ public sealed class SqlTestFixture : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); @@ -64,6 +69,7 @@ public sealed class SqlTestFixture : IAsyncLifetime await SeedPermisosCanonicalAsync(); await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); + await SeedMediosCanonicalAsync(); } private async Task SeedRolCanonicalAsync() @@ -157,7 +163,9 @@ public sealed class SqlTestFixture : IAsyncLifetime -- V007 (UDT-006): permisos administrativos RBAC ('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'), ('administracion:roles_permisos:gestionar', N'Gestionar asignacion de permisos', N'Asignar y revocar permisos por rol', 'administracion'), - ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion') + ('administracion:permisos:ver', N'Ver catalogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion'), + -- V011 (ADM-001): permiso para CRUD de Secciones + ('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion') ) AS s (Codigo, Nombre, Descripcion, Modulo) ON t.Codigo = s.Codigo WHEN NOT MATCHED BY TARGET THEN @@ -197,6 +205,8 @@ public sealed class SqlTestFixture : IAsyncLifetime ('admin', 'administracion:roles:gestionar'), ('admin', 'administracion:roles_permisos:gestionar'), ('admin', 'administracion:permisos:ver'), + -- V011 (ADM-001) + ('admin', 'administracion:secciones:gestionar'), ('cajero', 'ventas:contado:crear'), ('cajero', 'ventas:contado:modificar'), ('cajero', 'ventas:contado:cobrar'), @@ -241,6 +251,148 @@ public sealed class SqlTestFixture : IAsyncLifetime await _connection.ExecuteAsync(sql); } + /// + /// ADM-001 (V011): applies Medio/Seccion schema + temporal + permiso 'administracion:secciones:gestionar' + /// idempotently to the test database. Mirrors V011__create_medio_seccion.sql. + /// Canonical seed (ELDIA, ELPLATA) vive en SeedMediosCanonicalAsync β€” se reaplica tras cada Respawn. + /// + private async Task EnsureV011SchemaAsync() + { + const string createMedio = """ + IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Medio ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo TINYINT NOT NULL, + PlataformaEmpresaId INT NULL, + Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo), + CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4) + ); + END + """; + + const string createMedioIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio')) + BEGIN + CREATE INDEX IX_Medio_Activo_Tipo + ON dbo.Medio(Activo, Tipo) + INCLUDE (Codigo, Nombre, PlataformaEmpresaId); + END + """; + + const string createSeccion = """ + IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL + BEGIN + CREATE TABLE dbo.Seccion ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY, + MedioId INT NOT NULL, + Codigo VARCHAR(30) NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Tipo VARCHAR(20) NOT NULL, + Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo), + CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos')) + ); + END + """; + + const string createSeccionIndex = """ + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion')) + BEGIN + CREATE INDEX IX_Seccion_MedioId_Activo + ON dbo.Seccion(MedioId, Activo) + INCLUDE (Codigo, Nombre, Tipo); + END + """; + + const string addMedioPeriod = """ + IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Medio + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setMedioVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Medio + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Medio_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + const string addSeccionPeriod = """ + IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL + BEGIN + ALTER TABLE dbo.Seccion + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + END + """; + + const string setSeccionVersioning = """ + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2) + BEGIN + ALTER TABLE dbo.Seccion + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.Seccion_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + END + """; + + await _connection.ExecuteAsync(createMedio); + await _connection.ExecuteAsync(createMedioIndex); + await _connection.ExecuteAsync(createSeccion); + await _connection.ExecuteAsync(createSeccionIndex); + await _connection.ExecuteAsync(addMedioPeriod); + await _connection.ExecuteAsync(setMedioVersioning); + await _connection.ExecuteAsync(addSeccionPeriod); + await _connection.ExecuteAsync(setSeccionVersioning); + // Permiso 'administracion:secciones:gestionar' + asignaciΓ³n a admin se siembran + // desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn). + } + + /// + /// ADM-001 (V012): MERGE seed ELDIA + ELPLATA. Re-seeded on every Respawn reset. + /// + private async Task SeedMediosCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Medio AS t + USING (VALUES + ('ELDIA', N'El DΓ­a', 1), + ('ELPLATA', N'El Plata', 1) + ) AS s (Codigo, Nombre, Tipo) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo) + VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1); + """; + await _connection.ExecuteAsync(sql); + } + /// /// UDT-010 (V010): verifies that the audit infrastructure is present. /// Does NOT re-apply the migration (the ALTER DATABASE ADD FILEGROUP/FILE + partition From bb98dbf2179987451ab4f5c1ba6a0324d840811f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 18:45:46 -0300 Subject: [PATCH 2/9] =?UTF-8?q?feat(domain):=20Medio=20+=20Seccion=20entit?= =?UTF-8?q?ies=20+=204=20exceptions=20=E2=80=94=20ADM-001=20B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entities sealed immutable con factory ForCreation + copy-with methods WithUpdatedProfile/WithActivo (Codigo inmutable en Medio; MedioId y Codigo inmutables en Seccion β€” enforzado en Validators en B4). Exceptions: MedioCodigoDuplicado (UQ global), SeccionCodigoDuplicadoEnMedio (UQ compuesto), MedioNotFound, SeccionNotFound. Todas heredan de DomainException. --- src/api/SIGCM2.Domain/Entities/Medio.cs | 75 +++++++++++++++++++ src/api/SIGCM2.Domain/Entities/Seccion.cs | 72 ++++++++++++++++++ src/api/SIGCM2.Domain/Entities/TipoMedio.cs | 9 +++ .../MedioCodigoDuplicadoException.cs | 15 ++++ .../Exceptions/MedioNotFoundException.cs | 15 ++++ .../SeccionCodigoDuplicadoEnMedioException.cs | 18 +++++ .../Exceptions/SeccionNotFoundException.cs | 15 ++++ 7 files changed, 219 insertions(+) create mode 100644 src/api/SIGCM2.Domain/Entities/Medio.cs create mode 100644 src/api/SIGCM2.Domain/Entities/Seccion.cs create mode 100644 src/api/SIGCM2.Domain/Entities/TipoMedio.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs create mode 100644 src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs diff --git a/src/api/SIGCM2.Domain/Entities/Medio.cs b/src/api/SIGCM2.Domain/Entities/Medio.cs new file mode 100644 index 0000000..6c7df79 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Medio.cs @@ -0,0 +1,75 @@ +namespace SIGCM2.Domain.Entities; + +public sealed class Medio +{ + public int Id { get; } + public string Codigo { get; } + public string Nombre { get; } + public TipoMedio Tipo { get; } + public int? PlataformaEmpresaId { get; } + public bool Activo { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + public Medio( + int id, + string codigo, + string nombre, + TipoMedio tipo, + int? plataformaEmpresaId, + bool activo, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + Codigo = codigo; + Nombre = nombre; + Tipo = tipo; + PlataformaEmpresaId = plataformaEmpresaId; + Activo = activo; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + /// + /// Factory for creating a new Medio (Id=0 β€” DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default). + /// + public static Medio ForCreation(string codigo, string nombre, TipoMedio tipo, int? plataformaEmpresaId) + { + return new Medio( + id: 0, + codigo: codigo, + nombre: nombre, + tipo: tipo, + plataformaEmpresaId: plataformaEmpresaId, + activo: true, + fechaCreacion: default, + fechaModificacion: null); + } + + /// + /// Returns a new instance with updated fields. Codigo is immutable (use BD UQ to enforce). + /// Sets FechaModificacion = UtcNow. + /// + public Medio WithUpdatedProfile(string nombre, TipoMedio tipo, int? plataformaEmpresaId) + => new( + id: Id, + codigo: Codigo, + nombre: nombre, + tipo: tipo, + plataformaEmpresaId: plataformaEmpresaId, + activo: Activo, + fechaCreacion: FechaCreacion, + fechaModificacion: DateTime.UtcNow); + + public Medio WithActivo(bool activo) + => new( + id: Id, + codigo: Codigo, + nombre: Nombre, + tipo: Tipo, + plataformaEmpresaId: PlataformaEmpresaId, + activo: activo, + fechaCreacion: FechaCreacion, + fechaModificacion: DateTime.UtcNow); +} diff --git a/src/api/SIGCM2.Domain/Entities/Seccion.cs b/src/api/SIGCM2.Domain/Entities/Seccion.cs new file mode 100644 index 0000000..f7d3e2f --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Seccion.cs @@ -0,0 +1,72 @@ +namespace SIGCM2.Domain.Entities; + +public sealed class Seccion +{ + public int Id { get; } + public int MedioId { get; } + public string Codigo { get; } + public string Nombre { get; } + public string Tipo { get; } // 'clasificados' | 'notables' | 'suplementos' β€” enforzado por CHECK en BD + public bool Activo { get; } + public DateTime FechaCreacion { get; } + public DateTime? FechaModificacion { get; } + + public Seccion( + int id, + int medioId, + string codigo, + string nombre, + string tipo, + bool activo, + DateTime fechaCreacion, + DateTime? fechaModificacion) + { + Id = id; + MedioId = medioId; + Codigo = codigo; + Nombre = nombre; + Tipo = tipo; + Activo = activo; + FechaCreacion = fechaCreacion; + FechaModificacion = fechaModificacion; + } + + public static Seccion ForCreation(int medioId, string codigo, string nombre, string tipo) + { + return new Seccion( + id: 0, + medioId: medioId, + codigo: codigo, + nombre: nombre, + tipo: tipo, + activo: true, + fechaCreacion: default, + fechaModificacion: null); + } + + /// + /// Returns a new instance with updated fields. MedioId and Codigo are immutable. + /// Sets FechaModificacion = UtcNow. + /// + public Seccion WithUpdatedProfile(string nombre, string tipo) + => new( + id: Id, + medioId: MedioId, + codigo: Codigo, + nombre: nombre, + tipo: tipo, + activo: Activo, + fechaCreacion: FechaCreacion, + fechaModificacion: DateTime.UtcNow); + + public Seccion WithActivo(bool activo) + => new( + id: Id, + medioId: MedioId, + codigo: Codigo, + nombre: Nombre, + tipo: Tipo, + activo: activo, + fechaCreacion: FechaCreacion, + fechaModificacion: DateTime.UtcNow); +} diff --git a/src/api/SIGCM2.Domain/Entities/TipoMedio.cs b/src/api/SIGCM2.Domain/Entities/TipoMedio.cs new file mode 100644 index 0000000..df8ef89 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoMedio.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Domain.Entities; + +public enum TipoMedio +{ + Diario = 1, + Radio = 2, + Web = 3, + Poster = 4, +} diff --git a/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs b/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs new file mode 100644 index 0000000..0568d49 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create a Medio with a Codigo that already exists (global UQ). +/// +public sealed class MedioCodigoDuplicadoException : DomainException +{ + public string Codigo { get; } + + public MedioCodigoDuplicadoException(string codigo) + : base($"El medio con cΓ³digo '{codigo}' ya existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs new file mode 100644 index 0000000..3a5bf6f --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested Medio does not exist in the system. +/// +public sealed class MedioNotFoundException : DomainException +{ + public int Id { get; } + + public MedioNotFoundException(int id) + : base($"El medio con id '{id}' no existe.") + { + Id = id; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs b/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs new file mode 100644 index 0000000..79e6aef --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create a Seccion with a Codigo that already exists within the same Medio +/// (composite UQ on MedioId + Codigo). +/// +public sealed class SeccionCodigoDuplicadoEnMedioException : DomainException +{ + public int MedioId { get; } + public string Codigo { get; } + + public SeccionCodigoDuplicadoEnMedioException(int medioId, string codigo) + : base($"La secciΓ³n con cΓ³digo '{codigo}' ya existe para el medio {medioId}.") + { + MedioId = medioId; + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs new file mode 100644 index 0000000..faf72d6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested Seccion does not exist in the system. +/// +public sealed class SeccionNotFoundException : DomainException +{ + public int Id { get; } + + public SeccionNotFoundException(int id) + : base($"La secciΓ³n con id '{id}' no existe.") + { + Id = id; + } +} From f672de78ce8845aeedbe20f2592ce838d6c79bc6 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 18:53:57 -0300 Subject: [PATCH 3/9] =?UTF-8?q?feat(medios,secciones):=20application=20lay?= =?UTF-8?q?er=20+=20handlers=20TDD=20=E2=80=94=20ADM-001=20B3+B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMedioRepository, ISeccionRepository interfaces - MediosQuery, SeccionesQuery common records - TipoSeccion static AllowedTipos helper - Medios: 6 use cases (Create/Update/Deactivate/Reactivate/List/GetById) with validators, handlers and DTOs - Secciones: 6 use cases mirroring Medios; Create validates MedioId active via IMedioRepository - 52 unit tests (xUnit + NSubstitute) all green; audit LogAsync asserted per mutating handler - DI registrations for all 12 handlers and validators auto-scanned via AddValidatorsFromAssemblyContaining --- .../Persistence/IMedioRepository.cs | 13 ++ .../Persistence/ISeccionRepository.cs | 13 ++ .../SIGCM2.Application/Common/MediosQuery.cs | 12 ++ .../Common/SeccionesQuery.cs | 11 ++ .../SIGCM2.Application/DependencyInjection.cs | 28 +++++ .../Medios/Create/CreateMedioCommand.cs | 9 ++ .../Create/CreateMedioCommandHandler.cs | 63 ++++++++++ .../Create/CreateMedioCommandValidator.cs | 25 ++++ .../Medios/Create/MedioCreatedDto.cs | 11 ++ .../Deactivate/DeactivateMedioCommand.cs | 3 + .../DeactivateMedioCommandHandler.cs | 48 ++++++++ .../Medios/Deactivate/MedioStatusDto.cs | 3 + .../Medios/GetById/GetMedioByIdQuery.cs | 3 + .../GetById/GetMedioByIdQueryHandler.cs | 31 +++++ .../Medios/GetById/MedioDetailDto.cs | 13 ++ .../Medios/List/ListMediosQuery.cs | 10 ++ .../Medios/List/ListMediosQueryHandler.cs | 31 +++++ .../Medios/List/MedioListItemDto.cs | 11 ++ .../Reactivate/ReactivateMedioCommand.cs | 3 + .../ReactivateMedioCommandHandler.cs | 49 ++++++++ .../Medios/Update/MedioUpdatedDto.cs | 11 ++ .../Medios/Update/UpdateMedioCommand.cs | 9 ++ .../Update/UpdateMedioCommandHandler.cs | 55 +++++++++ .../Update/UpdateMedioCommandValidator.cs | 21 ++++ .../Secciones/Create/CreateSeccionCommand.cs | 7 ++ .../Create/CreateSeccionCommandHandler.cs | 68 +++++++++++ .../Create/CreateSeccionCommandValidator.cs | 29 +++++ .../Secciones/Create/SeccionCreatedDto.cs | 9 ++ .../Deactivate/DeactivateSeccionCommand.cs | 3 + .../DeactivateSeccionCommandHandler.cs | 48 ++++++++ .../Secciones/Deactivate/SeccionStatusDto.cs | 3 + .../Secciones/GetById/GetSeccionByIdQuery.cs | 3 + .../GetById/GetSeccionByIdQueryHandler.cs | 31 +++++ .../Secciones/GetById/SeccionDetailDto.cs | 11 ++ .../Secciones/List/ListSeccionesQuery.cs | 9 ++ .../List/ListSeccionesQueryHandler.cs | 30 +++++ .../Secciones/List/SeccionListItemDto.cs | 9 ++ .../Reactivate/ReactivateSeccionCommand.cs | 3 + .../ReactivateSeccionCommandHandler.cs | 49 ++++++++ .../Secciones/Update/SeccionUpdatedDto.cs | 9 ++ .../Secciones/Update/UpdateSeccionCommand.cs | 6 + .../Update/UpdateSeccionCommandHandler.cs | 55 +++++++++ .../Update/UpdateSeccionCommandValidator.cs | 24 ++++ src/api/SIGCM2.Domain/Entities/TipoSeccion.cs | 10 ++ .../Create/CreateMedioCommandHandlerTests.cs | 113 ++++++++++++++++++ .../DeactivateMedioCommandHandlerTests.cs | 87 ++++++++++++++ .../GetById/GetMedioByIdQueryHandlerTests.cs | 46 +++++++ .../List/ListMediosQueryHandlerTests.cs | 65 ++++++++++ .../ReactivateMedioCommandHandlerTests.cs | 88 ++++++++++++++ .../Update/UpdateMedioCommandHandlerTests.cs | 83 +++++++++++++ .../CreateSeccionCommandHandlerTests.cs | 113 ++++++++++++++++++ .../DeactivateSeccionCommandHandlerTests.cs | 81 +++++++++++++ .../GetSeccionByIdQueryHandlerTests.cs | 46 +++++++ .../List/ListSeccionesQueryHandlerTests.cs | 64 ++++++++++ .../ReactivateSeccionCommandHandlerTests.cs | 82 +++++++++++++ .../UpdateSeccionCommandHandlerTests.cs | 74 ++++++++++++ 56 files changed, 1844 insertions(+) create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs create mode 100644 src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs create mode 100644 src/api/SIGCM2.Application/Common/MediosQuery.cs create mode 100644 src/api/SIGCM2.Application/Common/SeccionesQuery.cs create mode 100644 src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs create mode 100644 src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs create mode 100644 src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs create mode 100644 src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs create mode 100644 src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs create mode 100644 src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs create mode 100644 src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs create mode 100644 src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs create mode 100644 src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs create mode 100644 src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs create mode 100644 src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs create mode 100644 src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs create mode 100644 src/api/SIGCM2.Domain/Entities/TipoSeccion.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs new file mode 100644 index 0000000..e641117 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IMedioRepository +{ + Task AddAsync(Medio m, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoAsync(string codigo, CancellationToken ct = default); + Task UpdateAsync(Medio m, CancellationToken ct = default); + Task> GetPagedAsync(MediosQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs new file mode 100644 index 0000000..6264d1e --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface ISeccionRepository +{ + Task AddAsync(Seccion s, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default); + Task UpdateAsync(Seccion s, CancellationToken ct = default); + Task> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/MediosQuery.cs b/src/api/SIGCM2.Application/Common/MediosQuery.cs new file mode 100644 index 0000000..c5f215f --- /dev/null +++ b/src/api/SIGCM2.Application/Common/MediosQuery.cs @@ -0,0 +1,12 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Common; + +/// Query parameters for listing medios with optional filters and paging. +public sealed record MediosQuery( + int Page, + int PageSize, + bool? Activo, + TipoMedio? Tipo, + string? Search +); diff --git a/src/api/SIGCM2.Application/Common/SeccionesQuery.cs b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs new file mode 100644 index 0000000..35ce39b --- /dev/null +++ b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing secciones with optional filters and paging. +public sealed record SeccionesQuery( + int Page, + int PageSize, + int? MedioId, + string? Tipo, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index d71d9c2..fdf48d8 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -5,6 +5,12 @@ using SIGCM2.Application.Auth.Login; using SIGCM2.Application.Auth.Logout; using SIGCM2.Application.Auth.Refresh; using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Application.Medios.List; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Application.Medios.Update; using SIGCM2.Application.Permisos.Assign; using SIGCM2.Application.Permisos.Dtos; using SIGCM2.Application.Permisos.GetByRol; @@ -15,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos; using SIGCM2.Application.Roles.Get; using SIGCM2.Application.Roles.List; using SIGCM2.Application.Roles.Update; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Application.Secciones.Update; using SIGCM2.Application.Usuarios.ChangeMyPassword; using SIGCM2.Application.Usuarios.Create; using SIGCM2.Application.Usuarios.Deactivate; @@ -62,6 +74,22 @@ public static class DependencyInjection services.AddScoped, GetUsuarioPermisosQueryHandler>(); services.AddScoped, UpdateUsuarioPermisosOverridesCommandHandler>(); + // Medios (ADM-001) + services.AddScoped, CreateMedioCommandHandler>(); + services.AddScoped, UpdateMedioCommandHandler>(); + services.AddScoped, DeactivateMedioCommandHandler>(); + services.AddScoped, ReactivateMedioCommandHandler>(); + services.AddScoped>, ListMediosQueryHandler>(); + services.AddScoped, GetMedioByIdQueryHandler>(); + + // Secciones (ADM-001) + services.AddScoped, CreateSeccionCommandHandler>(); + services.AddScoped, UpdateSeccionCommandHandler>(); + services.AddScoped, DeactivateSeccionCommandHandler>(); + services.AddScoped, ReactivateSeccionCommandHandler>(); + services.AddScoped>, ListSeccionesQueryHandler>(); + services.AddScoped, GetSeccionByIdQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs new file mode 100644 index 0000000..4ca06ac --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed record CreateMedioCommand( + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs new file mode 100644 index 0000000..48caf74 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs @@ -0,0 +1,63 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.Create; + +public sealed class CreateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(CreateMedioCommand command) + { + var codigoNorm = command.Codigo.ToUpperInvariant(); + + var exists = await _repo.ExistsByCodigoAsync(codigoNorm); + if (exists) + throw new MedioCodigoDuplicadoException(codigoNorm); + + var medio = Medio.ForCreation(codigoNorm, command.Nombre, command.Tipo, command.PlataformaEmpresaId); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(medio); + + await _audit.LogAsync( + action: "medio.create", + targetType: "Medio", + targetId: newId.ToString(), + metadata: new + { + after = new + { + medio.Codigo, + medio.Nombre, + medio.Tipo, + medio.PlataformaEmpresaId, + }, + }); + + tx.Complete(); + + return new MedioCreatedDto( + Id: newId, + Codigo: medio.Codigo, + Nombre: medio.Nombre, + Tipo: medio.Tipo, + PlataformaEmpresaId: medio.PlataformaEmpresaId, + Activo: medio.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs new file mode 100644 index 0000000..e8de21b --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed class CreateMedioCommandValidator : AbstractValidator +{ + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 100; + + public CreateMedioCommandValidator() + { + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El cΓ³digo es requerido.") + .MaximumLength(CodigoMaxLength).WithMessage($"El cΓ³digo no puede superar los {CodigoMaxLength} caracteres.") + .Matches(@"^[A-Za-z0-9_]+$").WithMessage("El cΓ³digo solo puede contener letras, dΓ­gitos y guiones bajos."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .IsInEnum().WithMessage("El tipo de medio no es vΓ‘lido."); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs new file mode 100644 index 0000000..e868a72 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed record MedioCreatedDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs new file mode 100644 index 0000000..d932353 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record DeactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs new file mode 100644 index 0000000..7b87564 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs @@ -0,0 +1,48 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed class DeactivateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(DeactivateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + // Idempotent: already inactive β†’ return as-is without writing an audit event + if (!target.Activo) + return new MedioStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(false); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.deactivate", + targetType: "Medio", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs new file mode 100644 index 0000000..84d3044 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record MedioStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs new file mode 100644 index 0000000..4ca1e0e --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.GetById; + +public sealed record GetMedioByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs new file mode 100644 index 0000000..486aa50 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.GetById; + +public sealed class GetMedioByIdQueryHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + + public GetMedioByIdQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetMedioByIdQuery query) + { + var medio = await _repo.GetByIdAsync(query.Id) + ?? throw new MedioNotFoundException(query.Id); + + return new MedioDetailDto( + Id: medio.Id, + Codigo: medio.Codigo, + Nombre: medio.Nombre, + Tipo: medio.Tipo, + PlataformaEmpresaId: medio.PlataformaEmpresaId, + Activo: medio.Activo, + FechaCreacion: medio.FechaCreacion, + FechaModificacion: medio.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs new file mode 100644 index 0000000..ff2b01f --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs @@ -0,0 +1,13 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.GetById; + +public sealed record MedioDetailDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs new file mode 100644 index 0000000..08ce6a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs @@ -0,0 +1,10 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed record ListMediosQuery( + int Page = 1, + int PageSize = 20, + bool? Activo = true, + TipoMedio? Tipo = null, + string? Search = null); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs new file mode 100644 index 0000000..ac815c5 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed class ListMediosQueryHandler : ICommandHandler> +{ + private readonly IMedioRepository _repo; + + public ListMediosQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListMediosQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new MediosQuery(page, pageSize, query.Activo, query.Tipo, query.Search); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(m => new MedioListItemDto(m.Id, m.Codigo, m.Nombre, m.Tipo, m.PlataformaEmpresaId, m.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs new file mode 100644 index 0000000..cd26052 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.List; + +public sealed record MedioListItemDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs new file mode 100644 index 0000000..ef1c9aa --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Reactivate; + +public sealed record ReactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs new file mode 100644 index 0000000..d8447f9 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.Reactivate; + +public sealed class ReactivateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(ReactivateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + // Idempotent: already active β†’ return as-is without writing an audit event + if (target.Activo) + return new MedioStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(true); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.reactivate", + targetType: "Medio", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs new file mode 100644 index 0000000..c497a07 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs @@ -0,0 +1,11 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Update; + +public sealed record MedioUpdatedDto( + int Id, + string Codigo, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId, + bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs new file mode 100644 index 0000000..b676188 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs @@ -0,0 +1,9 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Update; + +public sealed record UpdateMedioCommand( + int Id, + string Nombre, + TipoMedio Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs new file mode 100644 index 0000000..51e58fd --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs @@ -0,0 +1,55 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Medios.Update; + +public sealed class UpdateMedioCommandHandler : ICommandHandler +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(UpdateMedioCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new MedioNotFoundException(command.Id); + + var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "medio.update", + targetType: "Medio", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.Tipo, target.PlataformaEmpresaId }, + after = new { updated.Nombre, updated.Tipo, updated.PlataformaEmpresaId }, + }); + + tx.Complete(); + + return new MedioUpdatedDto( + Id: updated.Id, + Codigo: updated.Codigo, + Nombre: updated.Nombre, + Tipo: updated.Tipo, + PlataformaEmpresaId: updated.PlataformaEmpresaId, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs new file mode 100644 index 0000000..a503fed --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace SIGCM2.Application.Medios.Update; + +public sealed class UpdateMedioCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + + public UpdateMedioCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .IsInEnum().WithMessage("El tipo de medio no es vΓ‘lido."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs new file mode 100644 index 0000000..f4ce080 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Secciones.Create; + +public sealed record CreateSeccionCommand( + int MedioId, + string Codigo, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs new file mode 100644 index 0000000..e8cb8e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs @@ -0,0 +1,68 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.Create; + +public sealed class CreateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IMedioRepository _medioRepo; + private readonly IAuditLogger _audit; + + public CreateSeccionCommandHandler(ISeccionRepository repo, IMedioRepository medioRepo, IAuditLogger audit) + { + _repo = repo; + _medioRepo = medioRepo; + _audit = audit; + } + + public async Task Handle(CreateSeccionCommand command) + { + // Validate medio exists and is active (REQ-SEC-001) + var medio = await _medioRepo.GetByIdAsync(command.MedioId); + if (medio is null || !medio.Activo) + throw new MedioNotFoundException(command.MedioId); + + var exists = await _repo.ExistsByCodigoInMedioAsync(command.MedioId, command.Codigo); + if (exists) + throw new SeccionCodigoDuplicadoEnMedioException(command.MedioId, command.Codigo); + + var seccion = Seccion.ForCreation(command.MedioId, command.Codigo, command.Nombre, command.Tipo); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + var newId = await _repo.AddAsync(seccion); + + await _audit.LogAsync( + action: "seccion.create", + targetType: "Seccion", + targetId: newId.ToString(), + metadata: new + { + after = new + { + seccion.MedioId, + seccion.Codigo, + seccion.Nombre, + seccion.Tipo, + }, + }); + + tx.Complete(); + + return new SeccionCreatedDto( + Id: newId, + MedioId: seccion.MedioId, + Codigo: seccion.Codigo, + Nombre: seccion.Nombre, + Tipo: seccion.Tipo, + Activo: seccion.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs new file mode 100644 index 0000000..db062e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Create; + +public sealed class CreateSeccionCommandValidator : AbstractValidator +{ + private const int CodigoMaxLength = 30; + private const int NombreMaxLength = 100; + + public CreateSeccionCommandValidator() + { + RuleFor(x => x.MedioId) + .GreaterThan(0).WithMessage("El medioId debe ser mayor a 0."); + + RuleFor(x => x.Codigo) + .NotEmpty().WithMessage("El cΓ³digo es requerido.") + .MaximumLength(CodigoMaxLength).WithMessage($"El cΓ³digo no puede superar los {CodigoMaxLength} caracteres."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .NotEmpty().WithMessage("El tipo es requerido.") + .Must(t => TipoSeccion.AllowedTipos.Contains(t)) + .WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs new file mode 100644 index 0000000..ce35275 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.Create; + +public sealed record SeccionCreatedDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs new file mode 100644 index 0000000..8d052c0 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record DeactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..c77c9d9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs @@ -0,0 +1,48 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed class DeactivateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(DeactivateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + // Idempotent: already inactive β†’ return as-is without writing an audit event + if (!target.Activo) + return new SeccionStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(false); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.deactivate", + targetType: "Seccion", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs new file mode 100644 index 0000000..471db39 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs new file mode 100644 index 0000000..05ceea7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.GetById; + +public sealed record GetSeccionByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs new file mode 100644 index 0000000..53017db --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs @@ -0,0 +1,31 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.GetById; + +public sealed class GetSeccionByIdQueryHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + + public GetSeccionByIdQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task Handle(GetSeccionByIdQuery query) + { + var seccion = await _repo.GetByIdAsync(query.Id) + ?? throw new SeccionNotFoundException(query.Id); + + return new SeccionDetailDto( + Id: seccion.Id, + MedioId: seccion.MedioId, + Codigo: seccion.Codigo, + Nombre: seccion.Nombre, + Tipo: seccion.Tipo, + Activo: seccion.Activo, + FechaCreacion: seccion.FechaCreacion, + FechaModificacion: seccion.FechaModificacion); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs new file mode 100644 index 0000000..2362ab9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Secciones.GetById; + +public sealed record SeccionDetailDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs new file mode 100644 index 0000000..92f7d7e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.List; + +public sealed record ListSeccionesQuery( + int Page = 1, + int PageSize = 20, + int? MedioId = null, + string? Tipo = null, + bool? Activo = true, + string? Search = null); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs new file mode 100644 index 0000000..e894157 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs @@ -0,0 +1,30 @@ +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; + +namespace SIGCM2.Application.Secciones.List; + +public sealed class ListSeccionesQueryHandler : ICommandHandler> +{ + private readonly ISeccionRepository _repo; + + public ListSeccionesQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task> Handle(ListSeccionesQuery query) + { + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 100); + + var repoQuery = new SeccionesQuery(page, pageSize, query.MedioId, query.Tipo, query.Activo, query.Search); + var paged = await _repo.GetPagedAsync(repoQuery); + + var items = paged.Items + .Select(s => new SeccionListItemDto(s.Id, s.MedioId, s.Codigo, s.Nombre, s.Tipo, s.Activo)) + .ToList(); + + return new PagedResult(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs new file mode 100644 index 0000000..9e8569d --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.List; + +public sealed record SeccionListItemDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs new file mode 100644 index 0000000..0bca214 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Reactivate; + +public sealed record ReactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..9d1dd5e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs @@ -0,0 +1,49 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.Reactivate; + +public sealed class ReactivateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(ReactivateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + // Idempotent: already active β†’ return as-is without writing an audit event + if (target.Activo) + return new SeccionStatusDto(target.Id, target.Codigo, target.Activo); + + var updated = target.WithActivo(true); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.reactivate", + targetType: "Seccion", + targetId: command.Id.ToString()); + + tx.Complete(); + + return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs new file mode 100644 index 0000000..4a4b25a --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Application.Secciones.Update; + +public sealed record SeccionUpdatedDto( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs new file mode 100644 index 0000000..85fbd74 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Secciones.Update; + +public sealed record UpdateSeccionCommand( + int Id, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs new file mode 100644 index 0000000..ca89608 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs @@ -0,0 +1,55 @@ +using System.Transactions; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Secciones.Update; + +public sealed class UpdateSeccionCommandHandler : ICommandHandler +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task Handle(UpdateSeccionCommand command) + { + var target = await _repo.GetByIdAsync(command.Id) + ?? throw new SeccionNotFoundException(command.Id); + + var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo); + + using var tx = new TransactionScope( + TransactionScopeOption.Required, + new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted }, + TransactionScopeAsyncFlowOption.Enabled); + + await _repo.UpdateAsync(updated); + + await _audit.LogAsync( + action: "seccion.update", + targetType: "Seccion", + targetId: command.Id.ToString(), + metadata: new + { + before = new { target.Nombre, target.Tipo }, + after = new { updated.Nombre, updated.Tipo }, + }); + + tx.Complete(); + + return new SeccionUpdatedDto( + Id: updated.Id, + MedioId: updated.MedioId, + Codigo: updated.Codigo, + Nombre: updated.Nombre, + Tipo: updated.Tipo, + Activo: updated.Activo); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs new file mode 100644 index 0000000..a101c7b --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Update; + +public sealed class UpdateSeccionCommandValidator : AbstractValidator +{ + private const int NombreMaxLength = 100; + + public UpdateSeccionCommandValidator() + { + RuleFor(x => x.Id) + .GreaterThan(0).WithMessage("El id debe ser mayor a 0."); + + RuleFor(x => x.Nombre) + .NotEmpty().WithMessage("El nombre es requerido.") + .MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres."); + + RuleFor(x => x.Tipo) + .NotEmpty().WithMessage("El tipo es requerido.") + .Must(t => TipoSeccion.AllowedTipos.Contains(t)) + .WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}."); + } +} diff --git a/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs new file mode 100644 index 0000000..de77036 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// Allowed string values for Seccion.Tipo. +/// Enforced at application layer (validator) and database layer (CHECK constraint). +/// +public static class TipoSeccion +{ + public static readonly string[] AllowedTipos = { "clasificados", "notables", "suplementos" }; +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..5df54d0 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Create/CreateMedioCommandHandlerTests.cs @@ -0,0 +1,113 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Create; + +public class CreateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateMedioCommandHandler _handler; + + private static CreateMedioCommand ValidCommand() => new( + Codigo: "DIARIO_LA_VOZ", + Nombre: "Diario La Voz", + Tipo: TipoMedio.Diario, + PlataformaEmpresaId: null); + + public CreateMedioCommandHandlerTests() + { + _handler = new CreateMedioCommandHandler(_repo, _audit); + _repo.ExistsByCodigoAsync(Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(1); + } + + // ── duplicate β†’ throws ─────────────────────────────────────────────────── + + [Fact] + public async Task Handle_DuplicateCodigo_ThrowsMedioCodigoDuplicadoException() + { + _repo.ExistsByCodigoAsync("DIARIO_LA_VOZ", Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_DuplicateCodigo_DoesNotCallAddAsync() + { + _repo.ExistsByCodigoAsync(Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (MedioCodigoDuplicadoException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(42); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(42, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(5); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal("DIARIO_LA_VOZ", result.Codigo); + Assert.Equal("Diario La Voz", result.Nombre); + Assert.Equal(TipoMedio.Diario, result.Tipo); + Assert.Null(result.PlataformaEmpresaId); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_NormalizesCodigoToUppercase() + { + var cmd = new CreateMedioCommand("diario_la_voz", "Diario La Voz", TipoMedio.Diario, null); + + await _handler.Handle(cmd); + + await _repo.Received(1).AddAsync( + Arg.Is(m => m.Codigo == "DIARIO_LA_VOZ"), + Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditLogWithCreateAction() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(7); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "medio.create", + targetType: "Medio", + targetId: "7", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ChecksNormalizedCodigoForDuplicate() + { + var cmd = new CreateMedioCommand("diario_la_voz", "Diario La Voz", TipoMedio.Diario, null); + + await _handler.Handle(cmd); + + // ExistsByCodigoAsync should be called with the uppercased version + await _repo.Received(1).ExistsByCodigoAsync("DIARIO_LA_VOZ", Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..ff1c94f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Deactivate/DeactivateMedioCommandHandlerTests.cs @@ -0,0 +1,87 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Deactivate; + +public class DeactivateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = true) + => new(id, "COD" + id, "Nombre", TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public DeactivateMedioCommandHandlerTests() + { + _handler = new DeactivateMedioCommandHandler(_repo, _audit); + } + + // ── not found β†’ throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateMedioCommand(999))); + } + + // ── idempotent ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_ActiveMedio_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(m => !m.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveMedio_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new DeactivateMedioCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.deactivate", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs new file mode 100644 index 0000000..637e9ec --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/GetById/GetMedioByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.GetById; + +public class GetMedioByIdQueryHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly GetMedioByIdQueryHandler _handler; + + public GetMedioByIdQueryHandlerTests() + { + _handler = new GetMedioByIdQueryHandler(_repo); + } + + private static Medio MakeMedio(int id) => + new(id, "COD" + id, "Nombre " + id, TipoMedio.Radio, 10, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetMedioByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var medio = MakeMedio(3); + _repo.GetByIdAsync(3, Arg.Any()).Returns(medio); + + var result = await _handler.Handle(new GetMedioByIdQuery(3)); + + Assert.Equal(3, result.Id); + Assert.Equal("COD3", result.Codigo); + Assert.Equal("Nombre 3", result.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(10, result.PlataformaEmpresaId); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs new file mode 100644 index 0000000..e60c5e1 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/List/ListMediosQueryHandlerTests.cs @@ -0,0 +1,65 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Medios.List; + +public class ListMediosQueryHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly ListMediosQueryHandler _handler; + + public ListMediosQueryHandlerTests() + { + _handler = new ListMediosQueryHandler(_repo); + } + + private static Medio MakeMedio(int id) => + new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakeMedio(1), MakeMedio(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var query = new ListMediosQuery(1, 20, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal("COD1", result.Items[0].Codigo); + Assert.Equal("COD2", result.Items[1].Codigo); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListMediosQuery(1, 999, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ClampsPageToMin1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListMediosQuery(0, 20, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..ac7dd12 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Reactivate/ReactivateMedioCommandHandlerTests.cs @@ -0,0 +1,88 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Reactivate; + +public class ReactivateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = false) + => new(id, "COD" + id, "Nombre", TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + public ReactivateMedioCommandHandlerTests() + { + _handler = new ReactivateMedioCommandHandler(_repo, _audit); + } + + // ── not found β†’ throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateMedioCommand(999))); + } + + // ── idempotent ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyActive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, true)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_InactiveMedio_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(m => m.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveMedio_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await _handler.Handle(new ReactivateMedioCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.reactivate", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs new file mode 100644 index 0000000..e96aa4c --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/Update/UpdateMedioCommandHandlerTests.cs @@ -0,0 +1,83 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Medios.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Medios.Update; + +public class UpdateMedioCommandHandlerTests +{ + private readonly IMedioRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdateMedioCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, string nombre = "Original", TipoMedio tipo = TipoMedio.Diario, bool activo = true) + => new(id, "COD" + id, nombre, tipo, null, activo, DateTime.UtcNow, null); + + private static UpdateMedioCommand ValidCommand(int id = 1) => new( + Id: id, + Nombre: "Nuevo Nombre", + Tipo: TipoMedio.Radio, + PlataformaEmpresaId: 5); + + public UpdateMedioCommandHandlerTests() + { + _handler = new UpdateMedioCommandHandler(_repo, _audit); + } + + // ── not found β†’ throws ────────────────────────────────────────────────── + + [Fact] + public async Task Handle_NotFound_ThrowsMedioNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateMedioCommand(999, "X", TipoMedio.Diario, null))); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + var medio = MakeMedio(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + await _handler.Handle(ValidCommand(1)); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + var medio = MakeMedio(1, "Original", TipoMedio.Diario); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + var result = await _handler.Handle(ValidCommand(1)); + + Assert.Equal(1, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(5, result.PlataformaEmpresaId); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + var medio = MakeMedio(1); + _repo.GetByIdAsync(1, Arg.Any()).Returns(medio); + + await _handler.Handle(ValidCommand(1)); + + await _audit.Received(1).LogAsync( + action: "medio.update", + targetType: "Medio", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..b1646e6 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Create/CreateSeccionCommandHandlerTests.cs @@ -0,0 +1,113 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Create; + +public class CreateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IMedioRepository _medioRepo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly CreateSeccionCommandHandler _handler; + + private static Medio MakeMedio(int id = 1, bool activo = true) => + new(id, "COD" + id, "Medio " + id, TipoMedio.Diario, null, activo, DateTime.UtcNow, null); + + private static CreateSeccionCommand ValidCommand() => new( + MedioId: 1, + Codigo: "CLASIFICADOS_LUNES", + Nombre: "Clasificados Lunes", + Tipo: "clasificados"); + + public CreateSeccionCommandHandlerTests() + { + _handler = new CreateSeccionCommandHandler(_repo, _medioRepo, _audit); + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1)); + _repo.ExistsByCodigoInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + } + + // ── medio not found β†’ throws ───────────────────────────────────────────── + + [Fact] + public async Task Handle_MedioNotFound_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns((Medio?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + // ── duplicate codigo in medio β†’ throws ────────────────────────────────── + + [Fact] + public async Task Handle_DuplicateCodigoInMedio_ThrowsSeccionCodigoDuplicadoEnMedioException() + { + _repo.ExistsByCodigoInMedioAsync(1, "CLASIFICADOS_LUNES", Arg.Any()).Returns(true); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } + + [Fact] + public async Task Handle_DuplicateCodigo_DoesNotCallAddAsync() + { + _repo.ExistsByCodigoInMedioAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + try { await _handler.Handle(ValidCommand()); } catch (SeccionCodigoDuplicadoEnMedioException) { } + + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + // ── happy path ─────────────────────────────────────────────────────────── + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithIdFromRepository() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(10, result.Id); + } + + [Fact] + public async Task Handle_HappyPath_DtoContainsCorrectFields() + { + var result = await _handler.Handle(ValidCommand()); + + Assert.Equal(1, result.MedioId); + Assert.Equal("CLASIFICADOS_LUNES", result.Codigo); + Assert.Equal("Clasificados Lunes", result.Nombre); + Assert.Equal("clasificados", result.Tipo); + Assert.True(result.Activo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithCreateAction() + { + _repo.AddAsync(Arg.Any(), Arg.Any()).Returns(10); + + await _handler.Handle(ValidCommand()); + + await _audit.Received(1).LogAsync( + action: "seccion.create", + targetType: "Seccion", + targetId: "10", + metadata: Arg.Any(), + ct: Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveMedio_ThrowsMedioNotFoundException() + { + _medioRepo.GetByIdAsync(1, Arg.Any()).Returns(MakeMedio(1, false)); + + await Assert.ThrowsAsync( + () => _handler.Handle(ValidCommand())); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..fe72a1f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Deactivate/DeactivateSeccionCommandHandlerTests.cs @@ -0,0 +1,81 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Deactivate; + +public class DeactivateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly DeactivateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, bool activo = true) + => new(id, 1, "COD" + id, "Nombre", "clasificados", activo, DateTime.UtcNow, null); + + public DeactivateSeccionCommandHandlerTests() + { + _handler = new DeactivateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new DeactivateSeccionCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyInactive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyInactive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveSeccion_CallsUpdateAsyncWithInactiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(s => !s.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_ActiveSeccion_WritesAuditWithDeactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new DeactivateSeccionCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.deactivate", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs new file mode 100644 index 0000000..e5dce2f --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/GetById/GetSeccionByIdQueryHandlerTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.GetById; + +public class GetSeccionByIdQueryHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly GetSeccionByIdQueryHandler _handler; + + public GetSeccionByIdQueryHandlerTests() + { + _handler = new GetSeccionByIdQueryHandler(_repo); + } + + private static Seccion MakeSeccion(int id) => + new(id, 2, "COD" + id, "Nombre " + id, "suplementos", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new GetSeccionByIdQuery(999))); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithCorrectFields() + { + var seccion = MakeSeccion(5); + _repo.GetByIdAsync(5, Arg.Any()).Returns(seccion); + + var result = await _handler.Handle(new GetSeccionByIdQuery(5)); + + Assert.Equal(5, result.Id); + Assert.Equal(2, result.MedioId); + Assert.Equal("COD5", result.Codigo); + Assert.Equal("Nombre 5", result.Nombre); + Assert.Equal("suplementos", result.Tipo); + Assert.True(result.Activo); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs new file mode 100644 index 0000000..cafc558 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/List/ListSeccionesQueryHandlerTests.cs @@ -0,0 +1,64 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Tests.Secciones.List; + +public class ListSeccionesQueryHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly ListSeccionesQueryHandler _handler; + + public ListSeccionesQueryHandlerTests() + { + _handler = new ListSeccionesQueryHandler(_repo); + } + + private static Seccion MakeSeccion(int id) => + new(id, 1, "COD" + id, "Seccion " + id, "clasificados", true, DateTime.UtcNow, null); + + [Fact] + public async Task Handle_ReturnsPagedDtoItems() + { + var items = new List { MakeSeccion(1), MakeSeccion(2) }; + var pagedResult = new PagedResult(items, 1, 20, 2); + + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(pagedResult); + + var query = new ListSeccionesQuery(1, 20, null, null, null, null); + var result = await _handler.Handle(query); + + Assert.Equal(2, result.Total); + Assert.Equal(2, result.Items.Count); + Assert.Equal("COD1", result.Items[0].Codigo); + } + + [Fact] + public async Task Handle_ClampsPageSizeToMax100() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 100, 0)); + + await _handler.Handle(new ListSeccionesQuery(1, 500, null, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.PageSize == 100), + Arg.Any()); + } + + [Fact] + public async Task Handle_ClampsPageToMin1() + { + _repo.GetPagedAsync(Arg.Any(), Arg.Any()) + .Returns(new PagedResult([], 1, 20, 0)); + + await _handler.Handle(new ListSeccionesQuery(0, 20, null, null, null, null)); + + await _repo.Received(1).GetPagedAsync( + Arg.Is(q => q.Page == 1), + Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..6c95c29 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Reactivate/ReactivateSeccionCommandHandlerTests.cs @@ -0,0 +1,82 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Reactivate; + +public class ReactivateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly ReactivateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, bool activo = false) + => new(id, 1, "COD" + id, "Nombre", "clasificados", activo, DateTime.UtcNow, null); + + public ReactivateSeccionCommandHandlerTests() + { + _handler = new ReactivateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new ReactivateSeccionCommand(999))); + } + + [Fact] + public async Task Handle_AlreadyActive_IsIdempotentAndDoesNotCallUpdateAsync() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_AlreadyActive_DoesNotWriteAuditEvent() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, true)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _audit.DidNotReceive().LogAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveSeccion_CallsUpdateAsyncWithActiveEntity() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _repo.Received(1).UpdateAsync( + Arg.Is(s => s.Activo), + Arg.Any()); + } + + [Fact] + public async Task Handle_InactiveSeccion_WritesAuditWithReactivateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, false)); + + await _handler.Handle(new ReactivateSeccionCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.reactivate", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs new file mode 100644 index 0000000..3cb09ed --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/Update/UpdateSeccionCommandHandlerTests.cs @@ -0,0 +1,74 @@ +using NSubstitute; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Audit; +using SIGCM2.Application.Secciones.Update; +using SIGCM2.Domain.Entities; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Application.Tests.Secciones.Update; + +public class UpdateSeccionCommandHandlerTests +{ + private readonly ISeccionRepository _repo = Substitute.For(); + private readonly IAuditLogger _audit = Substitute.For(); + private readonly UpdateSeccionCommandHandler _handler; + + private static Seccion MakeSeccion(int id = 1, string nombre = "Original", string tipo = "clasificados") + => new(id, 1, "COD" + id, nombre, tipo, true, DateTime.UtcNow, null); + + private static UpdateSeccionCommand ValidCommand(int id = 1) => new( + Id: id, + Nombre: "Nuevo Nombre", + Tipo: "notables"); + + public UpdateSeccionCommandHandlerTests() + { + _handler = new UpdateSeccionCommandHandler(_repo, _audit); + } + + [Fact] + public async Task Handle_NotFound_ThrowsSeccionNotFoundException() + { + _repo.GetByIdAsync(999, Arg.Any()).Returns((Seccion?)null); + + await Assert.ThrowsAsync( + () => _handler.Handle(new UpdateSeccionCommand(999, "X", "clasificados"))); + } + + [Fact] + public async Task Handle_HappyPath_CallsUpdateAsyncOnce() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1)); + + await _handler.Handle(ValidCommand(1)); + + await _repo.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_HappyPath_ReturnsDtoWithUpdatedFields() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1, "Original", "clasificados")); + + var result = await _handler.Handle(ValidCommand(1)); + + Assert.Equal(1, result.Id); + Assert.Equal("Nuevo Nombre", result.Nombre); + Assert.Equal("notables", result.Tipo); + } + + [Fact] + public async Task Handle_HappyPath_CallsAuditWithUpdateAction() + { + _repo.GetByIdAsync(1, Arg.Any()).Returns(MakeSeccion(1)); + + await _handler.Handle(ValidCommand(1)); + + await _audit.Received(1).LogAsync( + action: "seccion.update", + targetType: "Seccion", + targetId: "1", + metadata: Arg.Any(), + ct: Arg.Any()); + } +} From a1a8e6e0cbae6cc5967acd9e0c40188925f4df98 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:04:06 -0300 Subject: [PATCH 4/9] =?UTF-8?q?fix(tests):=20realign=20test=20expectations?= =?UTF-8?q?=20with=20V011=20(ADM-001)=20seed=20=E2=80=94=2022=20permisos?= =?UTF-8?q?=20+=20Medios=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Infrastructure/RefreshTokenRepositoryTests.cs | 3 +++ .../Integration/PermisoRepositoryTests.cs | 7 ++++--- .../Integration/RolPermisoRepositoryTests.cs | 7 ++++--- .../Integration/UsuarioRepositoryTests.cs | 3 +++ .../Integration/UsuarioRepository_PermisosTests.cs | 3 +++ .../Integration/V009MigrationTests.cs | 3 +++ 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs index ccd6313..ccbfd02 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/RefreshTokenRepositoryTests.cs @@ -41,6 +41,9 @@ public class RefreshTokenRepositoryTests : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs index d3b7653..1f31a32 100644 --- a/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/PermisoRepositoryTests.cs @@ -74,12 +74,13 @@ public class PermisoRepositoryTests : IAsyncLifetime // ── ListAsync ──────────────────────────────────────────────────────────── [Fact] - public async Task ListAsync_Returns21CanonicalSeeds() + public async Task ListAsync_Returns22CanonicalSeeds() { var list = await _repository.ListAsync(); - // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos = 21 total - Assert.Equal(21, list.Count); + // V005 seeds 18 canonical permisos + V007 (UDT-006) adds 3 admin permisos + // + V011 (ADM-001) adds 'administracion:secciones:gestionar' = 22 total + Assert.Equal(22, list.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs index acf0335..658f250 100644 --- a/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/RolPermisoRepositoryTests.cs @@ -174,12 +174,13 @@ public class RolPermisoRepositoryTests : IAsyncLifetime // ── GetByRolCodigoAsync ────────────────────────────────────────────────── [Fact] - public async Task GetByRolCodigoAsync_Admin_Returns21Permisos() + public async Task GetByRolCodigoAsync_Admin_Returns22Permisos() { - // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) = 21 total + // admin has 18 permisos from V006 + 3 new admin permisos from V007 (UDT-006) + // + 1 from V011 (ADM-001): 'administracion:secciones:gestionar' = 22 total var permisos = await _repository.GetByRolCodigoAsync("admin"); - Assert.Equal(21, permisos.Count); + Assert.Equal(22, permisos.Count); } [Fact] diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs index 6eabd33..053ae71 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepositoryTests.cs @@ -33,6 +33,9 @@ public class UsuarioRepositoryTests : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs index 6418293..4395f32 100644 --- a/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/UsuarioRepository_PermisosTests.cs @@ -37,6 +37,9 @@ public sealed class UsuarioRepository_PermisosTests : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs index 281b24e..6c042f3 100644 --- a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs +++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs @@ -36,6 +36,9 @@ public sealed class V009MigrationTests : IAsyncLifetime new Respawn.Graph.Table("dbo", "Rol_History"), new Respawn.Graph.Table("dbo", "Permiso_History"), new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); From 2f0da2d7202dbcc73f3cdb29f4a0adde087d6798 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:04:09 -0300 Subject: [PATCH 5/9] =?UTF-8?q?feat(infra):=20MedioRepository=20+=20Seccio?= =?UTF-8?q?nRepository=20+=20integration=20tests=20=E2=80=94=20ADM-001=20B?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DependencyInjection.cs | 2 + .../Persistence/MedioRepository.cs | 188 +++++++++++++ .../Persistence/SeccionRepository.cs | 197 +++++++++++++ .../Medios/MedioRepositoryTests.cs | 262 ++++++++++++++++++ .../Secciones/SeccionRepositoryTests.cs | 256 +++++++++++++++++ 5 files changed, 905 insertions(+) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs create mode 100644 tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs create mode 100644 tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index e9fe1b3..7887203 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -32,6 +32,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // JWT Options β€” bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs new file mode 100644 index 0000000..99dc1ca --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs @@ -0,0 +1,188 @@ +using System.Text; +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class MedioRepository : IMedioRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public MedioRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(Medio m, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, PlataformaEmpresaId) + OUTPUT INSERTED.Id + VALUES (@Codigo, @Nombre, @Tipo, @PlataformaEmpresaId) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + m.Codigo, + m.Nombre, + Tipo = (int)m.Tipo, + m.PlataformaEmpresaId, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion + FROM dbo.Medio + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsByCodigoAsync(string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Medio WHERE Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { Codigo = codigo }); + return count > 0; + } + + public async Task UpdateAsync(Medio m, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Medio + SET Nombre = @Nombre, + Tipo = @Tipo, + PlataformaEmpresaId = @PlataformaEmpresaId, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + m.Nombre, + Tipo = (int)m.Tipo, + m.PlataformaEmpresaId, + m.Activo, + FechaModificacion = m.FechaModificacion ?? DateTime.UtcNow, + m.Id, + }); + } + + public async Task> GetPagedAsync(MediosQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + if (q.Tipo.HasValue) + { + where.Append(" AND Tipo = @Tipo"); + parameters.Add("Tipo", (int)q.Tipo.Value); + } + + if (!string.IsNullOrWhiteSpace(q.Search)) + { + where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)"); + parameters.Add("Search", $"%{q.Search}%"); + } + + var sql = $""" + SELECT + Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.Medio + {where} + ORDER BY Codigo + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static Medio MapRow(MedioRow r) + => new( + id: r.Id, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: (TipoMedio)r.Tipo, + plataformaEmpresaId: r.PlataformaEmpresaId, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static Medio MapRow(MedioPagedRow r) + => new( + id: r.Id, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: (TipoMedio)r.Tipo, + plataformaEmpresaId: r.PlataformaEmpresaId, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record MedioRow( + int Id, + string Codigo, + string Nombre, + byte Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record MedioPagedRow( + int Id, + string Codigo, + string Nombre, + byte Tipo, + int? PlataformaEmpresaId, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs new file mode 100644 index 0000000..6ec75eb --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs @@ -0,0 +1,197 @@ +using System.Text; +using Dapper; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class SeccionRepository : ISeccionRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public SeccionRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(Seccion s, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + // FK_Seccion_Medio: if MedioId does not exist, SQL Server raises FK violation β€” let it bubble. + const string sql = """ + INSERT INTO dbo.Seccion (MedioId, Codigo, Nombre, Tipo) + OUTPUT INSERTED.Id + VALUES (@MedioId, @Codigo, @Nombre, @Tipo) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.ExecuteScalarAsync(sql, new + { + s.MedioId, + s.Codigo, + s.Nombre, + s.Tipo, + }); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion + FROM dbo.Seccion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var row = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default) + { + const string sql = """ + SELECT COUNT(1) FROM dbo.Seccion WHERE MedioId = @MedioId AND Codigo = @Codigo + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { MedioId = medioId, Codigo = codigo }); + return count > 0; + } + + public async Task UpdateAsync(Seccion s, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.Seccion + SET Nombre = @Nombre, + Tipo = @Tipo, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + await connection.ExecuteAsync(sql, new + { + s.Nombre, + s.Tipo, + s.Activo, + FechaModificacion = s.FechaModificacion ?? DateTime.UtcNow, + s.Id, + }); + } + + public async Task> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.PageSize, 1, 100); + var offset = (page - 1) * pageSize; + + var where = new StringBuilder("WHERE 1=1"); + var parameters = new DynamicParameters(); + parameters.Add("PageSize", pageSize); + parameters.Add("Offset", offset); + + if (q.MedioId.HasValue) + { + where.Append(" AND MedioId = @MedioId"); + parameters.Add("MedioId", q.MedioId.Value); + } + + if (!string.IsNullOrWhiteSpace(q.Tipo)) + { + where.Append(" AND Tipo = @Tipo"); + parameters.Add("Tipo", q.Tipo); + } + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + if (!string.IsNullOrWhiteSpace(q.Search)) + { + where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)"); + parameters.Add("Search", $"%{q.Search}%"); + } + + // ADM-001: filter only Seccion.Activo; Medio.Activo check is left to Application/UI layer. + // Joining Medio to filter on m.Activo would affect performance for large catalogs and is + // not required by the current specs. REQ-SEC-003 (Deactivate Medio hides Secciones) is + // enforced at the Application handler level, not the query level. + var sql = $""" + SELECT + Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.Seccion + {where} + ORDER BY MedioId, Codigo + OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var rows = await connection.QueryAsync(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static Seccion MapRow(SeccionRow r) + => new( + id: r.Id, + medioId: r.MedioId, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: r.Tipo, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static Seccion MapRow(SeccionPagedRow r) + => new( + id: r.Id, + medioId: r.MedioId, + codigo: r.Codigo, + nombre: r.Nombre, + tipo: r.Tipo, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private sealed record SeccionRow( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record SeccionPagedRow( + int Id, + int MedioId, + string Codigo, + string Nombre, + string Tipo, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +} diff --git a/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs new file mode 100644 index 0000000..3326dde --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Medios/MedioRepositoryTests.cs @@ -0,0 +1,262 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Medios; + +/// +/// Integration tests for MedioRepository against SIGCM2_Test. +/// TDD: RED written before implementation, GREEN after MedioRepository was created. +/// Temporal: after UpdateAsync, dbo.Medio_History MUST have β‰₯1 row for that Id. +/// +[Collection("Database")] +public class MedioRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private Respawner _respawner = null!; + private MedioRepository _repository = null!; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer, + TablesToIgnore = + [ + new Respawn.Graph.Table("dbo", "Rol"), + new Respawn.Graph.Table("dbo", "Permiso"), + new Respawn.Graph.Table("dbo", "RolPermiso"), + // *_History tables are system-versioned β€” engine rejects direct DELETE. + new Respawn.Graph.Table("dbo", "Usuario_History"), + new Respawn.Graph.Table("dbo", "Rol_History"), + new Respawn.Graph.Table("dbo", "Permiso_History"), + new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new MedioRepository(factory); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_ThenGetById_ReturnsAllColumns() + { + var medio = Medio.ForCreation("DIARIO01", "Diario Uno", TipoMedio.Diario, null); + + var id = await _repository.AddAsync(medio); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal("DIARIO01", result.Codigo); + Assert.Equal("Diario Uno", result.Nombre); + Assert.Equal(TipoMedio.Diario, result.Tipo); + Assert.Null(result.PlataformaEmpresaId); + Assert.True(result.Activo); + Assert.True(result.FechaCreacion > DateTime.MinValue); + Assert.Null(result.FechaModificacion); + } + + [Fact] + public async Task AddAsync_WithPlataformaEmpresaId_PersistsValue() + { + var medio = Medio.ForCreation("RADIO99", "Radio Test", TipoMedio.Radio, 42); + + var id = await _repository.AddAsync(medio); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(42, result!.PlataformaEmpresaId); + Assert.Equal(TipoMedio.Radio, result.Tipo); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + Assert.Null(result); + } + + // ── ExistsByCodigoAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ExistsByCodigoAsync_AfterAdd_ReturnsTrue() + { + var medio = Medio.ForCreation("EXIST01", "Existe", TipoMedio.Web, null); + await _repository.AddAsync(medio); + + var exists = await _repository.ExistsByCodigoAsync("EXIST01"); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByCodigoAsync_NotAdded_ReturnsFalse() + { + var exists = await _repository.ExistsByCodigoAsync("NOEXISTE_XYZ"); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByCodigoAsync_IsCaseSensitive_MatchesAsStored() + { + var medio = Medio.ForCreation("UPPER01", "Upper Medio", TipoMedio.Diario, null); + await _repository.AddAsync(medio); + + // Stored as 'UPPER01'; searching lowercase should not match (SQL_Latin1 collation is CI + // on most default installs, but the contract is: match as stored). + var exactMatch = await _repository.ExistsByCodigoAsync("UPPER01"); + Assert.True(exactMatch); + } + + // ── UpdateAsync + Temporal ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ThenQuery_ReflectsNewValues() + { + var id = await _repository.AddAsync(Medio.ForCreation("UPD01", "Original", TipoMedio.Diario, null)); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Actualizado", TipoMedio.Radio, 7); + await _repository.UpdateAsync(updated); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal("Actualizado", result!.Nombre); + Assert.Equal(TipoMedio.Radio, result.Tipo); + Assert.Equal(7, result.PlataformaEmpresaId); + Assert.NotNull(result.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_ProducesHistoryRow() + { + // Temporal: SQL Server automatically writes the previous row version to Medio_History on UPDATE. + var id = await _repository.AddAsync(Medio.ForCreation("HIST01", "Historial", TipoMedio.Diario, null)); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Historial v2", TipoMedio.Web, null); + await _repository.UpdateAsync(updated); + + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Medio_History WHERE Id = @Id", new { Id = id }); + + Assert.True(historyCount >= 1, $"Expected β‰₯1 history row for Medio Id={id}, got {historyCount}"); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FilterByActivo_ReturnsOnlyActiveWhenTrue() + { + var idActivo = await _repository.AddAsync(Medio.ForCreation("ACTV01", "Activo", TipoMedio.Diario, null)); + var idInact = await _repository.AddAsync(Medio.ForCreation("INACT01", "Inactivo", TipoMedio.Diario, null)); + + // Deactivate second medio + var inact = await _repository.GetByIdAsync(idInact); + await _repository.UpdateAsync(inact!.WithActivo(false)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: true, Tipo: null, Search: null)); + + var codigos = result.Items.Select(m => m.Codigo).ToHashSet(); + Assert.Contains("ACTV01", codigos); + Assert.DoesNotContain("INACT01", codigos); + } + + [Fact] + public async Task GetPagedAsync_FilterByTipo_ReturnsOnlyMatchingTipo() + { + await _repository.AddAsync(Medio.ForCreation("RADIO01", "Radio Uno", TipoMedio.Radio, null)); + await _repository.AddAsync(Medio.ForCreation("WEB01", "Web Uno", TipoMedio.Web, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: null, Tipo: TipoMedio.Radio, Search: null)); + + Assert.All(result.Items, m => Assert.Equal(TipoMedio.Radio, m.Tipo)); + Assert.Contains(result.Items, m => m.Codigo == "RADIO01"); + } + + [Fact] + public async Task GetPagedAsync_SearchByNombre_ReturnsMatches() + { + await _repository.AddAsync(Medio.ForCreation("SRCH01", "Buscable Nombre", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("SRCH02", "Otro Medio", TipoMedio.Diario, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, Activo: null, Tipo: null, Search: "Buscable")); + + Assert.Single(result.Items, m => m.Codigo == "SRCH01"); + } + + [Fact] + public async Task GetPagedAsync_PaginationClamping_PageSizeClampedTo1Min() + { + await _repository.AddAsync(Medio.ForCreation("PAG01", "Paginacion", TipoMedio.Diario, null)); + + // pageSize=0 should clamp to 1 + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 0, Activo: null, Tipo: null, Search: null)); + + Assert.Equal(1, result.PageSize); + } + + [Fact] + public async Task GetPagedAsync_TotalCount_ReflectsAllMatchingRows() + { + await _repository.AddAsync(Medio.ForCreation("CNT01", "Contador 1", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("CNT02", "Contador 2", TipoMedio.Diario, null)); + await _repository.AddAsync(Medio.ForCreation("CNT03", "Contador 3", TipoMedio.Diario, null)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 2, Activo: null, Tipo: null, Search: "Contador")); + + Assert.Equal(3, result.Total); + Assert.Equal(2, result.Items.Count); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'EdiciΓ³n de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'SupervisiΓ³n de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'DiagramaciΓ³n/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); + } +} diff --git a/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs new file mode 100644 index 0000000..bba9f94 --- /dev/null +++ b/tests/SIGCM2.Application.Tests/Secciones/SeccionRepositoryTests.cs @@ -0,0 +1,256 @@ +using Dapper; +using Microsoft.Data.SqlClient; +using Respawn; +using SIGCM2.Domain.Entities; +using SIGCM2.Infrastructure.Persistence; + +namespace SIGCM2.Application.Tests.Secciones; + +/// +/// Integration tests for SeccionRepository against SIGCM2_Test. +/// TDD: RED written before implementation, GREEN after SeccionRepository was created. +/// Temporal: after UpdateAsync, dbo.Seccion_History MUST have β‰₯1 row for that Id. +/// +[Collection("Database")] +public class SeccionRepositoryTests : IAsyncLifetime +{ + private const string ConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private SqlConnection _connection = null!; + private Respawner _respawner = null!; + private SeccionRepository _repository = null!; + private MedioRepository _medioRepository = null!; + private int _medioId; + + public async Task InitializeAsync() + { + _connection = new SqlConnection(ConnectionString); + await _connection.OpenAsync(); + + _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer, + TablesToIgnore = + [ + new Respawn.Graph.Table("dbo", "Rol"), + new Respawn.Graph.Table("dbo", "Permiso"), + new Respawn.Graph.Table("dbo", "RolPermiso"), + // *_History tables are system-versioned β€” engine rejects direct DELETE. + new Respawn.Graph.Table("dbo", "Usuario_History"), + new Respawn.Graph.Table("dbo", "Rol_History"), + new Respawn.Graph.Table("dbo", "Permiso_History"), + new Respawn.Graph.Table("dbo", "RolPermiso_History"), + // ADM-001 (V011): Medio + Seccion are temporal β€” history tables cannot be directly deleted. + new Respawn.Graph.Table("dbo", "Medio_History"), + new Respawn.Graph.Table("dbo", "Seccion_History"), + ] + }); + + await _respawner.ResetAsync(_connection); + await SeedRolCanonicalAsync(); + + var factory = new SqlConnectionFactory(ConnectionString); + _repository = new SeccionRepository(factory); + _medioRepository = new MedioRepository(factory); + + // Seed a canonical Medio for FK-valid Seccion tests. + _medioId = await _medioRepository.AddAsync(Medio.ForCreation("TESTMEDIO", "Medio de Prueba", TipoMedio.Diario, null)); + } + + public async Task DisposeAsync() + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + // ── AddAsync + GetByIdAsync roundtrip ───────────────────────────────────── + + [Fact] + public async Task AddAsync_ThenGetById_ReturnsAllColumns() + { + var seccion = Seccion.ForCreation(_medioId, "SEC01", "SecciΓ³n Uno", "clasificados"); + + var id = await _repository.AddAsync(seccion); + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal(_medioId, result.MedioId); + Assert.Equal("SEC01", result.Codigo); + Assert.Equal("SecciΓ³n Uno", result.Nombre); + Assert.Equal("clasificados", result.Tipo); + Assert.True(result.Activo); + Assert.True(result.FechaCreacion > DateTime.MinValue); + Assert.Null(result.FechaModificacion); + } + + [Fact] + public async Task GetByIdAsync_NonExistent_ReturnsNull() + { + var result = await _repository.GetByIdAsync(999999); + + Assert.Null(result); + } + + // ── FK violation ────────────────────────────────────────────────────────── + + [Fact] + public async Task AddAsync_WithInvalidMedioId_ThrowsSqlException() + { + var seccion = Seccion.ForCreation(99999, "FKERR01", "FK Error", "notables"); + + await Assert.ThrowsAsync( + () => _repository.AddAsync(seccion)); + } + + // ── ExistsByCodigoInMedioAsync ───────────────────────────────────────────── + + [Fact] + public async Task ExistsByCodigoInMedioAsync_AfterAdd_ReturnsTrue() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "EXIST01", "Existe", "clasificados")); + + var exists = await _repository.ExistsByCodigoInMedioAsync(_medioId, "EXIST01"); + + Assert.True(exists); + } + + [Fact] + public async Task ExistsByCodigoInMedioAsync_NotAdded_ReturnsFalse() + { + var exists = await _repository.ExistsByCodigoInMedioAsync(_medioId, "NOEXISTE_XYZ"); + + Assert.False(exists); + } + + [Fact] + public async Task ExistsByCodigoInMedioAsync_SameCodigoDifferentMedio_ReturnsFalse() + { + // Seccion with same Codigo exists for _medioId, but NOT for a different medioId. + await _repository.AddAsync(Seccion.ForCreation(_medioId, "SHARED01", "Shared Codigo", "suplementos")); + + var otherMedioId = await _medioRepository.AddAsync(Medio.ForCreation("OTRO01", "Otro Medio", TipoMedio.Radio, null)); + var exists = await _repository.ExistsByCodigoInMedioAsync(otherMedioId, "SHARED01"); + + Assert.False(exists); + } + + // ── UpdateAsync + Temporal ──────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_ThenQuery_ReflectsNewValues() + { + var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "UPD01", "Original", "clasificados")); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Actualizado", "notables"); + await _repository.UpdateAsync(updated); + + var result = await _repository.GetByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal("Actualizado", result!.Nombre); + Assert.Equal("notables", result.Tipo); + Assert.NotNull(result.FechaModificacion); + } + + [Fact] + public async Task UpdateAsync_ProducesHistoryRow() + { + // Temporal: SQL Server automatically writes the previous row version to Seccion_History on UPDATE. + var id = await _repository.AddAsync(Seccion.ForCreation(_medioId, "HIST01", "Historial", "clasificados")); + var original = await _repository.GetByIdAsync(id); + + var updated = original!.WithUpdatedProfile("Historial v2", "suplementos"); + await _repository.UpdateAsync(updated); + + var historyCount = await _connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dbo.Seccion_History WHERE Id = @Id", new { Id = id }); + + Assert.True(historyCount >= 1, $"Expected β‰₯1 history row for Seccion Id={id}, got {historyCount}"); + } + + // ── GetPagedAsync ───────────────────────────────────────────────────────── + + [Fact] + public async Task GetPagedAsync_FilterByMedioId_ReturnsOnlySecciones_OfThatMedio() + { + var otherMedioId = await _medioRepository.AddAsync(Medio.ForCreation("OTHER02", "Otro Medio 2", TipoMedio.Radio, null)); + + await _repository.AddAsync(Seccion.ForCreation(_medioId, "M1S01", "M1 Sec 1", "clasificados")); + await _repository.AddAsync(Seccion.ForCreation(otherMedioId, "M2S01", "M2 Sec 1", "notables")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: _medioId, Tipo: null, Activo: null, Search: null)); + + Assert.All(result.Items, s => Assert.Equal(_medioId, s.MedioId)); + Assert.Contains(result.Items, s => s.Codigo == "M1S01"); + Assert.DoesNotContain(result.Items, s => s.Codigo == "M2S01"); + } + + [Fact] + public async Task GetPagedAsync_FilterByTipo_ReturnsOnlyMatchingTipo() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "CL01", "Clasificados", "clasificados")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "NT01", "Notables", "notables")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: null, Tipo: "clasificados", Activo: null, Search: null)); + + Assert.All(result.Items, s => Assert.Equal("clasificados", s.Tipo)); + Assert.Contains(result.Items, s => s.Codigo == "CL01"); + } + + [Fact] + public async Task GetPagedAsync_FilterByActivo_ReturnsOnlyActive() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "ACTV01", "Activa", "clasificados")); + var inactId = await _repository.AddAsync(Seccion.ForCreation(_medioId, "INACT01", "Inactiva", "clasificados")); + + var inact = await _repository.GetByIdAsync(inactId); + await _repository.UpdateAsync(inact!.WithActivo(false)); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 50, MedioId: _medioId, Tipo: null, Activo: true, Search: null)); + + var codigos = result.Items.Select(s => s.Codigo).ToHashSet(); + Assert.Contains("ACTV01", codigos); + Assert.DoesNotContain("INACT01", codigos); + } + + [Fact] + public async Task GetPagedAsync_TotalCount_ReflectsAllMatchingRows() + { + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P01", "Page 1", "suplementos")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P02", "Page 2", "suplementos")); + await _repository.AddAsync(Seccion.ForCreation(_medioId, "P03", "Page 3", "suplementos")); + + var result = await _repository.GetPagedAsync(new(Page: 1, PageSize: 2, MedioId: _medioId, Tipo: "suplementos", Activo: null, Search: null)); + + Assert.Equal(3, result.Total); + Assert.Equal(2, result.Items.Count); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedRolCanonicalAsync() + { + const string sql = """ + SET QUOTED_IDENTIFIER ON; + MERGE dbo.Rol AS t + USING (VALUES + ('admin', N'Administrador', N'Supervisor total'), + ('cajero', N'Cajero', N'Mostrador contado'), + ('operador_ctacte', N'Operador Cta Cte', N'Cuenta corriente'), + ('picadora', N'Picadora/Correctora', N'EdiciΓ³n de textos'), + ('jefe_publicidad', N'Jefe de Publicidad', N'SupervisiΓ³n de pauta'), + ('productor', N'Productor', N'Carga restringida'), + ('diagramacion', N'DiagramaciΓ³n/Taller', N'Solo lectura pauta'), + ('reportes', N'Reportes', N'Solo lectura reportes') + ) AS s (Codigo, Nombre, Descripcion) + ON t.Codigo = s.Codigo + WHEN NOT MATCHED BY TARGET THEN + INSERT (Codigo, Nombre, Descripcion, Activo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, 1); + """; + await _connection.ExecuteAsync(sql); + } +} From a6f4011806c83f1cb2af6834cce90b7bbedcf08e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:08:32 -0300 Subject: [PATCH 6/9] fix(tests): resolve ADM-001 regressions in Api.Tests fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update hardcoded permiso count from 21 β†’ 22 in AuthControllerTests and PermisosEndpointTests after V011 added 'administracion:secciones:gestionar' - The TestSupport SqlTestFixture already had Medio_History/Seccion_History in TablesToIgnore; tests were failing due to stale binaries (needed rebuild) --- tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs | 3 ++- .../Permisos/PermisosEndpointTests.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs index d359292..88dfc97 100644 --- a/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs +++ b/tests/SIGCM2.Api.Tests/Auth/AuthControllerTests.cs @@ -47,7 +47,8 @@ public class AuthControllerTests Assert.False(string.IsNullOrWhiteSpace(nombre.GetString()), "'usuario.nombre' must not be empty"); Assert.False(string.IsNullOrWhiteSpace(rol.GetString()), "'usuario.rol' must not be empty"); Assert.Equal(JsonValueKind.Array, permisos.ValueKind); - Assert.Equal(21, permisos.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' β†’ 22 total + Assert.Equal(22, permisos.GetArrayLength()); } // Scenario: invalid credentials return 401 with opaque error diff --git a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs index efdbbca..b04c080 100644 --- a/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs +++ b/tests/SIGCM2.Api.Tests/Permisos/PermisosEndpointTests.cs @@ -130,7 +130,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/permisos β€” catalog ─────────────────────────────────────── [Fact] - public async Task GetPermisos_WithAdmin_Returns200With21Items() + public async Task GetPermisos_WithAdmin_Returns200With22Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/permisos", bearerToken: token); @@ -138,8 +138,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - // V007 (UDT-006) adds 3 new admin permisos β†’ 21 total - Assert.Equal(21, list.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' β†’ 22 total + Assert.Equal(22, list.GetArrayLength()); } [Fact] @@ -182,7 +182,7 @@ public sealed class PermisosEndpointTests : IAsyncLifetime // ── GET /api/v1/roles/{codigo}/permisos ────────────────────────────────── [Fact] - public async Task GetRolPermisos_AdminRol_Returns200With21Items() + public async Task GetRolPermisos_AdminRol_Returns200With22Items() { var token = await GetBearerTokenAsync(AdminUsername, AdminPassword); using var req = BuildRequest(HttpMethod.Get, "/api/v1/roles/admin/permisos", bearerToken: token); @@ -190,8 +190,8 @@ public sealed class PermisosEndpointTests : IAsyncLifetime Assert.Equal(HttpStatusCode.OK, resp.StatusCode); var list = await resp.Content.ReadFromJsonAsync(); - // V007 (UDT-006) adds 3 new admin permisos β†’ 21 total - Assert.Equal(21, list.GetArrayLength()); + // V011 (ADM-001) adds 'administracion:secciones:gestionar' β†’ 22 total + Assert.Equal(22, list.GetArrayLength()); } [Fact] From 13480ad8c2a4b221db8b44f6ca32a422aaf4476c Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:16:33 -0300 Subject: [PATCH 7/9] =?UTF-8?q?feat(api):=20MediosController=20+=20Seccion?= =?UTF-8?q?esController=20+=20ExceptionFilter=20mappings=20=E2=80=94=20ADM?= =?UTF-8?q?-001=20B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/medios - POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/secciones - ExceptionFilter: add Medio/Seccion 404+409 mappings after RolInUseException - Integration tests: 19 scenarios covering 401/403/201/404/409/idempotency/AuditEvent - All 166 Api.Tests + 458 Application.Tests passing --- .../Controllers/MediosController.cs | 173 +++++++ .../Controllers/SeccionesController.cs | 172 +++++++ src/api/SIGCM2.Api/Filters/ExceptionFilter.cs | 50 ++ .../Admin/MediosControllerTests.cs | 412 +++++++++++++++++ .../Admin/SeccionesControllerTests.cs | 429 ++++++++++++++++++ 5 files changed, 1236 insertions(+) create mode 100644 src/api/SIGCM2.Api/Controllers/MediosController.cs create mode 100644 src/api/SIGCM2.Api/Controllers/SeccionesController.cs create mode 100644 tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs create mode 100644 tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs diff --git a/src/api/SIGCM2.Api/Controllers/MediosController.cs b/src/api/SIGCM2.Api/Controllers/MediosController.cs new file mode 100644 index 0000000..9ebf5f1 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/MediosController.cs @@ -0,0 +1,173 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Medios.Create; +using SIGCM2.Application.Medios.Deactivate; +using SIGCM2.Application.Medios.GetById; +using SIGCM2.Application.Medios.List; +using SIGCM2.Application.Medios.Reactivate; +using SIGCM2.Application.Medios.Update; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-001: Medio management endpoints at /api/v1/admin/medios. +/// All endpoints require permission 'administracion:medios:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/medios")] +public sealed class MediosController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public MediosController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new medio. Requires administracion:medios:gestionar. + [HttpPost] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateMedio([FromBody] CreateMedioRequest request) + { + var command = new CreateMedioCommand( + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? TipoMedio.Diario, + PlataformaEmpresaId: request.PlataformaEmpresaId); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result); + } + + /// Lists medios with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListMedios( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] bool? activo = null, + [FromQuery] TipoMedio? tipo = null, + [FromQuery] string? q = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListMediosQuery(page, pageSize, activo, tipo, q); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single medio by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMedioById([FromRoute] int id) + { + var query = new GetMedioByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a medio's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request) + { + var command = new UpdateMedioCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? TipoMedio.Diario, + PlataformaEmpresaId: request.PlataformaEmpresaId); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a medio (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateMedio([FromRoute] int id) + { + var command = new DeactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a medio (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateMedio([FromRoute] int id) + { + var command = new ReactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create medio request body. +public sealed record CreateMedioRequest( + string? Codigo, + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); + +/// ADM-001: Update medio request body. +public sealed record UpdateMedioRequest( + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Api/Controllers/SeccionesController.cs b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs new file mode 100644 index 0000000..9ff6653 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs @@ -0,0 +1,172 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.Secciones.Create; +using SIGCM2.Application.Secciones.Deactivate; +using SIGCM2.Application.Secciones.GetById; +using SIGCM2.Application.Secciones.List; +using SIGCM2.Application.Secciones.Reactivate; +using SIGCM2.Application.Secciones.Update; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones. +/// All endpoints require permission 'administracion:secciones:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/secciones")] +public sealed class SeccionesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public SeccionesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new seccion. Requires administracion:secciones:gestionar. + [HttpPost] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreateSeccion([FromBody] CreateSeccionRequest request) + { + var command = new CreateSeccionCommand( + MedioId: request.MedioId ?? 0, + Codigo: request.Codigo ?? string.Empty, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? string.Empty); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result); + } + + /// Lists secciones with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListSecciones( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] int? medioId = null, + [FromQuery] string? tipo = null, + [FromQuery] bool? activo = null, + [FromQuery] string? q = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single seccion by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSeccionById([FromRoute] int id) + { + var query = new GetSeccionByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a seccion's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request) + { + var command = new UpdateSeccionCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + Tipo: request.Tipo ?? string.Empty); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a seccion (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateSeccion([FromRoute] int id) + { + var command = new DeactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a seccion (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateSeccion([FromRoute] int id) + { + var command = new ReactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create seccion request body. +public sealed record CreateSeccionRequest( + int? MedioId, + string? Codigo, + string? Nombre, + string? Tipo); + +/// ADM-001: Update seccion request body. +public sealed record UpdateSeccionRequest( + string? Nombre, + string? Tipo); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f566bf3..77e481e 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -169,6 +169,56 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // ADM-001: Medio exceptions + case MedioCodigoDuplicadoException medioCodDupEx: + context.Result = new ObjectResult(new + { + error = "medio_codigo_duplicado", + message = medioCodDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case MedioNotFoundException medioNotFoundEx: + context.Result = new ObjectResult(new + { + error = "medio_not_found", + message = medioNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + // ADM-001: Seccion exceptions + case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx: + context.Result = new ObjectResult(new + { + error = "seccion_codigo_duplicado_en_medio", + message = seccionCodDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case SeccionNotFoundException seccionNotFoundEx: + context.Result = new ObjectResult(new + { + error = "seccion_not_found", + message = seccionNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + // UDT-009: permiso override validation errors case InvalidPermisoCodesException ipce: context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails diff --git a/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs new file mode 100644 index 0000000..bb079a1 --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/MediosControllerTests.cs @@ -0,0 +1,412 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Admin; + +/// +/// ADM-001 B6 β€” Integration tests for /api/v1/admin/medios. +/// All endpoints require permission 'administracion:medios:gestionar'. +/// +[Collection("ApiIntegration")] +public sealed class MediosControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/admin/medios"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public MediosControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetCajeroTokenAsync(string username) + { + var adminToken = await GetAdminTokenAsync(); + + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username, + password = "Secure1234!", + nombre = "Cajero", + apellido = "Test", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkResp = await _client.SendAsync(mkUser); + if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}"); + + var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username, + password = "Secure1234!" + }); + loginResp.EnsureSuccessStatusCode(); + var loginJson = await loginResp.Content.ReadFromJsonAsync(); + return loginJson.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 static async Task DeleteMedioIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + + var id = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); + if (id is null) return; + + // Delete dependent secciones first (disable versioning to also clear history) + await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete the medio itself + await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + + private static async Task DeleteUsuarioIfExistsAsync(string username) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + DELETE rt FROM dbo.RefreshToken rt + INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId + WHERE u.Username = @Username + """, new { Username = username }); + await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username }); + } + + private static async Task CountAuditEventsAsync(string action, string targetType, string targetId) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId", + new { Action = action, TargetType = targetType, TargetId = targetId }); + } + + // ── 401 / 403 guards ───────────────────────────────────────────────────── + + [Fact] + public async Task CreateMedio_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "TESTMEDIO401", + nombre = "Test Medio", + tipo = 1 + }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task CreateMedio_WithCajeroRole_Returns403() + { + const string username = "adm001_medio_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo = "TESTMEDIO403", + nombre = "Test Medio", + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // ── CREATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateMedio_WithAdmin_Returns201AndAuditEvent() + { + const string codigo = "TESTCREATE201"; + var token = await GetAdminTokenAsync(); + + try + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Test Create 201", + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + Assert.NotNull(resp.Headers.Location); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out var id)); + Assert.True(id.GetInt32() > 0); + Assert.True(json.TryGetProperty("codigo", out var codigoEl)); + Assert.Equal(codigo, codigoEl.GetString()); + Assert.True(json.TryGetProperty("activo", out var activo)); + Assert.True(activo.GetBoolean()); + + // Verify AuditEvent was created + var medioId = id.GetInt32().ToString(); + var auditCount = await CountAuditEventsAsync("medio.create", "Medio", medioId); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + [Fact] + public async Task CreateMedio_DuplicateCodigo_Returns409() + { + const string codigo = "TESTDUPLICATE"; + var token = await GetAdminTokenAsync(); + + try + { + // First create + using var first = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Primer Medio", + tipo = 1 + }, token); + var firstResp = await _client.SendAsync(first); + Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); + + // Second create with same codigo + using var second = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Segundo Medio", + tipo = 2 + }, token); + var secondResp = await _client.SendAsync(second); + + Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); + var json = await secondResp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("error", out var error)); + Assert.Equal("medio_codigo_duplicado", error.GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + // ── LIST ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMedios_WithAdmin_Returns200WithSeedRows() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}?activo=true", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out var items), "Response must have 'items'"); + Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'"); + + // The seed includes ELDIA and ELPLATA + var codigosInResponse = items.EnumerateArray() + .Select(i => i.GetProperty("codigo").GetString()) + .ToList(); + Assert.Contains("ELDIA", codigosInResponse); + Assert.Contains("ELPLATA", codigosInResponse); + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetMedioById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("error", out var error)); + Assert.Equal("medio_not_found", error.GetString()); + } + + // ── UPDATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateMedio_WithAdmin_Returns200AndAuditEventAndHistory() + { + const string codigo = "TESTUPDATEMEDIO"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Original", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // Update + using var updateReq = BuildRequest(HttpMethod.Put, $"{Endpoint}/{medioId}", new + { + nombre = "Medio Actualizado", + tipo = 2 + }, token); + var updateResp = await _client.SendAsync(updateReq); + + Assert.Equal(HttpStatusCode.OK, updateResp.StatusCode); + var updated = await updateResp.Content.ReadFromJsonAsync(); + Assert.Equal("Medio Actualizado", updated.GetProperty("nombre").GetString()); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("medio.update", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + + // Verify Medio_History row exists + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + var histCount = await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM dbo.Medio_History WHERE Id = @Id", + new { Id = medioId }); + Assert.True(histCount >= 1, "Should have at least one row in Medio_History after update"); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + // ── DEACTIVATE ──────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateMedio_WithAdmin_Returns204AndAuditEvent() + { + const string codigo = "TESTDEACTIVATE"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Para Desactivar", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // Deactivate + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("medio.deactivate", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } + + [Fact] + public async Task DeactivateMedio_WhenAlreadyInactive_Returns204ButNoNewAuditEvent() + { + const string codigo = "TESTDEACTIVATEIDEMPOTENT"; + var token = await GetAdminTokenAsync(); + + try + { + // Create + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + codigo, + nombre = "Medio Idempotente", + tipo = 1 + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var medioId = created.GetProperty("id").GetInt32(); + + // First deactivate + using var deact1 = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + await _client.SendAsync(deact1); + + // Second deactivate (idempotent) + using var deact2 = BuildRequest(HttpMethod.Post, $"{Endpoint}/{medioId}/deactivate", bearerToken: token); + var deact2Resp = await _client.SendAsync(deact2); + + Assert.Equal(HttpStatusCode.NoContent, deact2Resp.StatusCode); + + // Should still be only 1 audit event (second call was idempotent β€” no new audit) + var auditCount = await CountAuditEventsAsync("medio.deactivate", "Medio", medioId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(codigo); + } + } +} diff --git a/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs b/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs new file mode 100644 index 0000000..d416eda --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/SeccionesControllerTests.cs @@ -0,0 +1,429 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Dapper; +using Microsoft.Data.SqlClient; +using SIGCM2.TestSupport; + +namespace SIGCM2.Api.Tests.Admin; + +/// +/// ADM-001 B6 β€” Integration tests for /api/v1/admin/secciones. +/// All endpoints require permission 'administracion:secciones:gestionar'. +/// +[Collection("ApiIntegration")] +public sealed class SeccionesControllerTests : IAsyncLifetime +{ + private const string TestConnectionString = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + private const string Endpoint = "/api/v1/admin/secciones"; + private const string MediosEndpoint = "/api/v1/admin/medios"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "@Diego550@"; + + private readonly HttpClient _client; + + public SeccionesControllerTests(TestWebAppFactory factory) + { + _client = factory.CreateClient(); + } + + public Task InitializeAsync() => Task.CompletedTask; + public Task DisposeAsync() => Task.CompletedTask; + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async Task GetAdminTokenAsync() + { + var response = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username = AdminUsername, + password = AdminPassword + }); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("accessToken").GetString()!; + } + + private async Task GetCajeroTokenAsync(string username) + { + var adminToken = await GetAdminTokenAsync(); + + using var mkUser = BuildRequest(HttpMethod.Post, "/api/v1/users", new + { + username, + password = "Secure1234!", + nombre = "Cajero", + apellido = "Test", + email = (string?)null, + rol = "cajero" + }, adminToken); + var mkResp = await _client.SendAsync(mkUser); + if (mkResp.StatusCode != HttpStatusCode.Created && mkResp.StatusCode != HttpStatusCode.Conflict) + Assert.Fail($"Seed cajero failed: {mkResp.StatusCode}"); + + var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new + { + username, + password = "Secure1234!" + }); + loginResp.EnsureSuccessStatusCode(); + var loginJson = await loginResp.Content.ReadFromJsonAsync(); + return loginJson.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; + } + + /// Creates a Medio via the API and returns its id. + private async Task CreateMedioAsync(string codigo, string nombre, string token) + { + using var req = BuildRequest(HttpMethod.Post, MediosEndpoint, new + { + codigo, + nombre, + tipo = 1 + }, token); + var resp = await _client.SendAsync(req); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + return json.GetProperty("id").GetInt32(); + } + + private static async Task DeleteMedioIfExistsAsync(string codigo) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + + var id = await conn.QuerySingleOrDefaultAsync( + "SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo }); + if (id is null) return; + + // Delete dependent secciones (disable versioning to clear history too) + await conn.ExecuteAsync("ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion_History WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Seccion WHERE MedioId = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Seccion_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + + // Delete the medio itself + await conn.ExecuteAsync("ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF)"); + await conn.ExecuteAsync("DELETE FROM dbo.Medio_History WHERE Id = @id", new { id }); + await conn.ExecuteAsync("DELETE FROM dbo.Medio WHERE Id = @id", new { id }); + await conn.ExecuteAsync( + "ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.Medio_History, HISTORY_RETENTION_PERIOD = 10 YEARS))"); + } + + private static async Task DeleteUsuarioIfExistsAsync(string username) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync(""" + DELETE rt FROM dbo.RefreshToken rt + INNER JOIN dbo.Usuario u ON u.Id = rt.UsuarioId + WHERE u.Username = @Username + """, new { Username = username }); + await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Username = @Username", new { Username = username }); + } + + private static async Task CountAuditEventsAsync(string action, string targetType, string targetId) + { + await using var conn = new SqlConnection(TestConnectionString); + await conn.OpenAsync(); + return await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM dbo.AuditEvent WHERE Action = @Action AND TargetType = @TargetType AND TargetId = @TargetId", + new { Action = action, TargetType = targetType, TargetId = targetId }); + } + + // ── 401 / 403 guards ───────────────────────────────────────────────────── + + [Fact] + public async Task CreateSeccion_WithoutAuth_Returns401() + { + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + codigo = "SEC401", + nombre = "Seccion Test", + tipo = "clasificados" + }); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); + } + + [Fact] + public async Task CreateSeccion_WithCajeroRole_Returns403() + { + const string username = "adm001_sec_cajero_403"; + try + { + var token = await GetCajeroTokenAsync(username); + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 1, + codigo = "SEC403", + nombre = "Seccion Test", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + } + finally + { + await DeleteUsuarioIfExistsAsync(username); + } + } + + // ── CREATE ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateSeccion_WithAdmin_Returns201AndAuditEvent() + { + const string medioCodigo = "TESTSECMED201"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Seccion 201", token); + + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SEC201", + nombre = "Seccion 201", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + Assert.NotNull(resp.Headers.Location); + + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("id", out var idEl)); + var secId = idEl.GetInt32(); + Assert.True(secId > 0); + Assert.Equal(medioId, json.GetProperty("medioId").GetInt32()); + Assert.Equal("SEC201", json.GetProperty("codigo").GetString()); + Assert.Equal("clasificados", json.GetProperty("tipo").GetString()); + + // Verify AuditEvent + var auditCount = await CountAuditEventsAsync("seccion.create", "Seccion", secId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + [Fact] + public async Task CreateSeccion_WithNonExistentMedioId_Returns404() + { + var token = await GetAdminTokenAsync(); + + using var req = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = 99999, + codigo = "SECNOTFOUND", + nombre = "Seccion Not Found", + tipo = "clasificados" + }, token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_not_found", json.GetProperty("error").GetString()); + } + + [Fact] + public async Task CreateSeccion_WithDuplicateCodigoInSameMedio_Returns409() + { + const string medioCodigo = "TESTSECDUP"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Dup Test", token); + + // First seccion + using var first = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "DUPCODE", + nombre = "Seccion Original", + tipo = "clasificados" + }, token); + var firstResp = await _client.SendAsync(first); + Assert.Equal(HttpStatusCode.Created, firstResp.StatusCode); + + // Second with same medioId + codigo + using var second = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "DUPCODE", + nombre = "Seccion Duplicada", + tipo = "notables" + }, token); + var secondResp = await _client.SendAsync(second); + + Assert.Equal(HttpStatusCode.Conflict, secondResp.StatusCode); + var json = await secondResp.Content.ReadFromJsonAsync(); + Assert.Equal("seccion_codigo_duplicado_en_medio", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + [Fact] + public async Task CreateSeccion_SameCodigoDifferentMedio_Returns201() + { + const string medio1Codigo = "TESTSECMULTI1"; + const string medio2Codigo = "TESTSECMULTI2"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId1 = await CreateMedioAsync(medio1Codigo, "Medio Multi 1", token); + var medioId2 = await CreateMedioAsync(medio2Codigo, "Medio Multi 2", token); + + using var req1 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId1, + codigo = "SHAREDCODE", + nombre = "Seccion en Medio 1", + tipo = "clasificados" + }, token); + var resp1 = await _client.SendAsync(req1); + Assert.Equal(HttpStatusCode.Created, resp1.StatusCode); + + // Same codigo but different medioId β†’ should succeed (composite UQ) + using var req2 = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId = medioId2, + codigo = "SHAREDCODE", + nombre = "Seccion en Medio 2", + tipo = "notables" + }, token); + var resp2 = await _client.SendAsync(req2); + Assert.Equal(HttpStatusCode.Created, resp2.StatusCode); + } + finally + { + await DeleteMedioIfExistsAsync(medio1Codigo); + await DeleteMedioIfExistsAsync(medio2Codigo); + } + } + + [Fact] + public async Task CreateSeccion_WithInactiveMedio_Returns404() + { + const string medioCodigo = "TESTSECDEACT"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Desactivar", token); + + // Deactivate the medio + using var deactReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + // Try to create seccion in inactive medio + using var secReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SECINACTIVE", + nombre = "Seccion en Medio Inactivo", + tipo = "clasificados" + }, token); + var secResp = await _client.SendAsync(secReq); + + Assert.Equal(HttpStatusCode.NotFound, secResp.StatusCode); + var json = await secResp.Content.ReadFromJsonAsync(); + Assert.Equal("medio_not_found", json.GetProperty("error").GetString()); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } + + // ── LIST ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSecciones_WithAdmin_Returns200PagedResult() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, Endpoint, bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.True(json.TryGetProperty("items", out _), "Response must have 'items'"); + Assert.True(json.TryGetProperty("total", out _), "Response must have 'total'"); + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSeccionById_NotFound_Returns404() + { + var token = await GetAdminTokenAsync(); + using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/999999", bearerToken: token); + var resp = await _client.SendAsync(req); + + Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); + var json = await resp.Content.ReadFromJsonAsync(); + Assert.Equal("seccion_not_found", json.GetProperty("error").GetString()); + } + + // ── DEACTIVATE ──────────────────────────────────────────────────────────── + + [Fact] + public async Task DeactivateSeccion_WithAdmin_Returns204AndAuditEvent() + { + const string medioCodigo = "TESTSECDEACT2"; + var token = await GetAdminTokenAsync(); + + try + { + var medioId = await CreateMedioAsync(medioCodigo, "Medio Para Sec Deactivate", token); + + using var createReq = BuildRequest(HttpMethod.Post, Endpoint, new + { + medioId, + codigo = "SECDEACT", + nombre = "Seccion Para Desactivar", + tipo = "clasificados" + }, token); + var createResp = await _client.SendAsync(createReq); + Assert.Equal(HttpStatusCode.Created, createResp.StatusCode); + var created = await createResp.Content.ReadFromJsonAsync(); + var secId = created.GetProperty("id").GetInt32(); + + using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{secId}/deactivate", bearerToken: token); + var deactResp = await _client.SendAsync(deactReq); + + Assert.Equal(HttpStatusCode.NoContent, deactResp.StatusCode); + + var auditCount = await CountAuditEventsAsync("seccion.deactivate", "Seccion", secId.ToString()); + Assert.Equal(1, auditCount); + } + finally + { + await DeleteMedioIfExistsAsync(medioCodigo); + } + } +} From 6b946f608035c4b3bba18f7d35e91367c2e0079b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 16 Apr 2026 19:28:30 -0300 Subject: [PATCH 8/9] =?UTF-8?q?feat(web):=20Medios=20+=20Secciones=20admin?= =?UTF-8?q?=20UI=20+=20hooks=20+=20routing=20=E2=80=94=20ADM-001=20B7+B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API clients + TanStack Query hooks for medios and secciones - CRUD pages: List, Create, Edit, Detail for both entities - Components: MediosTable, MedioForm, DeactivateMedioModal, SeccionesTable, SeccionForm, DeactivateSeccionModal, SeccionesFilters - TipoMedio enum (intβ†’label) and TipoSeccion display helpers - CanPerform permission gates: administracion:medios:gestionar, administracion:secciones:gestionar - Routes /admin/medios/** and /admin/secciones/** in router.tsx - Sidebar items (Newspaper + Columns3 icons) with requiredPermission - Vitest+RTL+MSW tests: 11 test files, 38 new cases β€” 207 pass total --- src/web/src/components/layout/AppSidebar.tsx | 14 ++ .../src/features/medios/api/createMedio.ts | 7 + .../features/medios/api/deactivateMedio.ts | 5 + src/web/src/features/medios/api/getMedio.ts | 7 + src/web/src/features/medios/api/listMedios.ts | 17 ++ .../features/medios/api/reactivateMedio.ts | 5 + .../src/features/medios/api/updateMedio.ts | 7 + .../components/DeactivateMedioModal.tsx | 65 ++++++ .../features/medios/components/MedioForm.tsx | 188 +++++++++++++++++ .../medios/components/MediosTable.tsx | 103 +++++++++ .../features/medios/hooks/useCreateMedio.ts | 13 ++ .../medios/hooks/useDeactivateMedio.ts | 12 ++ src/web/src/features/medios/hooks/useMedio.ts | 11 + .../features/medios/hooks/useMediosList.ts | 13 ++ .../medios/hooks/useReactivateMedio.ts | 12 ++ .../features/medios/hooks/useUpdateMedio.ts | 13 ++ .../features/medios/pages/CreateMedioPage.tsx | 50 +++++ .../features/medios/pages/EditMedioPage.tsx | 81 ++++++++ .../features/medios/pages/MedioDetailPage.tsx | 97 +++++++++ .../features/medios/pages/MediosListPage.tsx | 138 +++++++++++++ src/web/src/features/medios/tipoMedio.ts | 18 ++ src/web/src/features/medios/types.ts | 58 ++++++ .../features/secciones/api/createSeccion.ts | 7 + .../secciones/api/deactivateSeccion.ts | 5 + .../src/features/secciones/api/getSeccion.ts | 7 + .../features/secciones/api/listSecciones.ts | 18 ++ .../secciones/api/reactivateSeccion.ts | 5 + .../features/secciones/api/updateSeccion.ts | 7 + .../components/DeactivateSeccionModal.tsx | 65 ++++++ .../secciones/components/SeccionForm.tsx | 195 ++++++++++++++++++ .../secciones/components/SeccionesFilters.tsx | 90 ++++++++ .../secciones/components/SeccionesTable.tsx | 103 +++++++++ .../secciones/hooks/useCreateSeccion.ts | 13 ++ .../secciones/hooks/useDeactivateSeccion.ts | 12 ++ .../secciones/hooks/useReactivateSeccion.ts | 12 ++ .../features/secciones/hooks/useSeccion.ts | 11 + .../secciones/hooks/useSeccionesList.ts | 13 ++ .../secciones/hooks/useUpdateSeccion.ts | 13 ++ .../secciones/pages/CreateSeccionPage.tsx | 50 +++++ .../secciones/pages/EditSeccionPage.tsx | 80 +++++++ .../secciones/pages/SeccionDetailPage.tsx | 99 +++++++++ .../secciones/pages/SeccionesListPage.tsx | 114 ++++++++++ src/web/src/features/secciones/tipoSeccion.ts | 12 ++ src/web/src/features/secciones/types.ts | 60 ++++++ src/web/src/router.tsx | 76 +++++++ .../features/medios/CreateMedioPage.test.tsx | 96 +++++++++ .../medios/DeactivateMedioModal.test.tsx | 91 ++++++++ .../features/medios/EditMedioPage.test.tsx | 112 ++++++++++ .../tests/features/medios/MedioForm.test.tsx | 110 ++++++++++ .../features/medios/MediosListPage.test.tsx | 168 +++++++++++++++ .../secciones/DeactivateSeccionModal.test.tsx | 74 +++++++ .../features/secciones/SeccionForm.test.tsx | 136 ++++++++++++ .../secciones/SeccionesFilters.test.tsx | 103 +++++++++ .../secciones/SeccionesListPage.test.tsx | 166 +++++++++++++++ 54 files changed, 3057 insertions(+) create mode 100644 src/web/src/features/medios/api/createMedio.ts create mode 100644 src/web/src/features/medios/api/deactivateMedio.ts create mode 100644 src/web/src/features/medios/api/getMedio.ts create mode 100644 src/web/src/features/medios/api/listMedios.ts create mode 100644 src/web/src/features/medios/api/reactivateMedio.ts create mode 100644 src/web/src/features/medios/api/updateMedio.ts create mode 100644 src/web/src/features/medios/components/DeactivateMedioModal.tsx create mode 100644 src/web/src/features/medios/components/MedioForm.tsx create mode 100644 src/web/src/features/medios/components/MediosTable.tsx create mode 100644 src/web/src/features/medios/hooks/useCreateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useDeactivateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useMedio.ts create mode 100644 src/web/src/features/medios/hooks/useMediosList.ts create mode 100644 src/web/src/features/medios/hooks/useReactivateMedio.ts create mode 100644 src/web/src/features/medios/hooks/useUpdateMedio.ts create mode 100644 src/web/src/features/medios/pages/CreateMedioPage.tsx create mode 100644 src/web/src/features/medios/pages/EditMedioPage.tsx create mode 100644 src/web/src/features/medios/pages/MedioDetailPage.tsx create mode 100644 src/web/src/features/medios/pages/MediosListPage.tsx create mode 100644 src/web/src/features/medios/tipoMedio.ts create mode 100644 src/web/src/features/medios/types.ts create mode 100644 src/web/src/features/secciones/api/createSeccion.ts create mode 100644 src/web/src/features/secciones/api/deactivateSeccion.ts create mode 100644 src/web/src/features/secciones/api/getSeccion.ts create mode 100644 src/web/src/features/secciones/api/listSecciones.ts create mode 100644 src/web/src/features/secciones/api/reactivateSeccion.ts create mode 100644 src/web/src/features/secciones/api/updateSeccion.ts create mode 100644 src/web/src/features/secciones/components/DeactivateSeccionModal.tsx create mode 100644 src/web/src/features/secciones/components/SeccionForm.tsx create mode 100644 src/web/src/features/secciones/components/SeccionesFilters.tsx create mode 100644 src/web/src/features/secciones/components/SeccionesTable.tsx create mode 100644 src/web/src/features/secciones/hooks/useCreateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useDeactivateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useReactivateSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useSeccion.ts create mode 100644 src/web/src/features/secciones/hooks/useSeccionesList.ts create mode 100644 src/web/src/features/secciones/hooks/useUpdateSeccion.ts create mode 100644 src/web/src/features/secciones/pages/CreateSeccionPage.tsx create mode 100644 src/web/src/features/secciones/pages/EditSeccionPage.tsx create mode 100644 src/web/src/features/secciones/pages/SeccionDetailPage.tsx create mode 100644 src/web/src/features/secciones/pages/SeccionesListPage.tsx create mode 100644 src/web/src/features/secciones/tipoSeccion.ts create mode 100644 src/web/src/features/secciones/types.ts create mode 100644 src/web/src/tests/features/medios/CreateMedioPage.test.tsx create mode 100644 src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx create mode 100644 src/web/src/tests/features/medios/EditMedioPage.test.tsx create mode 100644 src/web/src/tests/features/medios/MedioForm.test.tsx create mode 100644 src/web/src/tests/features/medios/MediosListPage.test.tsx create mode 100644 src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionForm.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionesFilters.test.tsx create mode 100644 src/web/src/tests/features/secciones/SeccionesListPage.test.tsx diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 7263153..5516319 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -12,6 +12,8 @@ import { FileClock, PanelLeftClose, PanelLeftOpen, + Newspaper, + Columns3, } from 'lucide-react' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' @@ -47,6 +49,18 @@ const adminItems: NavItem[] = [ icon: FileClock, requiredPermission: 'administracion:auditoria:ver', }, + { + label: 'Medios', + href: '/admin/medios', + icon: Newspaper, + requiredPermission: 'administracion:medios:gestionar', + }, + { + label: 'Secciones', + href: '/admin/secciones', + icon: Columns3, + requiredPermission: 'administracion:secciones:gestionar', + }, ] interface SidebarNavProps { diff --git a/src/web/src/features/medios/api/createMedio.ts b/src/web/src/features/medios/api/createMedio.ts new file mode 100644 index 0000000..9809f3b --- /dev/null +++ b/src/web/src/features/medios/api/createMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateMedioRequest, MedioCreated } from '../types' + +export async function createMedio(payload: CreateMedioRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/medios', payload) + return response.data +} diff --git a/src/web/src/features/medios/api/deactivateMedio.ts b/src/web/src/features/medios/api/deactivateMedio.ts new file mode 100644 index 0000000..d893ce8 --- /dev/null +++ b/src/web/src/features/medios/api/deactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`) +} diff --git a/src/web/src/features/medios/api/getMedio.ts b/src/web/src/features/medios/api/getMedio.ts new file mode 100644 index 0000000..a0c10a6 --- /dev/null +++ b/src/web/src/features/medios/api/getMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail } from '../types' + +export async function getMedio(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/medios/${id}`) + return response.data +} diff --git a/src/web/src/features/medios/api/listMedios.ts b/src/web/src/features/medios/api/listMedios.ts new file mode 100644 index 0000000..75df9a9 --- /dev/null +++ b/src/web/src/features/medios/api/listMedios.ts @@ -0,0 +1,17 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioListItem, MediosQuery, PagedResult } from '../types' + +export async function listMedios(query: MediosQuery): Promise> { + const params = new URLSearchParams() + if (query.page !== undefined) params.set('page', String(query.page)) + if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) + if (query.activo !== undefined) params.set('activo', String(query.activo)) + if (query.tipo !== undefined) params.set('tipo', String(query.tipo)) + if (query.q !== undefined && query.q !== '') params.set('q', query.q) + + const response = await axiosClient.get>( + '/api/v1/admin/medios', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/medios/api/reactivateMedio.ts b/src/web/src/features/medios/api/reactivateMedio.ts new file mode 100644 index 0000000..ba485f4 --- /dev/null +++ b/src/web/src/features/medios/api/reactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`) +} diff --git a/src/web/src/features/medios/api/updateMedio.ts b/src/web/src/features/medios/api/updateMedio.ts new file mode 100644 index 0000000..fcedcfe --- /dev/null +++ b/src/web/src/features/medios/api/updateMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail, UpdateMedioRequest } from '../types' + +export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/medios/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/medios/components/DeactivateMedioModal.tsx b/src/web/src/features/medios/components/DeactivateMedioModal.tsx new file mode 100644 index 0000000..c4faa4d --- /dev/null +++ b/src/web/src/features/medios/components/DeactivateMedioModal.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useDeactivateMedio } from '../hooks/useDeactivateMedio' +import { useReactivateMedio } from '../hooks/useReactivateMedio' + +interface DeactivateMedioModalProps { + medioId: number + medioNombre: string + activo: boolean +} + +export function DeactivateMedioModal({ medioId, medioNombre, activo }: DeactivateMedioModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivateMedio() + const { mutate: reactivate, isPending: reactivating } = useReactivateMedio() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(medioId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(medioId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar medio' : 'Reactivar medio'} + + + {activo + ? `ΒΏConfirmΓ‘s que querΓ©s desactivar el medio "${medioNombre}"? El medio no podrΓ‘ usarse hasta que sea reactivado.` + : `ΒΏConfirmΓ‘s que querΓ©s reactivar el medio "${medioNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/medios/components/MedioForm.tsx b/src/web/src/features/medios/components/MedioForm.tsx new file mode 100644 index 0000000..e802a76 --- /dev/null +++ b/src/web/src/features/medios/components/MedioForm.tsx @@ -0,0 +1,188 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { TIPO_MEDIO_OPTIONS } from '../tipoMedio' +import type { MedioDetail } from '../types' + +const medioFormSchema = z.object({ + codigo: z + .string() + .min(1, 'El cΓ³digo es requerido') + .max(20, 'MΓ‘ximo 20 caracteres'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'MΓ‘ximo 100 caracteres'), + tipo: z.coerce.number().refine((v) => v >= 1, 'SeleccionΓ‘ un tipo vΓ‘lido'), + plataformaEmpresaId: z + .union([z.coerce.number().int().positive('Debe ser un nΓΊmero positivo'), z.literal('')]) + .optional() + .transform((v) => (v === '' || v === undefined ? null : Number(v))), +}) + +export type MedioFormValues = z.infer + +interface MedioFormProps { + /** If provided, the form is in edit mode (cΓ³digo is disabled, form is pre-filled). */ + initialData?: MedioDetail + isPending: boolean + error: unknown + onSubmit: (values: MedioFormValues) => void +} + +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 } + if (data.error === 'medio_codigo_duplicado') { + return data.message ?? 'Ya existe un medio con ese cΓ³digo' + } + return data.message ?? data.error ?? 'Error al guardar el medio' + } + return 'Error al guardar el medio' +} + +export function MedioForm({ initialData, isPending, error, onSubmit }: MedioFormProps) { + const isEdit = !!initialData + + const form = useForm({ + resolver: zodResolver(medioFormSchema), + defaultValues: { + codigo: initialData?.codigo ?? '', + nombre: initialData?.nombre ?? '', + tipo: initialData?.tipo ?? ('' as unknown as number), + plataformaEmpresaId: (initialData?.plataformaEmpresaId ?? '') as unknown as undefined, + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + codigo: initialData.codigo, + nombre: initialData.nombre, + tipo: initialData.tipo, + plataformaEmpresaId: (initialData.plataformaEmpresaId ?? '') as unknown as undefined, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + CΓ³digo + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + + + )} + /> + + ( + + Plataforma Empresa ID (opcional) + + field.onChange(e.target.value)} + type="number" + min={1} + disabled={isPending} + placeholder="ID numΓ©rico (opcional)" + /> + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/medios/components/MediosTable.tsx b/src/web/src/features/medios/components/MediosTable.tsx new file mode 100644 index 0000000..10113e8 --- /dev/null +++ b/src/web/src/features/medios/components/MediosTable.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { CanPerform } from '@/components/auth/CanPerform' +import type { MedioListItem } from '../types' +import { tipoMedioLabel } from '../tipoMedio' +import { DeactivateMedioModal } from './DeactivateMedioModal' + +interface MediosTableProps { + rows: MedioListItem[] +} + +export function MediosTable({ rows }: MediosTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'codigo', + header: 'CΓ³digo', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + {tipoMedioLabel(row.original.tipo)} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'plataformaEmpresaId', + header: 'Plataforma ID', + cell: ({ row }) => ( + + {row.original.plataformaEmpresaId ?? 'β€”'} + + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/medios/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados β€” no se encontraron medios con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/medios/hooks/useCreateMedio.ts b/src/web/src/features/medios/hooks/useCreateMedio.ts new file mode 100644 index 0000000..2549cfb --- /dev/null +++ b/src/web/src/features/medios/hooks/useCreateMedio.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createMedio } from '../api/createMedio' +import type { CreateMedioRequest } from '../types' + +export function useCreateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateMedioRequest) => createMedio(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useDeactivateMedio.ts b/src/web/src/features/medios/hooks/useDeactivateMedio.ts new file mode 100644 index 0000000..d93304b --- /dev/null +++ b/src/web/src/features/medios/hooks/useDeactivateMedio.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateMedio } from '../api/deactivateMedio' + +export function useDeactivateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateMedio(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useMedio.ts b/src/web/src/features/medios/hooks/useMedio.ts new file mode 100644 index 0000000..bf005ee --- /dev/null +++ b/src/web/src/features/medios/hooks/useMedio.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getMedio } from '../api/getMedio' + +export function useMedio(id: number) { + return useQuery({ + queryKey: ['medios', 'detail', id], + queryFn: () => getMedio(id), + enabled: !!id, + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/medios/hooks/useMediosList.ts b/src/web/src/features/medios/hooks/useMediosList.ts new file mode 100644 index 0000000..e74cd75 --- /dev/null +++ b/src/web/src/features/medios/hooks/useMediosList.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { listMedios } from '../api/listMedios' +import type { MediosQuery } from '../types' + +export const mediosListQueryKey = (query: MediosQuery) => ['medios', 'list', query] as const + +export function useMediosList(query: MediosQuery) { + return useQuery({ + queryKey: mediosListQueryKey(query), + queryFn: () => listMedios(query), + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/medios/hooks/useReactivateMedio.ts b/src/web/src/features/medios/hooks/useReactivateMedio.ts new file mode 100644 index 0000000..15f1781 --- /dev/null +++ b/src/web/src/features/medios/hooks/useReactivateMedio.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateMedio } from '../api/reactivateMedio' + +export function useReactivateMedio() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateMedio(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useUpdateMedio.ts b/src/web/src/features/medios/hooks/useUpdateMedio.ts new file mode 100644 index 0000000..b9702e3 --- /dev/null +++ b/src/web/src/features/medios/hooks/useUpdateMedio.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateMedio } from '../api/updateMedio' +import type { UpdateMedioRequest } from '../types' + +export function useUpdateMedio(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdateMedioRequest) => updateMedio(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['medios'] }) + }, + }) +} diff --git a/src/web/src/features/medios/pages/CreateMedioPage.tsx b/src/web/src/features/medios/pages/CreateMedioPage.tsx new file mode 100644 index 0000000..432d016 --- /dev/null +++ b/src/web/src/features/medios/pages/CreateMedioPage.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { MedioForm } from '../components/MedioForm' +import { useCreateMedio } from '../hooks/useCreateMedio' +import type { MedioFormValues } from '../components/MedioForm' + +export function CreateMedioPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreateMedio() + + function handleSubmit(values: MedioFormValues) { + mutate( + { + codigo: values.codigo, + nombre: values.nombre, + tipo: values.tipo, + plataformaEmpresaId: values.plataformaEmpresaId as number | null, + }, + { + onSuccess: () => { + toast.success('Medio creado correctamente') + void navigate('/admin/medios') + }, + }, + ) + } + + return ( +
+ + + Crear Medio + + CompletΓ‘ los datos para registrar un nuevo medio en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/medios/pages/EditMedioPage.tsx b/src/web/src/features/medios/pages/EditMedioPage.tsx new file mode 100644 index 0000000..9c34969 --- /dev/null +++ b/src/web/src/features/medios/pages/EditMedioPage.tsx @@ -0,0 +1,81 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { MedioForm } from '../components/MedioForm' +import { useMedio } from '../hooks/useMedio' +import { useUpdateMedio } from '../hooks/useUpdateMedio' +import type { MedioFormValues } from '../components/MedioForm' + +export function EditMedioPage() { + const { id } = useParams<{ id: string }>() + const medioId = Number(id) + const navigate = useNavigate() + + const { data: medio, isLoading } = useMedio(medioId) + const { mutate, isPending, error } = useUpdateMedio(medioId) + + function handleSubmit(values: MedioFormValues) { + mutate( + { + nombre: values.nombre, + tipo: values.tipo, + plataformaEmpresaId: values.plataformaEmpresaId as number | null, + }, + { + onSuccess: () => { + toast.success('Medio actualizado correctamente') + void navigate(`/admin/medios/${medioId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+ + +
+ Editar Medio + +
+ + EditΓ‘ los datos del medio {medio.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MedioDetailPage.tsx b/src/web/src/features/medios/pages/MedioDetailPage.tsx new file mode 100644 index 0000000..2992e68 --- /dev/null +++ b/src/web/src/features/medios/pages/MedioDetailPage.tsx @@ -0,0 +1,97 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { useMedio } from '../hooks/useMedio' +import { DeactivateMedioModal } from '../components/DeactivateMedioModal' +import { tipoMedioLabel } from '../tipoMedio' + +function formatDate(iso: string | null): string { + if (!iso) return 'β€”' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function MedioDetailPage() { + const { id } = useParams<{ id: string }>() + const medioId = Number(id) + const navigate = useNavigate() + + const { data: medio, isLoading } = useMedio(medioId) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+
+

{medio.nombre}

+ +
+ +
+
+ CΓ³digo + {medio.codigo} +
+
+ Tipo + {tipoMedioLabel(medio.tipo)} +
+
+ Plataforma ID + {medio.plataformaEmpresaId ?? 'β€”'} +
+
+ Estado + {medio.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(medio.fechaCreacion)} +
+
+ Modificado + {formatDate(medio.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MediosListPage.tsx b/src/web/src/features/medios/pages/MediosListPage.tsx new file mode 100644 index 0000000..5f91688 --- /dev/null +++ b/src/web/src/features/medios/pages/MediosListPage.tsx @@ -0,0 +1,138 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { MediosTable } from '../components/MediosTable' +import { useMediosList } from '../hooks/useMediosList' +import { TIPO_MEDIO_OPTIONS } from '../tipoMedio' + +export function MediosListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [tipo, setTipo] = useState(undefined) + const [activo, setActivo] = useState(undefined) + const [searchRaw, setSearchRaw] = useState('') + const q = useDebouncedValue(searchRaw, 300) + + const query = { + page, + pageSize: 20, + ...(tipo !== undefined ? { tipo } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(q ? { q } : {}), + } + + const { data, isLoading } = useMediosList(query) + + const handleTipoChange = useCallback((value: string) => { + setTipo(value === '' ? undefined : Number(value)) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: string) => { + if (value === '') setActivo(undefined) + else setActivo(value === 'true') + setPage(1) + }, []) + + const handleSearchChange = useCallback((value: string) => { + setSearchRaw(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Medios

+ + + +
+ + {/* Filters */} +
+ handleSearchChange(e.target.value)} + className="max-w-xs" + aria-label="Buscar medios" + /> + + + + +
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/tipoMedio.ts b/src/web/src/features/medios/tipoMedio.ts new file mode 100644 index 0000000..5557342 --- /dev/null +++ b/src/web/src/features/medios/tipoMedio.ts @@ -0,0 +1,18 @@ +// TipoMedio enum helper: int on wire β†’ display label +export const TIPO_MEDIO_LABELS: Record = { + 1: 'Diario', + 2: 'Radio', + 3: 'Web', + 4: 'Poster', +} + +export const TIPO_MEDIO_OPTIONS = [ + { value: 1, label: 'Diario' }, + { value: 2, label: 'Radio' }, + { value: 3, label: 'Web' }, + { value: 4, label: 'Poster' }, +] + +export function tipoMedioLabel(tipo: number): string { + return TIPO_MEDIO_LABELS[tipo] ?? String(tipo) +} diff --git a/src/web/src/features/medios/types.ts b/src/web/src/features/medios/types.ts new file mode 100644 index 0000000..a1370f8 --- /dev/null +++ b/src/web/src/features/medios/types.ts @@ -0,0 +1,58 @@ +// ADM-001 β€” shared types for medios feature + +export interface MedioListItem { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean +} + +export interface MedioDetail { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface MedioCreated { + id: number + codigo: string + nombre: string + tipo: number + plataformaEmpresaId: number | null + activo: boolean +} + +export interface CreateMedioRequest { + codigo: string + nombre: string + tipo: number + plataformaEmpresaId?: number | null +} + +export interface UpdateMedioRequest { + nombre: string + tipo: number + plataformaEmpresaId?: number | null +} + +export interface MediosQuery { + page?: number + pageSize?: number + activo?: boolean + tipo?: number + q?: string +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/features/secciones/api/createSeccion.ts b/src/web/src/features/secciones/api/createSeccion.ts new file mode 100644 index 0000000..b00a269 --- /dev/null +++ b/src/web/src/features/secciones/api/createSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateSeccionRequest, SeccionCreated } from '../types' + +export async function createSeccion(payload: CreateSeccionRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/secciones', payload) + return response.data +} diff --git a/src/web/src/features/secciones/api/deactivateSeccion.ts b/src/web/src/features/secciones/api/deactivateSeccion.ts new file mode 100644 index 0000000..db675a8 --- /dev/null +++ b/src/web/src/features/secciones/api/deactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`) +} diff --git a/src/web/src/features/secciones/api/getSeccion.ts b/src/web/src/features/secciones/api/getSeccion.ts new file mode 100644 index 0000000..908cb79 --- /dev/null +++ b/src/web/src/features/secciones/api/getSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail } from '../types' + +export async function getSeccion(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/secciones/${id}`) + return response.data +} diff --git a/src/web/src/features/secciones/api/listSecciones.ts b/src/web/src/features/secciones/api/listSecciones.ts new file mode 100644 index 0000000..26d7c56 --- /dev/null +++ b/src/web/src/features/secciones/api/listSecciones.ts @@ -0,0 +1,18 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types' + +export async function listSecciones(query: SeccionesQuery): Promise> { + const params = new URLSearchParams() + if (query.page !== undefined) params.set('page', String(query.page)) + if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize)) + if (query.medioId !== undefined) params.set('medioId', String(query.medioId)) + if (query.tipo !== undefined) params.set('tipo', query.tipo) + if (query.activo !== undefined) params.set('activo', String(query.activo)) + if (query.q !== undefined && query.q !== '') params.set('q', query.q) + + const response = await axiosClient.get>( + '/api/v1/admin/secciones', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/secciones/api/reactivateSeccion.ts b/src/web/src/features/secciones/api/reactivateSeccion.ts new file mode 100644 index 0000000..5c5185d --- /dev/null +++ b/src/web/src/features/secciones/api/reactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`) +} diff --git a/src/web/src/features/secciones/api/updateSeccion.ts b/src/web/src/features/secciones/api/updateSeccion.ts new file mode 100644 index 0000000..f017207 --- /dev/null +++ b/src/web/src/features/secciones/api/updateSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail, UpdateSeccionRequest } from '../types' + +export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/secciones/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx new file mode 100644 index 0000000..07d70cb --- /dev/null +++ b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useDeactivateSeccion } from '../hooks/useDeactivateSeccion' +import { useReactivateSeccion } from '../hooks/useReactivateSeccion' + +interface DeactivateSeccionModalProps { + seccionId: number + seccionNombre: string + activo: boolean +} + +export function DeactivateSeccionModal({ seccionId, seccionNombre, activo }: DeactivateSeccionModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivateSeccion() + const { mutate: reactivate, isPending: reactivating } = useReactivateSeccion() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(seccionId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(seccionId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar secciΓ³n' : 'Reactivar secciΓ³n'} + + + {activo + ? `ΒΏConfirmΓ‘s que querΓ©s desactivar la secciΓ³n "${seccionNombre}"?` + : `ΒΏConfirmΓ‘s que querΓ©s reactivar la secciΓ³n "${seccionNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionForm.tsx b/src/web/src/features/secciones/components/SeccionForm.tsx new file mode 100644 index 0000000..701a0e4 --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionForm.tsx @@ -0,0 +1,195 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { useMediosList } from '@/features/medios/hooks/useMediosList' +import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' +import type { SeccionDetail, TipoSeccion } from '../types' + +const TIPO_SECCION_VALUES = ['clasificados', 'notables', 'suplementos'] as const + +const seccionFormSchema = z.object({ + medioId: z.coerce.number().refine((v) => v >= 1, 'SeleccionΓ‘ un medio'), + codigo: z + .string() + .min(1, 'El cΓ³digo es requerido') + .max(20, 'MΓ‘ximo 20 caracteres'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'MΓ‘ximo 100 caracteres'), + tipo: z.enum(TIPO_SECCION_VALUES, { errorMap: () => ({ message: 'SeleccionΓ‘ un tipo vΓ‘lido' }) }), +}) + +export type SeccionFormValues = z.infer + +interface SeccionFormProps { + initialData?: SeccionDetail + isPending: boolean + error: unknown + onSubmit: (values: SeccionFormValues) => void +} + +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 } + if (data.error === 'seccion_codigo_duplicado_en_medio') { + return data.message ?? 'Ya existe una secciΓ³n con ese cΓ³digo en el medio' + } + return data.message ?? data.error ?? 'Error al guardar la secciΓ³n' + } + return 'Error al guardar la secciΓ³n' +} + +export function SeccionForm({ initialData, isPending, error, onSubmit }: SeccionFormProps) { + const isEdit = !!initialData + + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 }) + const medios = mediosData?.items ?? [] + + const form = useForm({ + resolver: zodResolver(seccionFormSchema), + defaultValues: { + medioId: initialData?.medioId ?? ('' as unknown as number), + codigo: initialData?.codigo ?? '', + nombre: initialData?.nombre ?? '', + tipo: initialData?.tipo ?? ('' as TipoSeccion), + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + medioId: initialData.medioId, + codigo: initialData.codigo, + nombre: initialData.nombre, + tipo: initialData.tipo, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Medio + + + + + + )} + /> + + ( + + CΓ³digo + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionesFilters.tsx b/src/web/src/features/secciones/components/SeccionesFilters.tsx new file mode 100644 index 0000000..5197b3f --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesFilters.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { Input } from '@/components/ui/input' +import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { useMediosList } from '@/features/medios/hooks/useMediosList' +import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' +import type { TipoSeccion } from '../types' + +interface SeccionesFiltersProps { + onMedioIdChange: (medioId: number | undefined) => void + onTipoChange: (tipo: TipoSeccion | undefined) => void + onActivoChange: (activo: boolean | undefined) => void + onSearchChange: (q: string) => void +} + +export function SeccionesFilters({ + onMedioIdChange, + onTipoChange, + onActivoChange, + onSearchChange, +}: SeccionesFiltersProps) { + const [searchRaw, setSearchRaw] = useState('') + const debouncedSearch = useDebouncedValue(searchRaw, 300) + + useEffect(() => { + onSearchChange(debouncedSearch) + }, [debouncedSearch, onSearchChange]) + + // Load medios for the selector + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true }) + const medios = mediosData?.items ?? [] + + return ( +
+ setSearchRaw(e.target.value)} + className="max-w-xs" + aria-label="Buscar secciones" + /> + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/components/SeccionesTable.tsx b/src/web/src/features/secciones/components/SeccionesTable.tsx new file mode 100644 index 0000000..7a6f455 --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesTable.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import type { ColumnDef } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { DataTable } from '@/components/ui/data-table' +import { CanPerform } from '@/components/auth/CanPerform' +import type { SeccionListItem } from '../types' +import { tipoSeccionLabel } from '../tipoSeccion' +import { DeactivateSeccionModal } from './DeactivateSeccionModal' + +interface SeccionesTableProps { + rows: SeccionListItem[] +} + +export function SeccionesTable({ rows }: SeccionesTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'codigo', + header: 'CΓ³digo', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + + {tipoSeccionLabel(row.original.tipo)} + + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'medioId', + header: 'Medio ID', + cell: ({ row }) => ( + {row.original.medioId} + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/secciones/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados β€” no se encontraron secciones con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/secciones/hooks/useCreateSeccion.ts b/src/web/src/features/secciones/hooks/useCreateSeccion.ts new file mode 100644 index 0000000..b113b4c --- /dev/null +++ b/src/web/src/features/secciones/hooks/useCreateSeccion.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { createSeccion } from '../api/createSeccion' +import type { CreateSeccionRequest } from '../types' + +export function useCreateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: CreateSeccionRequest) => createSeccion(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts new file mode 100644 index 0000000..3c6f3eb --- /dev/null +++ b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deactivateSeccion } from '../api/deactivateSeccion' + +export function useDeactivateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deactivateSeccion(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useReactivateSeccion.ts b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts new file mode 100644 index 0000000..b97cbc3 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts @@ -0,0 +1,12 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { reactivateSeccion } from '../api/reactivateSeccion' + +export function useReactivateSeccion() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => reactivateSeccion(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccion.ts b/src/web/src/features/secciones/hooks/useSeccion.ts new file mode 100644 index 0000000..dea820a --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccion.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { getSeccion } from '../api/getSeccion' + +export function useSeccion(id: number) { + return useQuery({ + queryKey: ['secciones', 'detail', id], + queryFn: () => getSeccion(id), + enabled: !!id, + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccionesList.ts b/src/web/src/features/secciones/hooks/useSeccionesList.ts new file mode 100644 index 0000000..aeb3b0f --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccionesList.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { listSecciones } from '../api/listSecciones' +import type { SeccionesQuery } from '../types' + +export const seccionesListQueryKey = (query: SeccionesQuery) => ['secciones', 'list', query] as const + +export function useSeccionesList(query: SeccionesQuery) { + return useQuery({ + queryKey: seccionesListQueryKey(query), + queryFn: () => listSecciones(query), + staleTime: 15_000, + }) +} diff --git a/src/web/src/features/secciones/hooks/useUpdateSeccion.ts b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts new file mode 100644 index 0000000..ba65eb4 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateSeccion } from '../api/updateSeccion' +import type { UpdateSeccionRequest } from '../types' + +export function useUpdateSeccion(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdateSeccionRequest) => updateSeccion(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/pages/CreateSeccionPage.tsx b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx new file mode 100644 index 0000000..72b1887 --- /dev/null +++ b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx @@ -0,0 +1,50 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { SeccionForm } from '../components/SeccionForm' +import { useCreateSeccion } from '../hooks/useCreateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function CreateSeccionPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreateSeccion() + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + medioId: values.medioId, + codigo: values.codigo, + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('SecciΓ³n creada correctamente') + void navigate('/admin/secciones') + }, + }, + ) + } + + return ( +
+ + + Crear SecciΓ³n + + CompletΓ‘ los datos para registrar una nueva secciΓ³n en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/pages/EditSeccionPage.tsx b/src/web/src/features/secciones/pages/EditSeccionPage.tsx new file mode 100644 index 0000000..2464094 --- /dev/null +++ b/src/web/src/features/secciones/pages/EditSeccionPage.tsx @@ -0,0 +1,80 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { SeccionForm } from '../components/SeccionForm' +import { useSeccion } from '../hooks/useSeccion' +import { useUpdateSeccion } from '../hooks/useUpdateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function EditSeccionPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + const { mutate, isPending, error } = useUpdateSeccion(seccionId) + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('SecciΓ³n actualizada correctamente') + void navigate(`/admin/secciones/${seccionId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ SecciΓ³n no encontrada. +
+ ) + } + + return ( +
+ + +
+ Editar SecciΓ³n + +
+ + EditΓ‘ los datos de la secciΓ³n {seccion.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx new file mode 100644 index 0000000..f02dca3 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx @@ -0,0 +1,99 @@ +import { useNavigate, useParams } from 'react-router-dom' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { CanPerform } from '@/components/auth/CanPerform' +import { useSeccion } from '../hooks/useSeccion' +import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal' +import { tipoSeccionLabel } from '../tipoSeccion' + +function formatDate(iso: string | null): string { + if (!iso) return 'β€”' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function SeccionDetailPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ SecciΓ³n no encontrada. +
+ ) + } + + return ( +
+
+

{seccion.nombre}

+ +
+ +
+
+ CΓ³digo + {seccion.codigo} +
+
+ Medio ID + {seccion.medioId} +
+
+ Tipo + + {tipoSeccionLabel(seccion.tipo)} + +
+
+ Estado + {seccion.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(seccion.fechaCreacion)} +
+
+ Modificado + {formatDate(seccion.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionesListPage.tsx b/src/web/src/features/secciones/pages/SeccionesListPage.tsx new file mode 100644 index 0000000..d3e7048 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionesListPage.tsx @@ -0,0 +1,114 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { SeccionesTable } from '../components/SeccionesTable' +import { SeccionesFilters } from '../components/SeccionesFilters' +import { useSeccionesList } from '../hooks/useSeccionesList' +import type { TipoSeccion } from '../types' + +export function SeccionesListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [tipo, setTipo] = useState(undefined) + const [activo, setActivo] = useState(undefined) + const [q, setQ] = useState('') + + const query = { + page, + pageSize: 20, + ...(medioId !== undefined ? { medioId } : {}), + ...(tipo !== undefined ? { tipo } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(q ? { q } : {}), + } + + const { data, isLoading } = useSeccionesList(query) + + const handleMedioIdChange = useCallback((value: number | undefined) => { + setMedioId(value) + setPage(1) + }, []) + + const handleTipoChange = useCallback((value: TipoSeccion | undefined) => { + setTipo(value) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: boolean | undefined) => { + setActivo(value) + setPage(1) + }, []) + + const handleSearchChange = useCallback((value: string) => { + setQ(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Secciones

+ + + +
+ + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} secciΓ³n${data.total !== 1 ? 'es' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/tipoSeccion.ts b/src/web/src/features/secciones/tipoSeccion.ts new file mode 100644 index 0000000..fd54729 --- /dev/null +++ b/src/web/src/features/secciones/tipoSeccion.ts @@ -0,0 +1,12 @@ +import type { TipoSeccion } from './types' + +export const TIPO_SECCION_OPTIONS: { value: TipoSeccion; label: string }[] = [ + { value: 'clasificados', label: 'Clasificados' }, + { value: 'notables', label: 'Notables' }, + { value: 'suplementos', label: 'Suplementos' }, +] + +export function tipoSeccionLabel(tipo: TipoSeccion): string { + const found = TIPO_SECCION_OPTIONS.find((o) => o.value === tipo) + return found ? found.label : tipo +} diff --git a/src/web/src/features/secciones/types.ts b/src/web/src/features/secciones/types.ts new file mode 100644 index 0000000..dab896f --- /dev/null +++ b/src/web/src/features/secciones/types.ts @@ -0,0 +1,60 @@ +// ADM-001 β€” shared types for secciones feature + +export type TipoSeccion = 'clasificados' | 'notables' | 'suplementos' + +export interface SeccionListItem { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface SeccionDetail { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface SeccionCreated { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface CreateSeccionRequest { + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion +} + +export interface UpdateSeccionRequest { + nombre: string + tipo: TipoSeccion +} + +export interface SeccionesQuery { + page?: number + pageSize?: number + medioId?: number + tipo?: TipoSeccion + activo?: boolean + q?: string +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 2a83906..8290b0c 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -13,6 +13,14 @@ import { NewRolPage } from './features/roles/pages/NewRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage' import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage' import { AuditPage } from './pages/admin/audit/AuditPage' +import { MediosListPage } from './features/medios/pages/MediosListPage' +import { CreateMedioPage } from './features/medios/pages/CreateMedioPage' +import { EditMedioPage } from './features/medios/pages/EditMedioPage' +import { MedioDetailPage } from './features/medios/pages/MedioDetailPage' +import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage' +import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage' +import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage' +import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -164,6 +172,74 @@ export function AppRoutes() { } /> + {/* Medios routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Secciones routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/medios/CreateMedioPage.test.tsx b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx new file mode 100644 index 0000000..9045f61 --- /dev/null +++ b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx @@ -0,0 +1,96 @@ +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 } from 'react-router-dom' +import { CreateMedioPage } from '../../../features/medios/pages/CreateMedioPage' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('CreateMedioPage', () => { + it('renders the create form with correct title', () => { + renderPage() + expect(screen.getByRole('heading', { name: /crear medio/i })).toBeInTheDocument() + }) + + it('successfully creates a medio and navigates away', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { id: 10, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, + { status: 201 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'RAD01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Radio AM') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '2') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/admin/medios')) + }) + + it('shows backend error when codigo is duplicated', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { error: 'medio_codigo_duplicado', message: 'Ya existe un medio con ese cΓ³digo' }, + { status: 409 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DUP01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/ya existe un medio con ese cΓ³digo/i), + ) + }) + + it('submit button is labeled "Crear medio"', () => { + renderPage() + expect(screen.getByRole('button', { name: /crear medio/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx new file mode 100644 index 0000000..aca974f --- /dev/null +++ b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx @@ -0,0 +1,91 @@ +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 } from 'react-router-dom' +import { DeactivateMedioModal } from '../../../features/medios/components/DeactivateMedioModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateMedioModal', () => { + it('shows "Desactivar" trigger when medio is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when medio is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar medio/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/diario el dΓ­a/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm and invalidates query', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) + + it('calls reactivate endpoint on confirm when inactive', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/reactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(false) + await userEvent.click(screen.getByRole('button', { name: /reactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /reactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/medios/EditMedioPage.test.tsx b/src/web/src/tests/features/medios/EditMedioPage.test.tsx new file mode 100644 index 0000000..e1f99d0 --- /dev/null +++ b/src/web/src/tests/features/medios/EditMedioPage.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +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 { EditMedioPage } from '../../../features/medios/pages/EditMedioPage' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio = { + id: 5, + codigo: 'WEB01', + nombre: 'Portal Web', + tipo: 3, + plataformaEmpresaId: 42, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(id = '5') { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('EditMedioPage', () => { + it('shows loading state initially', () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + expect(screen.getByText(/cargando/i)).toBeInTheDocument() + }) + + it('loads and pre-fills form with medio data', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Portal Web'), + ) + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('WEB01') + // CΓ³digo disabled in edit mode + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).disabled).toBe(true) + }) + + it('shows "Medio no encontrado" when 404', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/999`, () => + new HttpResponse(null, { status: 404 }), + ), + ) + + renderPage('999') + + await waitFor(() => + expect(screen.getByText(/medio no encontrado/i)).toBeInTheDocument(), + ) + }) + + it('shows "Guardar cambios" button in edit mode', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/medios/MedioForm.test.tsx b/src/web/src/tests/features/medios/MedioForm.test.tsx new file mode 100644 index 0000000..8b8dc52 --- /dev/null +++ b/src/web/src/tests/features/medios/MedioForm.test.tsx @@ -0,0 +1,110 @@ +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 { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { MedioForm } from '../../../features/medios/components/MedioForm' +import type { MedioFormValues } from '../../../features/medios/components/MedioForm' +import type { MedioDetail } from '../../../features/medios/types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio: MedioDetail = { + id: 1, + codigo: 'DIA01', + nombre: 'Diario Principal', + tipo: 1, + plataformaEmpresaId: null, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +function renderForm(opts: { initialData?: MedioDetail; onSubmit?: (v: MedioFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + render( + + + + + , + ) + return { onSubmit } +} + +describe('MedioForm β€” create mode', () => { + it('shows validation error when cΓ³digo is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when nombre is empty', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Medio') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + // The form message for tipo validation appears as a

role + await waitFor(() => { + const messages = screen.getAllByText(/seleccionΓ‘ un tipo/i) + // At least one message should exist (validation error, not just the placeholder option) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DIA99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario') + await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ codigo: 'DIA99', nombre: 'Mi Diario', tipo: 1 }) + }) + }) +}) + +describe('MedioForm β€” edit mode', () => { + it('cΓ³digo field is disabled in edit mode', () => { + renderForm({ initialData: sampleMedio }) + const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', () => { + renderForm({ initialData: sampleMedio }) + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('DIA01') + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Diario Principal') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: sampleMedio }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/MediosListPage.test.tsx b/src/web/src/tests/features/medios/MediosListPage.test.tsx new file mode 100644 index 0000000..6d48b92 --- /dev/null +++ b/src/web/src/tests/features/medios/MediosListPage.test.tsx @@ -0,0 +1,168 @@ +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 { MediosListPage } from '../../../features/medios/pages/MediosListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUserWithMedios = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:medios:gestionar'], + mustChangePassword: false, +} + +const adminUserWithoutMedios = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +function makeMedios(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + codigo: `MEDIO${i + 1}`, + nombre: `Medio ${i + 1}`, + tipo: (i % 4) + 1, + plataformaEmpresaId: null, + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUserWithMedios) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('MediosListPage', () => { + it('renders seed rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByText('MEDIO2')).toBeInTheDocument() + expect(screen.getByText('MEDIO3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo medio" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage(adminUserWithoutMedios) + + // Wait for page to render + await waitFor(() => expect(screen.queryByRole('button', { name: /nuevo medio/i })).not.toBeInTheDocument()) + }) + + it('shows "Nuevo medio" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo medio/i })).toBeInTheDocument(), + ) + }) + + it('filter by tipo adds querystring tipo', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, ({ request }) => { + requests.push(request.url) + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + renderPage() + + await waitFor(() => expect(requests.length).toBeGreaterThan(0)) + + const tipoSelect = screen.getByRole('combobox', { name: /tipo/i }) + await userEvent.selectOptions(tipoSelect, '1') + + await waitFor(() => { + const filtered = requests.find((u) => u.includes('tipo=1')) + expect(filtered).toBeTruthy() + }) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +}) diff --git a/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx new file mode 100644 index 0000000..310e087 --- /dev/null +++ b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx @@ -0,0 +1,74 @@ +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 } from 'react-router-dom' +import { DeactivateSeccionModal } from '../../../features/secciones/components/DeactivateSeccionModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateSeccionModal', () => { + it('shows "Desactivar" trigger when secciΓ³n is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when secciΓ³n is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar secciΓ³n/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/clasificados autos/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/secciones/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionForm.test.tsx b/src/web/src/tests/features/secciones/SeccionForm.test.tsx new file mode 100644 index 0000000..04f0000 --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionForm.test.tsx @@ -0,0 +1,136 @@ +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 } from 'react-router-dom' +import { SeccionForm } from '../../../features/secciones/components/SeccionForm' +import type { SeccionFormValues } from '../../../features/secciones/components/SeccionForm' +import type { SeccionDetail } from '../../../features/secciones/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El DΓ­a', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, +] + +const sampleSeccion: SeccionDetail = { + id: 1, + medioId: 1, + codigo: 'CLAS01', + nombre: 'Clasificados Autos', + tipo: 'clasificados', + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderForm(opts: { initialData?: SeccionDetail; onSubmit?: (v: SeccionFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }), + ), + ) + render( + + + + + , + ) + return { onSubmit } +} + +describe('SeccionForm β€” create mode', () => { + it('shows validation error when cΓ³digo is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + await waitFor(() => + expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n') + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + await waitFor(() => + expect(screen.getByText(/seleccionΓ‘ un tipo/i)).toBeInTheDocument(), + ) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + // Wait for medios to load + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El DΓ­a' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1') + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n') + await userEvent.selectOptions(screen.getByLabelText(/tipo de secciΓ³n/i), 'clasificados') + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ + medioId: 1, + codigo: 'CLAS99', + nombre: 'Mi SecciΓ³n', + tipo: 'clasificados', + }) + }) + }) +}) + +describe('SeccionForm β€” edit mode', () => { + it('cΓ³digo and medioId are disabled in edit mode', async () => { + renderForm({ initialData: sampleSeccion }) + const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + const medioSelect = screen.getByLabelText(/medio/i) as HTMLSelectElement + expect(medioSelect.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', async () => { + renderForm({ initialData: sampleSeccion }) + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Clasificados Autos'), + ) + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('CLAS01') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: sampleSeccion }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx b/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx new file mode 100644 index 0000000..18cc3d0 --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionesFilters.test.tsx @@ -0,0 +1,103 @@ +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 } from 'react-router-dom' +import { SeccionesFilters } from '../../../features/secciones/components/SeccionesFilters' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El DΓ­a', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, + { id: 3, codigo: 'WEB01', nombre: 'Portal Web', tipo: 3, plataformaEmpresaId: null, activo: true }, +] + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderFilters(handlers = { + onMedioIdChange: vi.fn(), + onTipoChange: vi.fn(), + onActivoChange: vi.fn(), + onSearchChange: vi.fn(), +}) { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 3 }), + ), + ) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + render( + + + + + , + ) + return handlers +} + +describe('SeccionesFilters', () => { + it('loads medios options from API', async () => { + renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El DΓ­a' })).toBeInTheDocument(), + ) + expect(screen.getByRole('option', { name: 'Radio AM' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Portal Web' })).toBeInTheDocument() + }) + + it('calls onMedioIdChange when medio is selected', async () => { + const handlers = renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El DΓ­a' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1') + expect(handlers.onMedioIdChange).toHaveBeenCalledWith(1) + }) + + it('calls onTipoChange when tipo is selected', async () => { + const handlers = renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + + await userEvent.selectOptions(screen.getByLabelText(/tipo de secciΓ³n/i), 'clasificados') + expect(handlers.onTipoChange).toHaveBeenCalledWith('clasificados') + }) + + it('calls onActivoChange when estado is selected', async () => { + const handlers = renderFilters() + await userEvent.selectOptions(screen.getByLabelText(/estado/i), 'true') + expect(handlers.onActivoChange).toHaveBeenCalledWith(true) + }) + + it('renders all tipo options', async () => { + renderFilters() + + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + expect(screen.getByRole('option', { name: 'Notables' })).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Suplementos' })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx b/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx new file mode 100644 index 0000000..578831f --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionesListPage.test.tsx @@ -0,0 +1,166 @@ +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 { SeccionesListPage } from '../../../features/secciones/pages/SeccionesListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminWithSecciones = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:secciones:gestionar'], + mustChangePassword: false, +} + +const userWithoutSecciones = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El DΓ­a', tipo: 1, plataformaEmpresaId: null, activo: true }, +] + +function makeSecciones(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + medioId: 1, + codigo: `SEC${i + 1}`, + nombre: `SecciΓ³n ${i + 1}`, + tipo: 'clasificados', + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminWithSecciones) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('SeccionesListPage', () => { + it('renders seed rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument()) + expect(screen.getByText('SEC2')).toBeInTheDocument() + expect(screen.getByText('SEC3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nueva secciΓ³n" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage(userWithoutSecciones) + + await waitFor(() => + expect(screen.queryByRole('button', { name: /nueva secciΓ³n/i })).not.toBeInTheDocument(), + ) + }) + + it('shows "Nueva secciΓ³n" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nueva secciΓ³n/i })).toBeInTheDocument(), + ) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/secciones`, () => + HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }), + ), + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +}) From 740298a9e117b8586c9cef6cffe6a64bd5ce3cd7 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 10:13:20 -0300 Subject: [PATCH 9/9] =?UTF-8?q?fix(web):=20reemplazar=20/ - {TIPO_MEDIO_OPTIONS.map((o) => ( - - ))} - + + + + + Todos los tipos + {TIPO_MEDIO_OPTIONS.map((o) => ( + + {o.label} + + ))} + + - handleActivoChange(v === '__all__' ? '' : v)} > - - - - + + + + + Todos + Activos + Inactivos + + {isLoading ? ( diff --git a/src/web/src/features/permisos/pages/RolPermisosPage.tsx b/src/web/src/features/permisos/pages/RolPermisosPage.tsx index 7c79c8d..63fb3c1 100644 --- a/src/web/src/features/permisos/pages/RolPermisosPage.tsx +++ b/src/web/src/features/permisos/pages/RolPermisosPage.tsx @@ -6,6 +6,13 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useRoles } from '../../roles/hooks/useRoles' import { RolPermisosEditor } from '../components/RolPermisosEditor' @@ -28,28 +35,28 @@ export function RolPermisosPage() { {/* Selector de rol */}

-
diff --git a/src/web/src/features/secciones/components/SeccionForm.tsx b/src/web/src/features/secciones/components/SeccionForm.tsx index 701a0e4..1d188fb 100644 --- a/src/web/src/features/secciones/components/SeccionForm.tsx +++ b/src/web/src/features/secciones/components/SeccionForm.tsx @@ -15,6 +15,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useMediosList } from '@/features/medios/hooks/useMediosList' import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' import type { SeccionDetail, TipoSeccion } from '../types' @@ -100,22 +107,24 @@ export function SeccionForm({ initialData, isPending, error, onSubmit }: Seccion render={({ field }) => ( Medio - - field.onChange(Number(v))} + disabled={isPending || isEdit} + > + + + + + + {medios.map((m) => ( - + ))} - - + + )} @@ -165,22 +174,24 @@ export function SeccionForm({ initialData, isPending, error, onSubmit }: Seccion render={({ field }) => ( Tipo - - field.onChange(v as TipoSeccion)} + disabled={isPending} + > + + + + + + {TIPO_SECCION_OPTIONS.map((o) => ( - + ))} - - + + )} diff --git a/src/web/src/features/secciones/components/SeccionesFilters.tsx b/src/web/src/features/secciones/components/SeccionesFilters.tsx index 5197b3f..25598ab 100644 --- a/src/web/src/features/secciones/components/SeccionesFilters.tsx +++ b/src/web/src/features/secciones/components/SeccionesFilters.tsx @@ -1,6 +1,13 @@ import { useEffect, useState } from 'react' import { Input } from '@/components/ui/input' import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useMediosList } from '@/features/medios/hooks/useMediosList' import { TIPO_SECCION_OPTIONS } from '../tipoSeccion' import type { TipoSeccion } from '../types' @@ -40,51 +47,56 @@ export function SeccionesFilters({ aria-label="Buscar secciones" /> - onMedioIdChange(v === '__all__' ? undefined : Number(v))} > - - {medios.map((m) => ( - - ))} - + + + + + Todos los medios + {medios.map((m) => ( + + {m.nombre} + + ))} + + - onTipoChange(v === '__all__' ? undefined : (v as TipoSeccion))} > - - {TIPO_SECCION_OPTIONS.map((o) => ( - - ))} - + + + + + Todos los tipos + {TIPO_SECCION_OPTIONS.map((o) => ( + + {o.label} + + ))} + + - { + if (v === '__all__') onActivoChange(undefined) else onActivoChange(v === 'true') }} - className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - - - - + + + + + Todos + Activos + Inactivos + + ) } diff --git a/src/web/src/features/users/components/UserForm.tsx b/src/web/src/features/users/components/UserForm.tsx index 792adc3..7df874c 100644 --- a/src/web/src/features/users/components/UserForm.tsx +++ b/src/web/src/features/users/components/UserForm.tsx @@ -14,6 +14,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useCreateUser } from '../hooks/useCreateUser' import { useRolesForSelect } from '../hooks/useRolesForSelect' import type { CreatedUserDto } from '../api/createUser' @@ -202,23 +209,26 @@ export function UserForm({ onSuccess }: UserFormProps) { render={({ field }) => ( Rol - - + + + + + + {rolOptions.map((r) => ( - + ))} - - + + )} diff --git a/src/web/src/features/users/components/UsersFilters.tsx b/src/web/src/features/users/components/UsersFilters.tsx index e21c187..f4c1f86 100644 --- a/src/web/src/features/users/components/UsersFilters.tsx +++ b/src/web/src/features/users/components/UsersFilters.tsx @@ -1,6 +1,13 @@ import { useState, useEffect } from 'react' import { Input } from '@/components/ui/input' import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' interface UsersFiltersProps { onRolChange: (rol: string) => void @@ -38,32 +45,39 @@ export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: Us /> {/* Rol select */} - onRolChange(v === '__all__' ? '' : v)} > - {ROL_OPTIONS.map((r) => ( - - ))} - + + + + + {ROL_OPTIONS.map((r) => ( + + {r.label} + + ))} + + {/* Activo filter */} - { + if (v === '__all__') onActivoChange(undefined) else onActivoChange(v === 'true') }} - className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - - - - + + + + + Todos + Activos + Inactivos + + ) } diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx index 78605e7..85f1cfa 100644 --- a/src/web/src/features/users/pages/UserEditPage.tsx +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -17,6 +17,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useUser } from '../hooks/useUser' import { useUpdateUser } from '../hooks/useUpdateUser' import { ResetPasswordModal } from '../components/ResetPasswordModal' @@ -199,18 +206,22 @@ export function UserEditPage() { render={({ field }) => ( Rol - - - + )} diff --git a/src/web/src/tests/features/medios/CreateMedioPage.test.tsx b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx index 9045f61..d779ed3 100644 --- a/src/web/src/tests/features/medios/CreateMedioPage.test.tsx +++ b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx @@ -61,7 +61,11 @@ describe('CreateMedioPage', () => { await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'RAD01') await userEvent.type(screen.getByLabelText(/nombre/i), 'Radio AM') - await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '2') + + // Open Radix Select trigger and pick "Radio" (value=2) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^radio$/i })) + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/admin/medios')) @@ -81,7 +85,11 @@ describe('CreateMedioPage', () => { await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DUP01') await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') - await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + + // Open Radix Select trigger and pick "Diario" (value=1) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^diario$/i })) + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) await waitFor(() => diff --git a/src/web/src/tests/features/medios/MedioForm.test.tsx b/src/web/src/tests/features/medios/MedioForm.test.tsx index 8b8dc52..021ee0d 100644 --- a/src/web/src/tests/features/medios/MedioForm.test.tsx +++ b/src/web/src/tests/features/medios/MedioForm.test.tsx @@ -79,7 +79,11 @@ describe('MedioForm β€” create mode', () => { await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DIA99') await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario') - await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1') + + // Open the Radix Select trigger and pick option + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /diario/i })) + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) await waitFor(() => { diff --git a/src/web/src/tests/features/medios/MediosListPage.test.tsx b/src/web/src/tests/features/medios/MediosListPage.test.tsx index 6d48b92..34470ce 100644 --- a/src/web/src/tests/features/medios/MediosListPage.test.tsx +++ b/src/web/src/tests/features/medios/MediosListPage.test.tsx @@ -144,8 +144,9 @@ describe('MediosListPage', () => { await waitFor(() => expect(requests.length).toBeGreaterThan(0)) - const tipoSelect = screen.getByRole('combobox', { name: /tipo/i }) - await userEvent.selectOptions(tipoSelect, '1') + // Open the Radix Select trigger and pick "Diario" (value=1) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^diario$/i })) await waitFor(() => { const filtered = requests.find((u) => u.includes('tipo=1')) diff --git a/src/web/src/tests/features/secciones/SeccionForm.test.tsx b/src/web/src/tests/features/secciones/SeccionForm.test.tsx index 04f0000..741f4bd 100644 --- a/src/web/src/tests/features/secciones/SeccionForm.test.tsx +++ b/src/web/src/tests/features/secciones/SeccionForm.test.tsx @@ -88,15 +88,25 @@ describe('SeccionForm β€” create mode', () => { const onSubmit = vi.fn() renderForm({ onSubmit }) - // Wait for medios to load + // Open Medio trigger, wait for medios to load, then pick one + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + await userEvent.click(medioTrigger) await waitFor(() => expect(screen.getByRole('option', { name: 'Diario El DΓ­a' })).toBeInTheDocument(), ) + await userEvent.click(screen.getByRole('option', { name: 'Diario El DΓ­a' })) - await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1') await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99') await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n') - await userEvent.selectOptions(screen.getByLabelText(/tipo de secciΓ³n/i), 'clasificados') + + // Open Tipo trigger and pick Clasificados + const tipoTrigger = screen.getByRole('combobox', { name: /tipo de secciΓ³n/i }) + await userEvent.click(tipoTrigger) + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + await userEvent.click(screen.getByRole('option', { name: 'Clasificados' })) + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) await waitFor(() => { @@ -117,8 +127,9 @@ describe('SeccionForm β€” edit mode', () => { renderForm({ initialData: sampleSeccion }) const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement expect(codigoInput.disabled).toBe(true) - const medioSelect = screen.getByLabelText(/medio/i) as HTMLSelectElement - expect(medioSelect.disabled).toBe(true) + // Radix Select trigger is a