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);
}
}
}
}