using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.AuditLog.Central; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests; /// /// Purge-scheduler tests for (#22, Piece B). /// Exercises the daily terminal-row purge tick in-memory — a recording /// captures the /// threshold the actor /// computes, with no live MSSQL fixture. The reconciliation collaborators are /// inert stubs (the purge tick doesn't use them, but they must be present to /// arm the scheduler — both timers gate on the collaborators together). /// public class SiteCallAuditPurgeTests : TestKit { private static SiteCallAuditOptions FastPurgeOptions(int retentionDays = 365) => new() { // Keep the reconciliation tick slow so it doesn't fight the purge tick // for the test window; drop the purge tick to 100 ms via its override. ReconciliationIntervalOverride = TimeSpan.FromMinutes(5), PurgeIntervalOverride = TimeSpan.FromMilliseconds(100), RetentionDays = retentionDays, }; /// Empty enumerator — the purge path never touches it, but it must be present to arm the scheduler. private sealed class EmptyEnumerator : ISiteEnumerator { public Task> EnumerateAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } /// No-op pull client — present only to arm the scheduler. private sealed class NoOpPullClient : IPullSiteCallsClient { public Task PullAsync( string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct) => Task.FromResult(new PullSiteCallsResponse(Array.Empty(), MoreAvailable: false)); } /// /// Recording repository capturing every /// threshold (and the configured deleted-row count it returns). /// private sealed class RecordingRepo : ISiteCallAuditRepository { public List PurgeThresholds { get; } = new(); public int RowsDeletedPerCall { get; set; } public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { PurgeThresholds.Add(olderThanUtc); return Task.FromResult(RowsDeletedPerCall); } public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult(null); public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } /// Repository whose purge always throws — to prove continue-on-error keeps the singleton alive. private sealed class PurgeThrowingRepo : ISiteCallAuditRepository { public int PurgeCallCount; public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) { Interlocked.Increment(ref PurgeCallCount); throw new InvalidOperationException("simulated purge failure"); } public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult(null); public Task> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); public Task ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0)); public Task> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); } private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) => Sys.ActorOf(Props.Create(() => new SiteCallAuditActor( repo, new EmptyEnumerator(), new NoOpPullClient(), NullLogger.Instance, options))); // --------------------------------------------------------------------- // 1. PurgeTick_CallsPurgeTerminal_WithRetentionThreshold // --------------------------------------------------------------------- [Fact] public void PurgeTick_CallsPurgeTerminalAsync_WithRetentionThreshold() { var repo = new RecordingRepo { RowsDeletedPerCall = 7 }; // Non-default retention (30 days) so the assertion isn't accidentally // satisfied by the 365-day default. CreateActor(repo, FastPurgeOptions(retentionDays: 30)); AwaitAssert( () => Assert.True(repo.PurgeThresholds.Count >= 1, $"expected >= 1 PurgeTerminalAsync call, got {repo.PurgeThresholds.Count}"), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); // The threshold the actor passed must be ~UtcNow - 30 days. 1-minute // slack covers scheduling jitter between the tick firing and the assert. var threshold = repo.PurgeThresholds[0]; var expected = DateTime.UtcNow - TimeSpan.FromDays(30); Assert.True( Math.Abs((threshold - expected).TotalMinutes) < 1.0, $"purge threshold {threshold:o} should be within 1 minute of {expected:o}"); } // --------------------------------------------------------------------- // 2. PurgeTick_UsesDefaultRetention_365Days // --------------------------------------------------------------------- [Fact] public void PurgeTick_DefaultRetention_Uses365DayThreshold() { var repo = new RecordingRepo(); CreateActor(repo, FastPurgeOptions()); // default 365 days AwaitAssert( () => Assert.True(repo.PurgeThresholds.Count >= 1), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); var threshold = repo.PurgeThresholds[0]; var expected = DateTime.UtcNow - TimeSpan.FromDays(365); Assert.True( Math.Abs((threshold - expected).TotalMinutes) < 1.0, $"purge threshold {threshold:o} should be within 1 minute of {expected:o}"); } // --------------------------------------------------------------------- // 3. PurgeTick_RepoThrows_ActorStaysAlive_RetriesNextTick (continue-on-error) // --------------------------------------------------------------------- [Fact] public void PurgeTick_PurgeThrows_ActorStaysAlive_RetriesNextTick() { var repo = new PurgeThrowingRepo(); CreateActor(repo, FastPurgeOptions()); // The singleton must NOT die on a purge fault — a second tick must still // arrive (continue-on-error). Two purge calls prove the actor survived // the first throw and the timer kept ticking. AwaitAssert( () => Assert.True(repo.PurgeCallCount >= 2, $"expected >= 2 purge attempts (actor survived the throw), got {repo.PurgeCallCount}"), duration: TimeSpan.FromSeconds(3), interval: TimeSpan.FromMilliseconds(50)); } }