using System.Text.Json; using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Services; namespace ScadaLink.ConfigurationDatabase.Tests; public class AuditServiceTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly AuditService _auditService; public AuditServiceTests() { var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; _context = new ScadaLinkDbContext(options); _context.Database.OpenConnection(); _context.Database.EnsureCreated(); _auditService = new AuditService(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange() { // Simulate entity change + audit in same transaction var template = new Template("TestTemplate"); _context.Templates.Add(template); await _auditService.LogAsync("admin", "Create", "Template", "1", "TestTemplate", new { Name = "TestTemplate" }); // Single SaveChangesAsync commits both await _context.SaveChangesAsync(); var audit = await _context.AuditLogEntries.SingleAsync(); Assert.Equal("admin", audit.User); Assert.Equal("Create", audit.Action); Assert.Equal("Template", audit.EntityType); Assert.NotNull(audit.AfterStateJson); // Template also committed Assert.Single(await _context.Templates.ToListAsync()); } [Fact] public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack() { // Use a separate context to simulate rollback via not calling SaveChanges var options = new DbContextOptionsBuilder() .UseSqlite(_context.Database.GetDbConnection()) .Options; using var context2 = new ScadaLinkDbContext(options); var auditService2 = new AuditService(context2); var template = new Template("RollbackTemplate"); context2.Templates.Add(template); await auditService2.LogAsync("admin", "Create", "Template", "99", "RollbackTemplate", new { Name = "RollbackTemplate" }); // Intentionally do NOT call SaveChangesAsync — simulates rollback // Verify nothing persisted Assert.Empty(await _context.AuditLogEntries.Where(a => a.EntityName == "RollbackTemplate").ToListAsync()); Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync()); } [Fact] public async Task LogAsync_SerializesAfterStateAsJson() { var state = new { Name = "Test", Value = 42, Nested = new { Prop = "inner" } }; await _auditService.LogAsync("admin", "Create", "Entity", "1", "Test", state); await _context.SaveChangesAsync(); var audit = await _context.AuditLogEntries.SingleAsync(); Assert.NotNull(audit.AfterStateJson); var deserialized = JsonSerializer.Deserialize(audit.AfterStateJson!); Assert.Equal("Test", deserialized.GetProperty("Name").GetString()); Assert.Equal(42, deserialized.GetProperty("Value").GetInt32()); } [Fact] public async Task LogAsync_NullAfterState_ForDeletes() { await _auditService.LogAsync("admin", "Delete", "Template", "1", "DeletedTemplate", null); await _context.SaveChangesAsync(); var audit = await _context.AuditLogEntries.SingleAsync(); Assert.Null(audit.AfterStateJson); Assert.Equal("Delete", audit.Action); } [Fact] public async Task LogAsync_SetsTimestampToUtcNow() { var before = DateTimeOffset.UtcNow; await _auditService.LogAsync("admin", "Create", "Template", "1", "T1", new { }); await _context.SaveChangesAsync(); var after = DateTimeOffset.UtcNow; var audit = await _context.AuditLogEntries.SingleAsync(); // Allow 2 second tolerance for SQLite precision Assert.True(audit.Timestamp >= before.AddSeconds(-2)); Assert.True(audit.Timestamp <= after.AddSeconds(2)); } [Fact] public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods() { // Verify IAuditService only exposes LogAsync — no update/delete var methods = typeof(IAuditService).GetMethods(); Assert.Single(methods, m => m.Name == "LogAsync"); Assert.DoesNotContain(methods, m => m.Name.Contains("Update", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase)); } }