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