using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; 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.Interfaces.Transport; 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.Communication; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Coverage for — the /// fleet-wide batch behind the pending-updates aggregate. /// /// /// The three properties under test are the ones that make the batch both cheap /// and exactly consistent with the per-instance Topology badge: ONE shared /// for the whole batch, a hash-projection read for /// everyone, and the fat snapshot read + I-1 List normalisation paid ONLY when /// the projected hash already disagrees. /// /// public class DeploymentServiceStalenessTests { private readonly IDeploymentManagerRepository _repo = Substitute.For(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly RevisionHashService _hasher = new(); private readonly DeploymentService _service; /// The single session every flatten in a batch must share. private readonly FlattenSession _session = new(new TemplateGraphWatermark()); /// Captures the service's log entries so the per-batch Warning can be asserted. private readonly CapturingLogger _logger = new(); /// /// Builds the service over substituted repository and pipeline seams; every /// other dependency is the real concrete collaborator (as elsewhere in this /// suite) because the staleness path only exercises hashing. /// public DeploymentServiceStalenessTests() { _pipeline.CreateSession().Returns(_session); var siteRepo = Substitute.For(); siteRepo.GetSiteByIdAsync(Arg.Any(), Arg.Any()) .Returns(callInfo => { var id = callInfo.ArgAt(0); return new Site($"Test Site {id}", $"site-{id}") { Id = id }; }); _service = new DeploymentService( _repo, siteRepo, _pipeline, new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance), new OperationLockManager(), Substitute.For(), new DiffService(), _hasher, new DeploymentStatusNotifier(NullLogger.Instance), Substitute.For(), Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }), Options.Create(new CommunicationOptions()), _logger); } /// /// Captures formatted log entries so the batch's single failure Warning can be /// asserted (mirrors the CapturingLogger used elsewhere in the test suite). /// private sealed class CapturingLogger : ILogger { /// Every entry logged, in order. public List<(LogLevel Level, string Message)> Entries { get; } = []; /// Begins a logical scope; this capturing logger does not track scopes. /// The type of the scope state. /// The scope state. /// Always . public IDisposable? BeginScope(TState state) where TState : notnull => null; /// Reports whether a log level is enabled; every level is. /// The log level to check. /// Always . public bool IsEnabled(LogLevel logLevel) => true; /// Records the level and formatted message in . /// The type of the log state. /// The severity of the entry. /// The event id of the entry. /// The log state. /// The exception associated with the entry, if any. /// Formats the state and exception into the message. public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception))); } } /// Builds a one-attribute flattened config with the given scalar value. private static FlattenedConfiguration ScalarConfig(string value) => new() { InstanceUniqueName = "TestInst", Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }] }; /// Stubs a successful flatten of . private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) => _pipeline.FlattenAndValidateAsync( instanceId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success( new FlatteningPipelineResult(config, hash, ValidationResult.Success()))); /// Stubs the fat snapshot read for . private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) => _repo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any()) .Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config)) { InstanceId = instanceId, DeployedAt = DateTimeOffset.UtcNow }); /// Stubs the (InstanceId, RevisionHash) projection read. private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) => _repo.GetDeployedRevisionHashesAsync(Arg.Any()) .Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash)); /// Verifies that the staleness check shares one flatten session across the whole batch. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_SharesOneSessionAcrossTheBatch() { var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash), (2, hash), (3, hash)); ArrangeFlatten(1, config, hash); ArrangeFlatten(2, config, hash); ArrangeFlatten(3, config, hash); var result = await _service.GetInstanceStalenessAsync([1, 2, 3]); Assert.Equal(3, result.Count); // One session created, and the SAME instance handed to every flatten — // that is what makes a template chain shared by N instances walked once. _pipeline.Received(1).CreateSession(); await _pipeline.Received(1).FlattenAndValidateAsync(1, Arg.Any(), false, _session); await _pipeline.Received(1).FlattenAndValidateAsync(2, Arg.Any(), false, _session); await _pipeline.Received(1).FlattenAndValidateAsync(3, Arg.Any(), false, _session); } /// Verifies that a matching hash is reported as not stale and no snapshot is read. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_HashMatches_NotStaleAndNoSnapshotRead() { var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash)); ArrangeFlatten(1, config, hash); var result = await _service.GetInstanceStalenessAsync([1]); Assert.False(result[1]); // The whole point of the projection read: an up-to-date fleet never // deserialises a single ConfigurationJson. await _repo.DidNotReceive().GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any()); } /// Verifies that an old-form list snapshot is normalised to not stale. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_OldFormListSnapshot_NormalisesToNotStale() { // I-1 parity with the Topology badge: a List attribute deployed in the OLD // quoted form froze a hash that differs from the native re-flatten's, so // the projection read reports a mismatch. Re-reading the snapshot and // recomputing over the normalised config collapses old form → native, and // the instance is correctly NOT stale. var oldFormConfig = new FlattenedConfiguration { InstanceUniqueName = "ListInst", Attributes = [ new ResolvedAttribute { CanonicalName = "Thresholds", DataType = "List", ElementDataType = "Int32", Value = "[\"10\",\"20\",\"30\"]" // OLD quoted form } ] }; var nativeConfig = oldFormConfig with { Attributes = [oldFormConfig.Attributes[0] with { Value = "[10,20,30]" }] }; var frozenOldFormHash = _hasher.ComputeHash(oldFormConfig); var nativeHash = _hasher.ComputeHash(nativeConfig); // Sanity: without the normalisation this instance WOULD read as stale. Assert.NotEqual(frozenOldFormHash, nativeHash); ArrangeDeployedHashes((50, frozenOldFormHash)); ArrangeSnapshot(50, oldFormConfig, frozenOldFormHash); ArrangeFlatten(50, nativeConfig, nativeHash); var result = await _service.GetInstanceStalenessAsync([50]); Assert.False(result[50]); await _repo.Received(1).GetDeployedSnapshotByInstanceIdAsync(50, Arg.Any()); } /// Verifies that a genuine hash mismatch is reported as stale. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_GenuineMismatch_IsStale() { var deployedConfig = ScalarConfig("5"); var deployedHash = _hasher.ComputeHash(deployedConfig); var currentConfig = ScalarConfig("9"); var currentHash = _hasher.ComputeHash(currentConfig); ArrangeDeployedHashes((1, deployedHash)); ArrangeSnapshot(1, deployedConfig, deployedHash); ArrangeFlatten(1, currentConfig, currentHash); var result = await _service.GetInstanceStalenessAsync([1]); Assert.True(result[1]); } /// Verifies that a failing flatten is reported with unknown staleness. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_FlattenFails_ReportsUnknown() { ArrangeDeployedHashes((1, "sha256:deployed")); _pipeline.FlattenAndValidateAsync( 1, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure("template missing")); var result = await _service.GetInstanceStalenessAsync([1]); // null = could not evaluate. Never counted as stale. Assert.True(result.ContainsKey(1)); Assert.Null(result[1]); } /// Verifies that many flatten failures log exactly one warning. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_ManyFlattenFailures_LogsExactlyOneWarning() { // The batch runs for the whole fleet on a cache-TTL cadence, so one broken // shared template fanning out to N instances must NOT produce N Warning // lines per minute: the per-instance detail drops to Debug and the batch // emits a single summary Warning naming the first failure. ArrangeDeployedHashes((1, "sha256:deployed"), (2, "sha256:deployed")); foreach (var id in new[] { 1, 2 }) { _pipeline.FlattenAndValidateAsync( id, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure($"broken template {id}")); } var result = await _service.GetInstanceStalenessAsync([1, 2]); Assert.Null(result[1]); Assert.Null(result[2]); var warnings = _logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); var warning = Assert.Single(warnings); Assert.Contains("2 of 2", warning.Message); Assert.Contains("broken template 1", warning.Message); // Both per-instance details are still available, one level down. Assert.Equal(2, _logger.Entries.Count(e => e.Level == LogLevel.Debug)); } /// Verifies that no flatten failures log no warning. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_NoFailures_LogsNoWarning() { var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash)); ArrangeFlatten(1, config, hash); await _service.GetInstanceStalenessAsync([1]); Assert.DoesNotContain(_logger.Entries, e => e.Level == LogLevel.Warning); } /// Verifies that an instance without a snapshot is omitted from the result. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_InstanceWithoutSnapshot_IsOmitted() { var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash)); ArrangeFlatten(1, config, hash); var result = await _service.GetInstanceStalenessAsync([1, 2]); Assert.True(result.ContainsKey(1)); // Instance 2 has never been deployed — not stale, not unknown, absent. Assert.False(result.ContainsKey(2)); await _pipeline.DidNotReceive().FlattenAndValidateAsync( 2, Arg.Any(), Arg.Any(), Arg.Any()); } /// Verifies that an instance whose snapshot vanishes between reads is omitted from the result. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_SnapshotVanishedBetweenReads_IsOmitted() { // Undeployed between the projection read and the mismatch's snapshot read: // there is nothing left to be stale against, so report nothing at all. ArrangeDeployedHashes((1, "sha256:deployed")); _repo.GetDeployedSnapshotByInstanceIdAsync(1, Arg.Any()) .Returns((DeployedConfigSnapshot?)null); ArrangeFlatten(1, ScalarConfig("5"), "sha256:current"); var result = await _service.GetInstanceStalenessAsync([1]); Assert.False(result.ContainsKey(1)); } /// Verifies that duplicate instance ids are flattened only once. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_DuplicateIds_FlattenedOnce() { var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash)); ArrangeFlatten(1, config, hash); var result = await _service.GetInstanceStalenessAsync([1, 1, 1]); Assert.Single(result); await _pipeline.Received(1).FlattenAndValidateAsync( 1, Arg.Any(), Arg.Any(), Arg.Any()); } /// Verifies that empty input returns an empty result without any reads. /// A task that represents the asynchronous operation. [Fact] public async Task GetInstanceStalenessAsync_EmptyInput_ReturnsEmptyWithoutReads() { var result = await _service.GetInstanceStalenessAsync([]); Assert.Empty(result); await _repo.DidNotReceive().GetDeployedRevisionHashesAsync(Arg.Any()); } }