263 lines
11 KiB
C#
263 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 =
|
||
|
|
"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<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
|
||
|
|
""");
|
||
|
|
}
|
||
|
|
}
|