using Dapper; using Microsoft.Data.SqlClient; using Respawn; namespace SIGCM2.Application.Tests.Integration; /// /// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009) /// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync. /// Uses SIGCM2_Test database directly. /// [Collection("Database")] public sealed class V009MigrationTests : 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!; 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"), // UDT-010: *_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"), // ADM-008 (V013): PuntoDeVenta is temporal; SecuenciaComprobante is NOT temporal (AD8 revisitado). new Respawn.Graph.Table("dbo", "PuntoDeVenta_History"), // ADM-009 (V014): TipoDeIva + IngresosBrutos son temporales. new Respawn.Graph.Table("dbo", "TipoDeIva_History"), new Respawn.Graph.Table("dbo", "IngresosBrutos_History"), new Respawn.Graph.Table("dbo", "TipoDeIva"), new Respawn.Graph.Table("dbo", "IngresosBrutos"), // CAT-001 (V016): Rubro es temporal — history no puede deletearse directo. new Respawn.Graph.Table("dbo", "Rubro_History"), ] }); await _respawner.ResetAsync(_connection); await SeedRolAsync(); } public async Task DisposeAsync() { if (_connection is not null) { await _connection.CloseAsync(); await _connection.DisposeAsync(); } } // M-01: migration file exists on filesystem [Fact] public void MigrationFile_Exists() { // Walk up from test assembly looking for the repo root var dir = new DirectoryInfo(AppContext.BaseDirectory); string? repoRoot = null; while (dir is not null) { if (dir.GetFiles("SIGCM2.slnx").Length > 0) { repoRoot = dir.FullName; break; } dir = dir.Parent; } // Known fallback if (repoRoot is null && Directory.Exists(@"E:\SIG-CM2.0")) repoRoot = @"E:\SIG-CM2.0"; Assert.NotNull(repoRoot); var migrationPath = Path.Combine(repoRoot!, "database", "migrations", "V009__activate_permisos_overrides.sql"); Assert.True(File.Exists(migrationPath), $"Migration file not found at: {migrationPath}"); } // M-02: re-run idempotent (EnsureV009SchemaAsync twice → no error) [Fact] public async Task EnsureV009SchemaAsync_IsIdempotent() { await EnsureV009SchemaAsync(); await EnsureV009SchemaAsync(); // second call must not throw } // M-03: after migration, DEFAULT constraint is the new shape [Fact] public async Task EnsureV009SchemaAsync_DefaultConstraint_IsNewShape() { await EnsureV009SchemaAsync(); const string sql = """ SELECT object_definition(default_object_id) AS DefaultDef FROM sys.columns WHERE object_id = OBJECT_ID('dbo.Usuario') AND name = 'PermisosJson' """; var definition = await _connection.QuerySingleOrDefaultAsync(sql); Assert.NotNull(definition); Assert.Contains(@"{""grant"":[]", definition); Assert.Contains(@"""deny"":[]}", definition); } // M-04: rows with '[]' are migrated to new shape [Fact] public async Task EnsureV009SchemaAsync_MigratesLegacyEmptyArray() { await EnsureV009SchemaAsync(); await _connection.ExecuteAsync(""" INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0) """); // Run migration again to migrate the newly inserted row await EnsureV009SchemaAsync(); var permisosJson = await _connection.QuerySingleAsync( "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'"); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); } // M-05: rows with '["*"]' are migrated [Fact] public async Task EnsureV009SchemaAsync_MigratesLegacyWildcard() { await EnsureV009SchemaAsync(); await _connection.ExecuteAsync(""" INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0) """); await EnsureV009SchemaAsync(); var permisosJson = await _connection.QuerySingleAsync( "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'"); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); } // M-06: the migration UPDATE statement includes NULL / empty-string conditions // The column is NOT NULL (V001 constraint), so we verify the UPDATE logic covers // all the WHERE conditions syntactically and that rows with '' are migrated. [Fact] public async Task EnsureV009SchemaAsync_MigratesEmptyStringRows() { // First apply V009 schema so the constraint is updated await EnsureV009SchemaAsync(); // Temporarily drop and re-add without the DEFAULT so we can insert '' await _connection.ExecuteAsync(""" IF EXISTS ( SELECT 1 FROM sys.default_constraints WHERE name = 'DF_Usuario_Permisos' AND parent_object_id = OBJECT_ID('dbo.Usuario') ) ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos; """); await _connection.ExecuteAsync(""" INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword) VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0) """); // Re-apply V009 (which restores constraint and migrates '' rows) await EnsureV009SchemaAsync(); var permisosJson = await _connection.QuerySingleAsync( "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'"); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); } // M-07: admin seed in SqlTestFixture uses new shape [Fact] public async Task SqlTestFixture_SeedAdmin_UsesNewPermisosJsonShape() { await EnsureV009SchemaAsync(); // Seed admin as TestFixture does post-V009 await _connection.ExecuteAsync(""" 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 ) """); var permisosJson = await _connection.QuerySingleAsync( "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'"); Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson); } // ── helpers ─────────────────────────────────────────────────────────────── private async Task SeedRolAsync() { await _connection.ExecuteAsync(""" SET QUOTED_IDENTIFIER ON; MERGE dbo.Rol AS t USING (VALUES ('admin', N'Administrador', N'Supervisor total')) 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); """); } /// /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync. /// 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); } }