using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; 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.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Actors; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Tests for DebugStreamService session lifecycle. /// /// /// Shares the DebugStreamStatics xUnit collection with DebugStreamBridgeActorTests /// so the two classes never race on the actor's static test seams (see that class). /// [Collection("DebugStreamStatics")] public class DebugStreamServiceTests : TestKit { [Fact] public async Task StartStreamAsync_StreamTerminatesBeforeSnapshot_ThrowsMeaningfulException() { // Regression test for Communication-001. When the debug stream terminates before // the initial snapshot arrives, StartStreamAsync used to let the raw // InvalidOperationException from onTerminatedWrapper escape its // OperationCanceledException-only catch — the caller saw an untranslated exception // and the failure path did not deterministically tear the bridge actor down. // The fix catches any failure, tells the bridge actor StopDebugStream, and throws // a descriptive exception that names the instance and wraps the underlying cause. var instance = new Instance("Site1.Pump01") { Id = 7, SiteId = 3 }; var site = new Site("Site One", "site-1") { Id = 3, GrpcNodeAAddress = "http://localhost:5100", GrpcNodeBAddress = "http://localhost:5200" }; var instanceRepo = Substitute.For(); instanceRepo.GetInstanceByIdAsync(7, Arg.Any()).Returns(instance); var siteRepo = Substitute.For(); siteRepo.GetSiteByIdAsync(3, Arg.Any()).Returns(site); var services = new ServiceCollection(); services.AddScoped(_ => instanceRepo); services.AddScoped(_ => siteRepo); using var provider = services.BuildServiceProvider(); var commProbe = CreateTestProbe(); var commService = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); commService.SetCommunicationActor(commProbe.Ref); using var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance); var service = new DebugStreamService( commService, provider, grpcFactory, NullLogger.Instance); service.SetActorSystem(Sys); // Act — start the stream; it blocks awaiting the initial snapshot. var startTask = service.StartStreamAsync(instanceId: 7, onEvent: _ => { }, onTerminated: () => { }); // The bridge actor's PreStart sends SubscribeDebugViewRequest to the comm actor; // the envelope's sender is the bridge actor itself. commProbe.ExpectMsg(TimeSpan.FromSeconds(5)); var bridgeActor = commProbe.LastSender; // Simulate the site terminating the stream before any snapshot is delivered. bridgeActor.Tell(new DebugStreamTerminated("site-1", "corr")); // Assert — a descriptive exception that names the instance and wraps the cause, // not the raw "terminated before snapshot received" InvalidOperationException. var ex = await Assert.ThrowsAsync(() => startTask); Assert.Contains("Site1.Pump01", ex.Message); Assert.NotNull(ex.InnerException); } [Fact] public async Task AttachedSession_IsKeptAliveByTheServiceKeepalive_AndDiesOnceDetached() { // The consumer half of the orphan net: holding a session in DebugStreamService IS // "a consumer is attached", and the service's shared timer is what renews the bridge // actor's window. Without this wiring every healthy session self-terminated one // window after its snapshot (the old mailbox-based ReceiveTimeout had nothing // recurring to reset it) and the consumer was told "Site disconnected". var previousKeepalive = DebugStreamService.KeepaliveInterval; var previousIdle = DebugStreamBridgeActor.ConsumerIdleTimeout; var previousCheck = DebugStreamBridgeActor.ConsumerLivenessCheckInterval; DebugStreamService.KeepaliveInterval = TimeSpan.FromMilliseconds(50); DebugStreamBridgeActor.ConsumerIdleTimeout = TimeSpan.FromMilliseconds(400); DebugStreamBridgeActor.ConsumerLivenessCheckInterval = TimeSpan.FromMilliseconds(50); try { var instance = new Instance("Site1.Pump01") { Id = 7, SiteId = 3 }; var site = new Site("Site One", "site-1") { Id = 3, GrpcNodeAAddress = "http://localhost:5100", GrpcNodeBAddress = "http://localhost:5200" }; var instanceRepo = Substitute.For(); instanceRepo.GetInstanceByIdAsync(7, Arg.Any()).Returns(instance); var siteRepo = Substitute.For(); siteRepo.GetSiteByIdAsync(3, Arg.Any()).Returns(site); var services = new ServiceCollection(); services.AddScoped(_ => instanceRepo); services.AddScoped(_ => siteRepo); using var provider = services.BuildServiceProvider(); var commProbe = CreateTestProbe(); var commService = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); commService.SetCommunicationActor(commProbe.Ref); // Mock gRPC factory: the real one would dial localhost:5100, fail, and trip the // bridge actor's retry budget — a termination unrelated to the orphan net under // test. The mock keeps the stream "up" so the only thing that can end this // session is the consumer-liveness decision. using var grpcFactory = new Grpc.MockSiteStreamGrpcClientFactory( new Grpc.MockSiteStreamGrpcClient()); using var service = new DebugStreamService( commService, provider, grpcFactory, NullLogger.Instance); service.SetActorSystem(Sys); var startTask = service.StartStreamAsync( instanceId: 7, onEvent: _ => { }, onTerminated: () => { }); commProbe.ExpectMsg(TimeSpan.FromSeconds(5)); var bridgeActor = commProbe.LastSender; Watch(bridgeActor); // Resolve the snapshot so the session is fully established. bridgeActor.Tell(new ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView.DebugViewSnapshot( "Site1.Pump01", new List(), new List(), DateTimeOffset.UtcNow)); var session = await startTask; // Several orphan windows with NO traffic of any kind — only the service's // keepalive timer. The session must survive. await Task.Delay(TimeSpan.FromMilliseconds(1500)); ExpectNoMsg(TimeSpan.FromMilliseconds(100)); Assert.False(bridgeActor.IsNobody()); // Detach the consumer: the session leaves the registry, keepalives stop, and the // actor is stopped (StopStream) — the orphan net is the backstop for the case // where that explicit stop never happens. service.StopStream(session.SessionId); ExpectTerminated(bridgeActor, TimeSpan.FromSeconds(3)); } finally { DebugStreamService.KeepaliveInterval = previousKeepalive; DebugStreamBridgeActor.ConsumerIdleTimeout = previousIdle; DebugStreamBridgeActor.ConsumerLivenessCheckInterval = previousCheck; } } }