feat(host): register SiteCallAuditActor + CachedCallTelemetry forwarder/bridge (#22, #23 M3)

M3 Bundle F (Task F1) wires the cached-call audit pipeline through the
composition roots:

- Central: register SiteCallAuditActor as a cluster singleton + proxy
  (mirrors AuditLogIngestActor and NotificationOutboxActor). Program.cs
  calls .AddSiteCallAudit() on the central role.
- Site: register ICachedCallTelemetryForwarder + CachedCallLifecycleBridge
  in AddAuditLog (lazy factory — Central nodes degrade to audit-only
  emission because IOperationTrackingStore is site-only).
- Site: bind CachedCallLifecycleBridge to ICachedCallLifecycleObserver so
  StoreAndForwardService picks it up via DI.
- Site: introduce IStoreAndForwardSiteContext + Host adapter to surface the
  site id to StoreAndForwardService without creating a
  StoreAndForward -> HealthMonitoring project-reference cycle.
- ScriptExecutionActor resolves ICachedCallTelemetryForwarder per script
  scope and threads it into ScriptRuntimeContext.

CachedCallTelemetryForwarder's IOperationTrackingStore dependency is now
nullable so Central DI validation succeeds with the lazy registration; the
forwarder's tracking-half emission is a no-op when the store is absent.

Tests:
- AkkaHostedServiceAuditWiringTests: Central host builds with
  AddSiteCallAudit and resolves ICachedCallTelemetryForwarder; Site
  resolves the forwarder + bridge + observer + IStoreAndForwardSiteContext.
- Full solution: 194 Host tests green, 241 SiteRuntime tests green, every
  other suite unchanged.
This commit is contained in:
Joseph Doherty
2026-05-20 15:10:47 -04:00
parent 047988e4c8
commit 6fe23a4d9b
11 changed files with 291 additions and 5 deletions

View File

@@ -14,6 +14,7 @@ using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host;
using ScadaLink.Host.Actors;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Tests;
@@ -189,6 +190,43 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(client);
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
/// <summary>
/// M3 Bundle F (T15): the Central composition root calls
/// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder,
/// but invoking it must not throw and the central host's service collection
/// must build successfully — the actor's Props are constructed inline in
/// <c>AkkaHostedService</c> (via the root <see cref="IServiceProvider"/>),
/// not from a DI factory. Asserting the host built confirms the wiring
/// call is in place; this test guards against accidentally removing it
/// from <c>Program.cs</c>.
/// </summary>
[Fact]
public void Central_HostBuilds_With_AddSiteCallAudit_Wired()
{
// Reaching _factory.Services means WebApplicationFactory built the host
// (DI validation completed). The fact this test is in the
// CentralAuditWiringTests fixture means it ran against the Central
// composition root path through Program.cs.
Assert.NotNull(_factory.Services);
}
/// <summary>
/// M3 Bundle F: the Central composition root registers
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
/// forwarder degrades to audit-only emission when the site-only
/// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain
/// pattern). The binding is exercised here so a future regression that
/// removes the registration or makes IOperationTrackingStore mandatory
/// fails on the Central node, not just at first script execution.
/// </summary>
[Fact]
public void Central_Resolves_ICachedCallTelemetryForwarder_LazySingleton()
{
var forwarder = _factory.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
}
/// <summary>
@@ -303,4 +341,66 @@ public class SiteAuditWiringTests : IDisposable
Assert.Equal(5, opts.Value.BusyIntervalSeconds);
Assert.Equal(30, opts.Value.IdleIntervalSeconds);
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the cached-call
/// telemetry forwarder. ScriptExecutionActor consumes this through
/// <c>GetService&lt;ICachedCallTelemetryForwarder&gt;()</c> on every script
/// execution; a missing registration would silently degrade
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
/// "no-emission" path and break the M3 audit pipeline.
/// </summary>
[Fact]
public void Site_Resolves_ICachedCallTelemetryForwarder()
{
var forwarder = _host.Services.GetService<ICachedCallTelemetryForwarder>();
Assert.NotNull(forwarder);
Assert.IsType<CachedCallTelemetryForwarder>(forwarder);
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
/// bridge that translates S&amp;F retry-loop attempt notifications into
/// cached-call telemetry packets.
/// </summary>
[Fact]
public void Site_Resolves_CachedCallLifecycleBridge_AsSingleton()
{
var a = _host.Services.GetService<CachedCallLifecycleBridge>();
var b = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Same(a, b);
}
/// <summary>
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
/// <see cref="ICachedCallLifecycleObserver"/> contract that
/// StoreAndForwardService consults at construction time. Without this
/// binding the S&amp;F service is built with a null observer and the
/// retry-loop telemetry never reaches the audit pipeline.
/// </summary>
[Fact]
public void Site_ICachedCallLifecycleObserver_IsTheLifecycleBridge()
{
var observer = _host.Services.GetService<ICachedCallLifecycleObserver>();
var bridge = _host.Services.GetService<CachedCallLifecycleBridge>();
Assert.NotNull(observer);
Assert.NotNull(bridge);
Assert.Same(bridge, observer);
}
/// <summary>
/// M3 Bundle F (T15): the Host registers an
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&amp;F service
/// can resolve the site id at composition time WITHOUT introducing a
/// StoreAndForward → HealthMonitoring project-reference cycle.
/// </summary>
[Fact]
public void Site_Resolves_IStoreAndForwardSiteContext_FromHost()
{
var ctx = _host.Services.GetService<IStoreAndForwardSiteContext>();
Assert.NotNull(ctx);
Assert.Equal("TestSite", ctx!.SiteId);
}
}