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:
@@ -0,0 +1,129 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after a gRPC stream is cancelled, the SiteStreamManager
|
||||
/// subscription is properly cleaned up with no leaked subscriptions.
|
||||
/// </summary>
|
||||
public class CleanupVerificationTests : TestKit
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_Cancellation_CleansUp_SiteStreamManager_Subscription()
|
||||
{
|
||||
// Arrange: create server with mock subscriber that tracks subscribe/remove calls
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
var subscribeCalled = false;
|
||||
var removeCalled = false;
|
||||
IActorRef? subscribedActor = null;
|
||||
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
subscribeCalled = true;
|
||||
subscribedActor = ci.Arg<IActorRef>();
|
||||
return "sub-cleanup-test";
|
||||
});
|
||||
|
||||
subscriber.When(x => x.RemoveSubscriber(Arg.Any<IActorRef>()))
|
||||
.Do(_ => removeCalled = true);
|
||||
|
||||
var logger = NullLogger<SiteStreamGrpcServer>.Instance;
|
||||
var server = new SiteStreamGrpcServer(subscriber, logger);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(cts.Token);
|
||||
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "corr-cleanup-verify",
|
||||
InstanceUniqueName = "Site1.TestInst"
|
||||
};
|
||||
|
||||
// Act: start a stream, wait for it to register, then cancel
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => subscribeCalled);
|
||||
Assert.True(subscribeCalled, "Subscribe should have been called");
|
||||
Assert.Equal(1, server.ActiveStreamCount);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
// Assert: verify cleanup
|
||||
Assert.True(removeCalled, "RemoveSubscriber should have been called after cancellation");
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
|
||||
// Verify the same actor that was subscribed is the one that was removed
|
||||
subscriber.Received(1).RemoveSubscriber(subscribedActor!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_Streams_Cancelled_AllCleanedUp()
|
||||
{
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
var removeCount = 0;
|
||||
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns("sub-multi");
|
||||
|
||||
subscriber.When(x => x.RemoveSubscriber(Arg.Any<IActorRef>()))
|
||||
.Do(_ => Interlocked.Increment(ref removeCount));
|
||||
|
||||
var logger = NullLogger<SiteStreamGrpcServer>.Instance;
|
||||
var server = new SiteStreamGrpcServer(subscriber, logger);
|
||||
server.SetReady(Sys);
|
||||
|
||||
// Start 3 streams
|
||||
var ctsList = new List<CancellationTokenSource>();
|
||||
var tasks = new List<Task>();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
ctsList.Add(cts);
|
||||
var ctx = Substitute.For<ServerCallContext>();
|
||||
ctx.CancellationToken.Returns(cts.Token);
|
||||
var w = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var req = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = $"corr-multi-{i}",
|
||||
InstanceUniqueName = $"Site1.Inst{i}"
|
||||
};
|
||||
|
||||
tasks.Add(Task.Run(() => server.SubscribeInstance(req, w, ctx)));
|
||||
}
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 3);
|
||||
|
||||
// Cancel all
|
||||
foreach (var cts in ctsList)
|
||||
cts.Cancel();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
Assert.Equal(3, removeCount);
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DebugStreamBridgeActor with gRPC streaming integration.
|
||||
/// </summary>
|
||||
public class DebugStreamBridgeActorTests : TestKit
|
||||
{
|
||||
private const string SiteId = "site-alpha";
|
||||
private const string InstanceName = "Site1.Pump01";
|
||||
private const string GrpcNodeA = "http://localhost:5100";
|
||||
private const string GrpcNodeB = "http://localhost:5200";
|
||||
|
||||
public DebugStreamBridgeActorTests() : base(@"akka.loglevel = DEBUG")
|
||||
{
|
||||
// Use a very short reconnect delay for testing
|
||||
DebugStreamBridgeActor.ReconnectDelay = TimeSpan.FromMilliseconds(100);
|
||||
// Long stability window so streams are never considered "stable" mid-test
|
||||
// unless a test deliberately waits it out.
|
||||
DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
private record TestContext(
|
||||
IActorRef BridgeActor,
|
||||
TestProbe CommProbe,
|
||||
MockSiteStreamGrpcClient MockGrpcClient,
|
||||
List<object> ReceivedEvents,
|
||||
bool[] TerminatedFlag);
|
||||
|
||||
private TestContext CreateBridgeActor()
|
||||
{
|
||||
var commProbe = CreateTestProbe();
|
||||
var mockClient = new MockSiteStreamGrpcClient();
|
||||
var factory = new MockSiteStreamGrpcClientFactory(mockClient);
|
||||
var events = new List<object>();
|
||||
var terminated = new[] { false };
|
||||
|
||||
Action<object> onEvent = evt => { lock (events) { events.Add(evt); } };
|
||||
Action onTerminated = () => terminated[0] = true;
|
||||
|
||||
var props = Props.Create(typeof(DebugStreamBridgeActor),
|
||||
SiteId,
|
||||
InstanceName,
|
||||
"corr-1",
|
||||
commProbe.Ref,
|
||||
onEvent,
|
||||
onTerminated,
|
||||
factory,
|
||||
GrpcNodeA,
|
||||
GrpcNodeB);
|
||||
|
||||
var actor = Sys.ActorOf(props);
|
||||
return new TestContext(actor, commProbe, mockClient, events, terminated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreStart_Sends_SubscribeDebugViewRequest_Via_ClusterClient()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
|
||||
var envelope = ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
Assert.Equal(SiteId, envelope.SiteId);
|
||||
Assert.IsType<SubscribeDebugViewRequest>(envelope.Message);
|
||||
|
||||
var req = (SubscribeDebugViewRequest)envelope.Message;
|
||||
Assert.Equal(InstanceName, req.InstanceUniqueName);
|
||||
Assert.Equal("corr-1", req.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_Snapshot_Forwards_To_OnEvent_Callback()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
|
||||
AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 1; } },
|
||||
TimeSpan.FromSeconds(3));
|
||||
lock (ctx.ReceivedEvents) { Assert.IsType<DebugViewSnapshot>(ctx.ReceivedEvents[0]); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_Snapshot_Opens_GrpcStream()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
var call = ctx.MockGrpcClient.SubscribeCalls[0];
|
||||
Assert.Equal("corr-1", call.CorrelationId);
|
||||
Assert.Equal(InstanceName, call.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Events_From_GrpcCallback_Forwarded_To_OnEvent()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Simulate gRPC event arriving via the onEvent callback
|
||||
var attrChange = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", DateTimeOffset.UtcNow);
|
||||
ctx.MockGrpcClient.SubscribeCalls[0].OnEvent(attrChange);
|
||||
|
||||
// snapshot + attr change
|
||||
AwaitCondition(() => { lock (ctx.ReceivedEvents) { return ctx.ReceivedEvents.Count == 2; } },
|
||||
TimeSpan.FromSeconds(3));
|
||||
lock (ctx.ReceivedEvents) { Assert.IsType<AttributeValueChanged>(ctx.ReceivedEvents[1]); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_GrpcError_Reconnects_To_Other_Node()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Simulate gRPC error
|
||||
ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));
|
||||
|
||||
// Should resubscribe
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("corr-1", ctx.MockGrpcClient.SubscribeCalls[1].CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_GrpcError_Unsubscribes_Old_Stream_Before_Reconnect()
|
||||
{
|
||||
// Communication-002 regression: a reconnect must unsubscribe the previous
|
||||
// stream so the old node does not keep a zombie relay actor / subscription.
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Simulate gRPC error → reconnect
|
||||
ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Stream broken"));
|
||||
|
||||
// Should resubscribe...
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
// ...and must have unsubscribed the prior correlation ID so the old node's
|
||||
// relay actor is released rather than left zombie.
|
||||
Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_MaxRetries_Terminates()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Watch(ctx.BridgeActor);
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// 4 consecutive errors: initial + 3 retries, then terminate
|
||||
ctx.MockGrpcClient.SubscribeCalls[0].OnError(new Exception("Error 1"));
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
ctx.MockGrpcClient.SubscribeCalls[1].OnError(new Exception("Error 2"));
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 3, TimeSpan.FromSeconds(5));
|
||||
|
||||
ctx.MockGrpcClient.SubscribeCalls[2].OnError(new Exception("Error 3"));
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 4, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Fourth error exceeds max retries
|
||||
ctx.MockGrpcClient.SubscribeCalls[3].OnError(new Exception("Error 4"));
|
||||
|
||||
ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(5));
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopDebugStream_Cancels_Grpc_And_Sends_Unsubscribe()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>(); // subscribe
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Watch(ctx.BridgeActor);
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
ctx.BridgeActor.Tell(new StopDebugStream());
|
||||
|
||||
// Should send ClusterClient unsubscribe
|
||||
var envelope = ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
Assert.IsType<UnsubscribeDebugViewRequest>(envelope.Message);
|
||||
|
||||
// Should unsubscribe gRPC
|
||||
AwaitCondition(() => ctx.MockGrpcClient.UnsubscribedCorrelationIds.Count > 0, TimeSpan.FromSeconds(3));
|
||||
Assert.Contains("corr-1", ctx.MockGrpcClient.UnsubscribedCorrelationIds);
|
||||
|
||||
// Should stop self
|
||||
ExpectTerminated(ctx.BridgeActor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugStreamTerminated_Stops_Actor_Idempotently()
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
Watch(ctx.BridgeActor);
|
||||
ctx.BridgeActor.Tell(new DebugStreamTerminated(SiteId, "corr-1"));
|
||||
|
||||
ExpectTerminated(ctx.BridgeActor);
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlappingStream_DeliveringEventsBetweenFailures_StillTerminatesAfterMaxRetries()
|
||||
{
|
||||
// Communication-008 regression: a stream that connects, delivers an event,
|
||||
// then fails — repeatedly — must still trip MaxRetries. The retry count is
|
||||
// NO LONGER reset by a received event (only by the stability window). The
|
||||
// previous behaviour reset _retryCount on every event, so a flapping site
|
||||
// reconnected forever and the debug session lived on indefinitely.
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Watch(ctx.BridgeActor);
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
var attrChange = new AttributeValueChanged(InstanceName, "IO", "Temp", 42.5, "Good", DateTimeOffset.UtcNow);
|
||||
|
||||
// Flap: deliver one event then fail, three times. Each event would, under
|
||||
// the old buggy logic, reset the retry budget and prevent termination.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var call = ctx.MockGrpcClient.SubscribeCalls[i];
|
||||
call.OnEvent(attrChange);
|
||||
call.OnError(new Exception($"Flap {i + 1}"));
|
||||
var expected = i + 2;
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == expected, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// Fourth error (after the 3 retries) must exceed MaxRetries and terminate.
|
||||
ctx.MockGrpcClient.SubscribeCalls[3].OnEvent(attrChange);
|
||||
ctx.MockGrpcClient.SubscribeCalls[3].OnError(new Exception("Flap 4"));
|
||||
|
||||
ExpectTerminated(ctx.BridgeActor, TimeSpan.FromSeconds(5));
|
||||
Assert.True(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void On_GrpcError_Reconnects_To_Other_Node_Endpoint()
|
||||
{
|
||||
// Communication-015 regression: drive the bridge actor through a node flip
|
||||
// with an endpoint-aware factory (one distinct mock client per endpoint).
|
||||
// The first subscribe targets NodeA; after a gRPC error the bridge must
|
||||
// reconnect via a client bound to the *NodeB* endpoint.
|
||||
var commProbe = CreateTestProbe();
|
||||
var factory = new EndpointTrackingGrpcClientFactory();
|
||||
var events = new List<object>();
|
||||
var terminated = new[] { false };
|
||||
|
||||
var props = Props.Create(typeof(DebugStreamBridgeActor),
|
||||
SiteId, InstanceName, "corr-1", commProbe.Ref,
|
||||
(Action<object>)(evt => { lock (events) { events.Add(evt); } }),
|
||||
(Action)(() => terminated[0] = true),
|
||||
factory, GrpcNodeA, GrpcNodeB);
|
||||
|
||||
var actor = Sys.ActorOf(props);
|
||||
commProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
actor.Tell(new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow));
|
||||
|
||||
// First subscribe goes to NodeA.
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeA).SubscribeCalls.Count == 1,
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// gRPC error → bridge flips to NodeB.
|
||||
factory.ClientFor(GrpcNodeA).SubscribeCalls[0].OnError(new Exception("NodeA down"));
|
||||
|
||||
// The reconnect must reach a client bound to the NodeB endpoint.
|
||||
AwaitCondition(() => factory.ClientFor(GrpcNodeB).SubscribeCalls.Count == 1,
|
||||
TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("corr-1", factory.ClientFor(GrpcNodeB).SubscribeCalls[0].CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryCount_RecoveredOnlyAfterStreamStaysStableForStabilityWindow()
|
||||
{
|
||||
// Communication-008: after a stream has been connected for the stability
|
||||
// window, the retry budget is recovered — a later transient fault then gets
|
||||
// a fresh set of retries rather than being counted against the old budget.
|
||||
DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromMilliseconds(300);
|
||||
try
|
||||
{
|
||||
var ctx = CreateBridgeActor();
|
||||
ctx.CommProbe.ExpectMsg<SiteEnvelope>();
|
||||
|
||||
var snapshot = new DebugViewSnapshot(
|
||||
InstanceName,
|
||||
new List<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged>(),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Watch(ctx.BridgeActor);
|
||||
ctx.BridgeActor.Tell(snapshot);
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == 1, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Two failures — but each new stream stays up long enough (the mock
|
||||
// stream only completes on cancel) for the stability window to elapse
|
||||
// and reset the retry budget before the next failure.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Thread.Sleep(450); // exceed the 300ms stability window
|
||||
ctx.MockGrpcClient.SubscribeCalls[i].OnError(new Exception($"Error {i + 1}"));
|
||||
var expected = i + 2;
|
||||
AwaitCondition(() => ctx.MockGrpcClient.SubscribeCalls.Count == expected, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// Five well-spaced failures did NOT terminate the actor because each
|
||||
// reconnect recovered its retry budget after the stability window.
|
||||
Assert.False(ctx.TerminatedFlag[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DebugStreamBridgeActor.StabilityWindow = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock gRPC client that records SubscribeAsync and Unsubscribe calls.
|
||||
/// </summary>
|
||||
internal class MockSiteStreamGrpcClient : SiteStreamGrpcClient
|
||||
{
|
||||
public List<MockSubscription> SubscribeCalls { get; } = new();
|
||||
public List<string> UnsubscribedCorrelationIds { get; } = new();
|
||||
|
||||
private MockSiteStreamGrpcClient(bool _) : base() { }
|
||||
|
||||
public MockSiteStreamGrpcClient() : base()
|
||||
{
|
||||
}
|
||||
|
||||
public override Task SubscribeAsync(
|
||||
string correlationId,
|
||||
string instanceUniqueName,
|
||||
Action<object> onEvent,
|
||||
Action<Exception> onError,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var subscription = new MockSubscription(correlationId, instanceUniqueName, onEvent, onError, ct);
|
||||
SubscribeCalls.Add(subscription);
|
||||
|
||||
// Return a task that completes when cancelled (simulates long-running stream)
|
||||
var tcs = new TaskCompletionSource();
|
||||
ct.Register(() => tcs.TrySetResult());
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public override void Unsubscribe(string correlationId)
|
||||
{
|
||||
UnsubscribedCorrelationIds.Add(correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
internal record MockSubscription(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
Action<object> OnEvent,
|
||||
Action<Exception> OnError,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Factory that always returns the pre-configured mock client.
|
||||
/// </summary>
|
||||
internal class MockSiteStreamGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly MockSiteStreamGrpcClient _mockClient;
|
||||
public List<string> RequestedEndpoints { get; } = new();
|
||||
|
||||
public MockSiteStreamGrpcClientFactory(MockSiteStreamGrpcClient mockClient)
|
||||
: base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
|
||||
{
|
||||
_mockClient = mockClient;
|
||||
}
|
||||
|
||||
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
RequestedEndpoints.Add(grpcEndpoint);
|
||||
return _mockClient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-aware mock factory: hands out a distinct <see cref="MockSiteStreamGrpcClient"/>
|
||||
/// per endpoint, mirroring the real factory's corrected NodeA→NodeB failover behaviour
|
||||
/// so node-flip coverage is meaningful (Communication-015).
|
||||
/// </summary>
|
||||
internal class EndpointTrackingGrpcClientFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, MockSiteStreamGrpcClient> _byEndpoint = new();
|
||||
|
||||
public EndpointTrackingGrpcClientFactory()
|
||||
: base(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
public MockSiteStreamGrpcClient ClientFor(string endpoint) =>
|
||||
_byEndpoint.GetOrAdd(endpoint, _ => new MockSiteStreamGrpcClient());
|
||||
|
||||
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint)
|
||||
=> ClientFor(grpcEndpoint);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Communication-005 — the gRPC keepalive and
|
||||
/// max-stream-lifetime / max-concurrent-stream options defined on
|
||||
/// <see cref="CommunicationOptions"/> must actually be applied to the
|
||||
/// gRPC client and server rather than hard-coded.
|
||||
/// </summary>
|
||||
public class GrpcOptionsWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void SiteStreamGrpcClient_AppliesKeepAliveFromOptions()
|
||||
{
|
||||
var options = new CommunicationOptions
|
||||
{
|
||||
GrpcKeepAlivePingDelay = TimeSpan.FromSeconds(42),
|
||||
GrpcKeepAlivePingTimeout = TimeSpan.FromSeconds(7)
|
||||
};
|
||||
|
||||
var client = new SiteStreamGrpcClient(
|
||||
"http://localhost:9999", NullLogger<SiteStreamGrpcClient>.Instance, options);
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), client.KeepAlivePingDelay);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), client.KeepAlivePingTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteStreamGrpcClientFactory_FlowsOptionsToCreatedClients()
|
||||
{
|
||||
var options = new CommunicationOptions
|
||||
{
|
||||
GrpcKeepAlivePingDelay = TimeSpan.FromSeconds(33),
|
||||
GrpcKeepAlivePingTimeout = TimeSpan.FromSeconds(11)
|
||||
};
|
||||
|
||||
using var factory = new SiteStreamGrpcClientFactory(
|
||||
NullLoggerFactory.Instance, Options.Create(options));
|
||||
|
||||
var client = factory.GetOrCreate("site1", "http://localhost:9999");
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(33), client.KeepAlivePingDelay);
|
||||
Assert.Equal(TimeSpan.FromSeconds(11), client.KeepAlivePingTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteStreamGrpcServer_BindsMaxConcurrentStreamsAndLifetimeFromOptions()
|
||||
{
|
||||
var options = new CommunicationOptions
|
||||
{
|
||||
GrpcMaxConcurrentStreams = 250,
|
||||
GrpcMaxStreamLifetime = TimeSpan.FromHours(2)
|
||||
};
|
||||
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber, NullLogger<SiteStreamGrpcServer>.Instance, Options.Create(options));
|
||||
|
||||
Assert.Equal(250, server.MaxConcurrentStreams);
|
||||
Assert.Equal(TimeSpan.FromHours(2), server.MaxStreamLifetime);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests ensuring that the old ClusterClient-based debug streaming
|
||||
/// path is not reintroduced. Debug streaming now flows through gRPC.
|
||||
///
|
||||
/// Note: The DebugStreamEvent type-does-not-exist check lives in
|
||||
/// ZB.MOM.WW.ScadaBridge.Commons.Tests/ArchitecturalConstraintTests.cs and is not
|
||||
/// duplicated here.
|
||||
/// </summary>
|
||||
public class NoClusterClientStreamingRegressionTests
|
||||
{
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_DoesNotHave_HandleDebugStreamEvent()
|
||||
{
|
||||
var type = typeof(CentralCommunicationActor);
|
||||
var method = type.GetMethod("HandleDebugStreamEvent",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCommunicationActor_DoesNotHave_HandleDebugStreamEvent()
|
||||
{
|
||||
var type = typeof(SiteCommunicationActor);
|
||||
var method = type.GetMethod("HandleDebugStreamEvent",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_DoesNotHave_ForwardDebugStreamEvent()
|
||||
{
|
||||
var type = typeof(CentralCommunicationActor);
|
||||
var method = type.GetMethod("ForwardDebugStreamEvent",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
Assert.Null(method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Communication_Assembly_DoesNotContain_DebugStreamEvent_Type()
|
||||
{
|
||||
// DebugStreamEvent should not exist in the Communication assembly either
|
||||
var assembly = typeof(CentralCommunicationActor).Assembly;
|
||||
var type = assembly.GetTypes().FirstOrDefault(t => t.Name == "DebugStreamEvent");
|
||||
Assert.Null(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Guardrail tests that verify all oneof variants in SiteStreamEvent have
|
||||
/// corresponding conversion handlers. Adding a new proto field without
|
||||
/// implementing the conversion will cause these tests to fail.
|
||||
/// </summary>
|
||||
public class ProtoContractTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The set of EventOneofCase values we handle in ConvertToDomainEvent.
|
||||
/// Update this array when adding a new oneof variant.
|
||||
/// </summary>
|
||||
private static readonly SiteStreamEvent.EventOneofCase[] HandledCases =
|
||||
[
|
||||
SiteStreamEvent.EventOneofCase.AttributeChanged,
|
||||
SiteStreamEvent.EventOneofCase.AlarmChanged
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void AllOneofVariants_HaveConversionHandlers()
|
||||
{
|
||||
var allCases = System.Enum.GetValues<SiteStreamEvent.EventOneofCase>()
|
||||
.Where(c => c != SiteStreamEvent.EventOneofCase.None)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(allCases.Length, HandledCases.Length);
|
||||
foreach (var c in allCases)
|
||||
Assert.Contains(c, HandledCases);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SiteStreamEvent.EventOneofCase.AttributeChanged)]
|
||||
[InlineData(SiteStreamEvent.EventOneofCase.AlarmChanged)]
|
||||
public void ConvertToDomainEvent_HandlesAllOneofVariants(SiteStreamEvent.EventOneofCase eventCase)
|
||||
{
|
||||
var evt = CreateTestEvent(eventCase);
|
||||
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
private static SiteStreamEvent CreateTestEvent(SiteStreamEvent.EventOneofCase eventCase)
|
||||
{
|
||||
var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow);
|
||||
|
||||
return eventCase switch
|
||||
{
|
||||
SiteStreamEvent.EventOneofCase.AttributeChanged => new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "test",
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Inst1",
|
||||
AttributePath = "Path",
|
||||
AttributeName = "Attr",
|
||||
Value = "42",
|
||||
Quality = Quality.Good,
|
||||
Timestamp = ts
|
||||
}
|
||||
},
|
||||
SiteStreamEvent.EventOneofCase.AlarmChanged => new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "test",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Inst1",
|
||||
AlarmName = "HighTemp",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 1,
|
||||
Timestamp = ts
|
||||
}
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(eventCase), eventCase, "Unhandled event case")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class ProtoRoundtripTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Quality.Good)]
|
||||
[InlineData(Quality.Uncertain)]
|
||||
[InlineData(Quality.Bad)]
|
||||
[InlineData(Quality.Unspecified)]
|
||||
public void AttributeValueUpdate_RoundTrip(Quality quality)
|
||||
{
|
||||
var timestamp = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 3, 21, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var original = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
AttributePath = "Modules.PressureModule",
|
||||
AttributeName = "CurrentPressure",
|
||||
Value = "42.5",
|
||||
Quality = quality,
|
||||
Timestamp = timestamp
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = AttributeValueUpdate.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(original.InstanceUniqueName, deserialized.InstanceUniqueName);
|
||||
Assert.Equal(original.AttributePath, deserialized.AttributePath);
|
||||
Assert.Equal(original.AttributeName, deserialized.AttributeName);
|
||||
Assert.Equal(original.Value, deserialized.Value);
|
||||
Assert.Equal(original.Quality, deserialized.Quality);
|
||||
Assert.Equal(original.Timestamp, deserialized.Timestamp);
|
||||
Assert.Equal(timestamp.Seconds, deserialized.Timestamp.Seconds);
|
||||
Assert.Equal(timestamp.Nanos, deserialized.Timestamp.Nanos);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmStateEnum.AlarmStateNormal)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateActive)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateUnspecified)]
|
||||
public void AlarmStateUpdate_RoundTrip(AlarmStateEnum state)
|
||||
{
|
||||
var timestamp = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 3, 21, 12, 30, 0, TimeSpan.Zero));
|
||||
|
||||
var original = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
AlarmName = "HighPressure",
|
||||
State = state,
|
||||
Priority = 3,
|
||||
Timestamp = timestamp
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = AlarmStateUpdate.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(original.InstanceUniqueName, deserialized.InstanceUniqueName);
|
||||
Assert.Equal(original.AlarmName, deserialized.AlarmName);
|
||||
Assert.Equal(original.State, deserialized.State);
|
||||
Assert.Equal(original.Priority, deserialized.Priority);
|
||||
Assert.Equal(original.Timestamp, deserialized.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteStreamEvent_OneOf_AttributeChanged()
|
||||
{
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-123",
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
AttributePath = "Modules.PressureModule",
|
||||
AttributeName = "CurrentPressure",
|
||||
Value = "42.5",
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, evt.EventCase);
|
||||
Assert.NotNull(evt.AttributeChanged);
|
||||
Assert.Null(evt.AlarmChanged);
|
||||
|
||||
// Round-trip
|
||||
var bytes = evt.ToByteArray();
|
||||
var deserialized = SiteStreamEvent.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, deserialized.EventCase);
|
||||
Assert.Equal("corr-123", deserialized.CorrelationId);
|
||||
Assert.Equal("42.5", deserialized.AttributeChanged.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteStreamEvent_OneOf_AlarmChanged()
|
||||
{
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-456",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
AlarmName = "HighPressure",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 1,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, evt.EventCase);
|
||||
Assert.NotNull(evt.AlarmChanged);
|
||||
Assert.Null(evt.AttributeChanged);
|
||||
|
||||
// Round-trip
|
||||
var bytes = evt.ToByteArray();
|
||||
var deserialized = SiteStreamEvent.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, deserialized.EventCase);
|
||||
Assert.Equal("corr-456", deserialized.CorrelationId);
|
||||
Assert.Equal(AlarmStateEnum.AlarmStateActive, deserialized.AlarmChanged.State);
|
||||
Assert.Equal(1, deserialized.AlarmChanged.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_DateTimeOffset_FullRoundTrip()
|
||||
{
|
||||
var original = new DateTimeOffset(2026, 3, 21, 14, 30, 45, 123, TimeSpan.Zero);
|
||||
var update = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "Motor-1",
|
||||
AttributePath = "Speed",
|
||||
AttributeName = "Speed",
|
||||
Value = "42.5",
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(original)
|
||||
};
|
||||
|
||||
var bytes = update.ToByteArray();
|
||||
var deserialized = AttributeValueUpdate.Parser.ParseFrom(bytes);
|
||||
|
||||
var roundTripped = deserialized.Timestamp.ToDateTimeOffset();
|
||||
Assert.Equal(original.Year, roundTripped.Year);
|
||||
Assert.Equal(original.Month, roundTripped.Month);
|
||||
Assert.Equal(original.Day, roundTripped.Day);
|
||||
Assert.Equal(original.Hour, roundTripped.Hour);
|
||||
Assert.Equal(original.Minute, roundTripped.Minute);
|
||||
Assert.Equal(original.Second, roundTripped.Second);
|
||||
Assert.Equal(original.Millisecond, roundTripped.Millisecond);
|
||||
Assert.Equal(TimeSpan.Zero, roundTripped.Offset);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Communication-007 — the factory's synchronous
|
||||
/// <see cref="SiteStreamGrpcClientFactory.Dispose"/> must not block on the
|
||||
/// async disposal path (sync-over-async). It must dispose each client through
|
||||
/// the client's synchronous <see cref="SiteStreamGrpcClient.Dispose"/>.
|
||||
/// </summary>
|
||||
public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test client that records whether it was disposed via the sync or async path.
|
||||
/// </summary>
|
||||
private sealed class TrackingClient : SiteStreamGrpcClient
|
||||
{
|
||||
public bool SyncDisposeCalled { get; private set; }
|
||||
public bool AsyncDisposeCalled { get; private set; }
|
||||
|
||||
public override void Dispose() => SyncDisposeCalled = true;
|
||||
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
AsyncDisposeCalled = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test factory that hands out <see cref="TrackingClient"/> instances while
|
||||
/// still exercising the base factory's real caching and disposal machinery.
|
||||
/// </summary>
|
||||
private sealed class TrackingFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly ConcurrentBag<TrackingClient> _created = new();
|
||||
|
||||
public TrackingFactory() : base(NullLoggerFactory.Instance) { }
|
||||
|
||||
public IReadOnlyCollection<TrackingClient> Created => _created.ToList();
|
||||
|
||||
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
|
||||
{
|
||||
var client = new TrackingClient();
|
||||
_created.Add(client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DisposesClientsSynchronously_NotViaAsyncPath()
|
||||
{
|
||||
var factory = new TrackingFactory();
|
||||
factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
factory.GetOrCreate("site-b", "http://localhost:5200");
|
||||
|
||||
factory.Dispose();
|
||||
|
||||
Assert.NotEmpty(factory.Created);
|
||||
Assert.All(factory.Created, c =>
|
||||
{
|
||||
Assert.True(c.SyncDisposeCalled, "client should be disposed via synchronous Dispose()");
|
||||
Assert.False(c.AsyncDisposeCalled, "synchronous Dispose() must not route through DisposeAsync()");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotDeadlock_UnderSingleThreadedSynchronizationContext()
|
||||
{
|
||||
// A strict single-threaded SynchronizationContext: continuations posted to
|
||||
// it are only pumped by the worker loop. Sync-over-async (blocking the only
|
||||
// thread on an async continuation that needs that same thread) deadlocks here.
|
||||
using var ctx = new SingleThreadSyncContext();
|
||||
Exception? captured = null;
|
||||
var done = new ManualResetEventSlim();
|
||||
|
||||
ctx.Post(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var factory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||
factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
factory.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
captured = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
done.Set();
|
||||
}
|
||||
}, null);
|
||||
|
||||
Assert.True(done.Wait(TimeSpan.FromSeconds(5)),
|
||||
"factory.Dispose() did not complete — likely a sync-over-async deadlock");
|
||||
Assert.Null(captured);
|
||||
}
|
||||
|
||||
/// <summary>Minimal single-threaded synchronization context for the deadlock test.</summary>
|
||||
private sealed class SingleThreadSyncContext : SynchronizationContext, IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<(SendOrPostCallback cb, object? state)> _queue = new();
|
||||
private readonly Thread _thread;
|
||||
|
||||
public SingleThreadSyncContext()
|
||||
{
|
||||
_thread = new Thread(Run) { IsBackground = true };
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
SetSynchronizationContext(this);
|
||||
foreach (var (cb, state) in _queue.GetConsumingEnumerable())
|
||||
cb(state);
|
||||
}
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_queue.CompleteAdding();
|
||||
_thread.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class SiteStreamGrpcClientFactoryTests
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance;
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsSameClientForSameSite()
|
||||
{
|
||||
using var factory = new SiteStreamGrpcClientFactory(_loggerFactory);
|
||||
|
||||
var client1 = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
var client2 = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
|
||||
Assert.Same(client1, client2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsDifferentClientsForDifferentSites()
|
||||
{
|
||||
using var factory = new SiteStreamGrpcClientFactory(_loggerFactory);
|
||||
|
||||
var client1 = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
var client2 = factory.GetOrCreate("site-b", "http://localhost:5200");
|
||||
|
||||
Assert.NotSame(client1, client2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveSite_DisposesClient()
|
||||
{
|
||||
var factory = new SiteStreamGrpcClientFactory(_loggerFactory);
|
||||
|
||||
var client1 = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
await factory.RemoveSiteAsync("site-a");
|
||||
|
||||
// After removal, GetOrCreate should return a new instance
|
||||
var client2 = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
Assert.NotSame(client1, client2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveSite_NonExistent_DoesNotThrow()
|
||||
{
|
||||
var factory = new SiteStreamGrpcClientFactory(_loggerFactory);
|
||||
await factory.RemoveSiteAsync("does-not-exist"); // Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_DisposesAllClients()
|
||||
{
|
||||
var factory = new SiteStreamGrpcClientFactory(_loggerFactory);
|
||||
|
||||
factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
factory.GetOrCreate("site-b", "http://localhost:5200");
|
||||
|
||||
await factory.DisposeAsync();
|
||||
|
||||
// After dispose, creating new clients should work (new instances)
|
||||
// This tests that Dispose doesn't throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_EndpointChanged_ReturnsClientBoundToNewEndpoint()
|
||||
{
|
||||
// Communication-012 regression: when the same site is requested with a
|
||||
// *different* endpoint (the NodeA→NodeB failover flip), the factory must
|
||||
// hand back a client bound to the new endpoint, not the stale cached one.
|
||||
using var factory = new TrackingEndpointFactory();
|
||||
|
||||
var nodeA = factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
var nodeB = factory.GetOrCreate("site-a", "http://localhost:5200");
|
||||
|
||||
Assert.NotSame(nodeA, nodeB);
|
||||
Assert.Equal("http://localhost:5100", nodeA.Endpoint);
|
||||
Assert.Equal("http://localhost:5200", nodeB.Endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_EndpointChanged_DisposesPriorClient()
|
||||
{
|
||||
// Communication-013 regression: a later edit to a site's gRPC address must
|
||||
// invalidate (and dispose) the stale cached client, so the corrected
|
||||
// endpoint takes effect without a central restart.
|
||||
using var factory = new TrackingEndpointFactory();
|
||||
|
||||
var first = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
var second = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5200");
|
||||
|
||||
Assert.NotSame(first, second);
|
||||
Assert.True(first.Disposed, "stale client for the old endpoint should be disposed");
|
||||
Assert.False(second.Disposed, "fresh client for the new endpoint should still be live");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_SameEndpoint_DoesNotDisposeOrRecreate()
|
||||
{
|
||||
// Endpoint unchanged → the cached client is reused untouched.
|
||||
using var factory = new TrackingEndpointFactory();
|
||||
|
||||
var first = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
var second = (TrackingEndpointClient)factory.GetOrCreate("site-a", "http://localhost:5100");
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.False(first.Disposed);
|
||||
}
|
||||
|
||||
/// <summary>Test client that records its endpoint and disposal (no real channel).</summary>
|
||||
private sealed class TrackingEndpointClient : SiteStreamGrpcClient
|
||||
{
|
||||
public TrackingEndpointClient(string endpoint) : base(endpoint) { }
|
||||
public bool Disposed { get; private set; }
|
||||
public override void Dispose() => Disposed = true;
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
Disposed = true;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory that hands out endpoint-tracking clients.</summary>
|
||||
private sealed class TrackingEndpointFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
public TrackingEndpointFactory() : base(NullLoggerFactory.Instance) { }
|
||||
protected override SiteStreamGrpcClient CreateClient(string grpcEndpoint)
|
||||
=> new TrackingEndpointClient(grpcEndpoint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class SiteStreamGrpcClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly()
|
||||
{
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-1",
|
||||
AttributeChanged = new AttributeValueUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Pump01",
|
||||
AttributePath = "Modules.IO",
|
||||
AttributeName = "Temperature",
|
||||
Value = "42.5",
|
||||
Quality = Quality.Good,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
}
|
||||
};
|
||||
|
||||
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
|
||||
|
||||
var attr = Assert.IsType<AttributeValueChanged>(result);
|
||||
Assert.Equal("Site1.Pump01", attr.InstanceUniqueName);
|
||||
Assert.Equal("Modules.IO", attr.AttributePath);
|
||||
Assert.Equal("Temperature", attr.AttributeName);
|
||||
Assert.Equal("42.5", attr.Value);
|
||||
Assert.Equal("Good", attr.Quality);
|
||||
Assert.Equal(ts, attr.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AlarmChanged_MapsCorrectly()
|
||||
{
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-2",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Motor01",
|
||||
AlarmName = "OverTemp",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 3,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(ts)
|
||||
}
|
||||
};
|
||||
|
||||
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
|
||||
|
||||
var alarm = Assert.IsType<AlarmStateChanged>(result);
|
||||
Assert.Equal("Site1.Motor01", alarm.InstanceUniqueName);
|
||||
Assert.Equal("OverTemp", alarm.AlarmName);
|
||||
Assert.Equal(AlarmState.Active, alarm.State);
|
||||
Assert.Equal(3, alarm.Priority);
|
||||
Assert.Equal(ts, alarm.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_UnknownEvent_ReturnsNull()
|
||||
{
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "corr-3"
|
||||
// No oneof case set
|
||||
};
|
||||
|
||||
var result = SiteStreamGrpcClient.ConvertToDomainEvent(evt);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Quality.Good, "Good")]
|
||||
[InlineData(Quality.Uncertain, "Uncertain")]
|
||||
[InlineData(Quality.Bad, "Bad")]
|
||||
[InlineData(Quality.Unspecified, "Unknown")]
|
||||
public void MapQuality_AllValues(Quality input, string expected)
|
||||
{
|
||||
var result = SiteStreamGrpcClient.MapQuality(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmStateEnum.AlarmStateNormal, AlarmState.Normal)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateActive, AlarmState.Active)]
|
||||
[InlineData(AlarmStateEnum.AlarmStateUnspecified, AlarmState.Normal)]
|
||||
public void MapAlarmState_AllValues(AlarmStateEnum input, AlarmState expected)
|
||||
{
|
||||
var result = SiteStreamGrpcClient.MapAlarmState(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelNone, AlarmLevel.None)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelLow, AlarmLevel.Low)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelLowLow, AlarmLevel.LowLow)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelHigh, AlarmLevel.High)]
|
||||
[InlineData(AlarmLevelEnum.AlarmLevelHighHigh, AlarmLevel.HighHigh)]
|
||||
public void MapAlarmLevel_AllValues(AlarmLevelEnum input, AlarmLevel expected)
|
||||
{
|
||||
var result = SiteStreamGrpcClient.MapAlarmLevel(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_AlarmChanged_PreservesLevel()
|
||||
{
|
||||
// Round-trip: a HiLo alarm emitted at HighHigh must come through with Level intact.
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
CorrelationId = "test",
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
AlarmName = "TempAlarm",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = 900,
|
||||
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
Level = AlarmLevelEnum.AlarmLevelHighHigh
|
||||
}
|
||||
};
|
||||
|
||||
var domain = SiteStreamGrpcClient.ConvertToDomainEvent(evt) as AlarmStateChanged;
|
||||
|
||||
Assert.NotNull(domain);
|
||||
Assert.Equal(AlarmState.Active, domain.State);
|
||||
Assert.Equal(AlarmLevel.HighHigh, domain.Level);
|
||||
Assert.Equal(900, domain.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_CancelsSubscription()
|
||||
{
|
||||
// We can't easily test the full Subscribe flow without a real gRPC server,
|
||||
// but we can test the Unsubscribe path by registering a CTS directly.
|
||||
// Use the internal AddSubscription helper for testability.
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
client.AddSubscriptionForTesting("corr-test", cts);
|
||||
|
||||
Assert.False(cts.IsCancellationRequested);
|
||||
|
||||
client.Unsubscribe("corr-test");
|
||||
|
||||
Assert.True(cts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_NonExistent_DoesNotThrow()
|
||||
{
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
client.Unsubscribe("does-not-exist"); // Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CancelsAllSubscriptions()
|
||||
{
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var cts2 = new CancellationTokenSource();
|
||||
client.AddSubscriptionForTesting("corr-1", cts1);
|
||||
client.AddSubscriptionForTesting("corr-2", cts2);
|
||||
|
||||
await client.DisposeAsync();
|
||||
|
||||
Assert.True(cts1.IsCancellationRequested);
|
||||
Assert.True(cts2.IsCancellationRequested);
|
||||
}
|
||||
|
||||
// --- Communication-003 regression tests ---
|
||||
|
||||
[Fact]
|
||||
public void RegisterSubscription_ReusedCorrelationId_CancelsAndDisposesPriorCts()
|
||||
{
|
||||
// Two SubscribeAsync calls briefly sharing a correlation ID (reconnect race).
|
||||
// Inserting the second must cancel + dispose the first so it does not leak.
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
|
||||
var first = new CancellationTokenSource();
|
||||
var second = new CancellationTokenSource();
|
||||
|
||||
client.RegisterSubscription("corr-shared", first);
|
||||
client.RegisterSubscription("corr-shared", second);
|
||||
|
||||
Assert.True(first.IsCancellationRequested);
|
||||
// Disposed CTS throws ObjectDisposedException when its token is touched.
|
||||
Assert.Throws<ObjectDisposedException>(() => _ = first.Token);
|
||||
|
||||
// The second (live) CTS must remain intact.
|
||||
Assert.False(second.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSubscription_OnlyRemovesOwnCts_NotAReplacement()
|
||||
{
|
||||
// First call's finally must NOT remove the second call's live entry.
|
||||
var client = SiteStreamGrpcClient.CreateForTesting();
|
||||
|
||||
var first = new CancellationTokenSource();
|
||||
var second = new CancellationTokenSource();
|
||||
|
||||
client.RegisterSubscription("corr-shared", first);
|
||||
// A racing second SubscribeAsync replaces the entry.
|
||||
client.RegisterSubscription("corr-shared", second);
|
||||
|
||||
// The first call's finally runs and tries to remove its (already-replaced) entry.
|
||||
client.RemoveSubscription("corr-shared", first);
|
||||
|
||||
// The live (second) subscription must still be cancellable via Unsubscribe.
|
||||
Assert.False(second.IsCancellationRequested);
|
||||
client.Unsubscribe("corr-shared");
|
||||
Assert.True(second.IsCancellationRequested);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.Threading.Channels;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class SiteStreamGrpcServerTests : TestKit
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber;
|
||||
private readonly ILogger<SiteStreamGrpcServer> _logger;
|
||||
|
||||
public SiteStreamGrpcServerTests()
|
||||
{
|
||||
_subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
_subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns("sub-1");
|
||||
_logger = NullLogger<SiteStreamGrpcServer>.Instance;
|
||||
}
|
||||
|
||||
private SiteStreamGrpcServer CreateServer(int maxStreams = 100)
|
||||
{
|
||||
return new SiteStreamGrpcServer(_subscriber, _logger, maxStreams);
|
||||
}
|
||||
|
||||
private static InstanceStreamRequest MakeRequest(string correlationId = "corr-1", string instance = "Site1.Pump01")
|
||||
{
|
||||
return new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
InstanceUniqueName = instance
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsWhenNotReady()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Do NOT call SetReady()
|
||||
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var context = CreateMockContext();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => server.SubscribeInstance(MakeRequest(), writer, context));
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsWhenMaxStreamsReached()
|
||||
{
|
||||
var server = CreateServer(maxStreams: 1);
|
||||
server.SetReady(Sys);
|
||||
|
||||
// Start one stream that blocks
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var context1 = CreateMockContext(cts1.Token);
|
||||
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-1"), writer1, context1));
|
||||
|
||||
// Wait for the first stream to register
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Second stream should be rejected
|
||||
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var context2 = CreateMockContext();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => server.SubscribeInstance(MakeRequest("corr-2"), writer2, context2));
|
||||
|
||||
Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
|
||||
|
||||
// Clean up first stream
|
||||
cts1.Cancel();
|
||||
await stream1Task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelsDuplicateCorrelationId()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var context1 = CreateMockContext(cts1.Token);
|
||||
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
// Start first stream
|
||||
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-dup"), writer1, context1));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Start second stream with same correlationId -- should cancel first
|
||||
var cts2 = new CancellationTokenSource();
|
||||
var context2 = CreateMockContext(cts2.Token);
|
||||
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var stream2Task = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-dup"), writer2, context2));
|
||||
|
||||
// First stream should complete (cancelled by duplicate replacement)
|
||||
await stream1Task;
|
||||
|
||||
// Second stream should be active
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Clean up
|
||||
cts2.Cancel();
|
||||
await stream2Task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleansUpOnCancellation()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-cleanup"), writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
// --- Host-017 / REQ-HOST-7: site-shutdown ordering ---
|
||||
|
||||
[Fact]
|
||||
public async Task Host017_CancelAllStreams_CancelsActiveStreamsAndRefusesNewOnes()
|
||||
{
|
||||
// REQ-HOST-7 step (1)+(2): on CoordinatedShutdown the gRPC server must
|
||||
// stop accepting new streams AND cancel every active stream so the
|
||||
// client observes a clean Cancelled (not a silent stream that only
|
||||
// times out via keepalive). Program.cs registers
|
||||
// ApplicationStopping → CancelAllStreams(); this test exercises the
|
||||
// server-side guarantee in isolation.
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var context1 = CreateMockContext(cts1.Token);
|
||||
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-shutdown-1"), writer1, context1));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Begin shutdown — flip the flag AND cancel the active stream.
|
||||
server.CancelAllStreams();
|
||||
|
||||
Assert.True(server.IsShuttingDown);
|
||||
|
||||
// Active stream's await foreach observes OCE and falls through finally
|
||||
// → entry is removed from _activeStreams.
|
||||
await stream1Task;
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
|
||||
// A second SubscribeInstance after shutdown is refused immediately
|
||||
// with Unavailable rather than allowed to register a new stream.
|
||||
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var context2 = CreateMockContext();
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => server.SubscribeInstance(MakeRequest("corr-shutdown-2"), writer2, context2));
|
||||
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
||||
Assert.Contains("shutting", ex.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host017_CancelAllStreams_IsIdempotent()
|
||||
{
|
||||
// Repeated calls during a double-fire shutdown sequence must not throw.
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
server.CancelAllStreams();
|
||||
server.CancelAllStreams();
|
||||
|
||||
Assert.True(server.IsShuttingDown);
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribesAndRemovesFromStreamManager()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-sub", "Site1.Motor01"), writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Verify Subscribe was called
|
||||
_subscriber.Received(1).Subscribe("Site1.Motor01", Arg.Any<IActorRef>());
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
// Verify RemoveSubscriber was called
|
||||
_subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritesEventsToResponseStream()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
// Capture the relay actor so we can send it events
|
||||
IActorRef? capturedActor = null;
|
||||
_subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
capturedActor = ci.Arg<IActorRef>();
|
||||
return "sub-write";
|
||||
});
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var writtenEvents = new List<SiteStreamEvent>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest("corr-write", "Site1.Pump01"), writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => capturedActor != null);
|
||||
|
||||
// Send a domain event to the relay actor
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
capturedActor!.Tell(new Commons.Messages.Streaming.AttributeValueChanged(
|
||||
"Site1.Pump01", "Path", "Attr", 99.5, "Good", ts));
|
||||
|
||||
// Wait for event to be written
|
||||
await WaitForConditionAsync(() => writtenEvents.Count >= 1);
|
||||
|
||||
Assert.Single(writtenEvents);
|
||||
Assert.Equal("corr-write", writtenEvents[0].CorrelationId);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[0].EventCase);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("corr/with/slash")]
|
||||
[InlineData("corr with space")]
|
||||
[InlineData("")]
|
||||
[InlineData("$weird")]
|
||||
public async Task RejectsCorrelationIdThatIsNotActorNameSafe(string badCorrelationId)
|
||||
{
|
||||
// Communication-014 regression: a public gRPC SubscribeInstance must not feed
|
||||
// an untrusted correlation_id straight into an Akka actor name. An unsafe id
|
||||
// must be rejected cleanly with InvalidArgument rather than escaping as an
|
||||
// unhandled InvalidActorNameException.
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var context = CreateMockContext();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => server.SubscribeInstance(MakeRequest(badCorrelationId), writer, context));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptsActorNameSafeCorrelationId()
|
||||
{
|
||||
// A normal GUID-style correlation id (what central always supplies) is accepted.
|
||||
var server = CreateServer();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(
|
||||
MakeRequest(Guid.NewGuid().ToString()), writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Comm021_SubscribeThrows_StopsRelayActorAndRemovesActiveStreamEntry()
|
||||
{
|
||||
// Communication-021 regression: SubscribeInstance creates a StreamRelayActor
|
||||
// and registers an _activeStreams entry BEFORE calling _streamSubscriber.Subscribe.
|
||||
// If Subscribe throws (e.g. stale instance, site runtime shutting down) and the
|
||||
// pre-fix code lets the throw escape without the wrapping try, the relay actor
|
||||
// and the activeStreams entry both leak. The fix wraps the Subscribe call so the
|
||||
// catch deterministically stops the actor and removes the entry before re-throw.
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns<string>(_ => throw new InvalidOperationException("instance not found"));
|
||||
|
||||
var server = new SiteStreamGrpcServer(subscriber, _logger);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var context = CreateMockContext();
|
||||
|
||||
// The InvalidOperationException is expected to propagate (the gRPC stack maps
|
||||
// unhandled throws to Internal); the load-bearing assertion is the cleanup.
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => server.SubscribeInstance(MakeRequest("corr-comm021"), writer, context));
|
||||
|
||||
// _activeStreams entry was inserted before Subscribe was called; the catch
|
||||
// must remove it so a follow-up subscription with the same correlation id is
|
||||
// not blocked, and the relay actor must be stopped so it does not leak.
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
|
||||
// RemoveSubscriber must NOT have been called (Subscribe never returned a
|
||||
// subscription id) — verifying we hit the catch path, not the finally path.
|
||||
subscriber.DidNotReceive().RemoveSubscriber(Arg.Any<IActorRef>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetReady_AllowsStreamCreation()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Initially not ready -- just verify the property works
|
||||
server.SetReady(Sys);
|
||||
// No assertion needed -- the other tests verify that SetReady enables streaming
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
private static ServerCallContext CreateMockContext(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(cancellationToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Threading.Channels;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using AlarmState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmState;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
public class StreamRelayActorTests : TestKit
|
||||
{
|
||||
[Fact]
|
||||
public void RelaysAttributeValueChanged_ToProtoEvent()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var correlationId = "corr-attr-1";
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor(correlationId, channel.Writer)));
|
||||
|
||||
var timestamp = new DateTimeOffset(2026, 3, 21, 10, 30, 0, TimeSpan.Zero);
|
||||
var domainEvent = new AttributeValueChanged(
|
||||
"Site1.Pump01", "Modules.Pressure", "CurrentPSI", 42.5, "Good", timestamp);
|
||||
|
||||
actor.Tell(domainEvent);
|
||||
|
||||
var success = channel.Reader.TryRead(out var protoEvent);
|
||||
if (!success)
|
||||
{
|
||||
// Give a moment for async processing
|
||||
Thread.Sleep(500);
|
||||
success = channel.Reader.TryRead(out protoEvent);
|
||||
}
|
||||
|
||||
Assert.True(success, "Expected a proto event on the channel");
|
||||
Assert.NotNull(protoEvent);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, protoEvent.EventCase);
|
||||
Assert.Equal(correlationId, protoEvent.CorrelationId);
|
||||
|
||||
var attr = protoEvent.AttributeChanged;
|
||||
Assert.Equal("Site1.Pump01", attr.InstanceUniqueName);
|
||||
Assert.Equal("Modules.Pressure", attr.AttributePath);
|
||||
Assert.Equal("CurrentPSI", attr.AttributeName);
|
||||
Assert.Equal("42.5", attr.Value);
|
||||
Assert.Equal(Quality.Good, attr.Quality);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), attr.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelaysAlarmStateChanged_ToProtoEvent()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var correlationId = "corr-alarm-1";
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor(correlationId, channel.Writer)));
|
||||
|
||||
var timestamp = new DateTimeOffset(2026, 3, 21, 11, 0, 0, TimeSpan.Zero);
|
||||
var domainEvent = new AlarmStateChanged(
|
||||
"Site1.Pump01", "HighPressure", AlarmState.Active, 2, timestamp);
|
||||
|
||||
actor.Tell(domainEvent);
|
||||
|
||||
var success = channel.Reader.TryRead(out var protoEvent);
|
||||
if (!success)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
success = channel.Reader.TryRead(out protoEvent);
|
||||
}
|
||||
|
||||
Assert.True(success, "Expected a proto event on the channel");
|
||||
Assert.NotNull(protoEvent);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, protoEvent.EventCase);
|
||||
Assert.Equal(correlationId, protoEvent.CorrelationId);
|
||||
|
||||
var alarm = protoEvent.AlarmChanged;
|
||||
Assert.Equal("Site1.Pump01", alarm.InstanceUniqueName);
|
||||
Assert.Equal("HighPressure", alarm.AlarmName);
|
||||
Assert.Equal(AlarmStateEnum.AlarmStateActive, alarm.State);
|
||||
Assert.Equal(2, alarm.Priority);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(timestamp), alarm.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetsCorrelationId_OnAllEvents()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var correlationId = "corr-multi-42";
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor(correlationId, channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
|
||||
actor.Tell(new AttributeValueChanged("Inst1", "Path", "Name", 1, "Good", ts));
|
||||
actor.Tell(new AlarmStateChanged("Inst1", "Alarm1", AlarmState.Normal, 1, ts));
|
||||
actor.Tell(new AttributeValueChanged("Inst2", "Path2", "Name2", null, "Bad", ts));
|
||||
|
||||
// Allow messages to process
|
||||
Thread.Sleep(500);
|
||||
|
||||
var events = new List<SiteStreamEvent>();
|
||||
while (channel.Reader.TryRead(out var evt))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
Assert.Equal(3, events.Count);
|
||||
Assert.All(events, e => Assert.Equal(correlationId, e.CorrelationId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DropsEvent_WhenChannelFull()
|
||||
{
|
||||
var channel = Channel.CreateBounded<SiteStreamEvent>(new BoundedChannelOptions(1)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
});
|
||||
var correlationId = "corr-drop-1";
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor(correlationId, channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
|
||||
// Fill the channel with one item directly
|
||||
var filler = new SiteStreamEvent { CorrelationId = "filler" };
|
||||
Assert.True(channel.Writer.TryWrite(filler));
|
||||
|
||||
// Send another event — should be dropped (channel full), no exception
|
||||
actor.Tell(new AttributeValueChanged("Inst1", "Path", "Name", 1, "Good", ts));
|
||||
|
||||
// Allow message to process
|
||||
Thread.Sleep(500);
|
||||
|
||||
// Channel should still have exactly 1 item (the filler)
|
||||
Assert.True(channel.Reader.TryRead(out var item));
|
||||
Assert.Equal("filler", item.CorrelationId);
|
||||
Assert.False(channel.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Good", Quality.Good)]
|
||||
[InlineData("Uncertain", Quality.Uncertain)]
|
||||
[InlineData("Bad", Quality.Bad)]
|
||||
[InlineData("Unknown", Quality.Unspecified)]
|
||||
[InlineData("", Quality.Unspecified)]
|
||||
[InlineData("good", Quality.Unspecified)]
|
||||
public void MapsQualityString_ToProtoEnum(string qualityString, Quality expectedProto)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor("corr", channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", 1, qualityString, ts));
|
||||
|
||||
Thread.Sleep(500);
|
||||
Assert.True(channel.Reader.TryRead(out var evt));
|
||||
Assert.Equal(expectedProto, evt.AttributeChanged.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullValue_MapsToEmptyString()
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
|
||||
var actor = Sys.ActorOf(Props.Create(() =>
|
||||
new StreamRelayActor("corr", channel.Writer)));
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new AttributeValueChanged("Inst", "Path", "Name", null, "Good", ts));
|
||||
|
||||
Thread.Sleep(500);
|
||||
Assert.True(channel.Reader.TryRead(out var evt));
|
||||
Assert.Equal("", evt.AttributeChanged.Value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user