using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; using ZB.MOM.WW.ScadaBridge.Host.Actors; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Deferred-work register row 80 — a site node with replication UNCONFIGURED must report /// LocalDbReplicationConnected and LocalDbOplogBacklog as null. /// /// /// /// The sibling fixture is a replicating node /// (it sets LocalDb:Replication:ApiKey, modelling the passive half of the rig's /// site-a pair), so false/0 is the right answer there. This fixture is the other posture — /// the rig's site-b/site-c, and the shipping default — with NEITHER PeerAddress nor /// ApiKey. /// /// /// The replication engine is registered unconditionally and idles, so ISyncStatus /// still answers Connected=false with a real 0 backlog. Publishing that pair /// is the row-80 bug: a dashboard reader sees connected: false on a perfectly healthy /// unreplicated site and reads a fault, and central's null branches — the conditional KPI /// emission and the sustained-badge anchor exclusion built for register row 33 — never /// receive the null they exist for, so unreplicated sites emitted a misleading /// localDbReplicationConnected=0 series. /// /// /// Built from the REAL composition root for the same reason the sibling fixture is: the /// defect is in the wiring, and a hand-assembled ServiceCollection would prove /// nothing about what the Host actually passes. /// /// public class UnreplicatedSiteLocalDbHealthTests : IDisposable { private readonly WebApplication _host; private readonly string _tempDbPath; private readonly string _tempSiteDbPath; private readonly string _tempTrackingDbPath; public UnreplicatedSiteLocalDbHealthTests() { var stamp = Guid.NewGuid(); _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_unrep_localdb_{stamp}.db"); _tempSiteDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_unrep_site_{stamp}.db"); _tempTrackingDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_unrep_track_{stamp}.db"); var builder = WebApplication.CreateBuilder(); builder.Configuration.Sources.Clear(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["ScadaBridge:Node:Role"] = "Site", ["ScadaBridge:Node:NodeName"] = "node-a", ["ScadaBridge:Node:NodeHostname"] = "test-site-b", ["ScadaBridge:Node:SiteId"] = "TestSiteB", ["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 point of this fixture: LocalDb:Path only. No PeerAddress, no ApiKey — // SiteLocalDbSetup.ReplicationIsConfigured is false, matching rig site-b/site-c. ["LocalDb:Path"] = _tempDbPath, }); builder.Services.AddGrpc(); builder.Services.AddSingleton(); 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 UnreplicatedSite_HealthReport_ReportsNullForBothLocalDbFields() { // The row-80 regression, end-to-end through the real container and the real // collector. Before the fix this produced false/0. var reporter = _host.Services.GetServices() .OfType() .Single(); var collector = _host.Services.GetRequiredService(); reporter.Probe(); var report = collector.CollectReport("TestSiteB"); Assert.Null(report.LocalDbReplicationConnected); Assert.Null(report.LocalDbOplogBacklog); } [Fact] public void UnreplicatedSite_StillRegistersTheHealthBridge() { // The fix is a GATE on what the reporter publishes, not a skipped registration. // Keeping the hosted service present means turning replication on is a config // change rather than a different service graph, and it keeps one code path for // both postures. var hosted = _host.Services.GetServices(); Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter); } [Fact] public void UnreplicatedSite_ReplicationEngine_StillResolvesAndIdles() { // Establishes the premise the row-80 fix rests on: the engine really does answer // false/0 here. If this ever starts returning null on its own, the gate becomes // redundant rather than wrong — but the test above still pins the observable // contract either way. var status = _host.Services.GetRequiredService(); Assert.False(status.Connected); Assert.Equal(0L, status.OplogBacklog); } }