using Dapper; using Microsoft.Data.SqlClient; using Respawn; using Xunit; namespace SIGCM2.TestSupport; /// /// Manages a real SQL Server test database. /// Resets state between test runs using Respawn. /// Seeds the admin user after each reset. /// public sealed class SqlTestFixture : IAsyncLifetime { private readonly string _connectionString; private SqlConnection _connection = null!; private Respawner _respawner = null!; public SqlTestFixture(string connectionString) { _connectionString = connectionString; } public async Task InitializeAsync() { _connection = new SqlConnection(_connectionString); await _connection.OpenAsync(); // V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB await EnsureV008SchemaAsync(); // V009: update PermisosJson DEFAULT constraint and migrate legacy rows await EnsureV009SchemaAsync(); // V010 (UDT-010): verify audit infrastructure + temporal tables are active. // 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/ADM-001 system-versioned — Respawn cannot DELETE them directly (engine rejects). TablesToIgnore = [ new Respawn.Graph.Table("dbo", "Rol"), new Respawn.Graph.Table("dbo", "Permiso"), new Respawn.Graph.Table("dbo", "RolPermiso"), 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"), new Respawn.Graph.Table("dbo", "Medio_History"), new Respawn.Graph.Table("dbo", "Seccion_History"), ] }); await ResetAndSeedAsync(); } public async Task ResetAndSeedAsync() { await _respawner.ResetAsync(_connection); await SeedRolCanonicalAsync(); await SeedPermisosCanonicalAsync(); await SeedRolPermisosCanonicalAsync(); await SeedAdminAsync(); await SeedMediosCanonicalAsync(); } 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); } public async Task DisposeAsync() { if (_connection is not null) { await _connection.CloseAsync(); await _connection.DisposeAsync(); } } /// /// Applies V008 schema changes idempotently to the test database. /// Mirrors V008__add_mustchangepassword_and_indexes.sql. /// private async Task EnsureV008SchemaAsync() { const string addColumn = """ IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL BEGIN ALTER TABLE dbo.Usuario ADD MustChangePassword BIT NOT NULL CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0); END """; const string addIndex = """ IF NOT EXISTS ( SELECT 1 FROM sys.indexes WHERE name = 'IX_Usuario_Activo_Rol' AND object_id = OBJECT_ID('dbo.Usuario') ) BEGIN CREATE INDEX IX_Usuario_Activo_Rol ON dbo.Usuario(Activo, Rol) INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion); END """; await _connection.ExecuteAsync(addColumn); await _connection.ExecuteAsync(addIndex); } private async Task SeedPermisosCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.Permiso AS t USING (VALUES ('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'), ('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'), ('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'), ('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'), ('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'), ('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'), ('textos:editar', N'Editar textos', NULL, 'textos'), ('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'), ('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'), ('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'), ('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'), ('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'), ('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'), ('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'), ('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'), ('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'), ('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuracion de medios', 'administracion'), ('administracion:auditoria:ver', N'Ver logs de auditoria', N'Acceso al dashboard de auditoria', 'administracion'), -- 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'), -- 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 INSERT (Codigo, Nombre, Descripcion, Modulo) VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); """; await _connection.ExecuteAsync(sql); } private async Task SeedRolPermisosCanonicalAsync() { const string sql = """ SET QUOTED_IDENTIFIER ON; MERGE dbo.RolPermiso AS t USING ( SELECT r.Id AS RolId, p.Id AS PermisoId FROM (VALUES ('admin', 'ventas:contado:crear'), ('admin', 'ventas:contado:modificar'), ('admin', 'ventas:contado:cobrar'), ('admin', 'ventas:contado:facturar'), ('admin', 'ventas:ctacte:crear'), ('admin', 'ventas:ctacte:facturar'), ('admin', 'textos:editar'), ('admin', 'textos:reclamos:ver'), ('admin', 'pauta:azanu:ver'), ('admin', 'pauta:limpiar'), ('admin', 'pauta:recursos:fueradehora'), ('admin', 'productores:deuda:ver'), ('admin', 'productores:pendientes:crear'), ('admin', 'productores:deuda:bypass'), ('admin', 'administracion:usuarios:gestionar'), ('admin', 'administracion:tarifarios:gestionar'), ('admin', 'administracion:medios:gestionar'), ('admin', 'administracion:auditoria:ver'), -- V007 (UDT-006): permisos administrativos RBAC para admin ('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'), ('cajero', 'ventas:contado:facturar'), ('operador_ctacte', 'ventas:ctacte:crear'), ('operador_ctacte', 'ventas:ctacte:facturar'), ('picadora', 'textos:editar'), ('picadora', 'textos:reclamos:ver'), ('jefe_publicidad', 'textos:editar'), ('jefe_publicidad', 'textos:reclamos:ver'), ('jefe_publicidad', 'pauta:azanu:ver'), ('jefe_publicidad', 'pauta:limpiar'), ('jefe_publicidad', 'pauta:recursos:fueradehora'), ('jefe_publicidad', 'productores:deuda:ver'), ('jefe_publicidad', 'productores:deuda:bypass'), ('productor', 'productores:deuda:ver'), ('productor', 'productores:pendientes:crear'), ('diagramacion', 'pauta:azanu:ver') ) 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); """; await _connection.ExecuteAsync(sql); } private async Task SeedAdminAsync() { // V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]' const string sql = """ SET QUOTED_IDENTIFIER ON; IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin') INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ( 'admin', '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW', 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0 ); """; 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 /// function/scheme creation requires the full script). If missing, fails with a clear /// message pointing to the migration script. /// private async Task EnsureV010SchemaAsync() { const string check = """ SELECT CAST(CASE WHEN OBJECT_ID('dbo.AuditEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasAuditEvent, CAST(CASE WHEN OBJECT_ID('dbo.SecurityEvent','U') IS NULL THEN 0 ELSE 1 END AS BIT) AS HasSecurityEvent, CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2) THEN 1 ELSE 0 END AS BIT) AS UsuarioVersioned """; var result = await _connection.QuerySingleAsync<(bool HasAuditEvent, bool HasSecurityEvent, bool UsuarioVersioned)>(check); if (!result.HasAuditEvent || !result.HasSecurityEvent || !result.UsuarioVersioned) { throw new InvalidOperationException( "V010 audit infrastructure is not applied in the test database. " + "Run: sqlcmd -S -d SIGCM2_Test -U -P -i database/migrations/V010__audit_infrastructure.sql"); } } /// /// Applies V009 schema changes idempotently to the test database. /// Mirrors V009__activate_permisos_overrides.sql. /// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows. /// private async Task EnsureV009SchemaAsync() { const string dropConstraint = """ IF EXISTS ( SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Usuario_Permisos' AND parent_object_id = OBJECT_ID('dbo.Usuario') ) BEGIN ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos; END """; const string addConstraint = """ IF NOT EXISTS ( SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Usuario_Permisos' AND parent_object_id = OBJECT_ID('dbo.Usuario') ) BEGIN ALTER TABLE dbo.Usuario ADD CONSTRAINT DF_Usuario_Permisos DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson; END """; const string migrateRows = """ UPDATE dbo.Usuario SET PermisosJson = '{"grant":[],"deny":[]}' WHERE PermisosJson IN ('[]', '["*"]', '') OR PermisosJson IS NULL OR LTRIM(RTRIM(PermisosJson)) = '' """; await _connection.ExecuteAsync(dropConstraint); await _connection.ExecuteAsync(addConstraint); await _connection.ExecuteAsync(migrateRows); } }