using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Gitea #62: skips the fleet flatten while the /// database-side config watermark and the secret-store stamp are unchanged, and never /// otherwise. "Did a full evaluation happen" is observed as a call to the flatten seam /// () — the cost being avoided. /// The watermark here is a substitute; the real reader's peer-node detection is proven /// against SQL Server in ConfigGraphWatermarkReaderTests. /// public class PendingUpdatesShortCircuitTests { private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly IConfigGraphWatermarkReader _watermark = Substitute.For(); private readonly ISecretStore _secrets = Substitute.For(); private readonly PendingUpdatesShortCircuitState _state = new(); private string _token = "wm-1"; private List _secretRows = [Secret("db-pass", revision: 1)]; /// Wires one site with one deployed, up-to-date instance. public PendingUpdatesShortCircuitTests() { _pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark())); _siteRepo.GetAllSitesAsync(Arg.Any()).Returns([new Site("Alpha", "site-a") { Id = 1 }]); _deploymentRepo.GetDeployedInstanceSummariesAsync(Arg.Any()) .Returns([new Instance("Inst7") { Id = 7, SiteId = 1, State = InstanceState.Enabled }]); _deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any()) .Returns(new Dictionary { [7] = "h7" }); _pipeline.ComputeRevisionHashAsync(7, Arg.Any(), Arg.Any()) .Returns(Result.Success("h7")); _watermark.ReadAsync(Arg.Any()).Returns(_ => _token); _secrets.ListAsync(true, Arg.Any()).Returns(_ => (IReadOnlyList)_secretRows); } private static SecretMetadata Secret(string name, long revision, string kek = "kek-1") => new() { Name = new SecretName(name), ContentType = SecretContentType.Text, KekId = kek, Revision = revision, CreatedUtc = new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero), UpdatedUtc = new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero).AddMinutes(revision), }; private PendingUpdatesEvaluator Evaluator(int every = 10, bool wired = true) { var comms = new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance); var options = Options.Create(new DeploymentManagerOptions { PendingUpdatesFullEvaluationEvery = every }); var artifacts = new ArtifactDeploymentService( _siteRepo, _deploymentRepo, Substitute.For(), Substitute.For(), comms, Substitute.For(), options, NullLogger.Instance, new ArtifactRevisionHashService(), new SiteSecretsCollector(_secrets)); var deployments = new DeploymentService( _deploymentRepo, _siteRepo, Substitute.For(), _pipeline, comms, new OperationLockManager(), Substitute.For(), new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), options, Options.Create(new CommunicationOptions()), NullLogger.Instance); return wired ? new PendingUpdatesEvaluator(_siteRepo, _deploymentRepo, artifacts, deployments, NullLogger.Instance, _watermark, _secrets, _state, options) : new PendingUpdatesEvaluator(_siteRepo, _deploymentRepo, artifacts, deployments, NullLogger.Instance); } private int FlattenCalls() => _pipeline.ReceivedCalls() .Count(c => c.GetMethodInfo().Name == nameof(IFlatteningPipeline.ComputeRevisionHashAsync)); /// Verifies that an unchanged watermark and secret stamp re-serve the snapshot without flattening. /// A task that represents the asynchronous operation. [Fact] public async Task Unchanged_ReservesTheSnapshot_WithoutAFleetFlatten() { // A stale instance, so the re-served answer is visibly "something pending", not a blank one. _pipeline.ComputeRevisionHashAsync(7, Arg.Any(), Arg.Any()) .Returns(Result.Success("h7-edited")); _deploymentRepo.GetDeployedSnapshotsByInstanceIdsAsync(Arg.Any>(), Arg.Any()) .Returns(new Dictionary { [7] = new("d", "h7", """{"InstanceUniqueName":"Inst7"}""") { InstanceId = 7 }, }); var evaluator = Evaluator(); var first = await evaluator.EvaluateAsync(); var second = await evaluator.EvaluateAsync(); Assert.Equal(1, FlattenCalls()); Assert.Equal(1, first.StaleInstanceCount); Assert.Equal(first.Sites, second.Sites); Assert.True(second.HasPendingUpdates); Assert.True(second.ComputedAt >= first.ComputedAt); } /// Verifies that a moved database watermark (e.g. a peer-node edit) forces a full evaluation. /// A task that represents the asynchronous operation. [Fact] public async Task WatermarkMoved_ForcesAFullEvaluation() { var evaluator = Evaluator(); await evaluator.EvaluateAsync(); _token = "wm-2"; // the shared DB's rowversion/count moved — whichever node wrote it await evaluator.EvaluateAsync(); Assert.Equal(2, FlattenCalls()); } /// Verifies that a secret-store change (revision, tombstone, re-wrap under a new KEK) forces a full evaluation. /// Which secret change to apply. /// A task that represents the asynchronous operation. [Theory] [InlineData("revision")] [InlineData("added")] [InlineData("deleted")] [InlineData("kek")] public async Task SecretStoreChanged_ForcesAFullEvaluation(string change) { var evaluator = Evaluator(); await evaluator.EvaluateAsync(); _secretRows = change switch { "revision" => [Secret("db-pass", revision: 2)], "added" => [Secret("db-pass", revision: 1), Secret("api-key", revision: 1)], "deleted" => [Secret("db-pass", revision: 1) with { IsDeleted = true }], _ => [Secret("db-pass", revision: 1, kek: "kek-2")], }; await evaluator.EvaluateAsync(); Assert.Equal(2, FlattenCalls()); } /// Verifies that every Nth evaluation is full even when nothing changed (the safety net). /// A task that represents the asynchronous operation. [Fact] public async Task EveryNthEvaluation_IsFull_EvenWhenUnchanged() { var evaluator = Evaluator(every: 3); for (var i = 0; i < 7; i++) await evaluator.EvaluateAsync(); // Evaluations 1, 4 and 7 are full; 2, 3, 5, 6 reuse. Assert.Equal(3, FlattenCalls()); } /// Verifies that an unavailable watermark, a failing secret-store listing, or N=1 never short-circuit. /// Which unavailability to simulate. /// A task that represents the asynchronous operation. [Theory] [InlineData("no-watermark")] [InlineData("watermark-throws")] [InlineData("secret-list-throws")] [InlineData("every-1")] [InlineData("unwired")] public async Task NoTrustworthyToken_EveryEvaluationIsFull(string mode) { switch (mode) { case "no-watermark": _watermark.ReadAsync(Arg.Any()).Returns((string?)null); break; case "watermark-throws": _watermark.ReadAsync(Arg.Any()).ThrowsAsync(new TimeoutException()); break; case "secret-list-throws": _secrets.ListAsync(true, Arg.Any()).ThrowsAsync(new InvalidOperationException("store down")); break; } var evaluator = Evaluator(every: mode == "every-1" ? 1 : 10, wired: mode != "unwired"); await evaluator.EvaluateAsync(); await evaluator.EvaluateAsync(); await evaluator.EvaluateAsync(); Assert.Equal(3, FlattenCalls()); } /// /// Verifies that an evaluation whose artifact hash could not be computed (a secret-store read /// failure while resolving a site's secret reference) is not reused, so the transient /// Out-of-date answer clears on the very next evaluation. /// /// A task that represents the asynchronous operation. [Fact] public async Task UncleanEvaluation_IsNotReused() { _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns([new DataConnection("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:db-pass"}""" }]); _secrets.GetAsync(Arg.Any(), Arg.Any()).ThrowsAsync(new TimeoutException("store down")); var evaluator = Evaluator(); await evaluator.EvaluateAsync(); await evaluator.EvaluateAsync(); Assert.Equal(2, FlattenCalls()); } /// Verifies that the cache's Re-check (forceRefresh) bypasses the short-circuit. /// A task that represents the asynchronous operation. [Fact] public async Task CacheForceRefresh_BypassesTheShortCircuit() { var services = new ServiceCollection(); services.AddSingleton(_state); services.AddScoped(_ => Evaluator()); await using var provider = services.BuildServiceProvider(); using var cache = new PendingUpdatesCache( provider.GetRequiredService(), Options.Create(new DeploymentManagerOptions()), new DeploymentStatusNotifier(NullLogger.Instance)); await cache.GetAsync(); await cache.GetAsync(forceRefresh: true); // unchanged token, but the operator asked: full Assert.Equal(2, FlattenCalls()); await cache.GetAsync(forceRefresh: true); Assert.Equal(3, FlattenCalls()); } /// Verifies that a deploy completion makes the next evaluation full. /// A task that represents the asynchronous operation. [Fact] public async Task DeployCompletion_MakesTheNextEvaluationFull() { var notifier = new DeploymentStatusNotifier(NullLogger.Instance); var services = new ServiceCollection(); services.AddSingleton(_state); services.AddScoped(_ => Evaluator()); await using var provider = services.BuildServiceProvider(); using var cache = new PendingUpdatesCache( provider.GetRequiredService(), Options.Create(new DeploymentManagerOptions()), notifier); await cache.GetAsync(); notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-7", 7, DeploymentStatus.Success)); await cache.GetAsync(); Assert.Equal(2, FlattenCalls()); } }