Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests/SiteCallAuditPurgeTests.cs
T
Joseph Doherty e675b34500 feat(sitecallaudit): daily terminal-row purge scheduler
Add a daily purge tick to SiteCallAuditActor that drops terminal SiteCalls
rows older than the retention window via ISiteCallAuditRepository.PurgeTerminalAsync.
The threshold is computed each tick as UtcNow - RetentionDays so an operator who
lowers RetentionDays sees it on the next purge without a restart. Mirrors
AuditLogPurgeActor's daily cadence + continue-on-error posture: a purge fault is
logged and swallowed so the central singleton stays alive and retries next tick.

The purge timer is started in PreStart alongside the reconciliation timer and
gates on the same collaborators (pull client + enumerator) being available — the
repo-only test ctor injects neither, so neither background timer runs there.

Options: PurgeInterval (default 24h, clamped >= 1 min so a zero config value
can't spin the scheduler) + RetentionDays (default 365), plus a test-only
override that bypasses the clamp for millisecond cadences.

Tests (all in-memory, no live MSSQL): purge tick calls PurgeTerminalAsync with a
UtcNow - RetentionDays threshold (non-default 30 days); default retention yields
a 365-day threshold; a throwing repo does not kill the singleton (a second tick
still arrives).
2026-06-15 12:03:49 -04:00

176 lines
8.5 KiB
C#

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;
/// <summary>
/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/> (#22, Piece B).
/// Exercises the daily terminal-row purge tick in-memory — a recording
/// <see cref="ISiteCallAuditRepository"/> captures the
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> 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).
/// </summary>
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,
};
/// <summary>Empty enumerator — the purge path never touches it, but it must be present to arm the scheduler.</summary>
private sealed class EmptyEnumerator : ISiteEnumerator
{
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteEntry>>(Array.Empty<SiteEntry>());
}
/// <summary>No-op pull client — present only to arm the scheduler.</summary>
private sealed class NoOpPullClient : IPullSiteCallsClient
{
public Task<PullSiteCallsResponse> PullAsync(
string siteId, DateTime sinceUtc, int batchSize, CancellationToken ct) =>
Task.FromResult(new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false));
}
/// <summary>
/// Recording repository capturing every <see cref="PurgeTerminalAsync"/>
/// threshold (and the configured deleted-row count it returns).
/// </summary>
private sealed class RecordingRepo : ISiteCallAuditRepository
{
public List<DateTime> PurgeThresholds { get; } = new();
public int RowsDeletedPerCall { get; set; }
public Task<int> 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<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
Task.FromResult<SiteCall?>(null);
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
}
/// <summary>Repository whose purge always throws — to prove continue-on-error keeps the singleton alive.</summary>
private sealed class PurgeThrowingRepo : ISiteCallAuditRepository
{
public int PurgeCallCount;
public Task<int> 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<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null);
public Task<IReadOnlyList<SiteCall>> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
}
private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
new EmptyEnumerator(),
new NoOpPullClient(),
NullLogger<SiteCallAuditActor>.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));
}
}