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