diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs new file mode 100644 index 00000000..77060548 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs @@ -0,0 +1,172 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.LocalDb.Replication; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.Host.Health; +using ZB.MOM.WW.OtOpcUa.Host.Observability; +using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// LocalDb Phase 1 (Task 11) — DI pins over the real composition methods. +/// +/// +/// +/// DI extension methods have shipped inert three times in this family (Secrets 0.2.0 / 0.2.2, +/// ScadaBridge #22): a registration that compiles, deploys, and does nothing because the seam +/// was never resolved from a built container. These tests build a real container from the +/// same AddOtOpcUa* methods Program.cs calls and resolve through it. +/// +/// +/// They deliberately do NOT boot the full host. WebApplicationFactory<Program> +/// would start hosted services — Akka, the OPC UA server, ValidateOnStart — which need +/// SQL Server and an Akka mesh (see TwoNodeClusterHarness on why the real host is not +/// driven here). What is under test is DI resolution, so the container is built and resolved +/// without ever starting it. +/// +/// +public sealed class LocalDbWiringTests : IDisposable +{ + private readonly List _dbPaths = []; + private readonly List _apps = []; + + /// + /// Builds a container the way Program.cs's driver branch does for LocalDb: the real + /// , health, and observability methods, + /// over a temp-file LocalDb:Path. + /// + private IServiceProvider BuildDriverGraph() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-wiring-{Guid.NewGuid():N}.db"); + _dbPaths.Add(dbPath); + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["LocalDb:Path"] = dbPath, + }); + + // The exact LocalDb composition Program.cs runs under hasDriver. + builder.Services.AddOtOpcUaLocalDb(builder.Configuration); + builder.Services.AddGrpc(o => o.Interceptors.Add()); + builder.Services.AddOtOpcUaHealth(); + builder.Services.AddOtOpcUaObservability(builder.Configuration); + + var app = builder.Build(); + _apps.Add(app); + return app.Services; + } + + /// + /// Builds an admin-only container: the shared registrations that run on every node, but NOT + /// — exactly as Program.cs gates it under + /// hasDriver. + /// + private IServiceProvider BuildAdminOnlyGraph() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [] }); + + // No LocalDb:Path configured — an admin-only node has no reason to set it. + builder.Services.AddOtOpcUaHealth(); + builder.Services.AddOtOpcUaObservability(builder.Configuration); + + var app = builder.Build(); + _apps.Add(app); + return app.Services; + } + + [Fact] + public void DriverGraph_LocalDbResolvesAsASingletonWithBothCacheTablesRegistered() + { + var sp = BuildDriverGraph(); + + var first = sp.GetService(); + first.ShouldNotBeNull(); + sp.GetService().ShouldBeSameAs(first); // singleton + + // Exact set, both directions — a dropped registration replicates nothing; an extra one costs + // three triggers and oplog volume on every write. + first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal) + .ShouldBe(["deployment_artifacts", "deployment_pointer"]); + } + + [Fact] + public void DriverGraph_ArtifactCacheResolvesToTheLocalDbBackedImplementation() + { + var sp = BuildDriverGraph(); + + sp.GetService() + .ShouldBeOfType(); + } + + [Fact] + public void DriverGraph_DefaultOff_SyncStatusReportsNotConnectedAndNoPeer() + { + // The default-OFF posture pin: registering the replication engine must not connect it. + var sp = BuildDriverGraph(); + + var status = sp.GetService(); + status.ShouldNotBeNull(); + status.Connected.ShouldBeFalse(); + status.PeerNodeId.ShouldBeNull(); + } + + [Fact] + public void DriverGraph_LocalDbReplicationHealthCheckIsRegistered() + { + var sp = BuildDriverGraph(); + + var registrations = sp.GetRequiredService>() + .Value.Registrations + .Select(r => r.Name) + .ToArray(); + + registrations.ShouldContain("localdb-replication"); + } + + [Fact] + public void AdminOnlyGraph_HasNoLocalDb_AndDoesNotDemandLocalDbPath() + { + // Boot must not require LocalDb:Path on a node that never registers LocalDb. Building the + // container without a configured path and resolving the shared services proves nothing in + // the always-on registrations reaches for ILocalDb or its options. + var sp = BuildAdminOnlyGraph(); + + sp.GetService().ShouldBeNull(); + sp.GetService().ShouldBeNull(); + + // The unconditionally-registered health check must still resolve and construct — it is meant + // to no-op to Healthy when LocalDb is absent, not to fail because ISyncStatus is missing. + var registrations = sp.GetRequiredService>() + .Value.Registrations + .Where(r => r.Name == "localdb-replication") + .ToArray(); + var localDbCheck = registrations.ShouldHaveSingleItem(); + Should.NotThrow(() => localDbCheck.Factory(sp)); + } + + public void Dispose() + { + foreach (var app in _apps) + ((IDisposable)app).Dispose(); + + SqliteConnection.ClearAllPools(); + + foreach (var basePath in _dbPaths) + { + foreach (var path in new[] { basePath, $"{basePath}-wal", $"{basePath}-shm" }) + { + if (File.Exists(path)) + File.Delete(path); + } + } + } +}