Files
SIG-CM2.0/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
dmolinari c95bc7fe01 fix(tests): extend Respawn + collection config for UDT-010 temporal tables
Follow-up of B1 (V010 migration). Issues found when running the full suite
cross-assembly:

1. Respawn 'Cannot delete rows from a temporal history table' error:
   4 per-class Respawner configs in SIGCM2.Application.Tests did not
   include the newly-created *_History tables introduced by V010
   (Usuario_History / Rol_History / Permiso_History / RolPermiso_History).
   The engine rejects direct DELETE on system-versioned history tables.
   Extended TablesToIgnore in all 4 configs.

2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync:
   Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from
   prior suites existed. Added 'DELETE FROM RefreshToken' before the
   Usuario cleanup to respect FK order. Latent bug surfaced by a new
   test-run ordering — not UDT-010 specific, but fixed in scope.

3. UQ_Usuario_Username duplicate admin race:
   TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were
   missing [Collection("ApiIntegration")], causing them to run in
   parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin.
   Serialized by adding the Collection attribute.

Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up)
2026-04-16 13:22:56 -03:00

262 lines
9.6 KiB
C#

using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
namespace SIGCM2.Application.Tests.Integration;
/// <summary>
/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
/// Uses SIGCM2_Test database directly.
/// </summary>
[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"),
]
});
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<string>(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<string>(
"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<string>(
"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<string>(
"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<string>(
"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);
""");
}
/// <summary>
/// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
/// </summary>
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);
}
}