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; /// /// WP-7: Tests for system-wide artifact deployment. /// 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(); _deploymentRepo = Substitute.For(); _templateRepo = Substitute.For(); _externalSystemRepo = Substitute.For(); _notificationRepo = Substitute.For(); _audit = Substitute.For(); } [Fact] public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure() { _siteRepo.GetAllSitesAsync().Returns(new List()); 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 { 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 { new("Site One", "site-1") { Id = 1 }, new("Site Two", "site-2") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).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(r => { using var doc = JsonDocument.Parse(r.PerSiteStatus!); Assert.Equal(result.Value.DeploymentId, doc.RootElement.GetProperty("DeploymentId").GetString()); }), Arg.Any()); } // ── 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 { new("Site One", "ok-site") { Id = 1 }, new("Site Two", "fail-site") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).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 { 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()).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()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any()); await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any()); // 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()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(2, Arg.Any()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any()); } [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()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any()); await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any()); } [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(), "retry-site", Arg.Any(), Arg.Any()); } private ArtifactDeploymentService CreateService() { var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); return new ArtifactDeploymentService( _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo, comms, _audit, Options.Create(new DeploymentManagerOptions()), NullLogger.Instance); } private ArtifactDeploymentService CreateServiceWithCommActor(IActorRef commActor) { var comms = new CommunicationService( Options.Create(new CommunicationOptions { ArtifactDeploymentTimeout = TimeSpan.FromSeconds(5) }), NullLogger.Instance); comms.SetCommunicationActor(commActor); return new ArtifactDeploymentService( _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo, comms, _audit, Options.Create(new DeploymentManagerOptions { ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5) }), NullLogger.Instance); } /// /// Per-test recorder for . 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. /// private sealed class ArtifactProbeRecorder { public readonly ConcurrentBag Received = new(); } /// /// Stand-in CentralCommunicationActor for artifact deployment. Records every /// it receives into the per-test /// and replies success unless the target /// site id is in the configured failure set. /// private class ArtifactProbeActor : ReceiveActor { public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites) { var failSet = new HashSet(failingSites); Receive(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)); } }); } } }