Wires Bundle E of the M2 site-sync pipeline: - AddAuditLog extended to register the site writer chain (SqliteAuditWriter singleton + ISiteAuditQueue forward + RingBufferFallback + FallbackAuditWriter composing them) and the telemetry collaborators (SiteAuditTelemetryOptions, SqliteAuditWriterOptions, IAuditWriteFailureCounter NoOp default, ISiteStreamAuditClient NoOp default). - AkkaHostedService central role: AuditLogIngestActor as ClusterSingletonManager (singleton name 'audit-log-ingest') + ClusterSingletonProxy, mirroring the Notification Outbox pattern. Proxy is offered to SiteStreamGrpcServer if it resolves (Site path only today; M6 reconciliation will host gRPC on central). - AkkaHostedService site role: SiteAuditTelemetryActor (per-site, NOT a singleton because each site is its own cluster), bound to a dedicated audit-telemetry-dispatcher (ForkJoinDispatcher, 2 dedicated threads). - Program.cs + SiteServiceRegistration.Configure call AddAuditLog on both roles. - AuditLogIngestActor gains a second constructor that takes IServiceProvider so the cluster singleton can create a fresh scope per message — IAuditLogRepository is a scoped EF Core service and cannot be pre-resolved from the root. The IAuditLogRepository constructor remains for Bundle D's MSSQL-fixture tests. NoOp ISiteStreamAuditClient is deliberate: no site→central gRPC channel exists in M2 (sites talk to central via Akka ClusterClient; gRPC SiteStreamService is hosted on sites for central→site streaming). M6 reconciliation introduces the real gRPC site→central client + central-hosted gRPC server. Bundle H's integration test substitutes a stub client directly via the actor's Props. Tests: - tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs — 11 tests (was 3): writer singleton, IAuditWriter as FallbackAuditWriter, ISiteAuditQueue same-instance as SqliteAuditWriter, options bind round-trip, NoOp default assertions. - tests/ScadaLink.Host.Tests/AkkaHostedServiceAuditWiringTests.cs (new) — 13 tests: BuildHocon emits audit-telemetry-dispatcher block with the expected type/throughput/thread-count; Central composition root resolves the writer chain + options; Site composition root resolves the writer chain + options + NoOp client. Verified: dotnet build clean, 23 test suites green (Host 194 + AuditLog 54).
This commit is contained in:
@@ -1,28 +1,42 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
using ScadaLink.AuditLog.Site;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.AuditLog.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies
|
||||
/// <c>AddAuditLog</c> registers <see cref="AuditLogOptions"/> against the
|
||||
/// <c>AuditLog</c> configuration section. Bundle E ships only the scaffold;
|
||||
/// the validator + full options surface land in Task 9.
|
||||
/// Bundle E (M2 Task E1) DI surface tests for <c>AddAuditLog</c>. M1 shipped
|
||||
/// the options-only scaffold; M2 extends it with the site writer chain
|
||||
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
|
||||
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
|
||||
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
|
||||
/// <see cref="IAuditWriteFailureCounter"/>).
|
||||
/// </summary>
|
||||
public class AddAuditLogTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddAuditLog_RegistersAuditLogOptions()
|
||||
private static ServiceProvider BuildProvider(IDictionary<string, string?>? settings = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.AddInMemoryCollection(settings ?? new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
|
||||
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
services.AddAuditLog(config);
|
||||
var provider = services.BuildServiceProvider();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_RegistersAuditLogOptions()
|
||||
{
|
||||
using var provider = BuildProvider();
|
||||
|
||||
var opts = provider.GetService<IOptions<AuditLogOptions>>();
|
||||
|
||||
@@ -47,4 +61,130 @@ public class AddAuditLogTests
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => services.AddAuditLog(null!));
|
||||
}
|
||||
|
||||
// -- Bundle E (M2 Task E1) ---------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_SqliteAuditWriter_Singleton_FromDI()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
// In-memory database keeps the writer's owned connection portable
|
||||
// across tests; the per-instance Cache=Shared in the writer's
|
||||
// default connection string ensures no on-disk file is touched.
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var writer = provider.GetService<SqliteAuditWriter>();
|
||||
|
||||
Assert.NotNull(writer);
|
||||
// Singleton — same instance on a second resolve.
|
||||
Assert.Same(writer, provider.GetService<SqliteAuditWriter>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_IAuditWriter_AsFallbackAuditWriter()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var writer = provider.GetService<IAuditWriter>();
|
||||
|
||||
Assert.NotNull(writer);
|
||||
Assert.IsType<FallbackAuditWriter>(writer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_ISiteAuditQueue_AsSameInstance_As_SqliteAuditWriter()
|
||||
{
|
||||
// The telemetry actor reads from ISiteAuditQueue while scripts write
|
||||
// through IAuditWriter → SqliteAuditWriter. Both surfaces MUST resolve
|
||||
// to the same instance or pending rows will never be visible.
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var queue = provider.GetService<ISiteAuditQueue>();
|
||||
var writer = provider.GetService<SqliteAuditWriter>();
|
||||
|
||||
Assert.NotNull(queue);
|
||||
Assert.NotNull(writer);
|
||||
Assert.Same(writer, queue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_RingBufferFallback_Singleton()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var ring = provider.GetService<RingBufferFallback>();
|
||||
Assert.NotNull(ring);
|
||||
Assert.Same(ring, provider.GetService<RingBufferFallback>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_AuditWriteFailureCounter_AsNoOpDefault()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var counter = provider.GetService<IAuditWriteFailureCounter>();
|
||||
Assert.NotNull(counter);
|
||||
Assert.IsType<NoOpAuditWriteFailureCounter>(counter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Registers_SiteStreamAuditClient_AsNoOpDefault()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = ":memory:",
|
||||
});
|
||||
|
||||
var client = provider.GetService<ISiteStreamAuditClient>();
|
||||
Assert.NotNull(client);
|
||||
Assert.IsType<NoOpSiteStreamAuditClient>(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Options_Bind_RoundTrip_SqliteWriter()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteWriter:DatabasePath"] = "/tmp/test-audit.db",
|
||||
["AuditLog:SiteWriter:ChannelCapacity"] = "8192",
|
||||
["AuditLog:SiteWriter:BatchSize"] = "128",
|
||||
["AuditLog:SiteWriter:FlushIntervalMs"] = "75",
|
||||
});
|
||||
|
||||
var opts = provider.GetRequiredService<IOptions<SqliteAuditWriterOptions>>().Value;
|
||||
Assert.Equal("/tmp/test-audit.db", opts.DatabasePath);
|
||||
Assert.Equal(8192, opts.ChannelCapacity);
|
||||
Assert.Equal(128, opts.BatchSize);
|
||||
Assert.Equal(75, opts.FlushIntervalMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAuditLog_Options_Bind_RoundTrip_SiteTelemetry()
|
||||
{
|
||||
using var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["AuditLog:SiteTelemetry:BatchSize"] = "512",
|
||||
["AuditLog:SiteTelemetry:BusyIntervalSeconds"] = "3",
|
||||
["AuditLog:SiteTelemetry:IdleIntervalSeconds"] = "60",
|
||||
});
|
||||
|
||||
var opts = provider.GetRequiredService<IOptions<SiteAuditTelemetryOptions>>().Value;
|
||||
Assert.Equal(512, opts.BatchSize);
|
||||
Assert.Equal(3, opts.BusyIntervalSeconds);
|
||||
Assert.Equal(60, opts.IdleIntervalSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user