181 lines
9.0 KiB
C#
181 lines
9.0 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>());
|
|
|
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
|
|
}
|
|
|
|
/// <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>());
|
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|