92fe5acca7
AuditLogIngestActor and SiteCallAuditActor each exposed constructors taking a scoped
repository and caching it on a field for the actor's lifetime. Safe only because no
production call site picked those overloads — i.e. one call site away from re-introducing
the row-45 captive-dependency bug — which is why CaptiveDependencyGuard had to allowlist
both fields.
Preferred fix applied: the test fixtures fold onto the IServiceProvider overload with a
stub provider, and the constructors are gone.
Product deletions:
- AuditLogIngestActor: the (IAuditLogRepository, ILogger) ctor, the _injectedRepository
field and its branch in OnIngestAsync, and OnCachedTelemetryWithoutDualWriteAsync (the
"this ctor cannot dual-write" fallback handler that only that ctor installed).
_serviceProvider is now non-nullable and the null-forgiving operators are gone.
- SiteCallAuditActor: both test ctors (repo-only, and repo + reconciliation
collaborators), the _injectedRepository field and its four branches, and the private
SerializedRepository class. _backgroundTimersEnabled went with them: only the deleted
repo-only ctor ever set it false, so it was a constant and its two guards are removed.
The reconciliation tick is still gated — on the collaborators being resolvable, which
is how production reaches the same state.
Test fixtures (12 call sites): two small TestSupport helpers build the stub providers.
SiteCallAuditActor's production wiring resolves a FRESH repository per scope, so the
serializing decorator existed only to protect fixtures that share one DbContext-backed
instance across every scope — it moves into the test project as
SerializingSiteCallAuditRepository, which is where the hazard is actually created.
Fixtures that relied on it keep it. The relay test's ICentralAuditWriter now arrives via
the provider, exactly as production resolves it.
Behaviour is preserved on the paths under test: the deleted audit ctor passed
redactor:null / failureCounter:null, and a provider registering neither resolves both to
null, so the SafeDefaultAuditRedactor fallback and counter-free logging are unchanged. No
fixture on the folded sites sends IngestCachedTelemetryCommand, so swapping to the real
dual-write handler is inert. The SiteCallAudit purge timer now arms in fixtures that
previously had timers off, but every one of them uses default purge settings whose first
tick is 5 minutes out — nothing fires inside a test.
One reconciliation test was renamed and rewritten rather than deleted:
TestCtor_RepositoryOnly_DoesNotStartReconciliationTick became
NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick — the same
guarantee, expressed the way production actually reaches it.
Guard: the allowlist is now EMPTY. The staleness self-test (every entry must still match a
flagged field) is replaced by two stronger pins — Allowlist_IsEmpty asserts nothing is
exempt at all, and FormerlyAllowlistedActors_NowScanClean scans both actors with the
allowlist DISABLED, so the empty allowlist is evidence the fields are gone rather than
evidence the scan stopped looking.
Negative control: re-adding an ISiteCallAuditRepository field to SiteCallAuditActor fails
both FormerlyAllowlistedActors_NowScanClean and the real
Central_NoActorHoldsCaptiveScopedState scan.
Verification: solution builds clean. AuditLog.Tests 3/3 (383), SiteCallAudit.Tests 3/3
(66), Host.Tests 2/2 (505), IntegrationTests 2/2 (115), Communication.Tests 2/2 (717).
230 lines
11 KiB
C#
230 lines
11 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
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, string? afterId, 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)
|
|
{
|
|
// Register row 61: the actor's repository-taking test ctor is gone. The fixture now
|
|
// supplies a stub provider that hands the recording repo out of every scope.
|
|
var provider = TestSupport.StubRepositoryProvider.Build(
|
|
repo, new EmptyEnumerator(), new NoOpPullClient());
|
|
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
|
provider,
|
|
options,
|
|
NullLogger<SiteCallAuditActor>.Instance)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 4. SiteCallAudit-007: purge timer arms even when the reconciliation
|
|
// collaborators are ABSENT (production ctor, no IPullSiteCallsClient /
|
|
// ISiteEnumerator registered). Proves the decoupling — a host that omits
|
|
// the reconciliation client still purges, so the central SiteCalls table
|
|
// cannot grow unbounded.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges()
|
|
{
|
|
var repo = new RecordingRepo { RowsDeletedPerCall = 3 };
|
|
|
|
// Build a DI container that registers the repository the production
|
|
// ctor resolves per-tick, but deliberately registers NEITHER
|
|
// IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for
|
|
// both, so the actor's reconciliation tick is gated off — but the purge
|
|
// tick must still arm (SiteCallAudit-007).
|
|
var provider = new ServiceCollection()
|
|
.AddScoped<ISiteCallAuditRepository>(_ => repo)
|
|
.BuildServiceProvider();
|
|
|
|
var options = FastPurgeOptions(retentionDays: 30);
|
|
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
|
provider,
|
|
options,
|
|
NullLogger<SiteCallAuditActor>.Instance)));
|
|
|
|
// No reconciliation collaborators were registered, yet the purge tick
|
|
// must still fire on the production path.
|
|
AwaitAssert(
|
|
() => Assert.True(repo.PurgeThresholds.Count >= 1,
|
|
"purge timer must arm even when the reconciliation collaborators are absent "
|
|
+ $"(SiteCallAudit-007), got {repo.PurgeThresholds.Count} purge calls"),
|
|
duration: TimeSpan.FromSeconds(3),
|
|
interval: TimeSpan.FromMilliseconds(50));
|
|
|
|
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}");
|
|
}
|
|
}
|