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,288 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + edge tests for the <see cref="AuditEventDtoMapper"/> that bridges
|
||||
/// <see cref="AuditEvent"/> (Commons) ↔ <see cref="AuditEventDto"/> (proto).
|
||||
/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
|
||||
/// the proto round-trip.
|
||||
/// </summary>
|
||||
public class AuditEventDtoMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
|
||||
{
|
||||
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
|
||||
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
|
||||
var correlationId = Guid.NewGuid();
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var eventId = Guid.NewGuid();
|
||||
|
||||
var original = new AuditEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
OccurredAtUtc = occurredAt,
|
||||
IngestedAtUtc = ingestedAt,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCallCached,
|
||||
CorrelationId = correlationId,
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
SourceSiteId = "site-1",
|
||||
SourceNode = "node-a",
|
||||
SourceInstanceId = "Pump01",
|
||||
SourceScript = "OnDemand",
|
||||
Actor = "design-key",
|
||||
Target = "weather-api",
|
||||
Status = AuditStatus.Forwarded,
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
ErrorMessage = "transient timeout",
|
||||
ErrorDetail = "stack-trace",
|
||||
RequestSummary = "GET /weather",
|
||||
ResponseSummary = "{ \"ok\": true }",
|
||||
PayloadTruncated = true,
|
||||
Extra = "{ \"retryCount\": 1 }",
|
||||
ForwardState = AuditForwardState.Pending
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(original);
|
||||
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Equal(original.EventId, roundTripped.EventId);
|
||||
Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc);
|
||||
Assert.Equal(original.Channel, roundTripped.Channel);
|
||||
Assert.Equal(original.Kind, roundTripped.Kind);
|
||||
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
|
||||
Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
|
||||
Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId);
|
||||
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
|
||||
Assert.Equal(original.SourceNode, roundTripped.SourceNode);
|
||||
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
|
||||
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
|
||||
Assert.Equal(original.Actor, roundTripped.Actor);
|
||||
Assert.Equal(original.Target, roundTripped.Target);
|
||||
Assert.Equal(original.Status, roundTripped.Status);
|
||||
Assert.Equal(original.HttpStatus, roundTripped.HttpStatus);
|
||||
Assert.Equal(original.DurationMs, roundTripped.DurationMs);
|
||||
Assert.Equal(original.ErrorMessage, roundTripped.ErrorMessage);
|
||||
Assert.Equal(original.ErrorDetail, roundTripped.ErrorDetail);
|
||||
Assert.Equal(original.RequestSummary, roundTripped.RequestSummary);
|
||||
Assert.Equal(original.ResponseSummary, roundTripped.ResponseSummary);
|
||||
Assert.Equal(original.PayloadTruncated, roundTripped.PayloadTruncated);
|
||||
Assert.Equal(original.Extra, roundTripped.Extra);
|
||||
|
||||
// ForwardState + IngestedAtUtc are NOT carried in the proto contract.
|
||||
Assert.Null(roundTripped.ForwardState);
|
||||
Assert.Null(roundTripped.IngestedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_NullableStringFields_BecomeEmptyString()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
Status = AuditStatus.Submitted
|
||||
// all string? fields left null; CorrelationId null
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
|
||||
Assert.Equal(string.Empty, dto.CorrelationId);
|
||||
Assert.Equal(string.Empty, dto.ExecutionId);
|
||||
Assert.Equal(string.Empty, dto.ParentExecutionId);
|
||||
Assert.Equal(string.Empty, dto.SourceSiteId);
|
||||
Assert.Equal(string.Empty, dto.SourceNode);
|
||||
Assert.Equal(string.Empty, dto.SourceInstanceId);
|
||||
Assert.Equal(string.Empty, dto.SourceScript);
|
||||
Assert.Equal(string.Empty, dto.Actor);
|
||||
Assert.Equal(string.Empty, dto.Target);
|
||||
Assert.Equal(string.Empty, dto.ErrorMessage);
|
||||
Assert.Equal(string.Empty, dto.ErrorDetail);
|
||||
Assert.Equal(string.Empty, dto.RequestSummary);
|
||||
Assert.Equal(string.Empty, dto.ResponseSummary);
|
||||
Assert.Equal(string.Empty, dto.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_EmptyString_BecomesNullProperty()
|
||||
{
|
||||
var dto = new AuditEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
Channel = nameof(AuditChannel.ApiOutbound),
|
||||
Kind = nameof(AuditKind.ApiCall),
|
||||
Status = nameof(AuditStatus.Submitted),
|
||||
CorrelationId = string.Empty,
|
||||
ExecutionId = string.Empty,
|
||||
ParentExecutionId = string.Empty,
|
||||
SourceSiteId = string.Empty,
|
||||
SourceNode = string.Empty,
|
||||
SourceInstanceId = string.Empty,
|
||||
SourceScript = string.Empty,
|
||||
Actor = string.Empty,
|
||||
Target = string.Empty,
|
||||
ErrorMessage = string.Empty,
|
||||
ErrorDetail = string.Empty,
|
||||
RequestSummary = string.Empty,
|
||||
ResponseSummary = string.Empty,
|
||||
Extra = string.Empty
|
||||
};
|
||||
|
||||
var evt = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Null(evt.CorrelationId);
|
||||
Assert.Null(evt.ExecutionId);
|
||||
Assert.Null(evt.ParentExecutionId);
|
||||
Assert.Null(evt.SourceSiteId);
|
||||
Assert.Null(evt.SourceNode);
|
||||
Assert.Null(evt.SourceInstanceId);
|
||||
Assert.Null(evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
Assert.Null(evt.Target);
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
Assert.Null(evt.RequestSummary);
|
||||
Assert.Null(evt.ResponseSummary);
|
||||
Assert.Null(evt.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_OccurredAtUtc_PreservesUtcKind()
|
||||
{
|
||||
var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAt,
|
||||
Channel = AuditChannel.DbOutbound,
|
||||
Kind = AuditKind.DbWrite,
|
||||
Status = AuditStatus.Delivered
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind);
|
||||
Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_NullableInt_BecomesNullInt32Value()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
DurationMs = null
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
|
||||
Assert.Null(dto.HttpStatus);
|
||||
Assert.Null(dto.DurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_NullInt32Value_BecomesNullProperty()
|
||||
{
|
||||
var dto = new AuditEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
Channel = nameof(AuditChannel.ApiInbound),
|
||||
Kind = nameof(AuditKind.InboundRequest),
|
||||
Status = nameof(AuditStatus.Delivered)
|
||||
// HttpStatus + DurationMs intentionally left absent
|
||||
};
|
||||
|
||||
Assert.Null(dto.HttpStatus);
|
||||
Assert.Null(dto.DurationMs);
|
||||
|
||||
var evt = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Null(evt.HttpStatus);
|
||||
Assert.Null(evt.DurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDto_EnumValues_StoredAsStringNames()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCallCached,
|
||||
Status = AuditStatus.Parked
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
|
||||
Assert.Equal("ApiOutbound", dto.Channel);
|
||||
Assert.Equal("ApiCallCached", dto.Kind);
|
||||
Assert.Equal("Parked", dto.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEventDto_round_trip_preserves_SourceNode()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceNode = "node-a"
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
|
||||
// Wire form: empty-string-means-null convention; populated value
|
||||
// travels verbatim.
|
||||
Assert.Equal("node-a", dto.SourceNode);
|
||||
|
||||
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Equal("node-a", roundTripped.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEventDto_round_trip_preserves_null_SourceNode()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceNode = null
|
||||
};
|
||||
|
||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||
|
||||
// ToDto collapses null → empty on the wire…
|
||||
Assert.Equal(string.Empty, dto.SourceNode);
|
||||
|
||||
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||
|
||||
// …and FromDto rehydrates empty → null.
|
||||
Assert.Null(roundTripped.SourceNode);
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Audit Log (#23) site→central ClusterClient ingest routing on
|
||||
/// <see cref="CentralCommunicationActor"/>. A site ClusterClient delivers
|
||||
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
|
||||
/// to the receptionist-registered actor, which forwards to the registered
|
||||
/// <c>AuditLogIngestActor</c> proxy and routes the reply back to the site.
|
||||
/// Mirrors the NotificationSubmit / RegisterNotificationOutbox pattern.
|
||||
/// </summary>
|
||||
public class CentralCommunicationActorAuditTests : TestKit
|
||||
{
|
||||
public CentralCommunicationActorAuditTests() : base(@"akka.loglevel = DEBUG") { }
|
||||
|
||||
private IActorRef CreateActor(TimeSpan? auditIngestAskTimeout = null)
|
||||
{
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.Site>());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var mockFactory = Substitute.For<ISiteClientFactory>();
|
||||
return Sys.ActorOf(Props.Create(() =>
|
||||
new CentralCommunicationActor(sp, mockFactory, auditIngestAskTimeout)));
|
||||
}
|
||||
|
||||
private static AuditEvent SampleAuditEvent() => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
};
|
||||
|
||||
private static SiteCall SampleSiteCall() => new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.New(),
|
||||
Channel = "OutboundApi",
|
||||
Target = "ExternalSystemA",
|
||||
SourceSite = "site1",
|
||||
Status = "Delivered",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IngestAuditEventsCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var auditProbe = CreateTestProbe();
|
||||
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||
|
||||
var evt = SampleAuditEvent();
|
||||
var cmd = new IngestAuditEventsCommand(new[] { evt });
|
||||
actor.Tell(cmd);
|
||||
|
||||
// The audit-ingest proxy receives the command, with the original site
|
||||
// sender preserved (Forward semantics).
|
||||
auditProbe.ExpectMsg(cmd);
|
||||
|
||||
// When the proxy replies, the actor routes it back to the original sender.
|
||||
var reply = new IngestAuditEventsReply(new[] { evt.EventId });
|
||||
auditProbe.Reply(reply);
|
||||
|
||||
var received = ExpectMsg<IngestAuditEventsReply>();
|
||||
Assert.Equal(new[] { evt.EventId }, received.AcceptedEventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestAuditEventsCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }));
|
||||
|
||||
var reply = ExpectMsg<IngestAuditEventsReply>();
|
||||
Assert.Empty(reply.AcceptedEventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestAuditEventsCommand_WhenProxyNeverReplies_PipesStatusFailureToSender()
|
||||
{
|
||||
// A short test-only Ask timeout (constructor seam) keeps the test fast —
|
||||
// production uses the 30 s default.
|
||||
var actor = CreateActor(auditIngestAskTimeout: TimeSpan.FromMilliseconds(200));
|
||||
var auditProbe = CreateTestProbe();
|
||||
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||
|
||||
var cmd = new IngestAuditEventsCommand(new[] { SampleAuditEvent() });
|
||||
actor.Tell(cmd);
|
||||
|
||||
// The proxy receives the command but deliberately never replies.
|
||||
auditProbe.ExpectMsg(cmd);
|
||||
|
||||
// The Ask times out; PipeTo forwards the faulted task as a Status.Failure
|
||||
// to the original sender. This is the real transient signal the site's
|
||||
// own Ask faults on — it is NOT swallowed into an empty ack.
|
||||
var failure = ExpectMsg<Status.Failure>();
|
||||
Assert.IsType<AskTimeoutException>(failure.Cause);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestCachedTelemetryCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var auditProbe = CreateTestProbe();
|
||||
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||
|
||||
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
|
||||
var cmd = new IngestCachedTelemetryCommand(new[] { entry });
|
||||
actor.Tell(cmd);
|
||||
|
||||
auditProbe.ExpectMsg(cmd);
|
||||
|
||||
var reply = new IngestCachedTelemetryReply(new[] { entry.Audit.EventId });
|
||||
auditProbe.Reply(reply);
|
||||
|
||||
var received = ExpectMsg<IngestCachedTelemetryReply>();
|
||||
Assert.Equal(new[] { entry.Audit.EventId }, received.AcceptedEventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestCachedTelemetryCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
|
||||
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
|
||||
actor.Tell(new IngestCachedTelemetryCommand(new[] { entry }));
|
||||
|
||||
var reply = ExpectMsg<IngestCachedTelemetryReply>();
|
||||
Assert.Empty(reply.AcceptedEventIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Collections.Immutable;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.Client;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using Akka.TestKit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CentralCommunicationActor with per-site ClusterClient routing.
|
||||
/// WP-4: Message routing via ClusterClient instances created per site.
|
||||
/// WP-5: Connection failure and failover handling.
|
||||
/// </summary>
|
||||
public class CentralCommunicationActorTests : TestKit
|
||||
{
|
||||
public CentralCommunicationActorTests() : base(@"akka.loglevel = DEBUG") { }
|
||||
|
||||
private (IActorRef actor, ISiteRepository mockRepo, Dictionary<string, TestProbe> siteProbes) CreateActorWithMockRepo(
|
||||
IEnumerable<Site>? sites = null)
|
||||
{
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(sites?.ToList() ?? new List<Site>());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var siteProbes = new Dictionary<string, TestProbe>();
|
||||
var mockFactory = Substitute.For<ISiteClientFactory>();
|
||||
mockFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var siteId = callInfo.ArgAt<string>(1);
|
||||
var probe = CreateTestProbe();
|
||||
siteProbes[siteId] = probe;
|
||||
return probe.Ref;
|
||||
});
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory)));
|
||||
return (actor, mockRepo, siteProbes);
|
||||
}
|
||||
|
||||
private (IActorRef actor, ISiteRepository mockRepo, Dictionary<string, TestProbe> siteProbes, ISiteClientFactory mockFactory) CreateActorWithFactory(
|
||||
IEnumerable<Site>? sites = null)
|
||||
{
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(sites?.ToList() ?? new List<Site>());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var siteProbes = new Dictionary<string, TestProbe>();
|
||||
var mockFactory = Substitute.For<ISiteClientFactory>();
|
||||
mockFactory.Create(Arg.Any<ActorSystem>(), Arg.Any<string>(), Arg.Any<ImmutableHashSet<ActorPath>>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var siteId = callInfo.ArgAt<string>(1);
|
||||
var probe = CreateTestProbe();
|
||||
siteProbes[siteId] = probe;
|
||||
return probe.Ref;
|
||||
});
|
||||
|
||||
var actor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory)));
|
||||
return (actor, mockRepo, siteProbes, mockFactory);
|
||||
}
|
||||
|
||||
private Site CreateSite(string identifier, string? nodeAAddress, string? nodeBAddress = null) =>
|
||||
new("Test Site", identifier) { NodeAAddress = nodeAAddress, NodeBAddress = nodeBAddress };
|
||||
|
||||
[Fact]
|
||||
public void ClusterClientRouting_RoutesToConfiguredSite()
|
||||
{
|
||||
var site = CreateSite("site1", "akka.tcp://scadabridge@host:8082");
|
||||
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { site });
|
||||
|
||||
// Wait for auto-refresh (PreStart schedules with TimeSpan.Zero initial delay)
|
||||
Thread.Sleep(1000);
|
||||
|
||||
var command = new DeployInstanceCommand(
|
||||
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("site1", command));
|
||||
|
||||
// The site1 probe (acting as ClusterClient) should receive a ClusterClient.Send
|
||||
var msg = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
|
||||
Assert.Equal("/user/site-communication", msg.Path);
|
||||
Assert.IsType<DeployInstanceCommand>(msg.Message);
|
||||
Assert.Equal("dep1", ((DeployInstanceCommand)msg.Message).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnconfiguredSite_MessageIsDropped()
|
||||
{
|
||||
var (actor, _, _) = CreateActorWithMockRepo();
|
||||
|
||||
// Wait for auto-refresh
|
||||
Thread.Sleep(1000);
|
||||
|
||||
var command = new DeployInstanceCommand(
|
||||
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("unknown-site", command));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
// Communication-016: the prior `ConnectionLost_DebugStreamsKilled` test was
|
||||
// removed alongside the dead HandleConnectionStateChanged handler. No
|
||||
// production code ever emitted ConnectionStateChanged, so the test was
|
||||
// exercising a workflow that never ran. Disconnect detection is owned by
|
||||
// the gRPC keepalive (DebugStreamBridgeActor self-terminates) and by the
|
||||
// Ask-timeout path at the CommunicationService layer (deploy callers see
|
||||
// a failure).
|
||||
|
||||
[Fact]
|
||||
public void Heartbeat_BumpsAggregatorTimestamp()
|
||||
{
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Site>());
|
||||
|
||||
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
services.AddSingleton(aggregator);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var siteClientFactory = Substitute.For<ISiteClientFactory>();
|
||||
var centralActor = Sys.ActorOf(
|
||||
Props.Create(() => new CentralCommunicationActor(sp, siteClientFactory)));
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
centralActor.Tell(new HeartbeatMessage("site1", "host1", true, timestamp));
|
||||
|
||||
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site1", timestamp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshSiteAddresses_UpdatesCache()
|
||||
{
|
||||
var site1 = CreateSite("site1", "akka.tcp://scadabridge@host1:8082");
|
||||
var (actor, mockRepo, siteProbes) = CreateActorWithMockRepo(new[] { site1 });
|
||||
|
||||
// Wait for initial load
|
||||
Thread.Sleep(1000);
|
||||
|
||||
// Verify routing to site1 works
|
||||
var cmd1 = new DeployInstanceCommand(
|
||||
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("site1", cmd1));
|
||||
var msg1 = siteProbes["site1"].ExpectMsg<ClusterClient.Send>();
|
||||
Assert.Equal("dep1", ((DeployInstanceCommand)msg1.Message).DeploymentId);
|
||||
|
||||
// Update mock repo to return both sites
|
||||
var site2 = CreateSite("site2", "akka.tcp://scadabridge@host2:8082");
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Site> { site1, site2 });
|
||||
|
||||
// Refresh again
|
||||
actor.Tell(new RefreshSiteAddresses());
|
||||
Thread.Sleep(1000);
|
||||
|
||||
// Verify routing to site2 now works
|
||||
var cmd2 = new DeployInstanceCommand(
|
||||
"dep2", "inst2", "hash2", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("site2", cmd2));
|
||||
var msg2 = siteProbes["site2"].ExpectMsg<ClusterClient.Send>();
|
||||
Assert.Equal("dep2", ((DeployInstanceCommand)msg2.Message).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadSiteAddressesFailure_IsLoggedNotSilentlySwallowed()
|
||||
{
|
||||
// Regression test for Communication-006. When the repository query throws,
|
||||
// PipeTo delivers a Status.Failure to the actor. Without a Receive<Status.Failure>
|
||||
// handler the failure becomes an unhandled message (debug-level only) and the
|
||||
// periodic refresh fails silently — operators cannot tell "no addresses
|
||||
// configured" from "database is down". The fix logs the failure at Warning.
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns<Task<IReadOnlyList<Site>>>(_ => throw new InvalidOperationException("database is down"));
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var mockFactory = Substitute.For<ISiteClientFactory>();
|
||||
|
||||
// The fix logs a Warning carrying the InvalidOperationException as the cause.
|
||||
EventFilter.Warning(contains: "Failed to load site addresses from the database").ExpectOne(() =>
|
||||
{
|
||||
Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, mockFactory)));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedSiteAddress_DoesNotAbortRefresh_OtherSitesStillRegistered()
|
||||
{
|
||||
// Regression test for Communication-009. HandleSiteAddressCacheLoaded calls
|
||||
// ActorPath.Parse for every site in a single loop. A malformed NodeAAddress
|
||||
// throws inside that loop; before the fix the whole refresh aborted partway
|
||||
// through, leaving the cache half-updated (some sites registered, others not).
|
||||
// The fix wraps the parse in a try/catch that logs and skips the bad site so
|
||||
// a single garbage row cannot starve every other site of its ClusterClient.
|
||||
var goodSite = CreateSite("good-site", "akka.tcp://scadabridge@host1:8082");
|
||||
// A garbage address that ActorPath.Parse rejects.
|
||||
var badSite = CreateSite("bad-site", "this is not a valid actor path !!!");
|
||||
|
||||
// Order the bad site first so a non-resilient loop aborts before reaching good-site.
|
||||
var (actor, _, siteProbes) = CreateActorWithMockRepo(new[] { badSite, goodSite });
|
||||
|
||||
Thread.Sleep(1000);
|
||||
|
||||
// good-site must still be registered and routable despite bad-site failing to parse.
|
||||
var cmd = new DeployInstanceCommand(
|
||||
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
actor.Tell(new SiteEnvelope("good-site", cmd));
|
||||
|
||||
Assert.True(siteProbes.ContainsKey("good-site"),
|
||||
"good-site should have a ClusterClient even though bad-site's address is malformed");
|
||||
var msg = siteProbes["good-site"].ExpectMsg<ClusterClient.Send>();
|
||||
Assert.Equal("dep1", ((DeployInstanceCommand)msg.Message).DeploymentId);
|
||||
}
|
||||
|
||||
private NotificationSubmit CreateSubmit(string id = "notif1") =>
|
||||
new(id, "ops-list", "Subject", "Body", "site1", "inst1", "script.cs", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ForwardedToOutboxProxy_AckRoutesBackToSite()
|
||||
{
|
||||
var (actor, _, _) = CreateActorWithMockRepo();
|
||||
var outboxProbe = CreateTestProbe();
|
||||
actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref));
|
||||
|
||||
// A second probe stands in for the site's ClusterClient (the original Sender).
|
||||
var siteProbe = CreateTestProbe();
|
||||
var submit = CreateSubmit();
|
||||
actor.Tell(submit, siteProbe.Ref);
|
||||
|
||||
// The outbox proxy receives the NotificationSubmit with the site as the sender,
|
||||
// so an ack it sends routes straight back to the site, not the central actor.
|
||||
outboxProbe.ExpectMsg<NotificationSubmit>(m => m.NotificationId == "notif1");
|
||||
outboxProbe.Reply(new NotificationSubmitAck("notif1", Accepted: true, Error: null));
|
||||
siteProbe.ExpectMsg<NotificationSubmitAck>(a => a.NotificationId == "notif1" && a.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_ForwardedToOutboxProxy_ResponseRoutesBackToSite()
|
||||
{
|
||||
var (actor, _, _) = CreateActorWithMockRepo();
|
||||
var outboxProbe = CreateTestProbe();
|
||||
actor.Tell(new RegisterNotificationOutbox(outboxProbe.Ref));
|
||||
|
||||
var siteProbe = CreateTestProbe();
|
||||
var query = new NotificationStatusQuery("corr1", "notif1");
|
||||
actor.Tell(query, siteProbe.Ref);
|
||||
|
||||
outboxProbe.ExpectMsg<NotificationStatusQuery>(m => m.CorrelationId == "corr1");
|
||||
outboxProbe.Reply(new NotificationStatusResponse(
|
||||
"corr1", Found: true, Status: "Delivered", RetryCount: 0,
|
||||
LastError: null, DeliveredAt: DateTimeOffset.UtcNow));
|
||||
siteProbe.ExpectMsg<NotificationStatusResponse>(r => r.CorrelationId == "corr1" && r.Found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NoOutboxConfigured_RepliesNonAccepted()
|
||||
{
|
||||
var (actor, _, _) = CreateActorWithMockRepo();
|
||||
|
||||
// No RegisterNotificationOutbox sent — the proxy is null.
|
||||
var submit = CreateSubmit();
|
||||
actor.Tell(submit);
|
||||
|
||||
var ack = ExpectMsg<NotificationSubmitAck>();
|
||||
Assert.Equal("notif1", ack.NotificationId);
|
||||
Assert.False(ack.Accepted);
|
||||
Assert.NotNull(ack.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_NoOutboxConfigured_RepliesNotFound()
|
||||
{
|
||||
var (actor, _, _) = CreateActorWithMockRepo();
|
||||
|
||||
// No RegisterNotificationOutbox sent — the proxy is null.
|
||||
var query = new NotificationStatusQuery("corr1", "notif1");
|
||||
actor.Tell(query);
|
||||
|
||||
var response = ExpectMsg<NotificationStatusResponse>();
|
||||
Assert.Equal("corr1", response.CorrelationId);
|
||||
Assert.False(response.Found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothContactPoints_UsedInSingleClient()
|
||||
{
|
||||
var site = CreateSite("site1",
|
||||
"akka.tcp://scadabridge@host1:8082",
|
||||
"akka.tcp://scadabridge@host2:8082");
|
||||
|
||||
var (actor, _, siteProbes, mockFactory) = CreateActorWithFactory(new[] { site });
|
||||
|
||||
// Wait for auto-refresh
|
||||
Thread.Sleep(1000);
|
||||
|
||||
// Verify the factory was called with 2 contact paths
|
||||
mockFactory.Received(1).Create(
|
||||
Arg.Any<ActorSystem>(),
|
||||
Arg.Is("site1"),
|
||||
Arg.Is<ImmutableHashSet<ActorPath>>(paths => paths.Count == 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Tests for per-pattern timeout configuration.
|
||||
/// </summary>
|
||||
public class CommunicationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultTimeouts_AreReasonable()
|
||||
{
|
||||
var options = new CommunicationOptions();
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(2), options.DeploymentTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.LifecycleTimeout);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), options.ArtifactDeploymentTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.QueryTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), options.IntegrationTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.DebugViewTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), options.HealthReportTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransportHeartbeat_HasExplicitDefaults()
|
||||
{
|
||||
var options = new CommunicationOptions();
|
||||
|
||||
// WP-3: Transport heartbeat is explicitly configured, not framework defaults
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), options.TransportHeartbeatInterval);
|
||||
Assert.Equal(TimeSpan.FromSeconds(15), options.TransportFailureThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentTimeout_IsLongestPattern()
|
||||
{
|
||||
var options = new CommunicationOptions();
|
||||
|
||||
Assert.True(options.DeploymentTimeout > options.LifecycleTimeout);
|
||||
Assert.True(options.DeploymentTimeout > options.QueryTimeout);
|
||||
Assert.True(options.DeploymentTimeout > options.IntegrationTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllTimeouts_AreConfigurable()
|
||||
{
|
||||
var options = new CommunicationOptions
|
||||
{
|
||||
DeploymentTimeout = TimeSpan.FromMinutes(5),
|
||||
LifecycleTimeout = TimeSpan.FromMinutes(1),
|
||||
ArtifactDeploymentTimeout = TimeSpan.FromMinutes(3),
|
||||
QueryTimeout = TimeSpan.FromMinutes(1),
|
||||
IntegrationTimeout = TimeSpan.FromMinutes(1),
|
||||
DebugViewTimeout = TimeSpan.FromSeconds(30),
|
||||
HealthReportTimeout = TimeSpan.FromSeconds(30),
|
||||
TransportHeartbeatInterval = TimeSpan.FromSeconds(2),
|
||||
TransportFailureThreshold = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), options.DeploymentTimeout);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), options.TransportHeartbeatInterval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Tests for CommunicationService initialization and state.
|
||||
/// </summary>
|
||||
public class CommunicationServiceTests : TestKit
|
||||
{
|
||||
[Fact]
|
||||
public async Task BeforeInitialization_ThrowsOnUsage()
|
||||
{
|
||||
var options = Options.Create(new CommunicationOptions());
|
||||
var logger = NullLogger<CommunicationService>.Instance;
|
||||
var service = new CommunicationService(options, logger);
|
||||
|
||||
// CommunicationService requires SetCommunicationActor before use
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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<CommunicationService>.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<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.RetryNotificationAsync(new RetryNotificationRequest("corr-1", "n1")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardNotificationAsync_BeforeOutboxSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.DiscardNotificationAsync(new DiscardNotificationRequest("corr-1", "n1")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotificationKpisAsync_BeforeOutboxSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
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<CommunicationService>.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<NotificationOutboxQueryRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new NotificationOutboxQueryResponse(
|
||||
"corr-q", true, null, Array.Empty<NotificationSummary>(), 0);
|
||||
probe.Reply(reply);
|
||||
|
||||
Assert.Same(reply, await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryNotificationAsync_AsksOutboxProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.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<RetryNotificationRequest>();
|
||||
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<CommunicationService>.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<DiscardNotificationRequest>();
|
||||
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<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetNotificationOutbox(probe.Ref);
|
||||
|
||||
var request = new NotificationKpiRequest("corr-k");
|
||||
var task = service.GetNotificationKpisAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<NotificationKpiRequest>();
|
||||
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<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetNotificationOutbox(probe.Ref);
|
||||
|
||||
var request = new PerSiteNotificationKpiRequest("corr-ps");
|
||||
var task = service.GetPerSiteNotificationKpisAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<PerSiteNotificationKpiRequest>();
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Site Call Audit: central-side audit actor calls ──
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySiteCallsAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.QuerySiteCallsAsync(new SiteCallQueryRequest(
|
||||
"corr-1", null, null, null, null, false, null, null, null, null, 50)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.GetSiteCallKpisAsync(new SiteCallKpiRequest("corr-1")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteCallDetailAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.GetSiteCallDetailAsync(new SiteCallDetailRequest("corr-1", Guid.NewGuid())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPerSiteSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.GetPerSiteSiteCallKpisAsync(new PerSiteSiteCallKpiRequest("corr-1")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySiteCallsAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
// The Site Call Audit actor is central-local: the request must be Asked
|
||||
// directly to its proxy (no SiteEnvelope wrapping).
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new SiteCallQueryRequest(
|
||||
"corr-q", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
|
||||
null, null, null, null, 25);
|
||||
var task = service.QuerySiteCallsAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<SiteCallQueryRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new SiteCallQueryResponse(
|
||||
"corr-q", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||
probe.Reply(reply);
|
||||
|
||||
Assert.Same(reply, await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteCallDetailAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new SiteCallDetailRequest("corr-d", Guid.NewGuid());
|
||||
var task = service.GetSiteCallDetailAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<SiteCallDetailRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new SiteCallDetailResponse("corr-d", false, "site call not found", null);
|
||||
probe.Reply(reply);
|
||||
|
||||
var result = await task;
|
||||
Assert.Same(reply, result);
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new SiteCallKpiRequest("corr-k");
|
||||
var task = service.GetSiteCallKpisAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<SiteCallKpiRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new SiteCallKpiResponse(
|
||||
"corr-k", true, null, 4, 1, 2, 9, TimeSpan.FromMinutes(7), 1);
|
||||
probe.Reply(reply);
|
||||
|
||||
var result = await task;
|
||||
Assert.Same(reply, result);
|
||||
Assert.Equal(4, result.BufferedCount);
|
||||
Assert.Equal(1, result.StuckCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPerSiteSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new PerSiteSiteCallKpiRequest("corr-ps");
|
||||
var task = service.GetPerSiteSiteCallKpisAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<PerSiteSiteCallKpiRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new PerSiteSiteCallKpiResponse(
|
||||
"corr-ps", true, null,
|
||||
new[] { new SiteCallSiteKpiSnapshot("plant-a", 3, 0, 0, 5, null, 0) });
|
||||
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].SourceSite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrySiteCallAsync_BeforeSiteCallAuditSet_Throws()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.RetrySiteCallAsync(new RetrySiteCallRequest("corr-1", Guid.NewGuid(), "plant-a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrySiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
// The relay is initiated by Asking the central-local Site Call Audit
|
||||
// proxy directly (no SiteEnvelope wrapping at this layer — the actor
|
||||
// does the site routing itself).
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new RetrySiteCallRequest("corr-r", Guid.NewGuid(), "plant-a");
|
||||
var task = service.RetrySiteCallAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<RetrySiteCallRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new RetrySiteCallResponse(
|
||||
"corr-r", SiteCallRelayOutcome.Applied, true, true, null);
|
||||
probe.Reply(reply);
|
||||
|
||||
Assert.Same(reply, await task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardSiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||
{
|
||||
var service = new CommunicationService(
|
||||
Options.Create(new CommunicationOptions()),
|
||||
NullLogger<CommunicationService>.Instance);
|
||||
var probe = CreateTestProbe();
|
||||
service.SetSiteCallAudit(probe.Ref);
|
||||
|
||||
var request = new DiscardSiteCallRequest("corr-d", Guid.NewGuid(), "plant-a");
|
||||
var task = service.DiscardSiteCallAsync(request);
|
||||
|
||||
var received = probe.ExpectMsg<DiscardSiteCallRequest>();
|
||||
Assert.Same(request, received);
|
||||
var reply = new DiscardSiteCallResponse(
|
||||
"corr-d", SiteCallRelayOutcome.SiteUnreachable, false, false, "unreachable");
|
||||
probe.Reply(reply);
|
||||
|
||||
var result = await task;
|
||||
Assert.Same(reply, result);
|
||||
Assert.False(result.SiteReachable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
|
||||
/// in a SiteEnvelope targeting the requested site and replies with a typed
|
||||
/// DeploymentStateQueryResponse.
|
||||
/// </summary>
|
||||
private class EchoStateQueryActor : ReceiveActor
|
||||
{
|
||||
public EchoStateQueryActor()
|
||||
{
|
||||
Receive<SiteEnvelope>(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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Communication-004 — coordinator actors must declare an
|
||||
/// explicit <c>Resume</c> supervision strategy per the CLAUDE.md decision
|
||||
/// ("Resume for coordinator actors"). A child fault under the default
|
||||
/// (Restart) strategy would wipe a child's in-memory state; the long-lived
|
||||
/// coordinators own per-site ClusterClients and must not silently discard
|
||||
/// their children on a transient fault.
|
||||
/// </summary>
|
||||
public class CoordinatorSupervisionTests : TestKit
|
||||
{
|
||||
/// <summary>
|
||||
/// Test-only subclass that exposes the protected <see cref="SupervisorStrategy"/>
|
||||
/// so the configured directive can be asserted directly.
|
||||
/// </summary>
|
||||
private sealed class CentralCommunicationActorProbe : CentralCommunicationActor
|
||||
{
|
||||
public CentralCommunicationActorProbe(IServiceProvider sp, ISiteClientFactory factory)
|
||||
: base(sp, factory) { }
|
||||
|
||||
public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only subclass that exposes the protected <see cref="SupervisorStrategy"/>.
|
||||
/// </summary>
|
||||
private sealed class SiteCommunicationActorProbe : SiteCommunicationActor
|
||||
{
|
||||
public SiteCommunicationActorProbe(string siteId, CommunicationOptions options, IActorRef dm)
|
||||
: base(siteId, options, dm) { }
|
||||
|
||||
public SupervisorStrategy GetSupervisorStrategy() => SupervisorStrategy();
|
||||
}
|
||||
|
||||
private static IServiceProvider EmptyServiceProvider()
|
||||
{
|
||||
var mockRepo = Substitute.For<ISiteRepository>();
|
||||
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.Site>());
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mockRepo);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_SupervisorStrategy_IsResume()
|
||||
{
|
||||
var sp = EmptyServiceProvider();
|
||||
var factory = Substitute.For<ISiteClientFactory>();
|
||||
|
||||
var actorRef = new Akka.TestKit.TestActorRef<CentralCommunicationActorProbe>(
|
||||
Sys, Props.Create(() => new CentralCommunicationActorProbe(sp, factory)));
|
||||
|
||||
var strategy = actorRef.UnderlyingActor.GetSupervisorStrategy();
|
||||
|
||||
var oneForOne = Assert.IsType<OneForOneStrategy>(strategy);
|
||||
var directive = oneForOne.Decider.Decide(new InvalidOperationException("transient child fault"));
|
||||
Assert.Equal(Directive.Resume, directive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCommunicationActor_SupervisorStrategy_IsResume()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
|
||||
var actorRef = new Akka.TestKit.TestActorRef<SiteCommunicationActorProbe>(
|
||||
Sys, Props.Create(() => new SiteCommunicationActorProbe("site1", new CommunicationOptions(), dmProbe.Ref)));
|
||||
|
||||
var strategy = actorRef.UnderlyingActor.GetSupervisorStrategy();
|
||||
|
||||
var oneForOne = Assert.IsType<OneForOneStrategy>(strategy);
|
||||
var directive = oneForOne.Decider.Decide(new InvalidOperationException("transient child fault"));
|
||||
Assert.Equal(Directive.Resume, directive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for DebugStreamService session lifecycle.
|
||||
/// </summary>
|
||||
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<ITemplateEngineRepository>();
|
||||
instanceRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>()).Returns(instance);
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(3, Arg.Any<CancellationToken>()).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<CommunicationService>.Instance);
|
||||
commService.SetCommunicationActor(commProbe.Ref);
|
||||
|
||||
using var grpcFactory = new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance);
|
||||
var service = new DebugStreamService(
|
||||
commService, provider, grpcFactory, NullLogger<DebugStreamService>.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<SiteEnvelope>(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<InvalidOperationException>(() => startTask);
|
||||
Assert.Contains("Site1.Pump01", ex.Message);
|
||||
Assert.NotNull(ex.InnerException);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Tests that message contracts have correlation IDs and proper structure.
|
||||
/// </summary>
|
||||
public class MessageContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void IntegrationCallRequest_HasCorrelationId()
|
||||
{
|
||||
var msg = new IntegrationCallRequest(
|
||||
"corr-123", "site1", "inst1", "ExtSys1", "GetData",
|
||||
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-123", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntegrationCallResponse_HasCorrelationId()
|
||||
{
|
||||
var msg = new IntegrationCallResponse(
|
||||
"corr-123", "site1", true, "{}", null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-123", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogQueryRequest_HasCorrelationId()
|
||||
{
|
||||
var msg = new EventLogQueryRequest(
|
||||
"corr-456", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-456", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogQueryResponse_HasCorrelationId()
|
||||
{
|
||||
var msg = new EventLogQueryResponse(
|
||||
"corr-456", "site1", [], null, false, true, null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-456", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParkedMessageQueryRequest_HasCorrelationId()
|
||||
{
|
||||
var msg = new ParkedMessageQueryRequest(
|
||||
"corr-789", "site1", 1, 25, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-789", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParkedMessageQueryResponse_HasCorrelationId()
|
||||
{
|
||||
var msg = new ParkedMessageQueryResponse(
|
||||
"corr-789", "site1", [], 0, 1, 25, true, null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal("corr-789", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMessagePatterns_ExistAsRecordTypes()
|
||||
{
|
||||
// Verify all 8 patterns have proper request/response types
|
||||
// Pattern 1: Deployment
|
||||
Assert.True(typeof(Commons.Messages.Deployment.DeployInstanceCommand).IsValueType == false);
|
||||
Assert.True(typeof(Commons.Messages.Deployment.DeploymentStatusResponse).IsValueType == false);
|
||||
|
||||
// Pattern 2: Lifecycle
|
||||
Assert.True(typeof(Commons.Messages.Lifecycle.DisableInstanceCommand).IsValueType == false);
|
||||
Assert.True(typeof(Commons.Messages.Lifecycle.InstanceLifecycleResponse).IsValueType == false);
|
||||
|
||||
// Pattern 3: Artifacts
|
||||
Assert.True(typeof(Commons.Messages.Artifacts.DeployArtifactsCommand).IsValueType == false);
|
||||
Assert.True(typeof(Commons.Messages.Artifacts.ArtifactDeploymentResponse).IsValueType == false);
|
||||
|
||||
// Pattern 4: Integration
|
||||
Assert.True(typeof(IntegrationCallRequest).IsValueType == false);
|
||||
Assert.True(typeof(IntegrationCallResponse).IsValueType == false);
|
||||
|
||||
// Pattern 5: Debug View
|
||||
Assert.True(typeof(Commons.Messages.DebugView.SubscribeDebugViewRequest).IsValueType == false);
|
||||
Assert.True(typeof(Commons.Messages.DebugView.DebugViewSnapshot).IsValueType == false);
|
||||
|
||||
// Pattern 6: Health
|
||||
Assert.True(typeof(Commons.Messages.Health.SiteHealthReport).IsValueType == false);
|
||||
|
||||
// Pattern 7: Remote Queries
|
||||
Assert.True(typeof(EventLogQueryRequest).IsValueType == false);
|
||||
Assert.True(typeof(EventLogQueryResponse).IsValueType == false);
|
||||
Assert.True(typeof(ParkedMessageQueryRequest).IsValueType == false);
|
||||
Assert.True(typeof(ParkedMessageQueryResponse).IsValueType == false);
|
||||
|
||||
// Pattern 8: Heartbeat
|
||||
Assert.True(typeof(Commons.Messages.Health.HeartbeatMessage).IsValueType == false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format round-trip tests for the Audit Log (#23) telemetry proto messages
|
||||
/// (<see cref="AuditEventDto"/>, <see cref="AuditEventBatch"/>, <see cref="IngestAck"/>).
|
||||
/// Locks the additive contract the site → central audit pipeline depends on.
|
||||
/// </summary>
|
||||
public class AuditEventProtoTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuditEventDto_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
var occurredAt = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 15, 30, 123, TimeSpan.Zero));
|
||||
|
||||
var original = new AuditEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
OccurredAtUtc = occurredAt,
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "ApiCall",
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
SourceSiteId = "site-1",
|
||||
SourceNode = "node-a",
|
||||
SourceInstanceId = "Pump01",
|
||||
SourceScript = "OnDemand",
|
||||
Actor = "design-key",
|
||||
Target = "weather-api",
|
||||
Status = "Delivered",
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
ErrorMessage = "no error",
|
||||
ErrorDetail = "stack",
|
||||
RequestSummary = "GET /weather?city=brisbane",
|
||||
ResponseSummary = "{ \"temp\": 22.5 }",
|
||||
PayloadTruncated = true,
|
||||
Extra = "{ \"retryCount\": 0 }"
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = AuditEventDto.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(original.EventId, deserialized.EventId);
|
||||
Assert.Equal(original.OccurredAtUtc, deserialized.OccurredAtUtc);
|
||||
Assert.Equal(original.Channel, deserialized.Channel);
|
||||
Assert.Equal(original.Kind, deserialized.Kind);
|
||||
Assert.Equal(original.CorrelationId, deserialized.CorrelationId);
|
||||
Assert.Equal(original.SourceSiteId, deserialized.SourceSiteId);
|
||||
Assert.Equal(original.SourceNode, deserialized.SourceNode);
|
||||
Assert.Equal(original.SourceInstanceId, deserialized.SourceInstanceId);
|
||||
Assert.Equal(original.SourceScript, deserialized.SourceScript);
|
||||
Assert.Equal(original.Actor, deserialized.Actor);
|
||||
Assert.Equal(original.Target, deserialized.Target);
|
||||
Assert.Equal(original.Status, deserialized.Status);
|
||||
Assert.Equal(original.HttpStatus, deserialized.HttpStatus);
|
||||
Assert.Equal(original.DurationMs, deserialized.DurationMs);
|
||||
Assert.Equal(original.ErrorMessage, deserialized.ErrorMessage);
|
||||
Assert.Equal(original.ErrorDetail, deserialized.ErrorDetail);
|
||||
Assert.Equal(original.RequestSummary, deserialized.RequestSummary);
|
||||
Assert.Equal(original.ResponseSummary, deserialized.ResponseSummary);
|
||||
Assert.Equal(original.PayloadTruncated, deserialized.PayloadTruncated);
|
||||
Assert.Equal(original.Extra, deserialized.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEventDto_NullableInt_AbsentByDefault_NotIncludedInWire()
|
||||
{
|
||||
// Int32Value fields (http_status, duration_ms) are wrapper-typed in proto;
|
||||
// when unset, the wrapper is absent, not serialized, and deserializes back to null.
|
||||
var original = new AuditEventDto
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
Channel = "Notification",
|
||||
Kind = "NotifySend",
|
||||
Status = "Submitted"
|
||||
};
|
||||
|
||||
Assert.Null(original.HttpStatus);
|
||||
Assert.Null(original.DurationMs);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = AuditEventDto.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Null(deserialized.HttpStatus);
|
||||
Assert.Null(deserialized.DurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEventBatch_Empty_RoundTrip_Yields_EmptyEvents()
|
||||
{
|
||||
var original = new AuditEventBatch();
|
||||
Assert.Empty(original.Events);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = AuditEventBatch.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Empty(deserialized.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IngestAck_PreservesAcceptedEventIds()
|
||||
{
|
||||
var id1 = Guid.NewGuid().ToString();
|
||||
var id2 = Guid.NewGuid().ToString();
|
||||
var id3 = Guid.NewGuid().ToString();
|
||||
|
||||
var original = new IngestAck();
|
||||
original.AcceptedEventIds.Add(id1);
|
||||
original.AcceptedEventIds.Add(id2);
|
||||
original.AcceptedEventIds.Add(id3);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = IngestAck.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(3, deserialized.AcceptedEventIds.Count);
|
||||
Assert.Equal(id1, deserialized.AcceptedEventIds[0]);
|
||||
Assert.Equal(id2, deserialized.AcceptedEventIds[1]);
|
||||
Assert.Equal(id3, deserialized.AcceptedEventIds[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format round-trip tests for the Audit Log (#23) M3 cached-telemetry
|
||||
/// proto messages (<see cref="SiteCallOperationalDto"/>,
|
||||
/// <see cref="CachedTelemetryPacket"/>, <see cref="CachedTelemetryBatch"/>).
|
||||
/// Locks the additive contract the central dual-write transaction depends on.
|
||||
/// </summary>
|
||||
public class CachedTelemetryProtoTests
|
||||
{
|
||||
private static AuditEventDto NewAuditDto(Guid? id = null) => new()
|
||||
{
|
||||
EventId = (id ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 15, 30, 123, TimeSpan.Zero)),
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "CachedSubmit",
|
||||
Status = "Submitted",
|
||||
SourceSiteId = "site-1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperationalDto_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
var createdAt = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero));
|
||||
var updatedAt = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 5, 0, TimeSpan.Zero));
|
||||
var terminalAt = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 10, 0, TimeSpan.Zero));
|
||||
|
||||
var original = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = Guid.NewGuid().ToString(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-melbourne",
|
||||
SourceNode = "node-a",
|
||||
Status = "Delivered",
|
||||
RetryCount = 3,
|
||||
LastError = "transient 503",
|
||||
HttpStatus = 200,
|
||||
CreatedAtUtc = createdAt,
|
||||
UpdatedAtUtc = updatedAt,
|
||||
TerminalAtUtc = terminalAt,
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(original.TrackedOperationId, deserialized.TrackedOperationId);
|
||||
Assert.Equal(original.Channel, deserialized.Channel);
|
||||
Assert.Equal(original.Target, deserialized.Target);
|
||||
Assert.Equal(original.SourceSite, deserialized.SourceSite);
|
||||
Assert.Equal(original.SourceNode, deserialized.SourceNode);
|
||||
Assert.Equal(original.Status, deserialized.Status);
|
||||
Assert.Equal(original.RetryCount, deserialized.RetryCount);
|
||||
Assert.Equal(original.LastError, deserialized.LastError);
|
||||
Assert.Equal(original.HttpStatus, deserialized.HttpStatus);
|
||||
Assert.Equal(original.CreatedAtUtc, deserialized.CreatedAtUtc);
|
||||
Assert.Equal(original.UpdatedAtUtc, deserialized.UpdatedAtUtc);
|
||||
Assert.Equal(original.TerminalAtUtc, deserialized.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperationalDto_TerminalAt_AbsentWhenNotTerminal()
|
||||
{
|
||||
// Lifecycle events prior to the terminal step leave TerminalAtUtc unset;
|
||||
// the well-known Timestamp wrapper is absent on the wire (null in C#).
|
||||
var dto = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = Guid.NewGuid().ToString(),
|
||||
Channel = "DbOutbound",
|
||||
Target = "warehouse.dbo.WriteOrder",
|
||||
SourceSite = "site-brisbane",
|
||||
Status = "Attempted",
|
||||
RetryCount = 1,
|
||||
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
};
|
||||
|
||||
Assert.Null(dto.TerminalAtUtc);
|
||||
|
||||
var bytes = dto.ToByteArray();
|
||||
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Null(deserialized.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperationalDto_NullableHttpStatus_AbsentByDefault()
|
||||
{
|
||||
// Int32Value wrapper-typed http_status — unset round-trips as null,
|
||||
// matching DB nullable column semantics for non-API cached writes.
|
||||
var dto = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = Guid.NewGuid().ToString(),
|
||||
Channel = "DbOutbound",
|
||||
Target = "warehouse.dbo.WriteOrder",
|
||||
SourceSite = "site-brisbane",
|
||||
Status = "Submitted",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
};
|
||||
|
||||
Assert.Null(dto.HttpStatus);
|
||||
|
||||
var bytes = dto.ToByteArray();
|
||||
var deserialized = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Null(deserialized.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedTelemetryPacket_RoundTrip_PreservesNestedEntities()
|
||||
{
|
||||
var trackedOpId = Guid.NewGuid().ToString();
|
||||
var auditDto = NewAuditDto();
|
||||
auditDto.Target = "ERP.GetOrder";
|
||||
auditDto.Status = "Attempted";
|
||||
|
||||
var operationalDto = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = trackedOpId,
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-1",
|
||||
Status = "Attempted",
|
||||
RetryCount = 2,
|
||||
HttpStatus = 503,
|
||||
LastError = "Service unavailable",
|
||||
CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||
};
|
||||
|
||||
var original = new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = auditDto,
|
||||
Operational = operationalDto,
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = CachedTelemetryPacket.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.NotNull(deserialized.AuditEvent);
|
||||
Assert.Equal(auditDto.EventId, deserialized.AuditEvent.EventId);
|
||||
Assert.Equal(auditDto.Target, deserialized.AuditEvent.Target);
|
||||
Assert.Equal(auditDto.Status, deserialized.AuditEvent.Status);
|
||||
|
||||
Assert.NotNull(deserialized.Operational);
|
||||
Assert.Equal(trackedOpId, deserialized.Operational.TrackedOperationId);
|
||||
Assert.Equal(operationalDto.Channel, deserialized.Operational.Channel);
|
||||
Assert.Equal(operationalDto.Status, deserialized.Operational.Status);
|
||||
Assert.Equal(operationalDto.RetryCount, deserialized.Operational.RetryCount);
|
||||
Assert.Equal(operationalDto.HttpStatus, deserialized.Operational.HttpStatus);
|
||||
Assert.Equal(operationalDto.LastError, deserialized.Operational.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedTelemetryBatch_Empty_RoundTrip_Yields_EmptyPackets()
|
||||
{
|
||||
var original = new CachedTelemetryBatch();
|
||||
Assert.Empty(original.Packets);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = CachedTelemetryBatch.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Empty(deserialized.Packets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Protos;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-format round-trip tests for the Audit Log (#23) M6 reconciliation
|
||||
/// pull proto messages (<see cref="PullAuditEventsRequest"/>,
|
||||
/// <see cref="PullAuditEventsResponse"/>). Locks the additive contract the
|
||||
/// central→site reconciliation puller depends on.
|
||||
/// </summary>
|
||||
public class PullAuditEventsProtoTests
|
||||
{
|
||||
private static AuditEventDto NewAuditDto(Guid? id = null) => new()
|
||||
{
|
||||
EventId = (id ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 10, 15, 30, 123, TimeSpan.Zero)),
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "ApiCall",
|
||||
Status = "Delivered",
|
||||
SourceSiteId = "site-1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsRequest_RoundTrip()
|
||||
{
|
||||
var sinceUtc = Timestamp.FromDateTimeOffset(
|
||||
new DateTimeOffset(2026, 5, 20, 9, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var original = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = sinceUtc,
|
||||
BatchSize = 250,
|
||||
};
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = PullAuditEventsRequest.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Equal(sinceUtc, deserialized.SinceUtc);
|
||||
Assert.Equal(250, deserialized.BatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_RoundTrip_WithEvents_And_MoreAvailable()
|
||||
{
|
||||
var dtos = Enumerable.Range(0, 4).Select(_ => NewAuditDto()).ToList();
|
||||
|
||||
var original = new PullAuditEventsResponse
|
||||
{
|
||||
MoreAvailable = true,
|
||||
};
|
||||
original.Events.AddRange(dtos);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = PullAuditEventsResponse.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.True(deserialized.MoreAvailable);
|
||||
Assert.Equal(4, deserialized.Events.Count);
|
||||
for (int i = 0; i < dtos.Count; i++)
|
||||
{
|
||||
Assert.Equal(dtos[i].EventId, deserialized.Events[i].EventId);
|
||||
Assert.Equal(dtos[i].Status, deserialized.Events[i].Status);
|
||||
Assert.Equal(dtos[i].SourceSiteId, deserialized.Events[i].SourceSiteId);
|
||||
Assert.Equal(dtos[i].OccurredAtUtc, deserialized.Events[i].OccurredAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_Empty_Yields_EmptyEvents()
|
||||
{
|
||||
var original = new PullAuditEventsResponse();
|
||||
Assert.Empty(original.Events);
|
||||
Assert.False(original.MoreAvailable);
|
||||
|
||||
var bytes = original.ToByteArray();
|
||||
var deserialized = PullAuditEventsResponse.Parser.ParseFrom(bytes);
|
||||
|
||||
Assert.Empty(deserialized.Events);
|
||||
Assert.False(deserialized.MoreAvailable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Field-coverage + edge tests for the <see cref="SiteCallDtoMapper"/> that
|
||||
/// decodes <see cref="SiteCallOperationalDto"/> (proto) into the
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/> persistence entity.
|
||||
/// Only the DTO→entity direction exists — nothing in the system maps a
|
||||
/// <c>SiteCall</c> back onto the wire — so there is no round-trip test.
|
||||
/// <c>IngestedAtUtc</c> is a site-side placeholder the central ingest actor
|
||||
/// overwrites, so it is asserted as "recent UTC" rather than a fixed value.
|
||||
/// </summary>
|
||||
public class SiteCallDtoMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromDto_FullyPopulated_MapsEveryField()
|
||||
{
|
||||
var trackedOperationId = Guid.NewGuid();
|
||||
var createdAt = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
var updatedAt = new DateTime(2026, 5, 20, 10, 5, 0, DateTimeKind.Utc);
|
||||
var terminalAt = new DateTime(2026, 5, 20, 10, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
var dto = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = trackedOperationId.ToString(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-melbourne",
|
||||
SourceNode = "node-a",
|
||||
Status = "Delivered",
|
||||
RetryCount = 3,
|
||||
LastError = "transient 503",
|
||||
HttpStatus = 200,
|
||||
CreatedAtUtc = Timestamp.FromDateTime(createdAt),
|
||||
UpdatedAtUtc = Timestamp.FromDateTime(updatedAt),
|
||||
TerminalAtUtc = Timestamp.FromDateTime(terminalAt),
|
||||
};
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Equal(trackedOperationId, entity.TrackedOperationId.Value);
|
||||
Assert.Equal("ApiOutbound", entity.Channel);
|
||||
Assert.Equal("ERP.GetOrder", entity.Target);
|
||||
Assert.Equal("site-melbourne", entity.SourceSite);
|
||||
Assert.Equal("node-a", entity.SourceNode);
|
||||
Assert.Equal("Delivered", entity.Status);
|
||||
Assert.Equal(3, entity.RetryCount);
|
||||
Assert.Equal("transient 503", entity.LastError);
|
||||
Assert.Equal(200, entity.HttpStatus);
|
||||
Assert.Equal(createdAt, entity.CreatedAtUtc);
|
||||
Assert.Equal(updatedAt, entity.UpdatedAtUtc);
|
||||
Assert.Equal(terminalAt, entity.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_EmptyLastError_BecomesNull()
|
||||
{
|
||||
var dto = NewMinimalDto();
|
||||
dto.LastError = string.Empty;
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Null(entity.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_AbsentHttpStatus_StaysNull()
|
||||
{
|
||||
// Int32Value wrapper unset on the wire — preserves true null semantics
|
||||
// for non-API cached writes.
|
||||
var dto = NewMinimalDto();
|
||||
|
||||
Assert.Null(dto.HttpStatus);
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Null(entity.HttpStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_AbsentTerminalAt_StaysNull()
|
||||
{
|
||||
// Timestamp wrapper unset while the call is still active.
|
||||
var dto = NewMinimalDto();
|
||||
|
||||
Assert.Null(dto.TerminalAtUtc);
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Null(entity.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_Timestamps_RehydrateAsUtcKind()
|
||||
{
|
||||
var dto = NewMinimalDto();
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, entity.CreatedAtUtc.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, entity.UpdatedAtUtc.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_IngestedAtUtc_StampedAsRecentPlaceholder()
|
||||
{
|
||||
// IngestedAtUtc is a site-side DateTime.UtcNow placeholder; the central
|
||||
// ingest actor overwrites it inside the dual-write transaction.
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(NewMinimalDto());
|
||||
|
||||
var after = DateTime.UtcNow;
|
||||
Assert.InRange(entity.IngestedAtUtc, before, after);
|
||||
Assert.Equal(DateTimeKind.Utc, entity.IngestedAtUtc.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDto_Null_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => SiteCallDtoMapper.FromDto(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperationalDto_round_trip_preserves_SourceNode()
|
||||
{
|
||||
// Populated SourceNode travels verbatim across the wire and through
|
||||
// the DTO→entity mapper.
|
||||
var dto = NewMinimalDto();
|
||||
dto.SourceNode = "node-a";
|
||||
|
||||
var bytes = dto.ToByteArray();
|
||||
var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
||||
Assert.Equal("node-a", onWire.SourceNode);
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(onWire);
|
||||
|
||||
Assert.Equal("node-a", entity.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperationalDto_round_trip_preserves_null_SourceNode()
|
||||
{
|
||||
// The DTO uses an empty-string-means-null convention on the wire;
|
||||
// FromDto rehydrates that back to a true null on the entity.
|
||||
var dto = NewMinimalDto();
|
||||
// SourceNode left at proto default (empty string) — semantically null.
|
||||
|
||||
var bytes = dto.ToByteArray();
|
||||
var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
||||
Assert.Equal(string.Empty, onWire.SourceNode);
|
||||
|
||||
var entity = SiteCallDtoMapper.FromDto(onWire);
|
||||
|
||||
Assert.Null(entity.SourceNode);
|
||||
}
|
||||
|
||||
private static SiteCallOperationalDto NewMinimalDto() => new()
|
||||
{
|
||||
TrackedOperationId = Guid.NewGuid().ToString(),
|
||||
Channel = "DbOutbound",
|
||||
Target = "warehouse.dbo.WriteOrder",
|
||||
SourceSite = "site-brisbane",
|
||||
Status = "Submitted",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.Client;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Tests for SiteCommunicationActor message routing to local actors.
|
||||
/// </summary>
|
||||
public class SiteCommunicationActorTests : TestKit
|
||||
{
|
||||
private readonly CommunicationOptions _options = new();
|
||||
|
||||
public SiteCommunicationActorTests()
|
||||
: base(@"akka.loglevel = DEBUG")
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeployCommand_ForwardedToDeploymentManager()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
var command = new DeployInstanceCommand(
|
||||
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
siteActor.Tell(command);
|
||||
|
||||
dmProbe.ExpectMsg<DeployInstanceCommand>(msg => msg.DeploymentId == "dep1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifecycleCommands_ForwardedToDeploymentManager()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new DisableInstanceCommand("cmd1", "inst1", DateTimeOffset.UtcNow));
|
||||
dmProbe.ExpectMsg<DisableInstanceCommand>();
|
||||
|
||||
siteActor.Tell(new EnableInstanceCommand("cmd2", "inst1", DateTimeOffset.UtcNow));
|
||||
dmProbe.ExpectMsg<EnableInstanceCommand>();
|
||||
|
||||
siteActor.Tell(new DeleteInstanceCommand("cmd3", "inst1", DateTimeOffset.UtcNow));
|
||||
dmProbe.ExpectMsg<DeleteInstanceCommand>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentStateQuery_ForwardedToDeploymentManager()
|
||||
{
|
||||
// DeploymentManager-006: the site-before-redeploy query travels over the
|
||||
// ClusterClient command/control transport and is routed to the local
|
||||
// Deployment Manager, which owns the deployed-config store.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
var request = new DeploymentStateQueryRequest("corr-q", "inst1", DateTimeOffset.UtcNow);
|
||||
siteActor.Tell(request);
|
||||
|
||||
dmProbe.ExpectMsg<DeploymentStateQueryRequest>(msg => msg.CorrelationId == "corr-q");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntegrationCall_WithoutHandler_ReturnsFailure()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
var request = new IntegrationCallRequest(
|
||||
"corr1", "site1", "inst1", "ExtSys1", "GetData",
|
||||
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
|
||||
|
||||
siteActor.Tell(request);
|
||||
|
||||
ExpectMsg<IntegrationCallResponse>(msg =>
|
||||
!msg.Success && msg.ErrorMessage == "Integration handler not available");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntegrationCall_WithHandler_ForwardedToHandler()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var handlerProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
// Register integration handler
|
||||
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.Integration, handlerProbe.Ref));
|
||||
|
||||
var request = new IntegrationCallRequest(
|
||||
"corr1", "site1", "inst1", "ExtSys1", "GetData",
|
||||
new Dictionary<string, object?>(), DateTimeOffset.UtcNow);
|
||||
|
||||
siteActor.Tell(request);
|
||||
handlerProbe.ExpectMsg<IntegrationCallRequest>(msg => msg.CorrelationId == "corr1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_WithCentralClient_ForwardedToCentralAndAckRoutedBack()
|
||||
{
|
||||
// The site forwards a buffered notification to central over the ClusterClient
|
||||
// command/control transport; the central ack must route back to the original
|
||||
// sender (the S&F forwarder's Ask), not to the SiteCommunicationActor.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var centralClientProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref));
|
||||
|
||||
var submit = new NotificationSubmit(
|
||||
"notif-1", "Operators", "Subj", "Body", "site1", "inst1", "alarmScript",
|
||||
DateTimeOffset.UtcNow);
|
||||
siteActor.Tell(submit);
|
||||
|
||||
// Central client (acting as ClusterClient) receives a ClusterClient.Send wrapping
|
||||
// the NotificationSubmit, addressed to the central communication actor. Fish past
|
||||
// any periodic HeartbeatMessage the actor's timer may interleave.
|
||||
var send = centralClientProbe.FishForMessage<ClusterClient.Send>(
|
||||
s => s.Message is NotificationSubmit);
|
||||
Assert.Equal("/user/central-communication", send.Path);
|
||||
var forwarded = Assert.IsType<NotificationSubmit>(send.Message);
|
||||
Assert.Equal("notif-1", forwarded.NotificationId);
|
||||
|
||||
// The ack is sent to the ClusterClient.Send's Sender — replying as that probe
|
||||
// must land back at the test actor (the original Tell sender).
|
||||
centralClientProbe.Reply(new NotificationSubmitAck("notif-1", Accepted: true, Error: null));
|
||||
ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-1" && ack.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_WithoutCentralClient_RepliesWithNonAccepted()
|
||||
{
|
||||
// No ClusterClient registered yet: the submit cannot be forwarded, so the actor
|
||||
// replies with a non-accepted ack and the S&F forwarder treats it as transient.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
var submit = new NotificationSubmit(
|
||||
"notif-2", "Operators", "Subj", "Body", "site1", null, null,
|
||||
DateTimeOffset.UtcNow);
|
||||
siteActor.Tell(submit);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>(ack => ack.NotificationId == "notif-2" && !ack.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_WithCentralClient_ForwardedToCentralAndResponseRoutedBack()
|
||||
{
|
||||
// Notify.Status(id) issues a NotificationStatusQuery; the site actor forwards it
|
||||
// to central over the ClusterClient command/control transport and the central
|
||||
// response must route back to the original sender (the helper's Ask).
|
||||
var dmProbe = CreateTestProbe();
|
||||
var centralClientProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref));
|
||||
|
||||
var query = new NotificationStatusQuery("corr-99", "notif-1");
|
||||
siteActor.Tell(query);
|
||||
|
||||
var send = centralClientProbe.FishForMessage<ClusterClient.Send>(
|
||||
s => s.Message is NotificationStatusQuery);
|
||||
Assert.Equal("/user/central-communication", send.Path);
|
||||
var forwarded = Assert.IsType<NotificationStatusQuery>(send.Message);
|
||||
Assert.Equal("notif-1", forwarded.NotificationId);
|
||||
|
||||
// The response is sent to the ClusterClient.Send's Sender — replying as that
|
||||
// probe must land back at the test actor (the original Tell sender).
|
||||
centralClientProbe.Reply(new NotificationStatusResponse(
|
||||
"corr-99", Found: true, Status: "Delivered", RetryCount: 0,
|
||||
LastError: null, DeliveredAt: DateTimeOffset.UtcNow));
|
||||
ExpectMsg<NotificationStatusResponse>(r => r.CorrelationId == "corr-99" && r.Found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_WithoutCentralClient_RepliesWithNotFound()
|
||||
{
|
||||
// No ClusterClient registered yet: the query cannot reach central, so the actor
|
||||
// replies Found: false. Notify.Status then falls back to the site S&F buffer.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new NotificationStatusQuery("corr-100", "notif-2"));
|
||||
|
||||
ExpectMsg<NotificationStatusResponse>(
|
||||
r => r.CorrelationId == "corr-100" && !r.Found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogQuery_WithoutHandler_ReturnsFailure()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
var request = new EventLogQueryRequest(
|
||||
"corr1", "site1", null, null, null, null, null, null, null, 25, DateTimeOffset.UtcNow);
|
||||
|
||||
siteActor.Tell(request);
|
||||
|
||||
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
|
||||
}
|
||||
|
||||
// ── Task 5 (#22): central→site Retry/Discard relay for parked cached calls ──
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var handlerProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||
|
||||
var id = Commons.Types.TrackedOperationId.New();
|
||||
siteActor.Tell(new RetryParkedOperation("corr-rp", id));
|
||||
|
||||
handlerProbe.ExpectMsg<RetryParkedOperation>(msg =>
|
||||
msg.CorrelationId == "corr-rp" && msg.TrackedOperationId.Equals(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var handlerProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||
|
||||
var id = Commons.Types.TrackedOperationId.New();
|
||||
siteActor.Tell(new DiscardParkedOperation("corr-dp", id));
|
||||
|
||||
handlerProbe.ExpectMsg<DiscardParkedOperation>(msg =>
|
||||
msg.CorrelationId == "corr-dp" && msg.TrackedOperationId.Equals(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
// No parked-message handler registered — the relay must get a definitive
|
||||
// non-applied ack, not silence (the SiteCallAuditActor's Ask must not
|
||||
// hang and then mis-report site-unreachable when the site IS reachable).
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new RetryParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.Equal("corr-no-handler", ack.CorrelationId);
|
||||
Assert.False(ack.Applied);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||
{
|
||||
var dmProbe = CreateTestProbe();
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||
|
||||
siteActor.Tell(new DiscardParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||
|
||||
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||
Assert.False(ack.Applied);
|
||||
Assert.NotNull(ack.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Communication-018: heartbeat IsActive reflects this node's cluster role ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void Heartbeat_StampsIsActive_FromInjectedCheck(bool isActive)
|
||||
{
|
||||
// Communication-018: HeartbeatMessage.IsActive must reflect the actual
|
||||
// active/standby role of this node, not a hard-coded `true`. The actor
|
||||
// now takes a Func<bool> override (defaulting to a real Akka.Cluster
|
||||
// leader check in production); tests inject a stub so they do not need
|
||||
// to bring up a full cluster in the TestKit ActorSystem.
|
||||
var dmProbe = CreateTestProbe();
|
||||
var centralClientProbe = CreateTestProbe();
|
||||
var fastHeartbeatOptions = new CommunicationOptions
|
||||
{
|
||||
TransportHeartbeatInterval = TimeSpan.FromMilliseconds(50)
|
||||
};
|
||||
|
||||
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||
new SiteCommunicationActor("site1", fastHeartbeatOptions, dmProbe.Ref, () => isActive)));
|
||||
|
||||
siteActor.Tell(new RegisterCentralClient(centralClientProbe.Ref));
|
||||
|
||||
var send = centralClientProbe.FishForMessage<ClusterClient.Send>(
|
||||
s => s.Message is HeartbeatMessage, TimeSpan.FromSeconds(3));
|
||||
var heartbeat = Assert.IsType<HeartbeatMessage>(send.Message);
|
||||
Assert.Equal(isActive, heartbeat.IsActive);
|
||||
Assert.Equal("site1", heartbeat.SiteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="SiteStreamGrpcServer.IngestAuditEvents"/>.
|
||||
/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler.
|
||||
/// A tiny <c>StubIngestActor</c> stands in for the central
|
||||
/// <c>AuditLogIngestActor</c>, replying with the EventIds it received so the
|
||||
/// test asserts the wiring without depending on MSSQL.
|
||||
/// </summary>
|
||||
public class SiteStreamIngestAuditEventsTests : TestKit
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static AuditEventDto NewDto(Guid? id = null) => new()
|
||||
{
|
||||
EventId = (id ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "ApiCall",
|
||||
Status = "Delivered",
|
||||
SourceSiteId = "site-1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_With_AuditIngestActor_Routes_To_Actor_Returns_Reply()
|
||||
{
|
||||
// Arrange — a stub actor that echoes every received EventId back.
|
||||
var stubActor = Sys.ActorOf(Props.Create(() => new EchoIngestActor()));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetAuditIngestActor(stubActor);
|
||||
|
||||
// Build a 3-event batch.
|
||||
var dtos = Enumerable.Range(0, 3).Select(_ => NewDto()).ToList();
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.AddRange(dtos);
|
||||
|
||||
// Act
|
||||
var ack = await server.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
// Assert — every dto's id appears in the ack, demonstrating end-to-
|
||||
// end routing through the actor.
|
||||
Assert.Equal(3, ack.AcceptedEventIds.Count);
|
||||
var expectedIds = dtos.Select(d => d.EventId).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_NoActor_Wired_ReturnsEmptyAck()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetAuditIngestActor — simulates host
|
||||
// startup race window.
|
||||
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.Add(NewDto());
|
||||
|
||||
var ack = await server.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny ReceiveActor that echoes every EventId in an incoming
|
||||
/// <see cref="IngestAuditEventsCommand"/> back as an
|
||||
/// <see cref="IngestAuditEventsReply"/>. Stands in for the central
|
||||
/// AuditLogIngestActor so this test never touches MSSQL.
|
||||
/// </summary>
|
||||
private sealed class EchoIngestActor : ReceiveActor
|
||||
{
|
||||
public EchoIngestActor()
|
||||
{
|
||||
Receive<IngestAuditEventsCommand>(cmd =>
|
||||
{
|
||||
var ids = cmd.Events.Select(e => e.EventId).ToList();
|
||||
Sender.Tell(new IngestAuditEventsReply(ids));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D D2 tests for <see cref="SiteStreamGrpcServer.IngestCachedTelemetry"/>.
|
||||
/// Verifies the DTO→entity→actor→ack round-trip through the gRPC handler. A
|
||||
/// tiny <c>EchoCachedIngestActor</c> stands in for the central
|
||||
/// <c>AuditLogIngestActor</c>, replying with the EventIds it received so the
|
||||
/// test asserts the wiring without depending on MSSQL.
|
||||
/// </summary>
|
||||
public class SiteStreamIngestCachedTelemetryTests : TestKit
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static CachedTelemetryPacket NewPacket(Guid? eventId = null, Guid? trackedId = null)
|
||||
{
|
||||
var now = Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc));
|
||||
return new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = new AuditEventDto
|
||||
{
|
||||
EventId = (eventId ?? Guid.NewGuid()).ToString(),
|
||||
OccurredAtUtc = now,
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "CachedSubmit",
|
||||
Status = "Submitted",
|
||||
SourceSiteId = "site-1",
|
||||
CorrelationId = (trackedId ?? Guid.NewGuid()).ToString(),
|
||||
},
|
||||
Operational = new SiteCallOperationalDto
|
||||
{
|
||||
TrackedOperationId = (trackedId ?? Guid.NewGuid()).ToString(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-1",
|
||||
Status = "Submitted",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_RoutesToActor_ReturnsReply()
|
||||
{
|
||||
// Arrange — stub actor that echoes every received EventId back.
|
||||
var stubActor = Sys.ActorOf(Props.Create(() => new EchoCachedIngestActor()));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetAuditIngestActor(stubActor);
|
||||
|
||||
var packets = Enumerable.Range(0, 3)
|
||||
.Select(_ => NewPacket())
|
||||
.ToList();
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.AddRange(packets);
|
||||
|
||||
// Act
|
||||
var ack = await server.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
// Assert — every packet's EventId appears in the ack, demonstrating
|
||||
// end-to-end routing through the actor.
|
||||
Assert.Equal(3, ack.AcceptedEventIds.Count);
|
||||
var expectedIds = packets.Select(p => p.AuditEvent.EventId).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(ack.AcceptedEventIds.ToHashSet()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_NoActorWired_ReturnsEmptyAck()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetAuditIngestActor — simulates host
|
||||
// startup race window.
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.Add(NewPacket());
|
||||
|
||||
var ack = await server.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiny ReceiveActor that echoes every EventId in an incoming
|
||||
/// <see cref="IngestCachedTelemetryCommand"/> back as an
|
||||
/// <see cref="IngestCachedTelemetryReply"/>. Stands in for the central
|
||||
/// AuditLogIngestActor so this test never touches MSSQL.
|
||||
/// </summary>
|
||||
private sealed class EchoCachedIngestActor : ReceiveActor
|
||||
{
|
||||
public EchoCachedIngestActor()
|
||||
{
|
||||
Receive<IngestCachedTelemetryCommand>(cmd =>
|
||||
{
|
||||
var ids = cmd.Entries.Select(e => e.Audit.EventId).ToList();
|
||||
Sender.Tell(new IngestCachedTelemetryReply(ids));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle A A2 tests for <see cref="SiteStreamGrpcServer.PullAuditEvents"/>.
|
||||
/// Verifies the request → ISiteAuditQueue.ReadPendingSinceAsync → response →
|
||||
/// MarkReconciledAsync round-trip through the gRPC handler. The queue is an
|
||||
/// NSubstitute stub so the tests never touch SQLite.
|
||||
/// </summary>
|
||||
public class SiteStreamPullAuditEventsTests : TestKit
|
||||
{
|
||||
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
|
||||
private SiteStreamGrpcServer CreateServer() =>
|
||||
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static AuditEvent NewEvent(DateTime? occurredAt = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAt
|
||||
?? DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = "site-1",
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_NoQueueWired_ReturnsEmptyResponse()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetSiteAuditQueue — simulates a central-only
|
||||
// host or a wiring-incomplete startup window.
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-5)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Events);
|
||||
Assert.False(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_With5PendingRows_ReturnsAllFiveDtos_AndFlipsToReconciled()
|
||||
{
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100, // larger than returned count so MoreAvailable should be false
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(5, response.Events.Count);
|
||||
Assert.False(response.MoreAvailable); // 5 < 100
|
||||
var expectedIds = events.Select(e => e.EventId.ToString()).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(response.Events.Select(d => d.EventId).ToHashSet()));
|
||||
|
||||
// Verify MarkReconciledAsync received the same 5 ids (best-effort flip).
|
||||
await queue.Received(1).MarkReconciledAsync(
|
||||
Arg.Is<IReadOnlyList<Guid>>(ids => ids.Count == 5 &&
|
||||
ids.ToHashSet().SetEquals(events.Select(e => e.EventId))),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_RowsOlderThanSinceUtc_Excluded()
|
||||
{
|
||||
// The handler delegates the since-utc filter to ReadPendingSinceAsync;
|
||||
// this test verifies it passes the request value through verbatim
|
||||
// (no clock skew, no off-by-one) and that an empty queue response
|
||||
// yields an empty gRPC response.
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var capturedSince = DateTime.MinValue;
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
capturedSince = call.ArgAt<DateTime>(0);
|
||||
return (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>();
|
||||
});
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var since = DateTime.SpecifyKind(new DateTime(2026, 5, 20, 9, 30, 0), DateTimeKind.Utc);
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(since),
|
||||
BatchSize = 50,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Events);
|
||||
Assert.False(response.MoreAvailable);
|
||||
Assert.Equal(since, capturedSince);
|
||||
// Empty result → no MarkReconciledAsync call (no rows to flip).
|
||||
await queue.DidNotReceive().MarkReconciledAsync(
|
||||
Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_BatchSize3_Returns3Rows_MoreAvailableTrue()
|
||||
{
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 3,
|
||||
};
|
||||
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(3, response.Events.Count);
|
||||
// saturated batch → central needs to know to issue a follow-up pull
|
||||
Assert.True(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullAuditEvents_MarkReconciledThrows_ResponseStillReturned()
|
||||
{
|
||||
// The Reconciled flip is best-effort — if it fails, the response must
|
||||
// still surface so central can ingest the rows (and dedup on EventId
|
||||
// when it pulls them again).
|
||||
var queue = Substitute.For<ISiteAuditQueue>();
|
||||
var events = Enumerable.Range(0, 2).Select(_ => NewEvent()).ToList();
|
||||
queue.ReadPendingSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<AuditEvent>)events);
|
||||
queue.MarkReconciledAsync(Arg.Any<IReadOnlyList<Guid>>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("SQLite disposed mid-call"));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetSiteAuditQueue(queue);
|
||||
|
||||
var request = new PullAuditEventsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
// Must NOT throw — the response is built before the flip and returned
|
||||
// regardless of the flip outcome.
|
||||
var response = await server.PullAuditEvents(request, NewContext());
|
||||
|
||||
Assert.Equal(2, response.Events.Count);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user