Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs
T
Joseph Doherty 271fcc4e15 test(localdb): alarm S&F convergence scenarios
Two driver nodes replicating the alarm buffer over the real loopback h2c
transport, through the real fail-closed interceptor. This is the test the
phase exists to pass: schema, registration, sink rewire and drain gate can
all be individually green while a pair still fails to share the undelivered
alarm history that is the whole point of moving the queue.

Rows are written through the production sink rather than hand-rolled
INSERTs, so the id derivation, column set and delete-on-ack semantics under
test are the ones production uses. The scenarios cover a buffered burst
converging with the oplog draining to zero, delivered rows being removed from
BOTH nodes as tombstones (the no-redeliver-after-failover property), writes
during a transport outage surviving the rejoin, and the same event accepted
on both nodes collapsing to one row.

The positive control found a weak assertion. With
RegisterReplicated("alarm_sf_events") commented out, three of the four went
red immediately -- but the same-event-on-both-nodes scenario still PASSED,
because with replication off each node trivially holds its own single copy
and "one row on each" is satisfied by two databases that never spoke. It now
also enqueues a distinct event on B and requires both nodes to hold two rows,
which cannot be satisfied without convergence; it goes red under the control
like the others. Control restored, all four green.

Also updates the second exact-set replicated-tables pin, in LocalDbWiringTests.
The plan predicted these pins would go red here, and that is them working:
one of them is asserted through the full driver DI graph rather than the
OnReady callback, so it catches a registration that exists in the callback
but never reaches a running host.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-21 04:39:32 -04:00

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&lt;Program&gt;</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_LocalDbResolvesAsASingletonWithEveryReplicatedTableRegistered()
{
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(["alarm_sf_events", "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);
}
}
}
}