From be6f76d107606be473d3d07777d65774f1d7a3ef Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sat, 18 Apr 2026 09:38:55 -0300 Subject: [PATCH] test(udt-011): V015 migration tests for timezone views (Red) --- .../Admin/V015MigrationTests.cs | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs diff --git a/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs b/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs new file mode 100644 index 0000000..ad9990d --- /dev/null +++ b/tests/SIGCM2.Api.Tests/Admin/V015MigrationTests.cs @@ -0,0 +1,262 @@ +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 = + "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; + + 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 + """); + } +}