diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index 67348661..3182fd66 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -8,6 +8,7 @@ using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using NSubstitute.ExceptionExtensions;
+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.Schemas;
@@ -90,6 +91,33 @@ public class ManagementActorTests : TestKit, IDisposable
_services.AddScoped(_ => _artifactService);
}
+ ///
+ /// Registers a REAL over substituted repositories
+ /// (its methods are not virtual, so it cannot be substituted itself — the same
+ /// approach the CentralUI bUnit tests take). Area lookups go through the
+ /// fixture's shared .
+ ///
+ private (IDeploymentManagerRepository DeployRepo, IFlatteningPipeline Pipeline, ISiteRepository SiteRepo) RegisterDeploymentService()
+ {
+ var deployRepo = Substitute.For();
+ var pipeline = Substitute.For();
+ var siteRepo = Substitute.For();
+ pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
+
+ var service = new DeploymentService(
+ deployRepo, siteRepo, _templateRepo, pipeline,
+ new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance),
+ new OperationLockManager(), _auditService,
+ new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService(),
+ new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService(),
+ new DeploymentStatusNotifier(NullLogger.Instance),
+ Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+ _services.AddScoped(_ => service);
+ return (deployRepo, pipeline, siteRepo);
+ }
+
private static Result DeploySummary() =>
Result.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty(), 1, 0));
@@ -1449,6 +1477,82 @@ public class ManagementActorTests : TestKit, IDisposable
ExpectMsg(TimeSpan.FromSeconds(5));
}
+ ///
+ /// The in-scope success path of MgmtDeployArea: the handler resolves the
+ /// REAL (its methods are not virtual, so it runs
+ /// over substituted repositories), relays the command's Force verbatim to
+ /// DeployAreaAsync, and returns the bulk summary as the success payload.
+ ///
+ ///
+ /// Force is observable without a site: the one instance in the area is
+ /// up-to-date, so unforced it is reported as an up-to-date skip; forced it is
+ /// attempted, and the substituted flatten refuses it, so it comes back as a
+ /// failed row — neither path ever reaches the site.
+ ///
+ ///
+ /// The command's Force flag.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DeployArea_InScopeForSiteScopedUser_RelaysForce_AndReturnsSummary(bool force)
+ {
+ var (deployRepo, pipeline, siteRepo) = RegisterDeploymentService();
+
+ var area = new Area("Line") { Id = 12, SiteId = 1 };
+ _templateRepo.GetAreaByIdAsync(12, Arg.Any()).Returns(area);
+ _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any())
+ .Returns((IReadOnlyList)new List { area });
+ siteRepo.GetSiteByIdAsync(1, Arg.Any())
+ .Returns(new Site("Site 1", "site-1") { Id = 1 });
+
+ var instance = new Instance("Pump5") { Id = 5, SiteId = 1, AreaId = 12, TemplateId = 10, State = InstanceState.Enabled };
+ siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any())
+ .Returns((IReadOnlyList)new List { instance });
+ deployRepo.GetInstanceByIdAsync(5, Arg.Any()).Returns(instance);
+
+ // Up-to-date: the deployed snapshot's hash equals the freshly recomputed one.
+ var prior = new DeploymentRecord("prior-5", "admin")
+ {
+ InstanceId = 5, Status = DeploymentStatus.Success, RevisionHash = "sha256:5",
+ };
+ deployRepo.GetDeployedRevisionHashesAsync(Arg.Any())
+ .Returns((IReadOnlyDictionary)new Dictionary { [5] = "sha256:5" });
+ deployRepo.GetCurrentDeploymentStatusesAsync(Arg.Any>(), Arg.Any())
+ .Returns((IReadOnlyDictionary)new Dictionary { [5] = prior });
+ deployRepo.GetCurrentDeploymentStatusAsync(5, Arg.Any()).Returns(prior);
+ pipeline.ComputeRevisionHashAsync(5, Arg.Any(), Arg.Any())
+ .Returns(Result.Success("sha256:5"));
+ pipeline.FlattenAndValidateAsync(5, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Result.Failure("flatten refused by test"));
+
+ var actor = CreateActor();
+ var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(12, force), new[] { "1" }, "Deployer");
+
+ actor.Tell(envelope);
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(10));
+ Assert.Equal(envelope.CorrelationId, response.CorrelationId);
+ var summary = JsonSerializer.Deserialize(
+ response.JsonData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
+ Assert.Equal("site-1", summary.SiteIdentifier);
+
+ if (force)
+ {
+ var row = Assert.Single(summary.InstanceResults);
+ Assert.Equal("Pump5", row.UniqueName);
+ Assert.False(row.Success);
+ Assert.Equal(1, summary.FailureCount);
+ Assert.Empty(summary.SkippedInstances ?? []);
+ }
+ else
+ {
+ Assert.Empty(summary.InstanceResults);
+ var skipped = Assert.Single(summary.SkippedInstances!);
+ Assert.Equal("Pump5", skipped.UniqueName);
+ Assert.Contains("up-to-date", skipped.Reason);
+ }
+ }
+
/// Verifies that get instance, when in scope for site scoped user, returns success.
[Fact]
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()