using Dapper; using FluentAssertions; using Microsoft.Data.SqlClient; using SIGCM2.Application.Audit; using SIGCM2.Infrastructure.Audit; using SIGCM2.Infrastructure.Persistence; using Xunit; namespace SIGCM2.Application.Tests.Infrastructure.Audit; /// UDT-010 Batch 5 — AuditEventRepository integration tests against SIGCM2_Test. /// Validates insert + cursor-paginated DESC query with all filter permutations. [Collection("Database")] public sealed class AuditEventRepositoryTests : IAsyncLifetime { private const string ConnectionString = "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;"; private SqlConnection _connection = null!; private AuditEventRepository _repo = null!; public async Task InitializeAsync() { _connection = new SqlConnection(ConnectionString); await _connection.OpenAsync(); // Clean slate for this test class: wipe prior audit events. Respawn does this too // between test classes but inside a class tests share state. await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;"); var factory = new SqlConnectionFactory(ConnectionString); _repo = new AuditEventRepository(factory); } public async Task DisposeAsync() { await _connection.ExecuteAsync("DELETE FROM dbo.AuditEvent;"); await _connection.CloseAsync(); await _connection.DisposeAsync(); } [Fact] public async Task InsertAsync_PersistsAllFields_AndReturnsId() { var occurredAt = DateTime.UtcNow; var correlationId = Guid.NewGuid(); var id = await _repo.InsertAsync( occurredAt: occurredAt, actorUserId: 42, actorRoleId: 7, action: "usuario.create", targetType: "Usuario", targetId: "99", correlationId: correlationId, ipAddress: "1.2.3.4", userAgent: "ua/1.0", metadata: """{"after":{"username":"juan"}}"""); id.Should().BeGreaterThan(0); var roundtrip = await _connection.QuerySingleAsync<(int? ActorUserId, int? ActorRoleId, string Action, Guid? CorrelationId, string? IpAddress)>( "SELECT ActorUserId, ActorRoleId, Action, CorrelationId, IpAddress FROM dbo.AuditEvent WHERE Id = @Id", new { Id = id }); roundtrip.ActorUserId.Should().Be(42); roundtrip.ActorRoleId.Should().Be(7); roundtrip.Action.Should().Be("usuario.create"); roundtrip.CorrelationId.Should().Be(correlationId); roundtrip.IpAddress.Should().Be("1.2.3.4"); } [Fact] public async Task QueryAsync_NoFilters_ReturnsAllInDescendingOrder() { var t0 = DateTime.UtcNow.AddSeconds(-30); await Seed(3, t0); var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, 50)); result.Items.Should().HaveCount(3); result.Items.Select(x => x.Action).Should().ContainInOrder("test.2", "test.1", "test.0"); result.NextCursor.Should().BeNull(); } [Fact] public async Task QueryAsync_FilterByActor_ReturnsOnlyMatching() { var t0 = DateTime.UtcNow.AddSeconds(-30); await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "1", null, null, null, null); await _repo.InsertAsync(t0.AddSeconds(1), 2, null, "test.1", "Usuario", "2", null, null, null, null); await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "3", null, null, null, null); var result = await _repo.QueryAsync(new AuditEventFilter(ActorUserId: 1, TargetType: null, TargetId: null, From: null, To: null, Cursor: null, Limit: 50)); result.Items.Should().HaveCount(2); result.Items.Select(x => x.ActorUserId).Should().OnlyContain(a => a == 1); } [Fact] public async Task QueryAsync_FilterByTarget_ReturnsOnlyMatching() { var t0 = DateTime.UtcNow.AddSeconds(-30); await _repo.InsertAsync(t0, 1, null, "test.0", "Usuario", "42", null, null, null, null); await _repo.InsertAsync(t0.AddSeconds(1), 1, null, "test.1", "Cliente", "99", null, null, null, null); await _repo.InsertAsync(t0.AddSeconds(2), 1, null, "test.2", "Usuario", "42", null, null, null, null); var result = await _repo.QueryAsync(new AuditEventFilter(null, TargetType: "Usuario", TargetId: "42", From: null, To: null, Cursor: null, Limit: 50)); result.Items.Should().HaveCount(2); result.Items.Should().OnlyContain(x => x.TargetType == "Usuario" && x.TargetId == "42"); } [Fact] public async Task QueryAsync_FilterByDateRange_RespectsFromAndTo() { var t0 = new DateTime(2026, 4, 10, 12, 0, 0, DateTimeKind.Utc); await _repo.InsertAsync(t0, 1, null, "test.0", "X", "1", null, null, null, null); await _repo.InsertAsync(t0.AddDays(3), 1, null, "test.1", "X", "2", null, null, null, null); await _repo.InsertAsync(t0.AddDays(6), 1, null, "test.2", "X", "3", null, null, null, null); var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, From: t0.AddDays(1), To: t0.AddDays(5), Cursor: null, Limit: 50)); result.Items.Should().HaveCount(1); result.Items[0].Action.Should().Be("test.1"); } [Fact] public async Task QueryAsync_Limit_EmitsCursor_WhenMoreRowsAvailable() { var t0 = DateTime.UtcNow.AddMinutes(-10); await Seed(5, t0); var page1 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, null, Limit: 2)); page1.Items.Should().HaveCount(2); page1.NextCursor.Should().NotBeNull(); var page2 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page1.NextCursor, Limit: 2)); page2.Items.Should().HaveCount(2); page2.NextCursor.Should().NotBeNull(); var page3 = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, page2.NextCursor, Limit: 2)); page3.Items.Should().HaveCount(1); page3.NextCursor.Should().BeNull(); // Across pages, all 5 events are visited exactly once (no overlap, no gap). var allActions = page1.Items.Concat(page2.Items).Concat(page3.Items).Select(x => x.Action).ToList(); allActions.Should().HaveCount(5).And.OnlyHaveUniqueItems(); } [Fact] public async Task QueryAsync_MalformedCursor_TreatedAsNoCursor() { var t0 = DateTime.UtcNow.AddSeconds(-30); await Seed(2, t0); var result = await _repo.QueryAsync(new AuditEventFilter(null, null, null, null, null, "not-a-valid-cursor", 50)); result.Items.Should().HaveCount(2); } [Fact] public async Task QueryAsync_JoinsUsuario_PopulatesActorUsername() { var adminId = await _connection.QuerySingleOrDefaultAsync("SELECT Id FROM dbo.Usuario WHERE Username = 'admin'"); if (adminId is null) { // Seed admin if not present (Respawn wiped it) adminId = await _connection.ExecuteScalarAsync(""" INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson) VALUES ('admin', 'hash', 'Admin', 'Sistema', 'admin', '{"grant":[],"deny":[]}'); SELECT CAST(SCOPE_IDENTITY() AS INT); """); } await _repo.InsertAsync(DateTime.UtcNow, adminId, null, "usuario.create", "Usuario", "99", null, null, null, null); var result = await _repo.QueryAsync(new AuditEventFilter(adminId, null, null, null, null, null, 50)); result.Items.Should().ContainSingle() .Which.ActorUsername.Should().Be("admin"); } [Fact] public async Task AuditEventCursor_EncodeDecode_RoundTrips() { var now = DateTime.UtcNow; var encoded = AuditEventCursor.Encode(now, 12345); var decoded = AuditEventCursor.TryDecode(encoded); decoded.Should().NotBeNull(); decoded!.Value.Id.Should().Be(12345); // DateTime roundtrip via "O" format preserves ticks-level precision. decoded.Value.OccurredAt.Should().BeCloseTo(now, TimeSpan.FromTicks(1)); } [Fact] public void AuditEventCursor_TryDecode_ReturnsNullForMalformed() { AuditEventCursor.TryDecode(null).Should().BeNull(); AuditEventCursor.TryDecode("").Should().BeNull(); AuditEventCursor.TryDecode("not-base64!!!").Should().BeNull(); AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("no-pipe"))).Should().BeNull(); AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("|123"))).Should().BeNull(); AuditEventCursor.TryDecode(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("2026-04-16|"))).Should().BeNull(); } private async Task Seed(int count, DateTime baseTime) { for (var i = 0; i < count; i++) { await _repo.InsertAsync( occurredAt: baseTime.AddSeconds(i), actorUserId: 1, actorRoleId: null, action: $"test.{i}", targetType: "Usuario", targetId: i.ToString(), correlationId: null, ipAddress: null, userAgent: null, metadata: null); } } }