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
+ """);
+ }
+}