refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+289
@@ -0,0 +1,289 @@
|
||||
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;
|
||||
|
||||
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>();
|
||||
_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, notification lists, SMTP configs) — 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>());
|
||||
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
|
||||
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
|
||||
// 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>());
|
||||
}
|
||||
|
||||
[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>());
|
||||
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
|
||||
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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>());
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Tests for deployed vs template-derived state comparison.
|
||||
/// </summary>
|
||||
public class DeploymentComparisonTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeploymentComparisonResult_MatchingHashes_NotStale()
|
||||
{
|
||||
var result = new DeploymentComparisonResult(
|
||||
1, "sha256:abc", "sha256:abc", false, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.False(result.IsStale);
|
||||
Assert.Equal("sha256:abc", result.DeployedRevisionHash);
|
||||
Assert.Equal("sha256:abc", result.CurrentRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentComparisonResult_DifferentHashes_IsStale()
|
||||
{
|
||||
var result = new DeploymentComparisonResult(
|
||||
1, "sha256:old", "sha256:new", true, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(result.IsStale);
|
||||
Assert.NotEqual(result.DeployedRevisionHash, result.CurrentRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentComparisonResult_ContainsDeployedTimestamp()
|
||||
{
|
||||
var deployedAt = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero);
|
||||
var result = new DeploymentComparisonResult(1, "h1", "h2", true, deployedAt);
|
||||
|
||||
Assert.Equal(deployedAt, result.DeployedAt);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
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.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-006: regression tests proving <see cref="DeploymentService"/>
|
||||
/// raises <see cref="IDeploymentStatusNotifier.StatusChanged"/> whenever it
|
||||
/// writes a deployment record's status. This is the event source the Central
|
||||
/// UI deployment-status page subscribes to instead of polling.
|
||||
/// </summary>
|
||||
public class DeploymentStatusNotifierTests : TestKit
|
||||
{
|
||||
private readonly IDeploymentManagerRepository _repo;
|
||||
private readonly IFlatteningPipeline _pipeline;
|
||||
private readonly CommunicationService _comms;
|
||||
private readonly OperationLockManager _lockManager;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly DeploymentStatusNotifier _notifier;
|
||||
private readonly DeploymentService _service;
|
||||
|
||||
public DeploymentStatusNotifierTests()
|
||||
{
|
||||
_repo = Substitute.For<IDeploymentManagerRepository>();
|
||||
_pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
_comms = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
_lockManager = new OperationLockManager();
|
||||
_audit = Substitute.For<IAuditService>();
|
||||
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
|
||||
var options = Options.Create(new DeploymentManagerOptions
|
||||
{
|
||||
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||
});
|
||||
|
||||
// DeploymentManager-021: the resolver now throws when the site row
|
||||
// is missing, so seed the substitute to return a real-shaped Site for
|
||||
// any id these tests touch.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var id = callInfo.ArgAt<int>(0);
|
||||
return new Site($"Test Site {id}", $"site-{id}") { Id = id };
|
||||
});
|
||||
|
||||
_service = new DeploymentService(
|
||||
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
|
||||
new DiffService(), _notifier, options,
|
||||
NullLogger<DeploymentService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployInstanceAsync_RaisesStatusChange_ForEveryRecordStatusWrite()
|
||||
{
|
||||
var instance = new Instance("TestInst") { Id = 7, SiteId = 1, State = InstanceState.NotDeployed };
|
||||
_repo.GetInstanceByIdAsync(7).Returns(instance);
|
||||
|
||||
var config = new FlattenedConfiguration { InstanceUniqueName = "TestInst" };
|
||||
_pipeline.FlattenAndValidateAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(config, "sha256:abc", ValidationResult.Success())));
|
||||
|
||||
var changes = new List<DeploymentStatusChange>();
|
||||
_notifier.StatusChanged += c => changes.Add(c);
|
||||
|
||||
// _comms has no actor set, so the deploy reaches the catch block and
|
||||
// the record ends Failed. The notifier must fire for the InProgress
|
||||
// and Failed writes — not be silent (the pre-fix behaviour).
|
||||
//
|
||||
// DeploymentManager-022: the transient Pending write was dropped from
|
||||
// the deploy path (the record is now created directly in InProgress),
|
||||
// so there is no Pending notification any more. The remaining two
|
||||
// writes — the initial InProgress insert and the catch-block Failed
|
||||
// update — must each raise a status-change.
|
||||
var result = await _service.DeployInstanceAsync(7, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.NotEmpty(changes);
|
||||
Assert.All(changes, c => Assert.Equal(7, c.InstanceId));
|
||||
Assert.DoesNotContain(changes, c => c.Status == DeploymentStatus.Pending);
|
||||
Assert.Contains(changes, c => c.Status == DeploymentStatus.InProgress);
|
||||
Assert.Contains(changes, c => c.Status == DeploymentStatus.Failed);
|
||||
|
||||
// All notifications carry the same deployment id (the one created here).
|
||||
var deploymentId = changes[0].DeploymentId;
|
||||
Assert.False(string.IsNullOrEmpty(deploymentId));
|
||||
Assert.All(changes, c => Assert.Equal(deploymentId, c.DeploymentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifyStatusChanged_WithNoSubscribers_DoesNotThrow()
|
||||
{
|
||||
var notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifyStatusChanged_ThrowingSubscriber_DoesNotBreakOtherSubscribers()
|
||||
{
|
||||
var notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
var reached = false;
|
||||
|
||||
notifier.StatusChanged += _ => throw new InvalidOperationException("boom");
|
||||
notifier.StatusChanged += _ => reached = true;
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-2", 2, DeploymentStatus.InProgress)));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.True(reached, "a faulting subscriber must not stop later subscribers from being notified");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-3: Tests for per-instance operation lock.
|
||||
/// </summary>
|
||||
public class OperationLockManagerTests
|
||||
{
|
||||
private readonly OperationLockManager _lockManager = new();
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ReturnsDisposable()
|
||||
{
|
||||
using var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(lockHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_SameInstance_BlocksSecondCaller()
|
||||
{
|
||||
using var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
|
||||
// Second acquire should time out
|
||||
await Assert.ThrowsAsync<TimeoutException>(() =>
|
||||
_lockManager.AcquireAsync("inst1", TimeSpan.FromMilliseconds(50)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_DifferentInstances_BothSucceed()
|
||||
{
|
||||
using var lock1 = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
using var lock2 = await _lockManager.AcquireAsync("inst2", TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.NotNull(lock1);
|
||||
Assert.NotNull(lock2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_AfterRelease_CanReacquire()
|
||||
{
|
||||
var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
firstLock.Dispose();
|
||||
|
||||
// Should succeed now
|
||||
using var secondLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
Assert.NotNull(secondLock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsLocked_ReturnsTrueWhileLocked()
|
||||
{
|
||||
Assert.False(_lockManager.IsLocked("inst1"));
|
||||
|
||||
using var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
Assert.True(_lockManager.IsLocked("inst1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsLocked_ReturnsFalseAfterRelease()
|
||||
{
|
||||
var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
lockHandle.Dispose();
|
||||
|
||||
Assert.False(_lockManager.IsLocked("inst1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_DoubleDispose_DoesNotThrow()
|
||||
{
|
||||
var lockHandle = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(5));
|
||||
lockHandle.Dispose();
|
||||
lockHandle.Dispose(); // Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_CancellationToken_Respected()
|
||||
{
|
||||
using var firstLock = await _lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30));
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||
_lockManager.AcquireAsync("inst1", TimeSpan.FromSeconds(30), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ConcurrentDifferentInstances_AllSucceed()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 10).Select(async i =>
|
||||
{
|
||||
using var lockHandle = await _lockManager.AcquireAsync($"inst{i}", TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(10);
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
// ── DeploymentManager-005: semaphore must not leak ──
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ReleasedLock_RemovesSemaphoreEntry()
|
||||
{
|
||||
// A semaphore that is created, used, and fully released must not be
|
||||
// retained — otherwise every distinct instance name leaks a
|
||||
// SemaphoreSlim (a kernel handle) for the life of the process.
|
||||
using (await _lockManager.AcquireAsync("transient-inst", TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
Assert.Equal(1, _lockManager.TrackedLockCount);
|
||||
}
|
||||
|
||||
// After release, the entry is reclaimed.
|
||||
Assert.Equal(0, _lockManager.TrackedLockCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ManyDistinctInstances_DoesNotAccumulateSemaphores()
|
||||
{
|
||||
// Simulates the long-running central process: many instances are
|
||||
// deployed/disabled over time. Their semaphores must be reclaimed.
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
using var handle = await _lockManager.AcquireAsync($"churn-{i}", TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
Assert.Equal(0, _lockManager.TrackedLockCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcquireAsync_ContendedLock_KeepsSemaphoreUntilLastReleaseThenReclaims()
|
||||
{
|
||||
// While a second caller is waiting, the semaphore must survive the
|
||||
// first release; only when the last holder releases is it reclaimed.
|
||||
var first = await _lockManager.AcquireAsync("contended", TimeSpan.FromSeconds(5));
|
||||
|
||||
var secondAcquire = _lockManager.AcquireAsync("contended", TimeSpan.FromSeconds(5));
|
||||
|
||||
first.Dispose(); // hands the lock to the waiter; entry must NOT be removed
|
||||
var second = await secondAcquire;
|
||||
|
||||
Assert.Equal(1, _lockManager.TrackedLockCount);
|
||||
second.Dispose();
|
||||
|
||||
Assert.Equal(0, _lockManager.TrackedLockCount);
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DeploymentManager-008: DeploymentManagerOptions must be resolvable via the
|
||||
/// Options pattern and bindable to the "ScadaBridge:DeploymentManager"
|
||||
/// configuration section. The component itself does not depend on
|
||||
/// IConfiguration (enforced by Host's OptionsTests) — the Host binds the
|
||||
/// section; AddDeploymentManager only guarantees IOptions resolvability.
|
||||
/// </summary>
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddDeploymentManager_RegistersResolvableOptions_WithDefaults()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
||||
|
||||
// No section bound -> the option-class defaults are retained.
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), options.OperationLockTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDeploymentManager_OptionsBindToConfigurationSection_AsTheHostWires()
|
||||
{
|
||||
// Mirrors the Host wiring: the Host calls Configure<DeploymentManagerOptions>
|
||||
// against OptionsSection, then AddDeploymentManager().
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaBridge:DeploymentManager:OperationLockTimeout"] = "00:00:09",
|
||||
["ScadaBridge:DeploymentManager:ArtifactDeploymentTimeoutPerSite"] = "00:03:00"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.Configure<DeploymentManagerOptions>(
|
||||
configuration.GetSection(ServiceCollectionExtensions.OptionsSection));
|
||||
services.AddDeploymentManager();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<DeploymentManagerOptions>>().Value;
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(9), options.OperationLockTimeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), options.ArtifactDeploymentTimeoutPerSite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionsSection_MatchesTheConventionalComponentSectionPath()
|
||||
{
|
||||
Assert.Equal("ScadaBridge:DeploymentManager", ServiceCollectionExtensions.OptionsSection);
|
||||
}
|
||||
|
||||
// CentralUI-006: the deployment-status notifier must be a singleton so the
|
||||
// scoped DeploymentService and the Central UI's scoped Blazor page share
|
||||
// one instance — without that, a push notification raised by the service
|
||||
// would never reach the page's subscription.
|
||||
[Fact]
|
||||
public void AddDeploymentManager_RegistersDeploymentStatusNotifier_AsSingleton()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDeploymentManager();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var fromRoot = provider.GetRequiredService<IDeploymentStatusNotifier>();
|
||||
using var scope = provider.CreateScope();
|
||||
var fromScope = scope.ServiceProvider.GetRequiredService<IDeploymentStatusNotifier>();
|
||||
|
||||
Assert.IsType<DeploymentStatusNotifier>(fromRoot);
|
||||
Assert.Same(fromRoot, fromScope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Tests for instance state transition matrix.
|
||||
/// </summary>
|
||||
public class StateTransitionValidatorTests
|
||||
{
|
||||
// ── Deploy transitions ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceState.NotDeployed)]
|
||||
[InlineData(InstanceState.Enabled)]
|
||||
[InlineData(InstanceState.Disabled)]
|
||||
public void CanDeploy_AllStates_ReturnsTrue(InstanceState state)
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanDeploy(state));
|
||||
}
|
||||
|
||||
// ── Disable transitions ──
|
||||
|
||||
[Fact]
|
||||
public void CanDisable_WhenEnabled_ReturnsTrue()
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanDisable(InstanceState.Enabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDisable_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
Assert.False(StateTransitionValidator.CanDisable(InstanceState.Disabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDisable_WhenNotDeployed_ReturnsFalse()
|
||||
{
|
||||
Assert.False(StateTransitionValidator.CanDisable(InstanceState.NotDeployed));
|
||||
}
|
||||
|
||||
// ── Enable transitions ──
|
||||
|
||||
[Fact]
|
||||
public void CanEnable_WhenDisabled_ReturnsTrue()
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanEnable(InstanceState.Disabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEnable_WhenEnabled_ReturnsFalse()
|
||||
{
|
||||
Assert.False(StateTransitionValidator.CanEnable(InstanceState.Enabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanEnable_WhenNotDeployed_ReturnsFalse()
|
||||
{
|
||||
Assert.False(StateTransitionValidator.CanEnable(InstanceState.NotDeployed));
|
||||
}
|
||||
|
||||
// ── Delete transitions ──
|
||||
|
||||
[Fact]
|
||||
public void CanDelete_WhenEnabled_ReturnsTrue()
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanDelete(InstanceState.Enabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDelete_WhenDisabled_ReturnsTrue()
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanDelete(InstanceState.Disabled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDelete_WhenNotDeployed_ReturnsTrue()
|
||||
{
|
||||
Assert.True(StateTransitionValidator.CanDelete(InstanceState.NotDeployed));
|
||||
}
|
||||
|
||||
// ── ValidateTransition ──
|
||||
|
||||
[Fact]
|
||||
public void ValidateTransition_ValidDeploy_ReturnsNull()
|
||||
{
|
||||
var error = StateTransitionValidator.ValidateTransition(InstanceState.NotDeployed, "deploy");
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTransition_InvalidEnable_ReturnsError()
|
||||
{
|
||||
var error = StateTransitionValidator.ValidateTransition(InstanceState.Enabled, "enable");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("not allowed", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTransition_InvalidDisable_ReturnsError()
|
||||
{
|
||||
var error = StateTransitionValidator.ValidateTransition(InstanceState.Disabled, "disable");
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTransition_ValidDeleteOnNotDeployed_ReturnsNull()
|
||||
{
|
||||
var error = StateTransitionValidator.ValidateTransition(InstanceState.NotDeployed, "delete");
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTransition_UnknownOperation_ReturnsError()
|
||||
{
|
||||
var error = StateTransitionValidator.ValidateTransition(InstanceState.Enabled, "unknown");
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user