5a21be0a62
Builds a container from the same AddOtOpcUa* methods Program.cs's driver branch calls (AddOtOpcUaLocalDb / AddGrpc / AddOtOpcUaHealth / AddOtOpcUaObservability) and resolves through it - DI extensions have shipped inert three times in this family. Does not boot the real host: WebApplicationFactory<Program> would start hosted services (Akka, OPC UA server, ValidateOnStart) needing SQL + a mesh, per D-5. The seam under test is DI resolution, so the container is built and resolved without starting. Five pins: ILocalDb singleton + exact replicated-table set; IDeploymentArtifactCache -> concrete; default-OFF ISyncStatus; localdb-replication health check registered and constructible; admin-only graph has NO ILocalDb and does not demand LocalDb:Path.
173 lines
6.6 KiB
C#
173 lines
6.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 11) — DI pins over the real composition methods.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <c>AddOtOpcUa*</c> methods <c>Program.cs</c> calls and resolve through it.
|
|
/// </para>
|
|
/// <para>
|
|
/// They deliberately do NOT boot the full host. <c>WebApplicationFactory<Program></c>
|
|
/// would start hosted services — Akka, the OPC UA server, <c>ValidateOnStart</c> — which need
|
|
/// SQL Server and an Akka mesh (see <c>TwoNodeClusterHarness</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LocalDbWiringTests : IDisposable
|
|
{
|
|
private readonly List<string> _dbPaths = [];
|
|
private readonly List<WebApplication> _apps = [];
|
|
|
|
/// <summary>
|
|
/// Builds a container the way <c>Program.cs</c>'s driver branch does for LocalDb: the real
|
|
/// <see cref="LocalDbRegistration.AddOtOpcUaLocalDb"/>, health, and observability methods,
|
|
/// over a temp-file <c>LocalDb:Path</c>.
|
|
/// </summary>
|
|
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<string, string?>
|
|
{
|
|
["LocalDb:Path"] = dbPath,
|
|
});
|
|
|
|
// The exact LocalDb composition Program.cs runs under hasDriver.
|
|
builder.Services.AddOtOpcUaLocalDb(builder.Configuration);
|
|
builder.Services.AddGrpc(o => o.Interceptors.Add<LocalDbSyncAuthInterceptor>());
|
|
builder.Services.AddOtOpcUaHealth();
|
|
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
|
|
|
var app = builder.Build();
|
|
_apps.Add(app);
|
|
return app.Services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an admin-only container: the shared registrations that run on every node, but NOT
|
|
/// <see cref="LocalDbRegistration.AddOtOpcUaLocalDb"/> — exactly as Program.cs gates it under
|
|
/// hasDriver.
|
|
/// </summary>
|
|
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<ILocalDb>();
|
|
first.ShouldNotBeNull();
|
|
sp.GetService<ILocalDb>().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<IDeploymentArtifactCache>()
|
|
.ShouldBeOfType<LocalDbDeploymentArtifactCache>();
|
|
}
|
|
|
|
[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<ISyncStatus>();
|
|
status.ShouldNotBeNull();
|
|
status.Connected.ShouldBeFalse();
|
|
status.PeerNodeId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void DriverGraph_LocalDbReplicationHealthCheckIsRegistered()
|
|
{
|
|
var sp = BuildDriverGraph();
|
|
|
|
var registrations = sp.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
|
|
.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<ILocalDb>().ShouldBeNull();
|
|
sp.GetService<IDeploymentArtifactCache>().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<IOptions<HealthCheckServiceOptions>>()
|
|
.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);
|
|
}
|
|
}
|
|
}
|
|
}
|