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 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 { /// Verifies that stopping the service after it has been disposed does not throw. /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_AfterDispose_DoesNotThrow() { // Regression: Dispose tears down the CTS StopAsync // cancels, and the host does not guarantee StopAsync is driven before the DI // container is disposed. Cancel() on a disposed CTS throws, and letting that // escape an IHostedService aborts the host's whole shutdown sequence. var queue = new RecordingSiteAuditQueue(); var options = Options.Create(new SiteAuditRetentionOptions { RetentionDays = 7, PurgeIntervalOverride = TimeSpan.FromMilliseconds(50), InitialDelay = TimeSpan.Zero, }); var svc = new SiteAuditRetentionService(queue, options, NullLogger.Instance); await svc.StartAsync(CancellationToken.None); svc.Dispose(); // The assertion is the absence of ObjectDisposedException. await svc.StopAsync(CancellationToken.None); svc.Dispose(); } /// Verifies that a tick purges events older than the retention cutoff. /// A task that represents the asynchronous operation. [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)); } /// Verifies that a failing tick is swallowed and later ticks still run. /// A task that represents the asynchronous operation. [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); } /// Verifies that stopping the service during an in-flight purge completes cleanly without surfacing a cancellation. /// A task that represents the asynchronous operation. [Fact] public async Task StopAsync_MidPurge_CompletesCleanly_WithoutSurfacingCancellation() { // PurgeExpiredAsync blocks until cancelled, so shutdown lands mid-purge: SafePurgeAsync // rethrows the OCE by design (abort the purge promptly), but the loop task must still // complete CLEANLY — StopAsync hands _loop straight to the host, and a canceled task // there is shutdown-log noise (arch-review 04 round 2, R7). var queue = new RecordingSiteAuditQueue { BlockUntilCancelled = true }; var options = Options.Create(new SiteAuditRetentionOptions { RetentionDays = 7, PurgeInterval = TimeSpan.FromHours(24), 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)); // Must NOT throw TaskCanceledException back into the host's shutdown path. await svc.StopAsync(CancellationToken.None); } 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 { /// Not supported by this stub. /// The execution ids to read. /// The lower time bound. /// The maximum number of rows. /// A token to cancel the operation. /// The keyset cursor. /// Never returns; always throws . public Task> ReadByExecutionIdsAsync( IReadOnlyCollection executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null) => throw new NotSupportedException("not exercised by the retention service"); private readonly ConcurrentQueue _purgeCalls = new(); private int _calls; /// Gets the cutoffs passed to each purge call, in call order. public IReadOnlyCollection PurgeCalls => _purgeCalls; /// Gets a value indicating whether the first purge call throws a simulated transient failure. public bool ThrowOnFirstCall { get; init; } /// When set, PurgeExpiredAsync records the call then blocks until the token /// cancels — simulating a shutdown that lands while a purge is in flight. public bool BlockUntilCancelled { get; init; } // Test stub: Records the cutoff and optionally throws or blocks, as configured. /// public async 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."); } if (BlockUntilCancelled) { await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); } return 0; } /// Not supported by this stub. /// The maximum number of rows. /// A token to cancel the operation. /// Never returns; always throws . public Task> ReadPendingAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); /// Not supported by this stub. /// The maximum number of rows. /// A token to cancel the operation. /// Never returns; always throws . public Task> ReadPendingCachedTelemetryAsync(int limit, CancellationToken ct = default) => throw new NotSupportedException(); // Test stub: Not supported by this stub. /// public Task MarkForwardedAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); /// Not supported by this stub. /// The lower time bound. /// The maximum batch size. /// The keyset cursor. /// A token to cancel the operation. /// Never returns; always throws . public Task> ReadPendingSinceAsync( DateTime sinceUtc, int batchSize, string? afterId = null, CancellationToken ct = default) => throw new NotSupportedException(); // Test stub: Not supported by this stub. /// public Task MarkReconciledAsync(IReadOnlyList eventIds, CancellationToken ct = default) => throw new NotSupportedException(); // Test stub: Not supported by this stub. /// public Task MarkReconciledUpToAsync(DateTime sinceUtc, string? afterId, CancellationToken ct = default) => throw new NotSupportedException(); // Test stub: Not supported by this stub. /// public Task GetBacklogStatsAsync(CancellationToken ct = default) => throw new NotSupportedException(); } }