using System.Collections.Concurrent; using Akka.Actor; using Akka.TestKit.Xunit2; 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.Messages.Deployment; 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.TemplateEngine.Flattening; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; /// /// Area-subtree bulk deployment (Gitea #40). DeployAreaAsync is /// DeploySiteAsync narrowed to the instances under one area and all of its /// descendants, so these tests pin the narrowing — descendants in, siblings and /// ancestors out — plus the bulk rules it must inherit unchanged (Disabled always /// skipped, up-to-date skipped unless forced). They also cover the cheap /// pre-confirm counts PreviewSubtreeDeployAsync returns. /// public class DeployAreaAsyncTests : TestKit { private const int SiteId = 1; private const int OtherSiteId = 2; // Site 1: Plant(100) ─┬─ Line1(101) ── Cell(102) // └─ Line2(103) // Warehouse(200) // Site 2: Remote(900) private const int PlantArea = 100; private const int Line1Area = 101; private const int CellArea = 102; private const int Line2Area = 103; private const int WarehouseArea = 200; private const int RemoteArea = 900; private readonly IDeploymentManagerRepository _repo = Substitute.For(); private readonly IFlatteningPipeline _pipeline = Substitute.For(); private readonly ISiteRepository _siteRepo = Substitute.For(); private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); private readonly IAuditService _audit = Substitute.For(); private readonly Dictionary _deployedHashes = new(); private readonly Dictionary _currentRecords = new(); private readonly OperationLockManager _lockManager = new(); private List _siteAreas = new(); /// Initializes the test class with the area tree above and substituted repositories. public DeployAreaAsyncTests() { _siteRepo.GetSiteByIdAsync(Arg.Any(), Arg.Any()) .Returns(ci => ci.ArgAt(0) is SiteId or OtherSiteId ? new Site($"Site {ci.ArgAt(0)}", $"site-{ci.ArgAt(0)}") { Id = ci.ArgAt(0) } : null); _pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark())); _repo.GetDeployedRevisionHashesAsync(Arg.Any()) .Returns(_ => (IReadOnlyDictionary)new Dictionary(_deployedHashes)); _repo.GetCurrentDeploymentStatusesAsync(Arg.Any>(), Arg.Any()) .Returns(ci => (IReadOnlyDictionary)ci.ArgAt>(0) .Where(_currentRecords.ContainsKey) .ToDictionary(id => id, id => _currentRecords[id])); ArrangeAreas( Area(PlantArea, SiteId, null), Area(Line1Area, SiteId, PlantArea), Area(CellArea, SiteId, Line1Area), Area(Line2Area, SiteId, PlantArea), Area(WarehouseArea, SiteId, null)); _templateRepo.GetAreaByIdAsync(RemoteArea, Arg.Any()) .Returns(Area(RemoteArea, OtherSiteId, null)); } private static Area Area(int id, int siteId, int? parentId) => new($"Area-{id}") { Id = id, SiteId = siteId, ParentAreaId = parentId }; /// Replaces site 1's area set (by-id and by-site lookups both answer from it). private void ArrangeAreas(params Area[] areas) { _siteAreas = areas.ToList(); foreach (var area in areas) _templateRepo.GetAreaByIdAsync(area.Id, Arg.Any()).Returns(area); _templateRepo.GetAreasBySiteIdAsync(SiteId, Arg.Any()) .Returns(_ => (IReadOnlyList)_siteAreas); } /// /// Seeds one deployable instance per entry, in the given area (null = no area). /// Instance ids are 1-based in argument order and named Inst-NN. /// private List ArrangeInstances(params int?[] areaIds) { var instances = new List(); for (var i = 1; i <= areaIds.Length; i++) { var instance = new Instance($"Inst-{i:00}") { Id = i, SiteId = SiteId, TemplateId = 10, AreaId = areaIds[i - 1], State = InstanceState.NotDeployed }; instances.Add(instance); _repo.GetInstanceByIdAsync(i, Arg.Any()).Returns(instance); _repo.GetCurrentDeploymentStatusAsync(i, Arg.Any()).Returns((DeploymentRecord?)null); var config = new FlattenedConfiguration { InstanceUniqueName = instance.UniqueName }; _pipeline.FlattenAndValidateAsync( i, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success( new FlatteningPipelineResult(config, $"sha256:{i}", ValidationResult.Success()))); } _siteRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()).Returns(instances); return instances; } /// Makes the instance Enabled with a deployed snapshot whose hash the probe recomputes. private void ArrangeUpToDate(Instance instance) { var hash = $"sha256:{instance.Id}"; instance.State = InstanceState.Enabled; _deployedHashes[instance.Id] = hash; var prior = new DeploymentRecord($"prior-{instance.Id}", "admin") { InstanceId = instance.Id, Status = DeploymentStatus.Success, RevisionHash = hash, }; _currentRecords[instance.Id] = prior; _repo.GetCurrentDeploymentStatusAsync(instance.Id, Arg.Any()).Returns(prior); _pipeline.ComputeRevisionHashAsync(instance.Id, Arg.Any(), Arg.Any()) .Returns(Result.Success(hash)); } private (DeploymentService Service, ConcurrentBag Applied) CreateService() { var applied = new ConcurrentBag(); var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied))); var comms = new CommunicationService( Options.Create(new CommunicationOptions { DeploymentTimeout = TimeSpan.FromSeconds(30) }), NullLogger.Instance); comms.SetCommunicationActor(commActor); var options = Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5), SiteDeploymentMaxParallelism = 3, SiteDeploymentTimeoutPerInstance = TimeSpan.FromSeconds(30), SiteDeploymentReconcileTimeout = TimeSpan.FromSeconds(5) }); var service = new DeploymentService( _repo, _siteRepo, _templateRepo, _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), options, Options.Create(new CommunicationOptions { CentralFetchBaseUrl = "https://central.test:9000", PendingDeploymentTtl = TimeSpan.FromMinutes(5) }), NullLogger.Instance); return (service, applied); } /// /// The subtree is the area plus every descendant at any depth; an instance /// sitting directly in the area counts, and siblings, ancestors, other root /// areas and area-less instances do not. /// /// A task that represents the asynchronous operation. [Fact] public async Task DeployAreaAsync_DeploysAreaAndDescendants_Only() { // 1:Plant 2:Line1 3:Cell 4:Line2 5:Warehouse 6:no area ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea, null); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(Line1Area, "admin"); Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); Assert.Equal("site-1", result.Value.SiteIdentifier); Assert.Equal(new[] { "Inst-02", "Inst-03" }, applied.Order().ToArray()); Assert.Equal(2, result.Value.SuccessCount); Assert.Equal(0, result.Value.FailureCount); Assert.Empty(result.Value.SkippedInstances!); // Nothing outside the subtree was even flattened. foreach (var id in new[] { 1, 4, 5, 6 }) await _pipeline.DidNotReceive().FlattenAndValidateAsync( id, Arg.Any(), Arg.Any(), Arg.Any()); } /// A root area's subtree spans every nested level beneath it. /// A task that represents the asynchronous operation. [Fact] public async Task DeployAreaAsync_RootArea_IncludesEveryNestedLevel() { ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea, null); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(PlantArea, "admin"); Assert.True(result.IsSuccess); Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03", "Inst-04" }, applied.Order().ToArray()); } /// Disabled instances inside the subtree are skipped, forced or not. /// Whether the deploy is forced. /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] public async Task DeployAreaAsync_DisabledInstancesAreSkipped_RegardlessOfForce(bool force) { var instances = ArrangeInstances(Line1Area, CellArea); instances[1].State = InstanceState.Disabled; var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(Line1Area, "admin", force); Assert.True(result.IsSuccess); Assert.Equal(new[] { "Inst-01" }, applied.ToArray()); var skipped = Assert.Single(result.Value.SkippedInstances!); Assert.Equal("Inst-02", skipped.UniqueName); Assert.Contains("deploy individually to re-enable", skipped.Reason); Assert.Equal(InstanceState.Disabled, instances[1].State); } /// Up-to-date instances inside the subtree are skipped unless the deploy is forced. /// Whether the deploy is forced. /// A task that represents the asynchronous operation. [Theory] [InlineData(false)] [InlineData(true)] public async Task DeployAreaAsync_UpToDateInstancesAreSkipped_UnlessForced(bool force) { var instances = ArrangeInstances(Line1Area, CellArea); ArrangeUpToDate(instances[1]); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(Line1Area, "admin", force); Assert.True(result.IsSuccess); if (force) { Assert.Equal(new[] { "Inst-01", "Inst-02" }, applied.Order().ToArray()); Assert.Empty(result.Value.SkippedInstances!); } else { Assert.Equal(new[] { "Inst-01" }, applied.ToArray()); var skipped = Assert.Single(result.Value.SkippedInstances!); Assert.Equal("Inst-02", skipped.UniqueName); Assert.Contains("up-to-date", skipped.Reason); } } /// An unknown area is a failure result, not an empty success. /// A task that represents the asynchronous operation. [Fact] public async Task DeployAreaAsync_UnknownArea_ReturnsFailure() { ArrangeInstances(Line1Area); _templateRepo.GetAreaByIdAsync(4242, Arg.Any()).Returns((Area?)null); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(4242, "admin"); Assert.True(result.IsFailure); Assert.Contains("not found", result.Error); Assert.Empty(applied); } /// An area with no instances anywhere in its subtree is an empty success. /// A task that represents the asynchronous operation. [Fact] public async Task DeployAreaAsync_EmptySubtree_SucceedsWithEmptySummary() { ArrangeInstances(WarehouseArea); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(Line2Area, "admin"); Assert.True(result.IsSuccess); Assert.Empty(result.Value.InstanceResults); Assert.Empty(applied); } /// /// A corrupt parent chain (a cycle) must not hang the walk: the subtree still /// resolves and the deploy completes. /// /// A task that represents the asynchronous operation. [Fact] public async Task DeployAreaAsync_ParentCycle_TerminatesAndDeploysTheReachableSet() { ArrangeAreas( Area(PlantArea, SiteId, CellArea), // Plant -> Cell -> Line1 -> Plant Area(Line1Area, SiteId, PlantArea), Area(CellArea, SiteId, Line1Area), Area(WarehouseArea, SiteId, null)); ArrangeInstances(PlantArea, Line1Area, CellArea, WarehouseArea); var (service, applied) = CreateService(); var result = await service.DeployAreaAsync(Line1Area, "admin") .WaitAsync(TimeSpan.FromSeconds(10)); Assert.True(result.IsSuccess); Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03" }, applied.Order().ToArray()); } /// The whole-site deploy is unaffected by areas: area-less and every-area instances all go. /// A task that represents the asynchronous operation. [Fact] public async Task DeploySiteAsync_StillDeploysEveryInstance_RegardlessOfArea() { ArrangeInstances(PlantArea, CellArea, WarehouseArea, null); var (service, applied) = CreateService(); var result = await service.DeploySiteAsync(SiteId, "admin"); Assert.True(result.IsSuccess); Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03", "Inst-04" }, applied.Order().ToArray()); } /// The whole-site preview counts every instance and the Disabled ones among them. /// A task that represents the asynchronous operation. [Fact] public async Task PreviewSubtreeDeployAsync_WholeSite_CountsAllAndDisabled() { var instances = ArrangeInstances(PlantArea, CellArea, WarehouseArea, null); instances[2].State = InstanceState.Disabled; var (service, _) = CreateService(); var result = await service.PreviewSubtreeDeployAsync(SiteId, null); Assert.True(result.IsSuccess); Assert.Equal(new SubtreeDeployPreview(4, 1), result.Value); // Cheap: no staleness probe, no flatten. _pipeline.DidNotReceive().CreateSession(); } /// The area preview counts exactly the subtree's instances. /// A task that represents the asynchronous operation. [Fact] public async Task PreviewSubtreeDeployAsync_Area_CountsSubtreeOnly() { var instances = ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea); instances[2].State = InstanceState.Disabled; var (service, _) = CreateService(); var result = await service.PreviewSubtreeDeployAsync(SiteId, Line1Area); Assert.True(result.IsSuccess); Assert.Equal(new SubtreeDeployPreview(2, 1), result.Value); } /// An area belonging to a different site, an unknown area, or an unknown site is a failure. /// A task that represents the asynchronous operation. [Fact] public async Task PreviewSubtreeDeployAsync_RejectsForeignOrUnknownAreaAndUnknownSite() { ArrangeInstances(Line1Area); _templateRepo.GetAreaByIdAsync(4242, Arg.Any()).Returns((Area?)null); var (service, _) = CreateService(); Assert.True((await service.PreviewSubtreeDeployAsync(SiteId, RemoteArea)).IsFailure); Assert.True((await service.PreviewSubtreeDeployAsync(SiteId, 4242)).IsFailure); Assert.True((await service.PreviewSubtreeDeployAsync(77, null)).IsFailure); } /// Answers every deploy with Success, recording which instances the site was asked to apply. private sealed class RecordingSiteActor : ReceiveActor { /// Initializes the actor with the collection that records applied instance names. /// The collection that receives each applied instance's unique name. public RecordingSiteActor(ConcurrentBag applied) { Receive(env => { if (env.Message is not RefreshDeploymentCommand cmd) return; applied.Add(cmd.InstanceUniqueName); Sender.Tell(new DeploymentStatusResponse( cmd.DeploymentId, cmd.InstanceUniqueName, DeploymentStatus.Success, null, DateTimeOffset.UtcNow)); }); } } }