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_LocalDbResolvesAsASingletonWithEveryReplicatedTableRegistered() { 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(["alarm_sf_events", "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); } } } }