using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// is authoritative: it re-flattens on EVERY probe and /// memoises nothing across calls. It used to memoise a revision hash against the /// process-local , which a peer central node writing /// the shared database could never bump (MG-3) — these tests pin that every probe sees /// the current configuration whatever the local watermark says. /// public class StaleInstanceProbeTests { private const int InstanceId = 1; private readonly TemplateGraphWatermark _watermark = new(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); private string _currentHash = "sha256:first"; private int _flattenCount; /// Initializes a new instance of the StaleInstanceProbeTests class. public StaleInstanceProbeTests() { _pipeline.CreateSession().Returns(_ => new FlattenSession(_watermark)); _pipeline .ComputeRevisionHashAsync(InstanceId, Arg.Any(), Arg.Any()) .Returns(_ => { _flattenCount++; return Result.Success(_currentHash); }); } private StaleInstanceProbe CreateProbe() => new(_pipeline); /// /// review 2026-10-21 04/U2: with the memo gone the probe has no use for the watermark, so /// its only dependency is the pipeline and the no-op ClearMemos hook is gone. /// [Fact] public void Probe_DependsOnlyOnThePipeline_AndHasNoMemoHook() { var ctor = Assert.Single(typeof(StaleInstanceProbe).GetConstructors()); var parameter = Assert.Single(ctor.GetParameters()); Assert.Equal(typeof(IFlatteningPipeline), parameter.ParameterType); Assert.Null(typeof(StaleInstanceProbe).GetMethod("ClearMemos")); } /// Verifies that peer edit with independent watermark is observed by warm active probe. /// A task that represents the asynchronous operation. [Fact] public async Task PeerEdit_WithIndependentWatermark_IsObservedByWarmActiveProbe() { var active = CreateProbe(); var peer = new TemplateGraphWatermark(); Assert.Equal("sha256:first", await active.GetCurrentRevisionHashAsync(InstanceId)); _currentHash = "sha256:peer-edit"; // Shared database changed through another central. peer.BumpAll(); Assert.Equal("sha256:peer-edit", await active.GetCurrentRevisionHashAsync(InstanceId)); } /// Verifies that unchanged watermark still reflattens authoritatively. /// A task that represents the asynchronous operation. [Fact] public async Task UnchangedWatermark_StillReflattensAuthoritatively() { var probe = CreateProbe(); Assert.Equal("sha256:first", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(1, _flattenCount); Assert.Equal("sha256:first", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(2, _flattenCount); } /// /// BumpAll is the unattributed fallback used by everything that changes a /// flattening input without an owning template id — data-connection saves and /// bundle imports both rely on it. It must invalidate the memo. /// /// A task that represents the asynchronous operation. [Fact] public async Task BumpAll_InvalidatesTheMemo() { var probe = CreateProbe(); await probe.GetCurrentRevisionHashAsync(InstanceId); // The underlying config drifted (e.g. a data connection was repointed). _currentHash = "sha256:second"; // Even without a local bump, a peer edit is authoritative. Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId)); _watermark.BumpAll(); Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(3, _flattenCount); } /// Verifies that bump instance invalidates the memo for that instance. /// A task that represents the asynchronous operation. [Fact] public async Task BumpInstance_InvalidatesTheMemoForThatInstance() { var probe = CreateProbe(); await probe.GetCurrentRevisionHashAsync(InstanceId); _currentHash = "sha256:second"; _watermark.BumpInstance(InstanceId); Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId)); } /// /// A non-structural template edit landing DURING the flatten must not be /// captured as if it were already included. /// /// /// The memo pairs a revision hash with the watermark readings that produced it. /// Reading the per-template versions AFTER the flatten pairs the PRE-edit hash /// with the POST-edit version, and every later probe then finds the watermark /// unchanged and answers "not stale" forever — until an unrelated edit, a /// restart, or the 4096-entry overflow clears it. The staleness sweep and /// ImportResult.StaleInstanceIds both read through this memo. /// /// /// /// The fix is that the versions come from the session, which records each /// template's version at the instant it READ that template — before the edit — /// so the mismatch on the next probe invalidates the memo. Only the recording is /// changed: the mid-flatten edit still produces the pre-edit hash on this pass, /// which is correct, since that is the graph the flatten actually saw. /// /// /// A task that represents the asynchronous operation. [Fact] public async Task TemplateEditedDuringFlatten_InvalidatesTheMemo_RatherThanPinningTheStaleHash() { const int TemplateId = 42; // The flatten reads the template through the session (recording the version // it read it at), and the edit lands before the flatten returns. _pipeline .ComputeRevisionHashAsync(InstanceId, Arg.Any(), Arg.Any()) .Returns(async call => { _flattenCount++; var session = call.ArgAt(1)!; await session.GetTemplateAsync(TemplateId, _ => Task.FromResult(new Template($"T{TemplateId}"))); // A non-structural template edit commits while the flatten is in flight. _watermark.BumpTemplate(TemplateId); _currentHash = "sha256:after-edit"; return Result.Success("sha256:before-edit"); }); var probe = CreateProbe(); // Pass 1 legitimately returns the hash of the graph the flatten walked. Assert.Equal("sha256:before-edit", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(1, _flattenCount); // Pass 2 must see the edit. Re-recording the versions after the flatten // would have memoised the post-edit version against the pre-edit hash, and // this call would still answer "sha256:before-edit" without re-flattening. _pipeline .ComputeRevisionHashAsync(InstanceId, Arg.Any(), Arg.Any()) .Returns(_ => { _flattenCount++; return Result.Success(_currentHash); }); Assert.Equal("sha256:after-edit", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(2, _flattenCount); } }