fix(health): decouple AuditCentralHealthSnapshot from ActorSystem (#23 M6)

The snapshot's per-site stalled latch now lives on the snapshot itself
and is fed by SiteAuditTelemetryStalledTracker via ApplyStalled, removing
the chain that required ActorSystem at DI composition time. The tracker
is now constructed by AkkaHostedService once ActorSystem.Create returns,
with a lock-guarded auxiliary-disposable list so concurrent host
start/stop in tests cannot race the enumeration.
This commit is contained in:
Joseph Doherty
2026-05-20 19:25:28 -04:00
parent 2744011ce9
commit ef49b55cf6
6 changed files with 144 additions and 45 deletions

View File

@@ -24,8 +24,7 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
[Fact]
public void Increment_Routes_To_Snapshot()
{
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
var snapshot = new AuditCentralHealthSnapshot(tracker);
var snapshot = new AuditCentralHealthSnapshot();
var counter = new CentralAuditRedactionFailureCounter(snapshot);
counter.Increment();
@@ -60,10 +59,10 @@ public class CentralAuditRedactionFailureCounterTests : TestKit
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
// The AuditCentralHealthSnapshot ctor takes the stalled tracker
// which itself needs an ActorSystem — register a real system
// (test-kit's Sys) so the DI graph composes.
services.AddSingleton<ActorSystem>(Sys);
// AuditCentralHealthSnapshot no longer takes a tracker dependency —
// the tracker is constructed later by the Akka bootstrap because its
// ctor needs an ActorSystem (not a DI-resolvable singleton). The
// snapshot itself composes purely from primitives.
services.AddAuditLog(config);
services.AddAuditLogCentralMaintenance(config);
using var provider = services.BuildServiceProvider();

View File

@@ -115,9 +115,10 @@ public class CentralAuditWriteFailuresTests : TestKit
{
// AuditCentralHealthSnapshot implements both writer surfaces; bumping
// through the writer interfaces is reflected on the read surface, and
// SiteAuditTelemetryStalled is sourced from the injected tracker.
using var tracker = new SiteAuditTelemetryStalledTracker(Sys);
var snapshot = new AuditCentralHealthSnapshot(tracker);
// the per-site stalled state is fed in via ApplyStalled — production
// wires that to a SiteAuditTelemetryStalledTracker, but the snapshot
// is testable in isolation against the same Apply surface.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
@@ -127,7 +128,11 @@ public class CentralAuditWriteFailuresTests : TestKit
((ICentralAuditWriteFailureCounter)snapshot).Increment();
((ScadaLink.AuditLog.Payload.IAuditRedactionFailureCounter)snapshot).Increment();
// Publish a stalled-changed event so the tracker registers a site.
// Wire the tracker so an EventStream publish reaches the snapshot.
// The tracker pushes into the snapshot's ApplyStalled when given
// the snapshot in its ctor; the tracker also keeps its own latch,
// but the snapshot read surface is what the central UI reads.
using var tracker = new SiteAuditTelemetryStalledTracker(Sys, snapshot);
Sys.EventStream.Publish(new SiteAuditTelemetryStalledChanged("siteA", Stalled: true));
AwaitAssert(() =>
{
@@ -143,9 +148,13 @@ public class CentralAuditWriteFailuresTests : TestKit
}
[Fact]
public void AuditCentralHealthSnapshot_Construction_Without_Tracker_Throws()
public void Snapshot_Empty_OnConstruction()
{
Assert.Throws<ArgumentNullException>(
() => new AuditCentralHealthSnapshot(null!));
// Sanity: the snapshot's three properties start at their zero values
// before any writer or stalled-event publication.
var snapshot = new AuditCentralHealthSnapshot();
Assert.Equal(0, snapshot.CentralAuditWriteFailures);
Assert.Equal(0, snapshot.AuditRedactionFailure);
Assert.Empty(snapshot.SiteAuditTelemetryStalled);
}
}