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 ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Communication; namespace ScadaLink.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 probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor())); var service = CreateServiceWithCommActor(probe); var result = await service.DeployToAllSitesAsync("admin"); Assert.True(result.IsSuccess); var commands = ArtifactProbeActor.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 probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor("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); } [Fact] public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits() { var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor())); 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); } /// /// Stand-in CentralCommunicationActor for artifact deployment. Records every /// it receives and replies success /// unless the target site id is in the configured failure set. /// private class ArtifactProbeActor : ReceiveActor { public static readonly ConcurrentBag Received = new(); public ArtifactProbeActor(params string[] failingSites) { Received.Clear(); var failSet = new HashSet(failingSites); Receive(env => { if (env.Message is DeployArtifactsCommand cmd) { 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)); } }); } } }