Files
SIG-CM2.0/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs
dmolinari e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00

262 lines
11 KiB
C#

using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Xunit;
namespace SIGCM2.Api.Tests.Admin;
/// <summary>
/// UDT-011 Batch 2 — V015 migration integration tests.
/// Validates:
/// REQ-DB-VIEWS-001 : Vista dbo.v_AuditEvent_Local existe tras aplicar V015.
/// REQ-DB-VIEWS-002 : Vista dbo.v_SecurityEvent_Local existe tras aplicar V015.
/// REQ-DB-VIEWS-003 : OccurredAtLocal retorna offset -03:00 (Argentina Standard Time).
/// REQ-DB-VIEWS-004 : V015 es idempotente (re-ejecución no falla ni duplica vistas).
/// REQ-DB-VIEWS-005 : V015_ROLLBACK elimina ambas vistas.
///
/// NOTA: Esta suite opera directamente sobre SIGCM2_Test con Dapper.
/// NO usa WebApplicationFactory (es test de migración pura, no API).
/// La migración se aplica via SqlTestFixture.EnsureV015SchemaAsync().
/// </summary>
[Collection("ApiIntegration")]
public sealed class V015MigrationTests : IClassFixture<SIGCM2.TestSupport.TestWebAppFactory>
{
private const string ConnectionString = TestConnectionStrings.ApiTestDb;
public V015MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _)
{
// Depend on the factory so SqlTestFixture.InitializeAsync runs
// (ensures V015 schema is present via EnsureV015SchemaAsync).
}
// ── REQ-DB-VIEWS-001 ─────────────────────────────────────────────────────
[Fact]
public async Task V015_DebeCrearVistaAuditEventLocal()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var objectId = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
""");
objectId.Should().NotBeNull("dbo.v_AuditEvent_Local debe existir tras aplicar V015");
}
// ── REQ-DB-VIEWS-002 ─────────────────────────────────────────────────────
[Fact]
public async Task V015_DebeCrearVistaSecurityEventLocal()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
var objectId = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
""");
objectId.Should().NotBeNull("dbo.v_SecurityEvent_Local debe existir tras aplicar V015");
}
// ── REQ-DB-VIEWS-003 ─────────────────────────────────────────────────────
// Inserta 1 fila con OccurredAt = 2026-05-01 01:30:00 UTC
// Espera OccurredAtLocal con offset -03:00 → 2026-04-30 22:30:00-03:00
[Fact]
public async Task v_AuditEvent_Local_RetornaOccurredAtLocalConOffsetArgentina()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Insert a test row with known OccurredAt UTC value.
// OccurredAt = 2026-05-01 01:30:00 UTC → Argentina (UTC-3) = 2026-04-30 22:30:00
var occurredAt = new DateTime(2026, 5, 1, 1, 30, 0, DateTimeKind.Utc);
long insertedId;
try
{
insertedId = await conn.ExecuteScalarAsync<long>("""
INSERT INTO dbo.AuditEvent
(OccurredAt, ActorUserId, ActorRoleId, Action, TargetType, TargetId, CorrelationId, IpAddress, UserAgent, Metadata)
VALUES
(@OccurredAt, NULL, NULL, 'test.v015', 'Test', '0', NULL, '127.0.0.1', NULL, NULL);
SELECT SCOPE_IDENTITY();
""", new { OccurredAt = occurredAt });
}
catch
{
// If insert fails (e.g. CHECK constraint on Action), skip — vista test only
throw;
}
var result = await conn.QuerySingleOrDefaultAsync<(DateTimeOffset OccurredAtLocal, DateTime OccurredAt)>("""
SELECT TOP 1 OccurredAtLocal, OccurredAt
FROM dbo.v_AuditEvent_Local
WHERE Action = 'test.v015'
AND OccurredAt = @OccurredAt
""", new { OccurredAt = occurredAt });
result.OccurredAt.Should().Be(occurredAt, "OccurredAt debe ser el UTC insertado");
// Argentina Standard Time = UTC-3 (sin DST desde 2009). Offset fijo = -03:00.
result.OccurredAtLocal.Offset.TotalHours.Should().Be(-3,
"OccurredAtLocal debe tener offset -03:00 (Argentina Standard Time)");
// 2026-05-01 01:30 UTC → 2026-04-30 22:30 ART
result.OccurredAtLocal.DateTime.Date.Should().Be(new DateTime(2026, 4, 30),
"La fecha local Argentina debe ser 2026-04-30 (día anterior al UTC)");
result.OccurredAtLocal.Hour.Should().Be(22, "La hora local Argentina debe ser 22");
result.OccurredAtLocal.Minute.Should().Be(30, "Los minutos deben ser 30");
}
// ── REQ-DB-VIEWS-004 — Idempotencia ──────────────────────────────────────
[Fact]
public async Task V015_EsIdempotente()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Re-ejecutar el DDL idempotente de V015 por segunda vez — NO debe fallar.
// La vista ya existe (creada en EnsureV015SchemaAsync), el guard IF OBJECT_ID IS NULL
// debe cortocircuitar sin error.
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_AuditEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
ActorRoleId,
Action,
TargetType,
TargetId,
CorrelationId,
IpAddress,
UserAgent,
Metadata
FROM dbo.AuditEvent;
');
END
""");
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_SecurityEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
AttemptedUsername,
SessionId,
Action,
Result,
FailureReason,
IpAddress,
UserAgent,
Metadata
FROM dbo.SecurityEvent;
');
END
""");
// Ambas vistas deben seguir existiendo.
var auditExists = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
""");
var secExists = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
""");
auditExists.Should().NotBeNull("v_AuditEvent_Local debe seguir existiendo tras re-ejecución idempotente");
secExists.Should().NotBeNull("v_SecurityEvent_Local debe seguir existiendo tras re-ejecución idempotente");
}
// ── REQ-DB-VIEWS-005 — Rollback ───────────────────────────────────────────
[Fact]
public async Task V015_ROLLBACK_EliminaVistas()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
// Apply rollback DDL (simula V015_ROLLBACK.sql).
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NOT NULL
DROP VIEW dbo.v_AuditEvent_Local;
""");
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NOT NULL
DROP VIEW dbo.v_SecurityEvent_Local;
""");
var auditExists = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V')
""");
var secExists = await conn.ExecuteScalarAsync<int?>("""
SELECT OBJECT_ID('dbo.v_SecurityEvent_Local', 'V')
""");
auditExists.Should().BeNull("v_AuditEvent_Local debe ser NULL tras el rollback");
secExists.Should().BeNull("v_SecurityEvent_Local debe ser NULL tras el rollback");
// IMPORTANT: Re-create views after rollback test so remaining tests still pass.
// (This test tears down — re-apply V015 DDL to restore the fixture state.)
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_AuditEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
ActorRoleId,
Action,
TargetType,
TargetId,
CorrelationId,
IpAddress,
UserAgent,
Metadata
FROM dbo.AuditEvent;
');
END
""");
await conn.ExecuteAsync("""
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_SecurityEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
AttemptedUsername,
SessionId,
Action,
Result,
FailureReason,
IpAddress,
UserAgent,
Metadata
FROM dbo.SecurityEvent;
');
END
""");
}
}