Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs
T
Joseph Doherty 41a5ecf14a feat(external-systems): deploy per-system retry policy to sites + CLI/management surface (register row 27)
An external system's MaxRetries/RetryDelay existed on the central entity but never
reached a site and had no management surface, so every ExternalSystem.CachedCall
buffered with the S&F engine default (DefaultMaxRetries 50 x DefaultRetryInterval
30s ~= 25 min to park) whatever central had configured. The sibling
DatabaseConnectionArtifact had always carried its equivalents.

The consumption end was already wired: ExternalSystemClient.CachedCallAsync already
read system.MaxRetries/RetryDelay and passed them to EnqueueAsync with the 0-as-unset
convention. Only the transport and persistence were missing.

Commons
- ExternalSystemArtifact gains MaxRetries/RetryDelay as optional positional params
  (0 / TimeSpan.Zero = unset), matching DatabaseConnectionArtifact rather than going
  nullable, so it maps 1:1 onto the entity and the proto3 scalars.
- Create/UpdateExternalSystemCommand gain nullable MaxRetries/RetryDelay, following
  the existing TimeoutSeconds "omit to preserve" convention. Both additive.

Central
- ManagementActor create maps both; update preserves-if-null.
- ArtifactDeploymentService projects them onto the artifact.

Wire
- site_command.proto ExternalSystemArtifactDto gains max_retries=7 /
  retry_delay=8 (google.protobuf.Duration), mirroring DatabaseConnectionArtifactDto.
  Additive: a pre-existing site decodes them as 0/absent, which IS the unset value,
  so old behaviour is preserved. Vendored C# regenerated via docker/regen-proto.sh
  sitecommand; csproj Protobuf item re-commented, no message types added or removed.

Site
- external_systems gains max_retries / retry_delay_ms via AddColumnIfMissing,
  DEFAULT 0 = unset. Storage upsert, repository SELECT/mapping (new ordinals 6/7
  appended so existing ones are untouched), apply path, and the legacy migrator's
  explicit column list all carry them.

Replicated-table decision: external_systems IS in SiteLocalDbSetup.ReplicatedTables,
so this was checked against the AckTime/native_alarm_state precedent. Real columns
are correct here, not a JSON bag: the table has no general-purpose config column
(auth_configuration and method_definitions are both semantically specific),
timeout_seconds already set the additive-column precedent on this exact table, and
sibling database_connections stores the same two values as plain columns. Verified in
LocalDb source that RegisterReplicated re-reads PRAGMA table_info every boot and its
install script is DROP-then-CREATE, so capture triggers rebuild over the new column
set automatically, and SiteStorageSchema.Apply runs before registration on the same
boot. The only cost is the ReplicatedTable.ComputeDigest change, which the sync
handshake compares fail-closed - a mixed-version pair refuses to sync loudly instead
of diverging silently. Both-nodes-together is already the standing site-pair rule, so
no new operational constraint; recorded in topology-guide.md.

Also fixed: ArtifactDiff.CompareExternalSystem never compared MaxRetries/RetryDelay,
so a bundle import that changed a system's retry budget previewed as "Identical" -
harmless while the values were inert, a live behaviour change now.

No EF migration: the central ExternalSystemDefinitions columns already exist
(InitialSchema). Central UI already had both form fields.

Tests: site storage round-trip + unset-default, site apply path, central artifact
projection, proto round-trip (via the shared sample), management create/preserve,
CLI option parse + alias, and diff reporting. Fail-before verified on the two core
tests (expected 7 got 0 on the read path; expected 9 got 0 on the apply path).

Docs: Component-ExternalSystemGateway, Component-StoreAndForward, Component-CLI,
components/StoreAndForward, components/SiteRuntime, deployment/topology-guide,
CLI README (which was also missing --timeout-seconds entirely).
2026-08-15 12:56:12 -04:00

374 lines
16 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
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.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP-7: Tests for system-wide artifact deployment.
/// </summary>
public class ArtifactDeploymentServiceTests : TestKit
{
private readonly ISiteRepository _siteRepo;
private readonly IDeploymentManagerRepository _deploymentRepo;
private readonly ITemplateEngineRepository _templateRepo;
private readonly IExternalSystemRepository _externalSystemRepo;
private readonly INotificationRepository _notificationRepo;
private readonly IAuditService _audit;
public ArtifactDeploymentServiceTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
_templateRepo = Substitute.For<ITemplateEngineRepository>();
_externalSystemRepo = Substitute.For<IExternalSystemRepository>();
// DeploymentManager-025/-027: the notification repo is retained only so the
// tests can assert the artifact path NEVER touches it (it is no longer a
// constructor dependency of ArtifactDeploymentService).
_notificationRepo = Substitute.For<INotificationRepository>();
_audit = Substitute.For<IAuditService>();
}
[Fact]
public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure()
{
_siteRepo.GetAllSitesAsync().Returns(new List<Site>());
var service = CreateService();
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsFailure);
Assert.Contains("No sites", result.Error);
}
[Fact]
public void SiteArtifactResult_ContainsSiteInfo()
{
var result = new SiteArtifactResult("site1", "Site One", true, null);
Assert.Equal("site1", result.SiteId);
Assert.Equal("Site One", result.SiteName);
Assert.True(result.Success);
Assert.Null(result.ErrorMessage);
}
[Fact]
public void ArtifactDeploymentSummary_CountsCorrectly()
{
var results = new List<SiteArtifactResult>
{
new("s1", "Site1", true, null),
new("s2", "Site2", false, "error"),
new("s3", "Site3", true, null)
};
var summary = new ArtifactDeploymentSummary("dep1", results, 2, 1);
Assert.Equal(2, summary.SuccessCount);
Assert.Equal(1, summary.FailureCount);
Assert.Equal(3, summary.SiteResults.Count);
}
// ── DeploymentManager-010: one logical deployment id across all per-site commands ──
[Fact]
public async Task DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploymentId()
{
// DeploymentManager-010: previously each per-site DeployArtifactsCommand
// minted its own GUID, so one logical deployment produced N+1 unrelated
// ids. Every per-site command must now carry the SAME id, equal to the
// id reported in the summary and audit log.
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
new("Site Two", "site-2") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
var commands = recorder.Received;
Assert.Equal(2, commands.Count);
// All per-site commands carry one shared id, equal to the summary id.
var distinctIds = commands.Select(c => c.DeploymentId).Distinct().ToList();
Assert.Single(distinctIds);
Assert.Equal(result.Value.DeploymentId, distinctIds[0]);
// The persisted record embeds the same logical deployment id.
await _deploymentRepo.Received().AddSystemArtifactDeploymentAsync(
Arg.Do<SystemArtifactDeploymentRecord>(r =>
{
using var doc = JsonDocument.Parse(r.PerSiteStatus!);
Assert.Equal(result.Value.DeploymentId,
doc.RootElement.GetProperty("DeploymentId").GetString());
}),
Arg.Any<CancellationToken>());
}
// ── DeploymentManager-014: real per-site success/failure coverage ──
[Fact]
public async Task DeployToAllSitesAsync_PartialFailure_ReportsPerSiteMatrix()
{
// Site one succeeds, site two fails -> the summary counts must reflect
// the per-site matrix.
var sites = new List<Site>
{
new("Site One", "ok-site") { Id = 1 },
new("Site Two", "fail-site") { Id = 2 }
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.SuccessCount);
Assert.Equal(1, result.Value.FailureCount);
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "ok-site" && r.Success);
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "fail-site" && !r.Success);
}
// ── DeploymentManager-023: global artifact queries hoisted out of the per-site loop ──
[Fact]
public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop()
{
// DeploymentManager-023: previously each per-site iteration of the deploy-many
// loop re-issued the global artifact queries (shared scripts, external systems,
// DB connections) — a textbook N+1 over the global sets. With three sites the
// queries must now be issued ONCE in total, regardless of site count.
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
new("Site Two", "site-2") { Id = 2 },
new("Site Three", "site-3") { Id = 3 },
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
// Each global query must be called EXACTLY ONCE for the whole multi-site sweep.
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: notification lists and SMTP configuration are
// central-only — the artifact path must NEVER fetch them, and the per-site
// command must NEVER carry them.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
Assert.All(recorder.Received, cmd =>
{
Assert.Null(cmd.NotificationLists);
Assert.Null(cmd.SmtpConfigurations);
});
// The per-site query (data connections) DOES vary per site and must still run
// once per site.
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
}
/// <summary>
/// Deferred-work row 27(a): the per-system retry policy must be projected onto the
/// artifact that ships to sites. Before the fix <c>ExternalSystemArtifact</c> had no such
/// fields, so a centrally-configured policy never left central and every site cached call
/// silently used the engine default (50 x 30 s) — while the sibling
/// <c>DatabaseConnectionArtifact</c> had always carried its equivalents.
/// </summary>
[Fact]
public async Task DeployedExternalSystemArtifact_CarriesRetryPolicy()
{
_externalSystemRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()).Returns(
[
new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
{
Id = 1,
MaxRetries = 6,
RetryDelay = TimeSpan.FromSeconds(120),
},
]);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
Assert.True(result.IsSuccess);
var artifact = Assert.Single(Assert.Single(recorder.Received).ExternalSystems!);
Assert.Equal(6, artifact.MaxRetries);
Assert.Equal(TimeSpan.FromSeconds(120), artifact.RetryDelay);
}
[Fact]
public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce()
{
// DeploymentManager-023: the single-site convenience overload still owns its
// own global-fetch (it cannot inherit from a sweep), so for one site every
// global query is issued exactly once. Pin this so a future refactor cannot
// accidentally route RetryForSiteAsync through the multi-site loop and lose
// the audit row's deploymentId guarantee.
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
Assert.True(result.IsSuccess);
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
// DeploymentManager-025/-027: central-only — never fetched, never shipped.
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
Assert.All(recorder.Received, cmd =>
{
Assert.Null(cmd.NotificationLists);
Assert.Null(cmd.SmtpConfigurations);
});
}
[Fact]
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
{
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("retry-site", result.Value.SiteId);
await _audit.Received().LogAsync(
"admin", "RetryArtifactDeployment", "SystemArtifact",
Arg.Any<string>(), "retry-site", Arg.Any<object>(), Arg.Any<CancellationToken>());
}
// ── PLAN-07 Task 4: single-site artifact deployment ──
[Fact]
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
{
// Two sites exist; deploying to site 1 must build/send exactly one per-site
// command (for SITE1) and never touch SITE2's per-site data.
var site1 = new Site("Site One", "SITE1") { Id = 1 };
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site1);
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToSiteAsync(1, "tester");
Assert.True(result.IsSuccess);
Assert.Single(result.Value.SiteResults);
Assert.Equal("SITE1", result.Value.SiteResults[0].SiteId);
Assert.Single(recorder.Received);
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
{
_siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Site?)null);
var service = CreateService();
var result = await service.DeployToSiteAsync(999, "tester");
Assert.True(result.IsFailure);
Assert.Contains("999", result.Error);
}
private ArtifactDeploymentService CreateService()
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
return new ArtifactDeploymentService(
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
comms, _audit,
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance);
}
private ArtifactDeploymentService CreateServiceWithCommActor(IActorRef commActor)
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions
{
ArtifactDeploymentTimeout = TimeSpan.FromSeconds(5)
}),
NullLogger<CommunicationService>.Instance);
comms.SetCommunicationActor(commActor);
return new ArtifactDeploymentService(
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
comms, _audit,
Options.Create(new DeploymentManagerOptions
{
ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5)
}),
NullLogger<ArtifactDeploymentService>.Instance);
}
/// <summary>
/// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024:
/// each test owns its own instance, passed into the actor's constructor, so
/// the received-command list is no longer shared static state that races
/// under parallel test execution.
/// </summary>
private sealed class ArtifactProbeRecorder
{
public readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
}
/// <summary>
/// Stand-in CentralCommunicationActor for artifact deployment. Records every
/// <see cref="DeployArtifactsCommand"/> it receives into the per-test
/// <see cref="ArtifactProbeRecorder"/> and replies success unless the target
/// site id is in the configured failure set.
/// </summary>
private class ArtifactProbeActor : ReceiveActor
{
public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites)
{
var failSet = new HashSet<string>(failingSites);
Receive<SiteEnvelope>(env =>
{
if (env.Message is DeployArtifactsCommand cmd)
{
recorder.Received.Add(cmd);
var success = !failSet.Contains(env.SiteId);
Sender.Tell(new ArtifactDeploymentResponse(
cmd.DeploymentId, env.SiteId, success,
success ? null : "site rejected artifacts", DateTimeOffset.UtcNow));
}
});
}
}
}