Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbWiringTests.cs
T
Joseph Doherty f8aa02e2a9 feat(localdb): create config + sf_messages tables in the consolidated DB
Task 7. SiteLocalDbSetup.OnReady now applies SiteStorageSchema and
StoreAndForwardSchema alongside the Phase 1 schemas, so the nine site
configuration tables and the store-and-forward buffer live in the
consolidated LocalDb file.

Deliberately NOT registered for replication. The bespoke SiteReplicationActor
and the StoreAndForward ReplicationService still own these tables until the
Task 14 cutover deletes both and registers them in one commit; registering
early would run two replicators over the same rows and let either one's
defects hide behind the other's writes.

Pinned by two tests through the real composition root: one asserting all
twelve tables exist, one asserting ReplicatedTables is EXACTLY the Phase 1
pair. Non-vacuity verified by removing the DDL and observing the failure.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 03:28:05 -04:00

285 lines
12 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.Telemetry;
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_Replication_HealthBridge_IsRegisteredAsAHostedService()
{
// Task 9. Registered via a factory lambda, so ImplementationType is null and the
// only honest assertion is on the resolved instance.
var hosted = _host.Services.GetServices<IHostedService>();
Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter);
}
[Fact]
public void Site_Replication_HealthBridge_PublishesStatusOntoTheHealthReport()
{
// End-to-end through the REAL collector: probe once, then collect. This is what
// catches a bridge that is registered but wired to the wrong provider — the
// registration test above would pass either way.
var reporter = _host.Services.GetServices<IHostedService>()
.OfType<LocalDbReplicationStatusReporter>()
.Single();
var collector = _host.Services.GetRequiredService<ISiteHealthCollector>();
reporter.Probe();
var report = collector.CollectReport("TestSite");
// No peer configured: not connected, and a REAL 0 backlog rather than null.
Assert.False(report.LocalDbReplicationConnected);
Assert.Equal(0L, report.LocalDbOplogBacklog);
}
[Fact]
public void Site_HealthReport_LocalDbFields_AreNull_BeforeTheReporterHasRun()
{
// Null means "no data yet", and must be distinguishable from a real
// "disconnected, zero backlog". A collector that defaulted these to false/0 would
// report an unwired node as a healthy connected one.
var collector = new SiteHealthCollector();
var report = collector.CollectReport("TestSite");
Assert.Null(report.LocalDbReplicationConnected);
Assert.Null(report.LocalDbOplogBacklog);
}
[Fact]
public void Site_Telemetry_Allowlists_TheLocalDbReplicationMeter()
{
// ZbTelemetryOptions.Meters is an ALLOWLIST: an unlisted Meter's instruments are
// never observed, with no error and no missing-metric warning anywhere. The
// replication meter was silently absent from the rig's /metrics scrape until the
// live gate looked for it, so pin the allowlist rather than trusting the next
// reader to remember.
//
// This asserts on the allowlist constant, not on OTel's observed-meter set:
// AddZbTelemetry applies its options to a local instance and never registers
// IOptions, so there is nothing resolvable to interrogate. The end-to-end proof
// that localdb_* actually reaches /metrics is the Task 12 live gate; this test
// exists to stop the entry being dropped again.
Assert.Contains(LocalDbMetrics.MeterName, SiteServiceRegistration.ObservedMeters);
}
[Fact]
public void Site_LocalDb_CreatesThePhase2Tables_InTheConsolidatedFile()
{
// Phase 2 (Task 7). The nine configuration tables and the store-and-forward buffer
// now live in the consolidated file rather than in scadabridge.db and
// store-and-forward.db. Asserting through the REAL composition root is what proves
// OnReady applies both schemas — the stores themselves also call Apply, so a test
// that went through a store would pass even if OnReady never touched them.
var db = _host.Services.GetRequiredService<ILocalDb>();
var tables = TableNames(db);
// One from each Phase 1 schema, then every Phase 2 table.
Assert.Contains("OperationTracking", tables);
Assert.Contains("site_events", tables);
Assert.Contains("sf_messages", tables);
foreach (var table in new[]
{
"deployed_configurations", "static_attribute_overrides", "shared_scripts",
"external_systems", "database_connections", "notification_lists",
"data_connection_definitions", "smtp_configurations", "native_alarm_state",
})
{
Assert.Contains(table, tables);
}
}
[Fact]
public void Site_LocalDb_DoesNotYetRegisterThePhase2Tables()
{
// The "not yet" is the assertion that matters, and it is why this is an EXACTLY
// check rather than a Contains. Until the Task 14 cutover, the bespoke
// SiteReplicationActor and the StoreAndForward ReplicationService still own these
// tables. Registering them early would run two replicators over the same rows and
// hide a defect in either one behind the other's writes.
//
// When Task 14 lands, this test does not get deleted — it gets inverted.
var db = _host.Services.GetRequiredService<ILocalDb>();
Assert.Equal(
["OperationTracking", "site_events"],
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
}
private static HashSet<string> TableNames(ILocalDb db)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
using var reader = cmd.ExecuteReader();
var names = new HashSet<string>(StringComparer.Ordinal);
while (reader.Read()) names.Add(reader.GetString(0));
return names;
}
[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}'.");
}
}