using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// Tests for (arch-review 04, S1/U2). /// The site SQLite store is ephemeral (~7-day retention); the retention purge deletes /// rows that have already left the site (Forwarded/Reconciled) and are older than the /// cutoff, while honoring the hard invariant: a /// row is NEVER purged on age alone — dropping /// an un-forwarded row would silently lose audit data that never reached central. /// Fixture mirrors (file-backed so the /// post-purge incremental_vacuum has a real file to shrink). /// public class SqliteAuditWriterRetentionTests : IDisposable { private readonly string _dbPath; public SqliteAuditWriterRetentionTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"audit-retention-{Guid.NewGuid():N}.db"); } public void Dispose() { if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* test cleanup best-effort */ } } } private SqliteAuditWriter CreateWriter() { var options = new SqliteAuditWriterOptions { DatabasePath = _dbPath }; return new SqliteAuditWriter( Options.Create(options), NullLogger.Instance, new FakeNodeIdentityProvider()); } private static async Task WriteEventAsync(SqliteAuditWriter writer, DateTime occurredAtUtc) { var id = Guid.NewGuid(); var evt = ScadaBridgeAuditEventFactory.Create( eventId: id, occurredAtUtc: occurredAtUtc, channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered); await writer.WriteAsync(evt); return id; } [Fact] public async Task PurgeExpired_DeletesForwardedAndReconciled_OlderThanCutoff() { await using var writer = CreateWriter(); var oldForwarded = await WriteEventAsync(writer, DateTime.UtcNow.AddDays(-10)); var oldReconciled = await WriteEventAsync(writer, DateTime.UtcNow.AddDays(-10)); var oldPending = await WriteEventAsync(writer, DateTime.UtcNow.AddDays(-10)); var fresh = await WriteEventAsync(writer, DateTime.UtcNow); await writer.MarkForwardedAsync(new[] { oldForwarded, oldReconciled, fresh }); await writer.MarkReconciledAsync(new[] { oldReconciled }); var purged = await writer.PurgeExpiredAsync(DateTime.UtcNow.AddDays(-7)); Assert.Equal(2, purged); // oldForwarded + oldReconciled // Hard ForwardState invariant: the old *Pending* row survives on age alone. var pending = await writer.ReadPendingAsync(10); Assert.Contains(pending, e => e.EventId == oldPending); // Fresh forwarded row inside the window survives; only oldPending remains Pending. var stats = await writer.GetBacklogStatsAsync(); Assert.Equal(1, stats.PendingCount); } [Fact] public async Task PurgeExpired_IsIdempotent() { await using var writer = CreateWriter(); var id = await WriteEventAsync(writer, DateTime.UtcNow.AddDays(-10)); await writer.MarkForwardedAsync(new[] { id }); Assert.Equal(1, await writer.PurgeExpiredAsync(DateTime.UtcNow.AddDays(-7))); Assert.Equal(0, await writer.PurgeExpiredAsync(DateTime.UtcNow.AddDays(-7))); } }