refactor(auditlog+sitecallaudit): delete the _injectedRepository test ctors — register row 61

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).
This commit is contained in:
Joseph Doherty
2026-08-15 17:49:39 -04:00
parent 7a7d345b89
commit 92fe5acca7
15 changed files with 474 additions and 452 deletions
@@ -73,11 +73,19 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
}
private IActorRef CreateActor(
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repository,
NullLogger<SiteCallAuditActor>.Instance,
options)));
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null)
{
// Register row 61: the actor's repository-taking test ctor is gone. This MSSQL-backed
// fixture supplies a stub provider that yields the same repository from every scope,
// serialized (one DbContext cannot service concurrent operations) — see
// StubRepositoryProvider. No reconciliation collaborators are registered, so the
// reconciliation tick stays disarmed exactly as it did under the old ctor.
var provider = TestSupport.StubRepositoryProvider.Build(repository);
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options ?? new SiteCallAuditOptions(),
NullLogger<SiteCallAuditActor>.Instance)));
}
[SkippableFact]
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
@@ -102,13 +102,17 @@ public class SiteCallAuditPurgeTests : TestKit
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)));
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
@@ -238,13 +238,18 @@ public class SiteCallAuditReconciliationTests : TestKit
ISiteEnumerator sites,
IPullSiteCallsClient client,
ISiteCallAuditRepository repo,
SiteCallAuditOptions options) =>
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
sites,
client,
NullLogger<SiteCallAuditActor>.Instance,
options)));
SiteCallAuditOptions options)
{
// Register row 61: the actor's repository+collaborators test ctor is gone. The
// collaborators now reach it the way production supplies them — resolved from the
// provider — and the repository comes out of a scope, serialized because this fixture
// deliberately shares one instance across every scope.
var provider = TestSupport.StubRepositoryProvider.Build(repo, sites, client);
return Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
}
// ---------------------------------------------------------------------
// 1. AbsentRow_PulledFromSite_IsUpserted
@@ -348,28 +353,30 @@ public class SiteCallAuditReconciliationTests : TestKit
}
// ---------------------------------------------------------------------
// 4. RepoOnly test ctor does NOT start the reconciliation tick
// 4. A composition root without the reconciliation collaborators does NOT
// start the reconciliation tick
// ---------------------------------------------------------------------
[Fact]
public void TestCtor_RepositoryOnly_DoesNotStartReconciliationTick()
public void NoReconciliationCollaboratorsRegistered_DoesNotStartReconciliationTick()
{
// The repo-only test ctor (used by the MSSQL-backed actor tests) injects
// no client/enumerator, so the tick must be gated OFF — otherwise those
// tests would fire phantom pulls. Build the actor via that ctor and
// confirm no pull ever happens. We can't observe a non-event directly,
// so we share a ScriptedPullClient with an isolated actor that DOES run
// the tick to bound the wait, then assert the repo-only actor's client
// (a separate instance) recorded nothing.
// Register row 61: this used to be phrased as "the repo-only test ctor gates the tick
// off". With that ctor deleted, the same guarantee is expressed the way production
// actually reaches it — a composition root that registers NEITHER IPullSiteCallsClient
// NOR ISiteEnumerator leaves the tick disarmed (GetService returns null for both), so
// the MSSQL-backed actor tests fire no phantom pulls. We can't observe a non-event
// directly, so we run an isolated actor that DOES tick to bound the wait, then assert
// this actor upserted nothing on its own.
var repo = new RecordingRepo();
var bareProvider = TestSupport.StubRepositoryProvider.Build(repo);
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
repo,
NullLogger<SiteCallAuditActor>.Instance,
FastTickOptions())));
bareProvider,
FastTickOptions(),
NullLogger<SiteCallAuditActor>.Instance)));
// Run a parallel actor with the full reconciliation ctor and a fast
// Run a parallel actor whose provider DOES carry the collaborators, with a fast
// tick; once IT has pulled we know enough wall-clock elapsed that the
// repo-only actor would have ticked too, had it been wired.
// collaborator-less actor would have ticked too, had it been wired.
var liveClient = new ScriptedPullClient();
var liveRepo = new RecordingRepo();
CreateActor(
@@ -383,8 +390,8 @@ public class SiteCallAuditReconciliationTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// The repo-only actor never reconciles: it has no client to pull with,
// so it upserts nothing on its own.
// The collaborator-less actor never reconciles: it has no client to pull
// with, so it upserts nothing on its own.
Assert.Equal(0, repo.UpsertCallCount);
}
@@ -64,10 +64,12 @@ public class SiteCallRelayTests : TestKit
private IActorRef CreateActor(IActorRef centralComm)
{
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
// Register row 61: stub provider in place of the deleted repository ctor.
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
new ThrowingRepository(),
NullLogger<SiteCallAuditActor>.Instance,
options)));
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RegisterCentralCommunication(centralComm));
return actor;
}
@@ -202,10 +204,11 @@ public class SiteCallRelayTests : TestKit
// No RegisterCentralCommunication — the actor has no transport to reach
// any site, so the only honest answer is "unreachable".
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
new ThrowingRepository(),
NullLogger<SiteCallAuditActor>.Instance,
options)));
provider,
options,
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north"));
@@ -286,11 +289,15 @@ public class SiteCallRelayTests : TestKit
{
var writer = new RecordingCentralAuditWriter();
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
// The audit writer now reaches the actor through the provider (production resolves it
// the same way, from the root provider) rather than through a ctor parameter.
var provider = TestSupport.StubRepositoryProvider.Build(
new RowReturningRepository(row),
NullLogger<SiteCallAuditActor>.Instance,
auditWriter: (ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer);
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
provider,
options,
(ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer)));
NullLogger<SiteCallAuditActor>.Instance)));
actor.Tell(new RegisterCentralCommunication(centralComm));
return (actor, writer);
}
@@ -0,0 +1,162 @@
using Microsoft.Extensions.DependencyInjection;
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.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.TestSupport;
/// <summary>
/// Deferred-work register row 61 — the replacement for <c>SiteCallAuditActor</c>'s deleted
/// repository-taking "test-mode" constructors.
///
/// <para>
/// Those constructors cached a scoped repository on an actor field for the actor's whole
/// lifetime. Nothing in production selected them, but the shape was one call site away from
/// re-introducing a captive-dependency bug, so it had to be allowlisted in the Host tests'
/// <c>CaptiveDependencyGuard</c>. The actor now has a single <see cref="IServiceProvider"/>
/// constructor; a fixture that wants a concrete repository builds one of these instead.
/// </para>
///
/// <para>
/// The actor's contract is unchanged and unweakened — it still resolves per scope and holds
/// nothing. Handing the SAME instance out of every scope is now the FIXTURE's explicit
/// choice, which is also why the serializing decorator below lives here rather than in
/// product code: it exists solely to compensate for that choice.
/// </para>
/// </summary>
internal static class StubRepositoryProvider
{
/// <summary>
/// Builds a provider whose every scope yields the same repository instance, wrapped in
/// <see cref="SerializingSiteCallAuditRepository"/>, plus any collaborators supplied.
/// </summary>
/// <param name="repository">Repository instance shared by every scope.</param>
/// <param name="siteEnumerator">Optional site enumerator; omit to leave the reconciliation tick disarmed.</param>
/// <param name="pullClient">Optional pull client; omit to leave the reconciliation tick disarmed.</param>
/// <param name="auditWriter">Optional central direct-write audit sink for relay actions.</param>
/// <param name="serialize">
/// False registers the repository unwrapped — for fixtures asserting on call ordering or
/// identity where the decorator would get in the way.
/// </param>
public static ServiceProvider Build(
ISiteCallAuditRepository repository,
ISiteEnumerator? siteEnumerator = null,
IPullSiteCallsClient? pullClient = null,
ICentralAuditWriter? auditWriter = null,
bool serialize = true)
{
ArgumentNullException.ThrowIfNull(repository);
var shared = serialize ? new SerializingSiteCallAuditRepository(repository) : repository;
var services = new ServiceCollection();
services.AddScoped(_ => shared);
if (siteEnumerator is not null)
{
services.AddSingleton(siteEnumerator);
}
if (pullClient is not null)
{
services.AddSingleton(pullClient);
}
if (auditWriter is not null)
{
services.AddSingleton(auditWriter);
}
return services.BuildServiceProvider();
}
}
/// <summary>
/// Serializing wrapper for a repository instance shared across every DI scope in a test.
/// One call at a time, in arrival order.
/// </summary>
/// <remarks>
/// <para>
/// In production every consumer of <c>SiteCallAuditActor</c>'s repository gets its OWN DI
/// scope — one per message, one per background pass — so the off-mailbox reconciliation and
/// purge passes can safely run alongside mailbox handlers: separate scopes mean separate
/// <c>DbContext</c>s. A fixture that hands ONE instance (typically wrapping one
/// <c>DbContext</c>) to every scope breaks that assumption, and <c>DbContext</c> forbids
/// concurrent operations — "A second operation was started on this context instance" is a
/// hard fault, and the interleaving is nondeterministic, so it surfaces as a flaky test
/// rather than a reliable one.
/// </para>
/// <para>
/// Serializing at the CALL, not around the whole pass, is deliberate: it removes the
/// concurrency hazard while preserving the property the off-mailbox passes exist for — a long
/// drain (blocked in a network pull, holding no repository call) still lets ingest, query and
/// KPI messages be answered. Suspending the mailbox for the duration of a pass would have
/// been simpler and would have invalidated exactly those regression tests.
/// </para>
/// <para>
/// This type used to live inside the product actor, reachable only from its test
/// constructors. It moved here with register row 61: production never constructed it, and the
/// hazard it guards is created by the fixture, not by the actor.
/// </para>
/// </remarks>
internal sealed class SerializingSiteCallAuditRepository : ISiteCallAuditRepository
{
private readonly ISiteCallAuditRepository _inner;
private readonly SemaphoreSlim _gate = new(1, 1);
public SerializingSiteCallAuditRepository(ISiteCallAuditRepository inner) => _inner = inner;
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
RunAsync(() => _inner.UpsertAsync(siteCall, ct));
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
RunAsync(() => _inner.GetAsync(id, ct));
public Task<IReadOnlyList<SiteCall>> QueryAsync(
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
RunAsync(() => _inner.QueryAsync(filter, paging, ct));
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));
private async Task RunAsync(Func<Task> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private async Task<T> RunAsync<T>(Func<Task<T>> call)
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
return await call().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}