using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace ScadaLink.SiteEventLogging.Tests; public class EventLogPurgeServiceTests : IDisposable { private readonly SiteEventLogger _eventLogger; private readonly string _dbPath; private readonly SiteEventLogOptions _options; public EventLogPurgeServiceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db"); _options = new SiteEventLogOptions { DatabasePath = _dbPath, RetentionDays = 30, MaxStorageMb = 1024 }; _eventLogger = new SiteEventLogger( Options.Create(_options), NullLogger.Instance); } public void Dispose() { _eventLogger.Dispose(); if (File.Exists(_dbPath)) File.Delete(_dbPath); } private EventLogPurgeService CreatePurgeService(SiteEventLogOptions? optionsOverride = null) { var opts = optionsOverride ?? _options; return new EventLogPurgeService( _eventLogger, Options.Create(opts), NullLogger.Instance); } private void InsertEventWithTimestamp(DateTimeOffset timestamp) { _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO site_events (timestamp, event_type, severity, source, message) VALUES ($ts, 'script', 'Info', 'Test', 'Test message') """; cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o")); cmd.ExecuteNonQuery(); }); } private long GetEventCount() { return _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM site_events"; return (long)cmd.ExecuteScalar()!; }); } [Fact] public void PurgeByRetention_DeletesOldEvents() { // Insert an old event (31 days ago) and a recent one InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31)); InsertEventWithTimestamp(DateTimeOffset.UtcNow); var purge = CreatePurgeService(); purge.RunPurge(); Assert.Equal(1, GetEventCount()); } [Fact] public void PurgeByRetention_KeepsRecentEvents() { InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-29)); InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-1)); InsertEventWithTimestamp(DateTimeOffset.UtcNow); var purge = CreatePurgeService(); purge.RunPurge(); Assert.Equal(3, GetEventCount()); } [Fact] public void PurgeByStorageCap_DeletesOldestWhenOverCap() { // Insert enough events to have some data for (int i = 0; i < 100; i++) { InsertEventWithTimestamp(DateTimeOffset.UtcNow); } // Set an artificially small cap to trigger purge var smallCapOptions = new SiteEventLogOptions { DatabasePath = _dbPath, RetentionDays = 30, MaxStorageMb = 0 // 0 MB cap forces purge }; var purge = CreatePurgeService(smallCapOptions); purge.RunPurge(); // All events should be purged since cap is 0 Assert.Equal(0, GetEventCount()); } [Fact] public void GetDatabaseSizeBytes_ReturnsPositiveValue() { InsertEventWithTimestamp(DateTimeOffset.UtcNow); var purge = CreatePurgeService(); var size = purge.GetDatabaseSizeBytes(); Assert.True(size > 0); } private void InsertBulkEvents(int count) { // Each event carries a sizeable details payload so the database grows // measurably and the storage cap can be exercised against a realistic file. var details = new string('x', 2000); _eventLogger.WithConnection(connection => { for (int i = 0; i < count; i++) { using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO site_events (timestamp, event_type, severity, source, message, details) VALUES ($ts, 'script', 'Info', 'Test', 'Bulk event', $details) """; cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToString("o")); cmd.Parameters.AddWithValue("$details", details); cmd.ExecuteNonQuery(); } }); } private long MinEventId() { return _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT MIN(id) FROM site_events"; var result = cmd.ExecuteScalar(); return result is long l ? l : 0; }); } [Fact] public void PurgeByStorageCap_StopsWhenUnderCap_DoesNotEmptyTable() { // Regression test for SiteEventLogging-001 / -002: // a realistic non-zero cap must trim the oldest events to the budget, // not delete the entire table. InsertBulkEvents(3000); var purge = CreatePurgeService(); var totalSize = purge.GetDatabaseSizeBytes(); // Cap at roughly half the current database size — purge must keep some rows. var capBytes = totalSize / 2; var capOptions = new SiteEventLogOptions { DatabasePath = _dbPath, RetentionDays = 30, MaxStorageMb = (int)Math.Max(1, capBytes / (1024 * 1024)) }; var cappedPurge = CreatePurgeService(capOptions); cappedPurge.RunPurge(); var remaining = GetEventCount(); Assert.True(remaining > 0, "Storage-cap purge must not delete the entire table."); Assert.True(remaining < 3000, "Storage-cap purge must remove some events when over cap."); // The database must actually be back under the cap after purge. var finalSize = cappedPurge.GetDatabaseSizeBytes(); var finalCapBytes = (long)capOptions.MaxStorageMb * 1024 * 1024; Assert.True(finalSize <= finalCapBytes, $"Database size {finalSize} must be at or below cap {finalCapBytes} after purge."); } [Fact] public void PurgeByStorageCap_RemovesOldestEventsFirst() { // Regression test for SiteEventLogging-002: only the oldest events // (lowest ids) should be removed when trimming to the cap. InsertBulkEvents(3000); var purge = CreatePurgeService(); var totalSize = purge.GetDatabaseSizeBytes(); var capOptions = new SiteEventLogOptions { DatabasePath = _dbPath, RetentionDays = 30, MaxStorageMb = (int)Math.Max(1, (totalSize / 2) / (1024 * 1024)) }; var minIdBefore = MinEventId(); var cappedPurge = CreatePurgeService(capOptions); cappedPurge.RunPurge(); var minIdAfter = MinEventId(); // The surviving rows must be the newest ones — minimum id has advanced. Assert.True(minIdAfter > minIdBefore, "Oldest events (lowest ids) must be purged first."); // The newest event (highest id) must still be present. var newestPresent = _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE id = 3000"; return (long)cmd.ExecuteScalar()!; }); Assert.Equal(1L, newestPresent); } [Fact] public async Task PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection() { // Regression test for SiteEventLogging-003: purge running on a background // thread while events are recorded on other threads must not throw // "DataReader already open" / "connection busy" from a shared connection. InsertBulkEvents(2000); var purge = CreatePurgeService(); var totalSize = purge.GetDatabaseSizeBytes(); var capOptions = new SiteEventLogOptions { DatabasePath = _dbPath, RetentionDays = 30, MaxStorageMb = (int)Math.Max(1, (totalSize / 2) / (1024 * 1024)) }; var exceptions = new System.Collections.Concurrent.ConcurrentBag(); var stop = false; var purgeTask = Task.Run(() => { try { var p = CreatePurgeService(capOptions); for (int i = 0; i < 20; i++) p.RunPurge(); } catch (Exception ex) { exceptions.Add(ex); } }); var writeTasks = Enumerable.Range(0, 4).Select(_ => Task.Run(async () => { try { while (!stop) { await _eventLogger.LogEventAsync("script", "Info", null, "Concurrent", "Concurrent write"); } } catch (Exception ex) { exceptions.Add(ex); } })).ToArray(); await purgeTask; stop = true; await Task.WhenAll(writeTasks); Assert.Empty(exceptions); } }