using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using Xunit; namespace SIGCM2.Api.Tests.Audit; /// UDT-010 Batch 1 — V010 migration integration smoke tests. /// Validates: /// REQ-AUD-1: SYSTEM_VERSIONING active on catalog entities (smoke: Usuario + Usuario_History query). /// REQ-AUD-2: dbo.AuditEvent exists, accepts valid INSERT, CHECK constraints reject invalid data. /// REQ-SEC-1: dbo.SecurityEvent exists, CK_SecurityEvent_Result rejects invalid Result. [Collection("ApiIntegration")] public sealed class V010MigrationTests : IClassFixture { private const string ConnectionString = TestConnectionStrings.ApiTestDb; public V010MigrationTests(SIGCM2.TestSupport.TestWebAppFactory _) { // Depend on the factory so SqlTestFixture.InitializeAsync runs (validates V010 applied + resets DB). } [Fact] public async Task AuditEvent_Insert_WithValidData_Persists() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var correlationId = Guid.NewGuid(); var insertedId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, CorrelationId, Metadata) VALUES (@ActorUserId, @Action, @TargetType, @TargetId, @CorrelationId, @Metadata); SELECT CAST(SCOPE_IDENTITY() AS BIGINT); """, new { ActorUserId = 1, Action = "usuario.create", TargetType = "Usuario", TargetId = "42", CorrelationId = correlationId, Metadata = """{"after":{"username":"juan"}}""" }); insertedId.Should().BeGreaterThan(0); var roundtrip = await conn.QuerySingleAsync<(string Action, string TargetType, string TargetId, Guid? CorrelationId)>( "SELECT Action, TargetType, TargetId, CorrelationId FROM dbo.AuditEvent WHERE Id = @Id", new { Id = insertedId }); roundtrip.Action.Should().Be("usuario.create"); roundtrip.TargetType.Should().Be("Usuario"); roundtrip.TargetId.Should().Be("42"); roundtrip.CorrelationId.Should().Be(correlationId); } [Fact] public async Task AuditEvent_Insert_WithInvalidActionFormat_FailsCheckConstraint() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var act = async () => await conn.ExecuteAsync(""" INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId) VALUES (1, 'invalid_no_dot', 'Usuario', '1'); """); await act.Should().ThrowAsync() .Where(e => e.Message.Contains("CK_AuditEvent_Action")); } [Fact] public async Task AuditEvent_Insert_WithNonJsonMetadata_FailsCheckConstraint() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var act = async () => await conn.ExecuteAsync(""" INSERT INTO dbo.AuditEvent (ActorUserId, Action, TargetType, TargetId, Metadata) VALUES (1, 'usuario.create', 'Usuario', '1', 'not-json'); """); await act.Should().ThrowAsync() .Where(e => e.Message.Contains("CK_AuditEvent_Metadata")); } [Fact] public async Task SecurityEvent_Insert_WithInvalidResult_FailsCheckConstraint() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); var act = async () => await conn.ExecuteAsync(""" INSERT INTO dbo.SecurityEvent (ActorUserId, Action, Result) VALUES (1, 'login', 'neutral'); """); await act.Should().ThrowAsync() .Where(e => e.Message.Contains("CK_SecurityEvent_Result")); } [Fact] public async Task Usuario_SystemVersioning_IsActive_And_TemporalQueryReturnsHistory() { await using var conn = new SqlConnection(ConnectionString); await conn.OpenAsync(); // Seed: insert a scratch user and wait long enough for ValidFrom to be in the past var username = "b1spike_" + Guid.NewGuid().ToString("N")[..8]; var newId = await conn.ExecuteScalarAsync(""" INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) VALUES (@u, 'hash', 'Temp', 'User', 'admin', '{"grant":[],"deny":[]}'); SELECT CAST(SCOPE_IDENTITY() AS INT); """, new { u = username }); // Capture a timestamp *after* creation but *before* update await Task.Delay(50); var snapshotTime = DateTime.UtcNow; await Task.Delay(50); // Update the email await conn.ExecuteAsync( "UPDATE dbo.Usuario SET Email = @e WHERE Id = @id", new { e = "new@example.com", id = newId }); // Temporal query: state at snapshotTime should NOT yet have the new email var historicalEmail = await conn.ExecuteScalarAsync(""" SELECT Email FROM dbo.Usuario FOR SYSTEM_TIME AS OF @ts WHERE Id = @id """, new { ts = snapshotTime, id = newId }); historicalEmail.Should().BeNull("the historical snapshot predates the email update"); // Current state HAS the new email var currentEmail = await conn.ExecuteScalarAsync( "SELECT Email FROM dbo.Usuario WHERE Id = @id", new { id = newId }); currentEmail.Should().Be("new@example.com"); // Usuario_History must have at least one row for this user var historyCount = await conn.ExecuteScalarAsync( "SELECT COUNT(*) FROM dbo.Usuario_History WHERE Id = @id", new { id = newId }); historyCount.Should().BeGreaterThan(0); // Cleanup: Respawn will reset between test runs, but this test could run alone — best-effort delete. await conn.ExecuteAsync("DELETE FROM dbo.Usuario WHERE Id = @id", new { id = newId }); } }