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; /// /// The staleness fast path: memoises a computed /// revision hash against the readings it was /// computed under, and the memo is PROCESS-STATIC. That makes the watermark the /// only thing standing between a cached hash and a wrong answer — so these tests /// pin that the memo is served only while the watermark has not moved, and is /// dropped the moment it has. /// /// /// The failure this guards against is a real one: a data-connection edit, or a /// bundle import committing through the raw DbContext, changes the flattened /// output without going through the repository path that derives bumps from the /// change tracker. If that write does not bump, the memo below keeps answering with /// the old hash forever. /// /// 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; public StaleInstanceProbeTests() { // Process-static memo: start every test from a clean slate. StaleInstanceProbe.ClearMemos(); _pipeline.CreateSession().Returns(_ => new FlattenSession(_watermark)); _pipeline .FlattenAndValidateAsync(InstanceId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => { _flattenCount++; var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" }; return Result.Success( new FlatteningPipelineResult(config, _currentHash, ValidationResult.Success())); }); } private StaleInstanceProbe CreateProbe() => new(_pipeline, _watermark); [Fact] public async Task UnchangedWatermark_ServesMemoisedHash_WithoutReflattening() { 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(1, _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. /// [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"; // Without a bump the stale hash would still be served... Assert.Equal("sha256:first", await probe.GetCurrentRevisionHashAsync(InstanceId)); _watermark.BumpAll(); Assert.Equal("sha256:second", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(2, _flattenCount); } [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)); } /// /// P7-4: 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. /// /// [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 .FlattenAndValidateAsync(InstanceId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(async call => { _flattenCount++; var session = call.ArgAt(3)!; 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"; var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" }; return Result.Success( new FlatteningPipelineResult(config, "sha256:before-edit", ValidationResult.Success())); }); 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 .FlattenAndValidateAsync(InstanceId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(_ => { _flattenCount++; var config = new FlattenedConfiguration { InstanceUniqueName = "Inst-01" }; return Result.Success( new FlatteningPipelineResult(config, _currentHash, ValidationResult.Success())); }); Assert.Equal("sha256:after-edit", await probe.GetCurrentRevisionHashAsync(InstanceId)); Assert.Equal(2, _flattenCount); } }