a560e9eaf9
Task 7 of the LocalDb Phase 1 adoption plan.
AddZbLocalDbReplication registers the engine; MapZbLocalDbSync exposes the
passive endpoint the peer node dials. Both are unconditional but INERT by
default: with no LocalDb:Replication:PeerAddress the initiating
SyncBackgroundService starts and immediately idles, and nothing dials the
endpoint. A site pair replicates only once an operator sets a peer on BOTH
nodes. The endpoint shares the Kestrel h2c listener the site gRPC server already
uses, so there are no listener or port changes.
Deviation from the plan: it put AddZbLocalDbReplication in Program.cs. Doing so
would have left it untested - the composition-root tests build
SiteServiceRegistration's graph, not Program.cs - so a registration that silently
went missing would still show green. It now sits next to AddZbLocalDb in
SiteServiceRegistration, where the DI-graph tests actually cover it.
MapZbLocalDbSync stays in Program.cs because it needs the WebApplication.
Two pins added to SiteLocalDbWiringTests, which configures no peer:
- the engine resolves and is idle (not connected, no peer, never synced) -
default-off means inert, not unregistered;
- OplogBacklog is 0, not null. It is deliberately nullable so a failed poll
reads as "unknown" instead of a healthy zero; a real 0 proves the provider is
wired to the oplog, which Task 9's health signal depends on.
Inbound auth on the sync endpoint is Task 8. Until that lands the endpoint is
unauthenticated - which is precisely why replication ships default-OFF and no
config in this repo sets a peer.
Verified: build 0 warnings; Host 296/296, SiteEventLogging 70/70,
SiteRuntime 530/530, Commons 684/684, Communication 312/312.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
164 lines
6.8 KiB
C#
164 lines
6.8 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
using ZB.MOM.WW.ScadaBridge.Host;
|
|
using ZB.MOM.WW.ScadaBridge.Host.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
|
|
/// composition root.
|
|
/// <para>
|
|
/// These tests build the actual container from
|
|
/// <see cref="SiteServiceRegistration.Configure"/> rather than asserting on a
|
|
/// hand-assembled <c>ServiceCollection</c>. That is deliberate and load-bearing: this
|
|
/// family has now shipped three wiring defects that only a container-built graph would
|
|
/// have caught — the Secrets Akka replicator that registered into a no-op sink (0.2.0),
|
|
/// the singleton cycle that deadlocked a real host (0.2.2), and Secrets.Ui's missing
|
|
/// <c>IAuditWriter</c> that 500'd only behind a login wall (ScadaBridge#22). A test that
|
|
/// merely checks "AddZbLocalDb was called" would have passed in every one of those cases.
|
|
/// </para>
|
|
/// </summary>
|
|
public class SiteLocalDbWiringTests : IDisposable
|
|
{
|
|
private readonly WebApplication _host;
|
|
private readonly string _tempDbPath;
|
|
private readonly string _tempSiteDbPath;
|
|
private readonly string _tempTrackingDbPath;
|
|
|
|
public SiteLocalDbWiringTests()
|
|
{
|
|
var stamp = Guid.NewGuid();
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_localdb_{stamp}.db");
|
|
_tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_site_{stamp}.db");
|
|
_tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_track_{stamp}.db");
|
|
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Configuration.Sources.Clear();
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ScadaBridge:Node:Role"] = "Site",
|
|
["ScadaBridge:Node:NodeName"] = "node-a",
|
|
["ScadaBridge:Node:NodeHostname"] = "test-site",
|
|
["ScadaBridge:Node:SiteId"] = "TestSite",
|
|
["ScadaBridge:Node:RemotingPort"] = "0",
|
|
["ScadaBridge:Node:GrpcPort"] = "0",
|
|
["ScadaBridge:Database:SiteDbPath"] = _tempSiteDbPath,
|
|
["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={_tempTrackingDbPath}",
|
|
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
|
|
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
|
|
|
|
// The consolidated site database. No Replication section at all — this
|
|
// fixture is also the default-OFF pin: storage must work standalone.
|
|
["LocalDb:Path"] = _tempDbPath,
|
|
});
|
|
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
|
|
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
|
|
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
|
|
|
_host = builder.Build();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
(_host as IDisposable)?.Dispose();
|
|
foreach (var path in new[] { _tempDbPath, _tempSiteDbPath, _tempTrackingDbPath })
|
|
{
|
|
try { File.Delete(path); } catch { /* best effort */ }
|
|
}
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Resolves_ILocalDb()
|
|
{
|
|
Assert.NotNull(_host.Services.GetService<ILocalDb>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_RegistersBothReplicatedTables()
|
|
{
|
|
// The whole point of Phase 1. If onReady silently failed to register these,
|
|
// storage would still work and every other test would pass — the pair would
|
|
// just never replicate anything. Assert on the library's own view of what is
|
|
// registered, not on our belief that we called it.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Contains("OperationTracking", db.ReplicatedTables.Keys);
|
|
Assert.Contains("site_events", db.ReplicatedTables.Keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
|
|
{
|
|
// RegisterReplicated refuses a table with no explicit PK, so reaching this
|
|
// assertion at all proves the DDL ran before registration. The PK names are
|
|
// pinned because they are what last-writer-wins conflict resolution keys on.
|
|
var db = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Equal(
|
|
["TrackedOperationId"], db.ReplicatedTables["OperationTracking"].PkColumns);
|
|
Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_IsASingleton()
|
|
{
|
|
// AddZbLocalDb is one-DB-per-process and first-registration-wins. Two
|
|
// instances would mean two SQLite handles and two trigger installations
|
|
// against the same file.
|
|
var first = _host.Services.GetRequiredService<ILocalDb>();
|
|
var second = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.Same(first, second);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_IsRegistered_AndInertWithNoPeer()
|
|
{
|
|
// Task 7's default-OFF pin. This fixture configures NO
|
|
// LocalDb:Replication:PeerAddress, which is the shipping default, so the
|
|
// engine must resolve cleanly and sit idle rather than throw, retry, or
|
|
// report a connection. "Default-off" here means inert, NOT unregistered —
|
|
// the services are always in the graph.
|
|
var status = _host.Services.GetService<ISyncStatus>();
|
|
|
|
Assert.NotNull(status);
|
|
Assert.False(status!.Connected);
|
|
Assert.Null(status.PeerNodeId);
|
|
Assert.Null(status.LastSyncUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode()
|
|
{
|
|
// OplogBacklog is deliberately nullable: null means "unknown" (no provider
|
|
// wired, or the poll failed) and must never be reported as a healthy 0. On a
|
|
// node whose local database is present and readable, a real 0 proves the
|
|
// backlog provider is actually wired to the oplog — a null here would mean
|
|
// Task 9's health signal is about to publish "unknown" forever.
|
|
var status = _host.Services.GetRequiredService<ISyncStatus>();
|
|
|
|
Assert.Equal(0L, status.OplogBacklog);
|
|
}
|
|
|
|
[Fact]
|
|
public void Site_LocalDb_CreatesTheConfiguredFile()
|
|
{
|
|
// Resolving is what triggers onReady; the file should exist afterwards at the
|
|
// configured path and not somewhere CWD-relative.
|
|
_ = _host.Services.GetRequiredService<ILocalDb>();
|
|
|
|
Assert.True(File.Exists(_tempDbPath),
|
|
$"Expected the consolidated site database at '{_tempDbPath}'.");
|
|
}
|
|
}
|