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);
}
}