using System.Collections.Concurrent; using System.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using AuditEvent = ZB.MOM.WW.Audit.AuditEvent; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// Tests for (PLAN-04 Task 3). The hosted /// service runs the site SQLite retention purge on a timer: first tick after /// , then every /// , passing a cutoff of /// UtcNow - ResolvedRetentionDays. Per-tick failures are swallowed so a /// transient SQLite error never tears the service down. /// public class SiteAuditRetentionServiceTests { [Fact] public async Task Tick_Purges_With_RetentionCutoff() { var queue = new RecordingSiteAuditQueue(); var options = Options.Create(new SiteAuditRetentionOptions { RetentionDays = 7, // Override bypasses the 1-min production clamp so the loop ticks in ms. PurgeIntervalOverride = TimeSpan.FromMilliseconds(50), InitialDelay = TimeSpan.Zero, }); using var svc = new SiteAuditRetentionService(queue, options, NullLogger.Instance); await svc.StartAsync(CancellationToken.None); await WaitUntilAsync(() => queue.PurgeCalls.Count >= 1, TimeSpan.FromSeconds(5)); await svc.StopAsync(CancellationToken.None); var cutoff = queue.PurgeCalls.First(); Assert.InRange(cutoff, DateTime.UtcNow.AddDays(-7).AddMinutes(-1), DateTime.UtcNow.AddDays(-7).AddMinutes(1)); } [Fact] public async Task Tick_SwallowsExceptions_AndKeepsTicking() { var queue = new RecordingSiteAuditQueue { ThrowOnFirstCall = true }; var options = Options.Create(new SiteAuditRetentionOptions { RetentionDays = 7, // Override bypasses the 1-min production clamp so the loop ticks in ms. PurgeIntervalOverride = TimeSpan.FromMilliseconds(50), InitialDelay = TimeSpan.Zero, }); using var svc = new SiteAuditRetentionService(queue, options, NullLogger.Instance); await svc.StartAsync(CancellationToken.None); // The first tick throws; a second tick must still arrive (service survives). await WaitUntilAsync(() => queue.PurgeCalls.Count >= 2, TimeSpan.FromSeconds(5)); await svc.StopAsync(CancellationToken.None); Assert.True(queue.PurgeCalls.Count >= 2); } private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var sw = Stopwatch.StartNew(); while (!condition()) { if (sw.Elapsed > timeout) { throw new TimeoutException("Condition was not met within the timeout."); } await Task.Delay(10); } } /// Stub queue that records purge cutoffs and can throw on the first tick. private sealed class RecordingSiteAuditQueue : ISiteAuditQueue { private readonly ConcurrentQueue _purgeCalls = new(); private int _calls; public IReadOnlyCollection PurgeCalls => _purgeCalls; public bool ThrowOnFirstCall { get; init; } public Task PurgeExpiredAsync(DateTime olderThanUtc, CancellationToken ct = default) { var n = Interlocked.Increment(ref _calls); _purgeCalls.Enqueue(olderThanUtc); if (ThrowOnFirstCall && n == 1) { throw new InvalidOperationException("Simulated transient purge failure."); } return Task.FromResult(0); } public Task> ReadPendingAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); public Task> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); public Task MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); public Task> ReadPendingSinceAsync(DateTime sinceUtc, int batchSize, CancellationToken ct = default) => throw new NotSupportedException(); public Task MarkReconciledAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetBacklogStatsAsync(CancellationToken ct = default) => throw new NotSupportedException(); } }