From 0956395d81cbdaf93d70cda1d72f17c82543e90e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:21:57 -0400 Subject: [PATCH] test(managementservice): MgmtDeployArea in-scope success path relays Force to a real DeploymentService and returns its summary (Gitea #40 review) --- .../ManagementActorTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) 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()