using System.Text.Json; 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.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.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; 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.DeploymentManager.PendingUpdates; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Coverage for — the single fleet-wide read /// behind the Central UI rail indicator and the Sites page badges. /// /// /// The evaluator is built over the REAL and /// (substituting only their repository and flattening /// seams, as the sibling suites do), because the two properties worth pinning are /// exactly the ones a mocked collaborator would paper over: that the artifact state /// is decided by comparing the site's STORED hash against a hash recomputed from the /// current definitions, and that only instances the operator has actually deployed /// are ever handed to the staleness batch. /// /// public class PendingUpdatesEvaluatorTests { private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); private readonly IExternalSystemRepository _externalSystemRepo = Substitute.For(); private readonly INotificationRepository _notificationRepo = Substitute.For(); private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly IAuditService _audit = Substitute.For(); private readonly RevisionHashService _hasher = new(); private readonly ArtifactRevisionHashService _artifactHasher = new(); private readonly ArtifactDeploymentService _artifactService; private readonly DeploymentService _deploymentService; private readonly PendingUpdatesEvaluator _evaluator; /// /// Builds the evaluator over the real services; every repository and the /// flattening pipeline are substituted. /// public PendingUpdatesEvaluatorTests() { _pipeline.CreateSession().Returns(new FlattenSession(new TemplateGraphWatermark())); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); _artifactService = new ArtifactDeploymentService( _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo, comms, _audit, Options.Create(new DeploymentManagerOptions()), NullLogger.Instance, _artifactHasher); _deploymentService = new DeploymentService( _deploymentRepo, _siteRepo, _pipeline, comms, new OperationLockManager(), _audit, new DiffService(), _hasher, new DeploymentStatusNotifier(NullLogger.Instance), Substitute.For(), Options.Create(new DeploymentManagerOptions()), Options.Create(new CommunicationOptions()), NullLogger.Instance); _evaluator = new PendingUpdatesEvaluator( _siteRepo, _templateRepo, _artifactService, _deploymentService, NullLogger.Instance); } // ── arrange helpers ────────────────────────────────────────────────────── /// Stubs the site list read. private void ArrangeSites(params Site[] sites) => _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(sites); /// Stubs the instance list read. private void ArrangeInstances(params Instance[] instances) => _templateRepo.GetAllInstancesAsync(Arg.Any()).Returns(instances); /// Builds an instance in the given lifecycle state at the given site. private static Instance MakeInstance(int id, int siteId, InstanceState state) => new($"Inst{id}") { Id = id, SiteId = siteId, State = state }; /// 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 at the given hash. 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 a FAILED flatten of — the "unknown" leg. private void ArrangeFlattenFailure(int instanceId) => _pipeline.FlattenAndValidateAsync( instanceId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure("template missing")); /// Stubs the (InstanceId, RevisionHash) projection read. private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) => _deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any()) .Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash)); /// Stubs the fat snapshot read for . private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) => _deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any()) .Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config)) { InstanceId = instanceId, DeployedAt = DateTimeOffset.UtcNow }); /// /// Asks the artifact service what the site's hash WOULD be right now — the value a /// successful deploy would have stamped, i.e. what makes a site read Current. /// private async Task CurrentArtifactHashAsync(Site site) => (await _artifactService.ComputeCurrentArtifactHashesAsync([site]))[site.Id]; // ── artifact state ─────────────────────────────────────────────────────── [Fact] public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate() { var site = new Site("Alpha", "site-a") { Id = 1 }; ArrangeSites(site); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); var row = Assert.Single(snapshot.Sites); Assert.Equal(ArtifactDeploymentState.NeverDeployed, row.ArtifactState); Assert.Null(row.ArtifactsDeployedAt); // NeverDeployed is the operator not having deployed yet, not a pending // update: it is surfaced on the Sites page but never counted or nagged about. Assert.Equal(0, snapshot.StaleArtifactSiteCount); Assert.False(snapshot.HasPendingUpdates); } [Fact] public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent() { var site = new Site("Alpha", "site-a") { Id = 1 }; var deployedAt = DateTimeOffset.UtcNow.AddHours(-2); site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site); site.ArtifactsDeployedAt = deployedAt; ArrangeSites(site); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); var row = Assert.Single(snapshot.Sites); Assert.Equal(ArtifactDeploymentState.Current, row.ArtifactState); Assert.Equal(deployedAt, row.ArtifactsDeployedAt); Assert.Equal(0, snapshot.StaleArtifactSiteCount); Assert.False(snapshot.HasPendingUpdates); } [Fact] public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted() { var site = new Site("Alpha", "site-a") { Id = 1 }; site.ArtifactRevisionHash = "a-hash-from-an-older-artifact-set"; site.ArtifactsDeployedAt = DateTimeOffset.UtcNow.AddDays(-3); ArrangeSites(site); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); var row = Assert.Single(snapshot.Sites); Assert.Equal(ArtifactDeploymentState.OutOfDate, row.ArtifactState); Assert.Equal(1, snapshot.StaleArtifactSiteCount); Assert.True(snapshot.HasPendingUpdates); } [Fact] public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow() { var site = new Site("Alpha Plant", "site-a") { Id = 7 }; ArrangeSites(site); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); var row = Assert.Single(snapshot.Sites); Assert.Equal(7, row.SiteId); Assert.Equal("site-a", row.SiteIdentifier); Assert.Equal("Alpha Plant", row.SiteName); } // ── instance counts ────────────────────────────────────────────────────── [Fact] public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite() { var site = new Site("Alpha", "site-a") { Id = 1 }; ArrangeSites(site); // 1 = deployed + stale, 2 = deployed but un-evaluatable, 3 = never deployed. ArrangeInstances( MakeInstance(1, 1, InstanceState.Enabled), MakeInstance(2, 1, InstanceState.Disabled), MakeInstance(3, 1, InstanceState.NotDeployed)); var deployedConfig = ScalarConfig("5"); var deployedHash = _hasher.ComputeHash(deployedConfig); ArrangeDeployedHashes((1, deployedHash), (2, deployedHash)); ArrangeFlatten(1, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9"))); ArrangeSnapshot(1, deployedConfig, deployedHash); ArrangeFlattenFailure(2); var snapshot = await _evaluator.EvaluateAsync(); var row = Assert.Single(snapshot.Sites); Assert.Equal(2, row.DeployedInstanceCount); Assert.Equal(1, row.StaleInstanceCount); Assert.Equal(1, row.UnknownInstanceCount); Assert.Equal(1, snapshot.StaleInstanceCount); Assert.True(snapshot.HasPendingUpdates); } [Fact] public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch() { var site = new Site("Alpha", "site-a") { Id = 1 }; ArrangeSites(site); ArrangeInstances( MakeInstance(1, 1, InstanceState.Enabled), MakeInstance(2, 1, InstanceState.NotDeployed)); var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); // Instance 2 deliberately HAS a leftover deployed-hash row (an undeploy that // left the snapshot behind), so the staleness batch would happily evaluate it // if its id were passed — the filter under test is the State gate, not the // batch's own omission of instances with no snapshot. ArrangeDeployedHashes((1, hash), (2, hash)); ArrangeFlatten(1, config, hash); ArrangeFlatten(2, config, hash); var snapshot = await _evaluator.EvaluateAsync(); // A NotDeployed instance has nothing to be stale against; flattening it would // be pure cost on every TTL expiry, so it must never reach the batch. await _pipeline.Received(1).FlattenAndValidateAsync( 1, Arg.Any(), Arg.Any(), Arg.Any()); await _pipeline.DidNotReceive().FlattenAndValidateAsync( 2, Arg.Any(), Arg.Any(), Arg.Any()); Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount); } [Fact] public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite() { var alpha = new Site("Alpha", "site-a") { Id = 1 }; var bravo = new Site("Bravo", "site-b") { Id = 2 }; ArrangeSites(alpha, bravo); ArrangeInstances( MakeInstance(1, 1, InstanceState.Enabled), MakeInstance(2, 2, InstanceState.Enabled)); var deployedConfig = ScalarConfig("5"); var deployedHash = _hasher.ComputeHash(deployedConfig); ArrangeDeployedHashes((1, deployedHash), (2, deployedHash)); ArrangeFlatten(1, deployedConfig, deployedHash); ArrangeFlatten(2, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9"))); ArrangeSnapshot(2, deployedConfig, deployedHash); var snapshot = await _evaluator.EvaluateAsync(); Assert.Equal(0, snapshot.Sites.Single(s => s.SiteId == 1).StaleInstanceCount); Assert.Equal(1, snapshot.Sites.Single(s => s.SiteId == 2).StaleInstanceCount); Assert.Equal(1, snapshot.StaleInstanceCount); } [Fact] public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates() { var site = new Site("Alpha", "site-a") { Id = 1 }; site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site); ArrangeSites(site); ArrangeInstances(MakeInstance(1, 1, InstanceState.Enabled)); var config = ScalarConfig("5"); var hash = _hasher.ComputeHash(config); ArrangeDeployedHashes((1, hash)); ArrangeFlatten(1, config, hash); var snapshot = await _evaluator.EvaluateAsync(); Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount); Assert.Equal(0, snapshot.StaleInstanceCount); Assert.Equal(0, snapshot.StaleArtifactSiteCount); Assert.False(snapshot.HasPendingUpdates); } // ── ordering, ComputedAt, ForSites ─────────────────────────────────────── [Fact] public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase() { ArrangeSites( new Site("charlie", "site-c") { Id = 3 }, new Site("Alpha", "site-a") { Id = 1 }, new Site("bravo", "site-b") { Id = 2 }); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName)); } [Fact] public async Task EvaluateAsync_StampsComputedAt() { var before = DateTimeOffset.UtcNow; ArrangeSites(new Site("Alpha", "site-a") { Id = 1 }); ArrangeInstances(); var snapshot = await _evaluator.EvaluateAsync(); Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow); } [Fact] public async Task EvaluateAsync_ExceptionsPropagate() { _siteRepo.GetAllSitesAsync(Arg.Any()) .Returns>(_ => throw new InvalidOperationException("db down")); // The caller's single-flight cache discards a faulted flight and the UI // renders "unavailable"; swallowing here would make a broken fleet read // "up to date", which is the one answer that must never be invented. var ex = await Assert.ThrowsAsync(() => _evaluator.EvaluateAsync()); Assert.Equal("db down", ex.Message); } [Fact] public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt() { var computedAt = DateTimeOffset.UtcNow.AddMinutes(-1); var snapshot = new PendingUpdatesSnapshot(computedAt, [ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0), new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.Current, null, 1, 1, 0), new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.OutOfDate, null, 0, 0, 0) ]); var scoped = snapshot.ForSites(new HashSet { 2 }); Assert.Equal(computedAt, scoped.ComputedAt); var row = Assert.Single(scoped.Sites); Assert.Equal(2, row.SiteId); // The totals are recomputed over the kept sites, so a site-scoped user is // never shown a count that includes a site they cannot see. Assert.Equal(1, scoped.StaleInstanceCount); Assert.Equal(0, scoped.StaleArtifactSiteCount); Assert.True(scoped.HasPendingUpdates); } [Fact] public void ForSites_EmptyScope_YieldsNoPendingUpdates() { var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0) ]); var scoped = snapshot.ForSites(new HashSet()); Assert.Empty(scoped.Sites); Assert.False(scoped.HasPendingUpdates); } [Fact] public void Snapshot_Totals_SumAcrossSites() { var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [ new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 4, 2, 1), new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.NeverDeployed, null, 0, 0, 0), new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.Current, null, 3, 3, 0) ]); Assert.Equal(5, snapshot.StaleInstanceCount); // NeverDeployed is deliberately not counted alongside OutOfDate. Assert.Equal(1, snapshot.StaleArtifactSiteCount); Assert.True(snapshot.HasPendingUpdates); } }