test(managementservice): MgmtDeployArea in-scope success path relays Force to a real DeploymentService and returns its summary (Gitea #40 review)

This commit is contained in:
Joseph Doherty
2026-10-22 14:21:57 -04:00
parent 74d5fba2b0
commit 0956395d81
@@ -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);
}
/// <summary>
/// Registers a REAL <see cref="DeploymentService"/> 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 <see cref="_templateRepo"/>.
/// </summary>
private (IDeploymentManagerRepository DeployRepo, IFlatteningPipeline Pipeline, ISiteRepository SiteRepo) RegisterDeploymentService()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
var pipeline = Substitute.For<IFlatteningPipeline>();
var siteRepo = Substitute.For<ISiteRepository>();
pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
var service = new DeploymentService(
deployRepo, siteRepo, _templateRepo, pipeline,
new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance),
new OperationLockManager(), _auditService,
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService(),
new ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
Options.Create(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.FromSeconds(5) }),
Options.Create(new CommunicationOptions()),
NullLogger<DeploymentService>.Instance);
_services.AddScoped(_ => service);
return (deployRepo, pipeline, siteRepo);
}
private static Result<ArtifactDeploymentSummary> DeploySummary() =>
Result<ArtifactDeploymentSummary>.Success(
new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0));
@@ -1449,6 +1477,82 @@ public class ManagementActorTests : TestKit, IDisposable
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>
/// The in-scope success path of <c>MgmtDeployArea</c>: the handler resolves the
/// REAL <see cref="DeploymentService"/> (its methods are not virtual, so it runs
/// over substituted repositories), relays the command's <c>Force</c> verbatim to
/// <c>DeployAreaAsync</c>, and returns the bulk summary as the success payload.
///
/// <para>
/// <c>Force</c> 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.
/// </para>
/// </summary>
/// <param name="force">The command's <c>Force</c> flag.</param>
[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<CancellationToken>()).Returns(area);
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns((IReadOnlyList<Area>)new List<Area> { area });
siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>())
.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<CancellationToken>())
.Returns((IReadOnlyList<Instance>)new List<Instance> { instance });
deployRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>()).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<CancellationToken>())
.Returns((IReadOnlyDictionary<int, string>)new Dictionary<int, string> { [5] = "sha256:5" });
deployRepo.GetCurrentDeploymentStatusesAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns((IReadOnlyDictionary<int, DeploymentRecord>)new Dictionary<int, DeploymentRecord> { [5] = prior });
deployRepo.GetCurrentDeploymentStatusAsync(5, Arg.Any<CancellationToken>()).Returns(prior);
pipeline.ComputeRevisionHashAsync(5, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success("sha256:5"));
pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Failure("flatten refused by test"));
var actor = CreateActor();
var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(12, force), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(10));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
var summary = JsonSerializer.Deserialize<SiteDeploymentSummary>(
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);
}
}
/// <summary>Verifies that get instance, when in scope for site scoped user, returns success.</summary>
[Fact]
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()