Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SiteReconciliationActorTests.cs
Joseph Doherty f2efeb37b7 refactor(sf,site): both stores take ILocalDb instead of a connection string
Tasks 5 and 6 of the Phase 2 plan, committed together because their test
fallout is entangled — several fixtures construct both stores.

StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections
come from ILocalDb.CreateConnection(), which hands out an already-open,
pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers
call; a raw connection would lack the UDF and every write to a replicated table
would fail closed. Deleted with the connection strings: S&F's
EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site
service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now.

DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the
Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and
StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source
locations in Tasks 8/9.

Two things the plan did not anticipate, both worth reading:

1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed
   directory creation simply moved to LocalDb along with file ownership. It did
   not: the LocalDb library never creates the parent directory, and
   SqliteLocalDb opens the file eagerly in its constructor — so a missing
   directory is a hard boot failure ("SQLite Error 14: unable to open database
   file"), not a degraded start. The default site config points at the RELATIVE
   path ./data/site-localdb.db, so any site node without a pre-existing data/
   directory fails to boot. The docker rig escapes only because its volume mount
   happens to create /app/data — a coincidence that would have hidden this until
   a bare-metal or fresh deployment. This has been latent since Phase 1 made
   LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here
   would have widened it. Re-established the guarantee at the layer that now
   owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and
   pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two
   tests written against the wrong assumption failed with exactly this
   SQLite Error 14 before the fix existed.

2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one
   project; the constructor change actually reaches 40 files across 7 test
   projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no
   equivalent for, so every one had to move to a real temp file. Rather than
   copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared
   tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the
   WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place.

Retargeted rather than deleted, in both directions: the S&F WAL test now asserts
against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the
directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's).
SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment.
DeploymentManagerMediumFindingsTests induced a persistence failure via an
unopenable path, which no longer reaches the assertion since the fixture now
throws first; it induces the same failure shape via an uninitialized store.

Verified: full solution build 0 warnings; SiteRuntime 532, Host 318,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97,
StoreAndForward 153 — 1597 passed, 0 failed.

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

319 lines
14 KiB
C#

using System.Collections.Concurrent;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Tests for <see cref="SiteReconciliationActor"/>: the per-node startup self-heal that
/// reports the node's local deployed inventory to central (over the SiteCommunicationActor
/// Ask), fetches the gap (missing/stale configs) over HTTP, guarded-writes them, and only
/// LOGS orphans (never deletes). Best-effort throughout — a central-unreachable Ask or a
/// per-item fetch failure must never crash the actor.
/// </summary>
public class SiteReconciliationActorTests : TestKit, IDisposable
{
private const string SiteIdentifier = "site-1";
private const string NodeId = "node-a";
private readonly SiteStorageService _storage;
private readonly TestLocalDb _localDb;
public SiteReconciliationActorTests()
{
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
// fixture is a real temp file (deleted in Dispose, after the TestKit shutdown).
_localDb = TestLocalDb.CreateTemp("site-reconcile-test");
_storage = new SiteStorageService(
_localDb.Db, Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
}
void IDisposable.Dispose()
{
// Shut the actor system down FIRST: a reconcile continuation may still be
// writing through the ILocalDb, which must outlive the actors.
Shutdown();
// Then dispose — the master connection anchors the WAL, so the sidecars cannot
// be removed while it is open.
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateReconciliationActor(
IActorRef siteCommunicationActor,
IDeploymentConfigFetcher fetcher,
ILogger<SiteReconciliationActor>? logger = null,
TimeSpan? askTimeout = null) =>
ActorOf(Props.Create(() => new SiteReconciliationActor(
_storage,
fetcher,
siteCommunicationActor,
SiteIdentifier,
NodeId,
logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteReconciliationActor>.Instance,
TimeSpan.FromMilliseconds(50), // initial delay — fast for tests
askTimeout ?? TimeSpan.FromSeconds(5)))); // ask timeout
[Fact]
public async Task MissingInstance_IsFetchedAndStored()
{
// Local inventory has A@rev1; central reports B is missing → fetch + store B.
await _storage.StoreDeployedConfigAsync("A", "{\"instanceUniqueName\":\"A\"}", "depA", "rev1", true);
const string configB = "{\"instanceUniqueName\":\"B\"}";
var fetcher = new FakeConfigFetcher(_ => Task.FromResult(configB));
var commProbe = CreateTestProbe();
CreateReconciliationActor(commProbe, fetcher);
var req = commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(SiteIdentifier, req.SiteIdentifier);
Assert.Equal(NodeId, req.NodeId);
Assert.True(req.LocalNameToRevisionHash.TryGetValue("A", out var hashA));
Assert.Equal("rev1", hashA);
commProbe.Reply(new ReconcileSiteResponse(
[new ReconcileGapItem("B", "depB", "rev2", true, "tok-b")],
[],
"http://central:9000"));
await AwaitAssertAsync(async () =>
{
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "B");
Assert.Equal(configB, row.ConfigJson);
Assert.Equal("depB", row.DeploymentId);
Assert.Equal("rev2", row.RevisionHash);
Assert.True(row.IsEnabled);
}, TimeSpan.FromSeconds(5));
var call = Assert.Single(fetcher.Calls);
Assert.Equal("http://central:9000", call.BaseUrl);
Assert.Equal("depB", call.DeploymentId);
Assert.Equal("tok-b", call.Token);
}
[Fact]
public async Task StaleInstance_IsRefreshed()
{
// Local A@rev1; central reports A is stale (now depA2/rev2) → fetch + guarded-write A.
await _storage.StoreDeployedConfigAsync("A", "{\"old\":true}", "depA", "rev1", true);
const string configA2 = "{\"instanceUniqueName\":\"A\",\"v\":2}";
var fetcher = new FakeConfigFetcher(_ => Task.FromResult(configA2));
var commProbe = CreateTestProbe();
CreateReconciliationActor(commProbe, fetcher);
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
commProbe.Reply(new ReconcileSiteResponse(
[new ReconcileGapItem("A", "depA2", "rev2", false, "tok-a")],
[],
"http://central:9000"));
await AwaitAssertAsync(async () =>
{
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "A");
Assert.Equal(configA2, row.ConfigJson);
Assert.Equal("depA2", row.DeploymentId);
Assert.Equal("rev2", row.RevisionHash);
Assert.False(row.IsEnabled);
}, TimeSpan.FromSeconds(5));
var call = Assert.Single(fetcher.Calls);
Assert.Equal("depA2", call.DeploymentId);
Assert.Equal("tok-a", call.Token);
}
[Fact]
public async Task Orphan_IsLoggedNotDeleted()
{
// Local Z exists; central reports Z as an orphan → log a warning, leave Z in place.
await _storage.StoreDeployedConfigAsync("Z", "{\"instanceUniqueName\":\"Z\"}", "depZ", "revZ", true);
var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never"));
var logger = new CapturingLogger<SiteReconciliationActor>();
var commProbe = CreateTestProbe();
CreateReconciliationActor(commProbe, fetcher, logger);
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
commProbe.Reply(new ReconcileSiteResponse([], ["Z"], "http://central:9000"));
// A warning mentioning the orphan name must be logged.
await AwaitAssertAsync(() =>
{
Assert.Contains(logger.Entries, e =>
e.Level == LogLevel.Warning && e.Message.Contains("Z"));
return Task.CompletedTask;
}, TimeSpan.FromSeconds(5));
// ...and Z is still present (never deleted) and no fetch happened.
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Contains(configs, c => c.InstanceUniqueName == "Z");
Assert.Empty(fetcher.Calls);
}
[Fact]
public async Task NoGap_DoesNotFetch()
{
// Empty gap and no orphans → the fetcher is never called.
var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never"));
var commProbe = CreateTestProbe();
var actor = CreateReconciliationActor(commProbe, fetcher);
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
commProbe.Reply(new ReconcileSiteResponse([], [], "http://central:9000"));
// Give any (erroneous) continuation time to run, then prove no fetch happened.
Watch(actor);
ExpectNoMsg(TimeSpan.FromMilliseconds(500));
Assert.Empty(fetcher.Calls);
}
[Fact]
public async Task PerItemFetchFailure_DoesNotAbortTheRest()
{
// Gap = [Bad, Good]; the Bad fetch throws but Good must still be fetched + stored.
const string configGood = "{\"instanceUniqueName\":\"Good\"}";
var fetcher = new FakeConfigFetcher(depId => depId == "depBad"
? Task.FromException<string>(new DeploymentConfigFetchException("boom", isSuperseded: false))
: Task.FromResult(configGood));
var commProbe = CreateTestProbe();
var actor = CreateReconciliationActor(commProbe, fetcher);
Watch(actor);
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
commProbe.Reply(new ReconcileSiteResponse(
[
new ReconcileGapItem("Bad", "depBad", "revBad", true, "tok-bad"),
new ReconcileGapItem("Good", "depGood", "revGood", true, "tok-good")
],
[],
"http://central:9000"));
await AwaitAssertAsync(async () =>
{
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "Good");
Assert.Equal(configGood, row.ConfigJson);
}, TimeSpan.FromSeconds(5));
// Bad was attempted but never stored; the actor survived the per-item failure.
var all = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(all, c => c.InstanceUniqueName == "Bad");
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task SupersededItem_IsSkippedQuietly_OthersStillApply()
{
// Gap = [Gone, Good]; the Gone fetch throws a 404 (superseded/expired). That branch is
// a quiet skip (logged Info, NOT counted as a failure) — the Good item must still apply
// and the actor must not crash.
const string configGood = "{\"instanceUniqueName\":\"Good\"}";
var fetcher = new FakeConfigFetcher(depId => depId == "depGone"
? Task.FromException<string>(new DeploymentConfigFetchException("expired", isSuperseded: true))
: Task.FromResult(configGood));
var logger = new CapturingLogger<SiteReconciliationActor>();
var commProbe = CreateTestProbe();
var actor = CreateReconciliationActor(commProbe, fetcher, logger);
Watch(actor);
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
commProbe.Reply(new ReconcileSiteResponse(
[
new ReconcileGapItem("Gone", "depGone", "revGone", true, "tok-gone"),
new ReconcileGapItem("Good", "depGood", "revGood", true, "tok-good")
],
[],
"http://central:9000"));
await AwaitAssertAsync(async () =>
{
var configs = await _storage.GetAllDeployedConfigsAsync();
var row = Assert.Single(configs, c => c.InstanceUniqueName == "Good");
Assert.Equal(configGood, row.ConfigJson);
}, TimeSpan.FromSeconds(5));
// The superseded item was attempted but never stored, and was logged at Info as a skip
// (not Error) — and the actor survived.
var all = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(all, c => c.InstanceUniqueName == "Gone");
Assert.Contains(logger.Entries, e =>
e.Level == LogLevel.Information && e.Message.Contains("Gone"));
Assert.DoesNotContain(logger.Entries, e =>
e.Level == LogLevel.Error && e.Message.Contains("Gone"));
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public void CentralAskFails_ActorSurvives()
{
// The SiteCommunicationActor probe never replies → the Ask times out. The actor must
// log + survive (reconcile re-runs on the next startup), not crash.
var fetcher = new FakeConfigFetcher(_ => Task.FromResult("never"));
var logger = new CapturingLogger<SiteReconciliationActor>();
var commProbe = CreateTestProbe();
var actor = CreateReconciliationActor(
commProbe, fetcher, logger, askTimeout: TimeSpan.FromMilliseconds(300));
Watch(actor);
// The request is sent...
commProbe.ExpectMsg<ReconcileSiteRequest>(TimeSpan.FromSeconds(5));
// ...but the probe does NOT reply, forcing an Ask timeout.
// The actor does not die (no Terminated within the window) and never fetched.
ExpectNoMsg(TimeSpan.FromSeconds(1));
Assert.Empty(fetcher.Calls);
}
/// <summary>
/// In-test fake <see cref="IDeploymentConfigFetcher"/>: runs a per-deploymentId behavior
/// (return config JSON or throw, as a Task) and records every call's coords thread-safely
/// (the fetch runs on a pool thread).
/// </summary>
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
{
private readonly Func<string, Task<string>> _behavior;
public ConcurrentQueue<(string BaseUrl, string DeploymentId, string Token)> Calls { get; } = new();
public FakeConfigFetcher(Func<string, Task<string>> behavior) => _behavior = behavior;
public async Task<string> FetchAsync(
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
{
Calls.Enqueue((centralFetchBaseUrl, deploymentId, token));
await Task.Yield();
return await _behavior(deploymentId);
}
}
/// <summary>Thread-safe capturing logger so tests can assert on emitted warnings.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
IDisposable? ILogger.BeginScope<TState>(TState state) => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Enqueue((logLevel, formatter(state, exception)));
}
}