using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types.Notifications; namespace ScadaLink.Communication.Tests; /// /// WP-2: Tests for CommunicationService initialization and state. /// public class CommunicationServiceTests : TestKit { [Fact] public async Task BeforeInitialization_ThrowsOnUsage() { var options = Options.Create(new CommunicationOptions()); var logger = NullLogger.Instance; var service = new CommunicationService(options, logger); // CommunicationService requires SetCommunicationActor before use await Assert.ThrowsAsync(() => service.DeployInstanceAsync("site1", new DeployInstanceCommand( "dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow))); } [Fact] public void UnsubscribeDebugView_IsTellNotAsk() { // Verify the method signature is void (fire-and-forget Tell pattern) var method = typeof(CommunicationService).GetMethod("UnsubscribeDebugView"); Assert.NotNull(method); Assert.Equal(typeof(void), method!.ReturnType); } // ── DeploymentManager-006: query-the-site-before-redeploy ── [Fact] public async Task QueryDeploymentStateAsync_BeforeInitialization_Throws() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); await Assert.ThrowsAsync(() => service.QueryDeploymentStateAsync("site1", new DeploymentStateQueryRequest("corr-1", "inst1", DateTimeOffset.UtcNow))); } [Fact] public async Task QueryDeploymentStateAsync_SendsEnvelopeAndReturnsResponse() { // The query must be dispatched as a SiteEnvelope over the existing // command/control transport, exactly like other site-directed commands, // and the typed response returned to the caller. var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); // A probe stands in for CentralCommunicationActor: it asserts the // envelope shape and replies with a typed response. var commActor = Sys.ActorOf(Props.Create(() => new EchoStateQueryActor())); service.SetCommunicationActor(commActor); var request = new DeploymentStateQueryRequest("corr-9", "QueriedInst", DateTimeOffset.UtcNow); var response = await service.QueryDeploymentStateAsync("site-a", request); Assert.Equal("corr-9", response.CorrelationId); Assert.Equal("QueriedInst", response.InstanceUniqueName); Assert.True(response.IsDeployed); Assert.Equal("sha256:applied", response.AppliedRevisionHash); } // ── Notification Outbox: central-side outbox actor calls ── [Fact] public async Task QueryNotificationOutboxAsync_BeforeOutboxSet_Throws() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); await Assert.ThrowsAsync(() => service.QueryNotificationOutboxAsync( new NotificationOutboxQueryRequest( "corr-1", null, null, null, null, false, null, null, null, 1, 50))); } [Fact] public async Task RetryNotificationAsync_BeforeOutboxSet_Throws() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); await Assert.ThrowsAsync(() => service.RetryNotificationAsync(new RetryNotificationRequest("corr-1", "n1"))); } [Fact] public async Task DiscardNotificationAsync_BeforeOutboxSet_Throws() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); await Assert.ThrowsAsync(() => service.DiscardNotificationAsync(new DiscardNotificationRequest("corr-1", "n1"))); } [Fact] public async Task GetNotificationKpisAsync_BeforeOutboxSet_Throws() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); await Assert.ThrowsAsync(() => service.GetNotificationKpisAsync(new NotificationKpiRequest("corr-1"))); } [Fact] public async Task QueryNotificationOutboxAsync_AsksOutboxProxyDirectly() { // The outbox actor is central-local: the request must be Asked directly // to the outbox proxy (no SiteEnvelope wrapping). var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var probe = CreateTestProbe(); service.SetNotificationOutbox(probe.Ref); var request = new NotificationOutboxQueryRequest( "corr-q", "Pending", null, null, null, true, "alarm", null, null, 2, 25); var task = service.QueryNotificationOutboxAsync(request); var received = probe.ExpectMsg(); Assert.Same(request, received); var reply = new NotificationOutboxQueryResponse( "corr-q", true, null, Array.Empty(), 0); probe.Reply(reply); Assert.Same(reply, await task); } [Fact] public async Task RetryNotificationAsync_AsksOutboxProxyDirectly() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var probe = CreateTestProbe(); service.SetNotificationOutbox(probe.Ref); var request = new RetryNotificationRequest("corr-r", "n-7"); var task = service.RetryNotificationAsync(request); var received = probe.ExpectMsg(); Assert.Same(request, received); var reply = new RetryNotificationResponse("corr-r", true, null); probe.Reply(reply); Assert.Same(reply, await task); } [Fact] public async Task DiscardNotificationAsync_AsksOutboxProxyDirectly() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var probe = CreateTestProbe(); service.SetNotificationOutbox(probe.Ref); var request = new DiscardNotificationRequest("corr-d", "n-9"); var task = service.DiscardNotificationAsync(request); var received = probe.ExpectMsg(); Assert.Same(request, received); var reply = new DiscardNotificationResponse("corr-d", false, "already delivered"); probe.Reply(reply); var result = await task; Assert.Same(reply, result); Assert.False(result.Success); } [Fact] public async Task GetNotificationKpisAsync_AsksOutboxProxyDirectly() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var probe = CreateTestProbe(); service.SetNotificationOutbox(probe.Ref); var request = new NotificationKpiRequest("corr-k"); var task = service.GetNotificationKpisAsync(request); var received = probe.ExpectMsg(); Assert.Same(request, received); var reply = new NotificationKpiResponse("corr-k", true, null, 3, 1, 0, 12, TimeSpan.FromMinutes(5)); probe.Reply(reply); var result = await task; Assert.Same(reply, result); Assert.Equal(3, result.QueueDepth); } [Fact] public async Task GetPerSiteNotificationKpisAsync_AsksOutboxProxyDirectly() { var service = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); var probe = CreateTestProbe(); service.SetNotificationOutbox(probe.Ref); var request = new PerSiteNotificationKpiRequest("corr-ps"); var task = service.GetPerSiteNotificationKpisAsync(request); var received = probe.ExpectMsg(); Assert.Same(request, received); var reply = new PerSiteNotificationKpiResponse( "corr-ps", true, null, new[] { new SiteNotificationKpiSnapshot("plant-a", 2, 0, 0, 5, null) }); probe.Reply(reply); var result = await task; Assert.Same(reply, result); Assert.True(result.Success); Assert.Single(result.Sites); Assert.Equal("plant-a", result.Sites[0].SourceSiteId); } /// /// Stand-in for CentralCommunicationActor: verifies the message is wrapped /// in a SiteEnvelope targeting the requested site and replies with a typed /// DeploymentStateQueryResponse. /// private class EchoStateQueryActor : ReceiveActor { public EchoStateQueryActor() { Receive(env => { if (env is { SiteId: "site-a", Message: DeploymentStateQueryRequest req }) { Sender.Tell(new DeploymentStateQueryResponse( req.CorrelationId, req.InstanceUniqueName, true, "dep-applied", "sha256:applied", DateTimeOffset.UtcNow)); } }); } } }