diff --git a/docs/requirements/Component-DeploymentManager.md b/docs/requirements/Component-DeploymentManager.md
index 17173f37..f13de138 100644
--- a/docs/requirements/Component-DeploymentManager.md
+++ b/docs/requirements/Component-DeploymentManager.md
@@ -219,6 +219,30 @@ runs for it. An escape from phase 1 or phase 3 unwinds every not-yet-finalised
entry the same way: `Failed` status plus lock release, so no instance touched by
a cancelled bulk deploy can be left holding its lock.
+### Area-subtree deployment (`DeployAreaAsync`, Gitea #40)
+
+`DeployAreaAsync(areaId, user, force)` is the same bulk deploy narrowed to one
+area **subtree** — the area itself plus every area nested beneath it at any
+depth — surfaced as the `MgmtDeployArea` management command, the CLI
+`deploy area --id`, and the Central UI Topology page's **Deploy all…** context
+item on area nodes (the site node's item calls `DeploySiteAsync`). Both entry
+points run one private core (`DeployInstancesOfSiteAsync`) that loads the site's
+instances, keeps only those whose `AreaId` is in the subtree (area-less instances
+belong to the whole-site scope only), and then applies everything above
+unchanged: the Disabled skip (even under `force`), the up-to-date skip unless
+`force`, the three phases, not-all-or-nothing, and the same
+`SiteDeploymentSummary` with `SkippedInstances`. An unknown area is a failure
+result, not an empty success.
+
+The subtree is resolved from ONE read of the site's areas
+(`GetAreasBySiteIdAsync`) walked in memory over `ParentAreaId`; each area is
+visited at most once, so a corrupt parent chain that loops cannot hang the walk.
+`PreviewSubtreeDeployAsync(siteId, areaId?)` shares that walk to return the
+confirm dialog's `SubtreeDeployPreview(Total, Disabled)` counts (`areaId` null =
+the whole site; an area of another site is a failure) — a plain DB read with no
+staleness probe, so the up-to-date count is only known once the deploy reports
+its skipped rows.
+
`DeployInstanceAsync` is composed from the same three phase helpers with a batch of
one, so the two entry points cannot drift on deployment identity, idempotency, lock
coverage, or optimistic concurrency.
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
index 936f620f..4b72c434 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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;
@@ -38,6 +39,7 @@ public class DeploymentService
{
private readonly IDeploymentManagerRepository _repository;
private readonly ISiteRepository _siteRepository;
+ private readonly ITemplateEngineRepository _templateRepository;
private readonly IFlatteningPipeline _flatteningPipeline;
private readonly CommunicationService _communicationService;
private readonly OperationLockManager _lockManager;
@@ -96,6 +98,11 @@ public class DeploymentService
///
/// Repository for deployment manager data access.
/// Repository for site data access.
+ ///
+ /// Repository for area lookups — the area-subtree bulk deploy
+ /// () and its pre-confirm counts resolve the area
+ /// tree through it.
+ ///
/// Pipeline for flattening and validating template configurations.
/// Service for cross-cluster communication with sites.
/// Manager for per-instance operation locks.
@@ -119,6 +126,7 @@ public class DeploymentService
public DeploymentService(
IDeploymentManagerRepository repository,
ISiteRepository siteRepository,
+ ITemplateEngineRepository templateRepository,
IFlatteningPipeline flatteningPipeline,
CommunicationService communicationService,
OperationLockManager lockManager,
@@ -132,6 +140,7 @@ public class DeploymentService
{
_repository = repository;
_siteRepository = siteRepository;
+ _templateRepository = templateRepository;
_flatteningPipeline = flatteningPipeline;
_communicationService = communicationService;
_lockManager = lockManager;
@@ -938,7 +947,37 @@ public class DeploymentService
if (site == null)
return Result.Failure($"Site with ID {siteId} not found.");
- var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
+ return await DeployInstancesOfSiteAsync(site, areaFilter: null, scope: "site", user, force, cancellationToken);
+ }
+
+ ///
+ /// The body of every bulk deploy — whole site ()
+ /// or one area subtree (). Loads the site's
+ /// instances, narrows them to when one is given,
+ /// then applies the Disabled skip, the up-to-date pre-filter and the three
+ /// phases exactly as documented on .
+ ///
+ /// The resolved site whose instances are deployed.
+ ///
+ /// Area ids an instance's must be in to be part of
+ /// the batch, or null for every instance at the site (area-less ones included).
+ ///
+ /// Human-readable batch scope for the log lines (e.g. site, area 12).
+ /// The username initiating the deployment.
+ /// Suppresses the up-to-date filter; never the Disabled one.
+ /// Cancellation token for the operation.
+ /// The per-instance result matrix.
+ private async Task> DeployInstancesOfSiteAsync(
+ Site site,
+ IReadOnlySet? areaFilter,
+ string scope,
+ string user,
+ bool force,
+ CancellationToken cancellationToken)
+ {
+ IReadOnlyList instances = await _siteRepository.GetInstancesBySiteIdAsync(site.Id, cancellationToken);
+ if (areaFilter != null)
+ instances = instances.Where(i => i.AreaId is { } areaId && areaFilter.Contains(areaId)).ToList();
if (instances.Count == 0)
return Result.Success(new SiteDeploymentSummary(site.SiteIdentifier, [], 0, 0, []));
@@ -1017,8 +1056,8 @@ public class DeploymentService
// fails only this instance. Recording it and moving on is what
// makes a bulk deploy usable while individual instances are busy.
_logger.LogWarning(ex,
- "Preparing instance {Instance} for bulk deployment of site {SiteId} failed",
- instance.UniqueName, site.SiteIdentifier);
+ "Preparing instance {Instance} for bulk deployment of site {SiteId} ({Scope}) failed",
+ instance.UniqueName, site.SiteIdentifier, scope);
results.Add(new InstanceDeploymentResult(
instance.Id, instance.UniqueName, null, false, ex.Message));
continue;
@@ -1101,12 +1140,12 @@ public class DeploymentService
}
_logger.LogInformation(
- "Bulk deployment of site {SiteId} complete: {SuccessCount} succeeded, {FailureCount} failed, " +
+ "Bulk deployment of site {SiteId} ({Scope}) complete: {SuccessCount} succeeded, {FailureCount} failed, " +
"{DisabledCount} skipped as Disabled, {UpToDateCount} skipped as up-to-date (force={Force}) " +
"({ChainLoads} template-chain load(s) across {InstanceCount} deployed instance(s); " +
"up-to-date pre-filter probed {ProbedCount} instance(s) in {PreFilterMs} ms with " +
"{PreFilterChainLoads} of those chain load(s))",
- site.SiteIdentifier, summary.SuccessCount, summary.FailureCount, disabledCount, upToDateCount,
+ site.SiteIdentifier, scope, summary.SuccessCount, summary.FailureCount, disabledCount, upToDateCount,
force, session.ChainLoads, deployable.Count,
preFilterProbed, preFilterTimer.ElapsedMilliseconds, preFilterChainLoads);
@@ -1124,12 +1163,25 @@ public class DeploymentService
/// Deploy up-to-date instances too; never re-enables Disabled ones.
/// Cancellation token for the operation.
/// The same per-instance result matrix as a site deploy, or a failure when the area cannot be resolved.
- public Task> DeployAreaAsync(
+ public async Task> DeployAreaAsync(
int areaId,
string user,
bool force = false,
CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
+ {
+ var area = await _templateRepository.GetAreaByIdAsync(areaId, cancellationToken);
+ if (area == null)
+ return Result.Failure($"Area with ID {areaId} not found.");
+
+ var site = await _siteRepository.GetSiteByIdAsync(area.SiteId, cancellationToken);
+ if (site == null)
+ return Result.Failure(
+ $"Site with ID {area.SiteId} (owner of area {areaId}) not found.");
+
+ var subtree = await GetAreaSubtreeIdsAsync(area, cancellationToken);
+ return await DeployInstancesOfSiteAsync(
+ site, subtree, scope: $"area {area.Id} '{area.Name}'", user, force, cancellationToken);
+ }
///
/// Cheap pre-confirm counts for a site or area subtree deploy (Gitea #40): how
@@ -1140,11 +1192,71 @@ public class DeploymentService
/// The subtree's root area, or null for the whole site.
/// Cancellation token for the operation.
/// The counts, or a failure when the site or area cannot be resolved.
- public Task> PreviewSubtreeDeployAsync(
+ public async Task> PreviewSubtreeDeployAsync(
int siteId,
int? areaId,
CancellationToken cancellationToken = default)
- => throw new NotImplementedException();
+ {
+ var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
+ if (site == null)
+ return Result.Failure($"Site with ID {siteId} not found.");
+
+ IReadOnlySet? subtree = null;
+ if (areaId is { } id)
+ {
+ var area = await _templateRepository.GetAreaByIdAsync(id, cancellationToken);
+ if (area == null)
+ return Result.Failure($"Area with ID {id} not found.");
+ if (area.SiteId != siteId)
+ return Result.Failure(
+ $"Area with ID {id} does not belong to site with ID {siteId}.");
+ subtree = await GetAreaSubtreeIdsAsync(area, cancellationToken);
+ }
+
+ var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
+ var total = 0;
+ var disabled = 0;
+ foreach (var instance in instances)
+ {
+ if (subtree != null && !(instance.AreaId is { } a && subtree.Contains(a)))
+ continue;
+ total++;
+ if (instance.State == InstanceState.Disabled)
+ disabled++;
+ }
+
+ return Result.Success(new SubtreeDeployPreview(total, disabled));
+ }
+
+ ///
+ /// The ids of and every area nested beneath it at any
+ /// depth, from ONE read of the site's areas walked in memory over
+ /// . Shared by and
+ /// so the confirm dialog's counts and the
+ /// deploy that follows always agree on the subtree. A corrupt parent chain that
+ /// loops is tolerated: each area is visited at most once.
+ ///
+ private async Task> GetAreaSubtreeIdsAsync(Area root, CancellationToken cancellationToken)
+ {
+ var areas = await _templateRepository.GetAreasBySiteIdAsync(root.SiteId, cancellationToken);
+ var childrenByParent = areas
+ .Where(a => a.ParentAreaId.HasValue)
+ .ToLookup(a => a.ParentAreaId!.Value, a => a.Id);
+
+ var subtree = new HashSet { root.Id };
+ var pending = new Queue();
+ pending.Enqueue(root.Id);
+ while (pending.Count > 0)
+ {
+ foreach (var childId in childrenByParent[pending.Dequeue()])
+ {
+ if (subtree.Add(childId))
+ pending.Enqueue(childId);
+ }
+ }
+
+ return subtree;
+ }
///
/// True when a bulk site deploy should leave this instance alone because the
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs
new file mode 100644
index 00000000..f082b7d5
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs
@@ -0,0 +1,409 @@
+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));
+ });
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
index 22d2d51e..d4fb73b7 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs
@@ -107,7 +107,7 @@ public class DeploySiteAsyncTests : TestKit
});
return new DeploymentService(
- _repo, _siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, _siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
index 8d710680..45c59a63 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs
@@ -61,6 +61,7 @@ public class DeploymentServiceStalenessTests
_service = new DeploymentService(
_repo,
siteRepo,
+ Substitute.For(),
_pipeline,
new CommunicationService(
Options.Create(new CommunicationOptions()),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
index 9d7fbfe7..6a75c534 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs
@@ -51,7 +51,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
_service = new DeploymentService(
- _repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -196,7 +196,7 @@ public class DeploymentServiceTests : TestKit
siteRepo.GetSiteByIdAsync(42, Arg.Any()).Returns((Site?)null);
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -428,7 +428,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -1243,7 +1243,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
return new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -1862,7 +1862,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -1922,7 +1922,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
var deadline = TimeSpan.FromMilliseconds(300);
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -1973,7 +1973,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
@@ -2021,7 +2021,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService(
- _repo, siteRepo, _pipeline, comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger.Instance),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
index f45e3620..89b66629 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs
@@ -60,7 +60,7 @@ public class DeploymentStatusNotifierTests : TestKit
});
_service = new DeploymentService(
- _repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
+ _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit,
new DiffService(), new RevisionHashService(), _notifier,
options,
// Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
index a000aec7..575d1201 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs
@@ -73,6 +73,7 @@ public class PendingUpdatesEvaluatorTests
_deploymentService = new DeploymentService(
_deploymentRepo,
_siteRepo,
+ Substitute.For(),
_pipeline,
comms,
new OperationLockManager(),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
index 3bf83e76..ad364c2c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs
@@ -12,6 +12,7 @@ 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.Entities.Templates;
+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;
@@ -206,7 +207,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
.Returns(Result.Failure("indeterminate"));
return new DeploymentService(
- repository, siteRepository, pipeline, comms, new OperationLockManager(),
+ repository, siteRepository, Substitute.For(), pipeline, comms, new OperationLockManager(),
Substitute.For(),
new DiffService(),
new RevisionHashService(),