using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using Xunit; namespace SIGCM2.Api.Tests.Admin; /// /// 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(). /// [Collection("ApiIntegration")] public sealed class V015MigrationTests : IClassFixture { 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(""" 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(""" 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(""" 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(""" SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V') """); var secExists = await conn.ExecuteScalarAsync(""" 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(""" SELECT OBJECT_ID('dbo.v_AuditEvent_Local', 'V') """); var secExists = await conn.ExecuteScalarAsync(""" 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 """); } }